mirror of
https://github.com/Drop-OSS/drop-api-docs.git
synced 2026-01-30 20:55:22 +01:00
feat: initial commit
This commit is contained in:
3
.eslintrc.json
Normal file
3
.eslintrc.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "next/core-web-vitals"
|
||||
}
|
||||
35
.gitignore
vendored
Normal file
35
.gitignore
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
158
CHANGELOG.md
Normal file
158
CHANGELOG.md
Normal file
@@ -0,0 +1,158 @@
|
||||
# Changelog
|
||||
|
||||
## 2025-07-29
|
||||
|
||||
- Update to React 19 and Next.js 15.4
|
||||
|
||||
## 2025-04-28
|
||||
|
||||
- Update template to Tailwind CSS v4.1.4
|
||||
|
||||
## 2025-04-17
|
||||
|
||||
- Fix header opacity
|
||||
- Organize imports
|
||||
- Fix scrolling issues when navigating from the mobile nav ([#1387](https://github.com/tailwindlabs/tailwind-plus-issues/issues/1387), [#1666](https://github.com/tailwindlabs/tailwind-plus-issues/issues/1666))
|
||||
|
||||
## 2025-04-10
|
||||
|
||||
- Update template to Tailwind CSS v4.1.3
|
||||
|
||||
## 2025-03-22
|
||||
|
||||
- Update template to Tailwind CSS v4.0.15
|
||||
|
||||
## 2025-02-18
|
||||
|
||||
- Fix responsive design issue in footer
|
||||
|
||||
## 2025-02-10
|
||||
|
||||
- Update template to Tailwind CSS v4.0.6
|
||||
|
||||
## 2025-01-23
|
||||
|
||||
- Update template to Tailwind CSS v4.0
|
||||
|
||||
## 2024-11-01
|
||||
|
||||
- Fix code block rendering when no snippet language is specified ([#1643](https://github.com/tailwindlabs/tailwind-plus-issues/issues/1643))
|
||||
|
||||
## 2024-08-08
|
||||
|
||||
- Configure experimental `outputFileTracingIncludes` for hosting on Vercel
|
||||
|
||||
## 2024-06-21
|
||||
|
||||
- Bump Headless UI dependency to v2.1
|
||||
- Update to new data-attribute-based transition API
|
||||
|
||||
## 2024-06-18
|
||||
|
||||
- Update `prettier` and `prettier-plugin-tailwindcss` dependencies
|
||||
|
||||
## 2024-05-31
|
||||
|
||||
- Fix `npm audit` warnings
|
||||
|
||||
## 2024-05-07
|
||||
|
||||
- Bump Headless UI dependency to v2.0
|
||||
|
||||
## 2024-01-17
|
||||
|
||||
- Fix `sharp` dependency issues ([#1549](https://github.com/tailwindlabs/tailwind-plus-issues/issues/1549))
|
||||
|
||||
## 2024-01-16
|
||||
|
||||
- Replace Twitter with X
|
||||
|
||||
## 2024-01-10
|
||||
|
||||
- Update Tailwind CSS, Next.js, Prettier, TypeScript, ESLint, and other dependencies
|
||||
- Update Tailwind `darkMode` setting to new `selector` option
|
||||
- Fix `not-prose` typography alignment issues
|
||||
- Add name to MDX search function
|
||||
- Sort classes
|
||||
|
||||
## 2023-10-03
|
||||
|
||||
- Add missing `@types/mdx` dependency ([#1512](https://github.com/tailwindlabs/tailwind-plus-issues/issues/1512))
|
||||
|
||||
## 2023-09-07
|
||||
|
||||
- Added TypeScript version of template
|
||||
|
||||
## 2023-08-15
|
||||
|
||||
- Bump Next.js dependency
|
||||
|
||||
## 2023-07-31
|
||||
|
||||
- Port template to Next.js app router
|
||||
|
||||
## 2023-07-24
|
||||
|
||||
- Fix search rendering bug in Safari ([#1470](https://github.com/tailwindlabs/tailwind-plus-issues/issues/1470))
|
||||
|
||||
## 2023-07-18
|
||||
|
||||
- Add 404 page
|
||||
- Sort imports and other formatting
|
||||
|
||||
## 2023-05-16
|
||||
|
||||
- Bump Next.js dependency
|
||||
|
||||
## 2023-05-15
|
||||
|
||||
- Replace Algolia DocSearch with basic built-in search ([#1395](https://github.com/tailwindlabs/tailwind-plus-issues/issues/1395))
|
||||
|
||||
## 2023-04-11
|
||||
|
||||
- Bump Next.js dependency
|
||||
|
||||
## 2023-03-29
|
||||
|
||||
- Bump Tailwind CSS and Prettier dependencies
|
||||
- Sort classes
|
||||
|
||||
## 2023-03-22
|
||||
|
||||
- Bump Headless UI dependency
|
||||
|
||||
## 2023-02-15
|
||||
|
||||
- Fix scroll restoration bug ([#1387](https://github.com/tailwindlabs/tailwind-plus-issues/issues/1387))
|
||||
|
||||
## 2023-02-02
|
||||
|
||||
- Bump Headless UI dependency
|
||||
|
||||
## 2023-01-16
|
||||
|
||||
- Fixes yarn compatibility ([#1403](https://github.com/tailwindlabs/tailwind-plus-issues/issues/1403))
|
||||
- Bump `zustand` dependency
|
||||
|
||||
## 2023-01-07
|
||||
|
||||
- Enable markdown table support in using `remark-gfm` plugin ([#1398](https://github.com/tailwindlabs/tailwind-plus-issues/issues/1398))
|
||||
- Fix SVG attribute casing ([#1402](https://github.com/tailwindlabs/tailwind-plus-issues/issues/1402))
|
||||
|
||||
## 2023-01-03
|
||||
|
||||
- Fix header disappearing in Safari ([#1392](https://github.com/tailwindlabs/tailwind-plus-issues/issues/1392))
|
||||
|
||||
## 2022-12-17
|
||||
|
||||
- Bump `mdx-annotations` dependency
|
||||
|
||||
## 2022-12-16
|
||||
|
||||
- Fix scroll jumping issue with Dialog in Safari ([#1387](https://github.com/tailwindlabs/tailwind-plus-issues/issues/1387))
|
||||
- Update "API" item in header navigation link to home page
|
||||
- Bump Headless UI dependency
|
||||
|
||||
## 2022-12-15
|
||||
|
||||
- Initial release
|
||||
129
LICENSE.md
Normal file
129
LICENSE.md
Normal file
@@ -0,0 +1,129 @@
|
||||
# Tailwind Plus License
|
||||
|
||||
## Personal License
|
||||
|
||||
Tailwind Labs Inc. grants you an on-going, non-exclusive license to use the Components and Templates.
|
||||
|
||||
The license grants permission to **one individual** (the Licensee) to access and use the Components and Templates.
|
||||
|
||||
You **can**:
|
||||
|
||||
- Use the Components and Templates to create unlimited End Products.
|
||||
- Modify the Components and Templates to create derivative components and templates. Those components and templates are subject to this license.
|
||||
- Use the Components and Templates to create unlimited End Products for unlimited Clients.
|
||||
- Use the Components and Templates to create End Products where the End Product is sold to End Users.
|
||||
- Use the Components and Templates to create End Products that are open source and freely available to End Users.
|
||||
|
||||
You **cannot**:
|
||||
|
||||
- Use the Components and Templates to create End Products that are designed to allow an End User to build their own End Products using the Components and Templates or derivatives of the Components and Templates.
|
||||
- Re-distribute the Components and Templates or derivatives of the Components and Templates separately from an End Product, neither in code or as design assets.
|
||||
- Share your access to the Components and Templates with any other individuals.
|
||||
- Use the Components and Templates to produce anything that may be deemed by Tailwind Labs Inc, in their sole and absolute discretion, to be competitive or in conflict with the business of Tailwind Labs Inc.
|
||||
|
||||
### Example usage
|
||||
|
||||
Examples of usage **allowed** by the license:
|
||||
|
||||
- Creating a personal website by yourself.
|
||||
- Creating a website or web application for a client that will be owned by that client.
|
||||
- Creating a commercial SaaS application (like an invoicing app for example) where end users have to pay a fee to use the application.
|
||||
- Creating a commercial self-hosted web application that is sold to end users for a one-time fee.
|
||||
- Creating a web application where the primary purpose is clearly not to simply re-distribute the components (like a conference organization app that uses the components for its UI for example) that is free and open source, where the source code is publicly available.
|
||||
|
||||
Examples of usage **not allowed** by the license:
|
||||
|
||||
- Creating a repository of your favorite Tailwind Plus components or templates (or derivatives based on Tailwind Plus components or templates) and publishing it publicly.
|
||||
- Creating a React or Vue version of Tailwind Plus and making it available either for sale or for free.
|
||||
- Create a Figma or Sketch UI kit based on the Tailwind Plus component designs.
|
||||
- Creating a "website builder" project where end users can build their own websites using components or templates included with or derived from Tailwind Plus.
|
||||
- Creating a theme, template, or project starter kit using the components or templates and making it available either for sale or for free.
|
||||
- Creating an admin panel tool (like [Laravel Nova](https://nova.laravel.com/) or [ActiveAdmin](https://activeadmin.info/)) that is made available either for sale or for free.
|
||||
|
||||
In simple terms, use Tailwind Plus for anything you like as long as it doesn't compete with Tailwind Plus.
|
||||
|
||||
### Personal License Definitions
|
||||
|
||||
Licensee is the individual who has purchased a Personal License.
|
||||
|
||||
Components and Templates are the source code and design assets made available to the Licensee after purchasing a Tailwind Plus license.
|
||||
|
||||
End Product is any artifact produced that incorporates the Components or Templates or derivatives of the Components or Templates.
|
||||
|
||||
End User is a user of an End Product.
|
||||
|
||||
Client is an individual or entity receiving custom professional services directly from the Licensee, produced specifically for that individual or entity. Customers of software-as-a-service products are not considered clients for the purpose of this document.
|
||||
|
||||
## Team License
|
||||
|
||||
Tailwind Labs Inc. grants you an on-going, non-exclusive license to use the Components and Templates.
|
||||
|
||||
The license grants permission for **up to 25 Employees and Contractors of the Licensee** to access and use the Components and Templates.
|
||||
|
||||
You **can**:
|
||||
|
||||
- Use the Components and Templates to create unlimited End Products.
|
||||
- Modify the Components and Templates to create derivative components and templates. Those components and templates are subject to this license.
|
||||
- Use the Components and Templates to create unlimited End Products for unlimited Clients.
|
||||
- Use the Components and Templates to create End Products where the End Product is sold to End Users.
|
||||
- Use the Components and Templates to create End Products that are open source and freely available to End Users.
|
||||
|
||||
You **cannot**:
|
||||
|
||||
- Use the Components or Templates to create End Products that are designed to allow an End User to build their own End Products using the Components or Templates or derivatives of the Components or Templates.
|
||||
- Re-distribute the Components or Templates or derivatives of the Components or Templates separately from an End Product.
|
||||
- Use the Components or Templates to create End Products that are the property of any individual or entity other than the Licensee or Clients of the Licensee.
|
||||
- Use the Components or Templates to produce anything that may be deemed by Tailwind Labs Inc, in their sole and absolute discretion, to be competitive or in conflict with the business of Tailwind Labs Inc.
|
||||
|
||||
### Example usage
|
||||
|
||||
Examples of usage **allowed** by the license:
|
||||
|
||||
- Creating a website for your company.
|
||||
- Creating a website or web application for a client that will be owned by that client.
|
||||
- Creating a commercial SaaS application (like an invoicing app for example) where end users have to pay a fee to use the application.
|
||||
- Creating a commercial self-hosted web application that is sold to end users for a one-time fee.
|
||||
- Creating a web application where the primary purpose is clearly not to simply re-distribute the components or templates (like a conference organization app that uses the components or a template for its UI for example) that is free and open source, where the source code is publicly available.
|
||||
|
||||
Examples of use **not allowed** by the license:
|
||||
|
||||
- Creating a repository of your favorite Tailwind Plus components or template (or derivatives based on Tailwind Plus components or templates) and publishing it publicly.
|
||||
- Creating a React or Vue version of Tailwind Plus and making it available either for sale or for free.
|
||||
- Creating a "website builder" project where end users can build their own websites using components or templates included with or derived from Tailwind Plus.
|
||||
- Creating a theme or template using the components or templates and making it available either for sale or for free.
|
||||
- Creating an admin panel tool (like [Laravel Nova](https://nova.laravel.com/) or [ActiveAdmin](https://activeadmin.info/)) that is made available either for sale or for free.
|
||||
- Creating any End Product that is not the sole property of either your company or a client of your company. For example your employees/contractors can't use your company Tailwind Plus license to build their own websites or side projects.
|
||||
|
||||
### Team License Definitions
|
||||
|
||||
Licensee is the business entity who has purchased a Team License.
|
||||
|
||||
Components and Templates are the source code and design assets made available to the Licensee after purchasing a Tailwind Plus license.
|
||||
|
||||
End Product is any artifact produced that incorporates the Components or Templates or derivatives of the Components or Templates.
|
||||
|
||||
End User is a user of an End Product.
|
||||
|
||||
Employee is a full-time or part-time employee of the Licensee.
|
||||
|
||||
Contractor is an individual or business entity contracted to perform services for the Licensee.
|
||||
|
||||
Client is an individual or entity receiving custom professional services directly from the Licensee, produced specifically for that individual or entity. Customers of software-as-a-service products are not considered clients for the purpose of this document.
|
||||
|
||||
## Enforcement
|
||||
|
||||
If you are found to be in violation of the license, access to your Tailwind Plus account will be terminated, and a refund may be issued at our discretion. When license violation is blatant and malicious (such as intentionally redistributing the Components or Templates through private warez channels), no refund will be issued.
|
||||
|
||||
The copyright of the Components and Templates is owned by Tailwind Labs Inc. You are granted only the permissions described in this license; all other rights are reserved. Tailwind Labs Inc. reserves the right to pursue legal remedies for any unauthorized use of the Components or Templates outside the scope of this license.
|
||||
|
||||
## Liability
|
||||
|
||||
Tailwind Labs Inc.’s liability to you for costs, damages, or other losses arising from your use of the Components or Templates — including third-party claims against you — is limited to a refund of your license fee. Tailwind Labs Inc. may not be held liable for any consequential damages related to your use of the Components or Templates.
|
||||
|
||||
This Agreement is governed by the laws of the Province of Ontario and the applicable laws of Canada. Legal proceedings related to this Agreement may only be brought in the courts of Ontario. You agree to service of process at the e-mail address on your original order.
|
||||
|
||||
## Questions?
|
||||
|
||||
Unsure which license you need, or unsure if your use case is covered by our licenses?
|
||||
|
||||
Email us at [support@tailwindcss.com](mailto:support@tailwindcss.com) with your questions.
|
||||
46
README.md
Normal file
46
README.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# Protocol
|
||||
|
||||
Protocol is a [Tailwind Plus](https://tailwindcss.com/plus) site template built using [Tailwind CSS](https://tailwindcss.com) and [Next.js](https://nextjs.org).
|
||||
|
||||
## Getting started
|
||||
|
||||
To get started with this template, first install the npm dependencies:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
Next, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Finally, open [http://localhost:3000](http://localhost:3000) in your browser to view the website.
|
||||
|
||||
## Customizing
|
||||
|
||||
You can start editing this template by modifying the files in the `/src` folder. The site will auto-update as you edit these files.
|
||||
|
||||
## Global search
|
||||
|
||||
This template includes a global search that's powered by the [FlexSearch](https://github.com/nextapps-de/flexsearch) library. It's available by clicking the search input or by using the `⌘K` shortcut.
|
||||
|
||||
This feature requires no configuration, and works out of the box by automatically scanning your documentation pages to build its index. You can adjust the search parameters by editing the `/src/mdx/search.mjs` file.
|
||||
|
||||
## License
|
||||
|
||||
This site template is a commercial product and is licensed under the [Tailwind Plus license](https://tailwindcss.com/plus/license).
|
||||
|
||||
## Learn more
|
||||
|
||||
To learn more about the technologies used in this site template, see the following resources:
|
||||
|
||||
- [Tailwind CSS](https://tailwindcss.com/docs) - the official Tailwind CSS documentation
|
||||
- [Next.js](https://nextjs.org/docs) - the official Next.js documentation
|
||||
- [Headless UI](https://headlessui.dev) - the official Headless UI documentation
|
||||
- [Framer Motion](https://www.framer.com/docs/) - the official Framer Motion documentation
|
||||
- [MDX](https://mdxjs.com/) - the official MDX documentation
|
||||
- [Algolia Autocomplete](https://www.algolia.com/doc/ui-libraries/autocomplete/introduction/what-is-autocomplete/) - the official Algolia Autocomplete documentation
|
||||
- [FlexSearch](https://github.com/nextapps-de/flexsearch) - the official FlexSearch documentation
|
||||
- [Zustand](https://docs.pmnd.rs/zustand/getting-started/introduction) - the official Zustand documentation
|
||||
10
mdx-components.tsx
Normal file
10
mdx-components.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { type MDXComponents } from 'mdx/types'
|
||||
|
||||
import * as mdxComponents from '@/components/mdx'
|
||||
|
||||
export function useMDXComponents(components: MDXComponents) {
|
||||
return {
|
||||
...components,
|
||||
...mdxComponents,
|
||||
}
|
||||
}
|
||||
24
next.config.mjs
Normal file
24
next.config.mjs
Normal file
@@ -0,0 +1,24 @@
|
||||
import nextMDX from '@next/mdx'
|
||||
|
||||
import { recmaPlugins } from './src/mdx/recma.mjs'
|
||||
import { rehypePlugins } from './src/mdx/rehype.mjs'
|
||||
import { remarkPlugins } from './src/mdx/remark.mjs'
|
||||
import withSearch from './src/mdx/search.mjs'
|
||||
|
||||
const withMDX = nextMDX({
|
||||
options: {
|
||||
remarkPlugins,
|
||||
rehypePlugins,
|
||||
recmaPlugins,
|
||||
},
|
||||
})
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
pageExtensions: ['js', 'jsx', 'ts', 'tsx', 'mdx'],
|
||||
outputFileTracingIncludes: {
|
||||
'/**/*': ['./src/app/**/*.mdx'],
|
||||
},
|
||||
}
|
||||
|
||||
export default withSearch(withMDX(nextConfig))
|
||||
9292
package-lock.json
generated
Normal file
9292
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
57
package.json
Normal file
57
package.json
Normal file
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"name": "tailwind-plus-protocol",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"browserslist": "defaults, not ie <= 11",
|
||||
"dependencies": {
|
||||
"@algolia/autocomplete-core": "^1.19.2",
|
||||
"@headlessui/react": "^2.2.6",
|
||||
"@mdx-js/loader": "^3.1.0",
|
||||
"@mdx-js/react": "^3.1.0",
|
||||
"@next/mdx": "^15",
|
||||
"@sindresorhus/slugify": "^2.2.1",
|
||||
"@tailwindcss/postcss": "^4.1.11",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@types/mdx": "^2.0.13",
|
||||
"@types/react-highlight-words": "^0.20.0",
|
||||
"acorn": "^8.15.0",
|
||||
"clsx": "^2.1.1",
|
||||
"fast-glob": "^3.3.3",
|
||||
"flexsearch": "^0.8.205",
|
||||
"framer-motion": "^12.23.11",
|
||||
"mdast-util-to-string": "^4.0.0",
|
||||
"mdx-annotations": "^0.1.4",
|
||||
"next": "^15",
|
||||
"next-themes": "^0.4.6",
|
||||
"prettier-plugin-organize-imports": "^4.2.0",
|
||||
"react": "^19",
|
||||
"react-dom": "^19",
|
||||
"react-highlight-words": "^0.21.0",
|
||||
"remark": "^15.0.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-mdx": "^3.1.0",
|
||||
"shiki": "^0.14.7",
|
||||
"simple-functional-loader": "^1.2.1",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"typescript": "^5.8.3",
|
||||
"unist-util-filter": "^5.0.1",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"zustand": "^5.0.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.1.0",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9.32.0",
|
||||
"eslint-config-next": "^15",
|
||||
"prettier": "^3.6.2",
|
||||
"prettier-plugin-tailwindcss": "^0.6.14",
|
||||
"sharp": "0.34.3"
|
||||
}
|
||||
}
|
||||
5
postcss.config.js
Normal file
5
postcss.config.js
Normal file
@@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
}
|
||||
7
prettier.config.js
Normal file
7
prettier.config.js
Normal file
@@ -0,0 +1,7 @@
|
||||
/** @type {import('prettier').Options} */
|
||||
module.exports = {
|
||||
singleQuote: true,
|
||||
semi: false,
|
||||
plugins: ['prettier-plugin-organize-imports', 'prettier-plugin-tailwindcss'],
|
||||
tailwindStylesheet: './src/styles/tailwind.css',
|
||||
}
|
||||
BIN
src/app/favicon.ico
Normal file
BIN
src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
3
src/app/guides/client/page.mdx
Normal file
3
src/app/guides/client/page.mdx
Normal file
@@ -0,0 +1,3 @@
|
||||
# Client API
|
||||
|
||||
Under construction.
|
||||
2
src/app/guides/plugins/page.mdx
Normal file
2
src/app/guides/plugins/page.mdx
Normal file
@@ -0,0 +1,2 @@
|
||||
# Plugins
|
||||
Plugins are not implemented yet.
|
||||
100
src/app/guides/quickstart/page.mdx
Normal file
100
src/app/guides/quickstart/page.mdx
Normal file
@@ -0,0 +1,100 @@
|
||||
export const metadata = {
|
||||
title: 'Quickstart',
|
||||
description:
|
||||
'This guide will help you setup a new Drop instance, and how to use it for development purposes.',
|
||||
}
|
||||
|
||||
# Quickstart
|
||||
|
||||
This guide will help you setup a small Drop instance to develop against on your local development machine. {{ className: 'lead' }}
|
||||
|
||||
<Note>
|
||||
This setup is not intended for deployment. If you are looking to setup a Drop
|
||||
instance for actual use, check out the [official
|
||||
docs](https://docs.droposs.org/docs/guides/quickstart).
|
||||
</Note>
|
||||
|
||||
## Install Docker
|
||||
|
||||
Docker is required to start the database, and optionally run a pre-built version of Drop. You should follow the Docker guide for your system on the [official Docker documentation](https://docs.docker.com/engine/install/).
|
||||
|
||||
## Option 1: Full Docker setup
|
||||
|
||||
The easier and fastest option is to spin up an instance of Drop within a Docker container. While you can't develop against Drop's `nightly` version, it's fast and headache-free.
|
||||
|
||||
This `compose.yaml` requires no additional configuration (other than the Drop setup wizard) to start up:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:14-alpine
|
||||
ports:
|
||||
- 5432:5432
|
||||
healthcheck:
|
||||
test: pg_isready -d drop -U drop
|
||||
interval: 30s
|
||||
timeout: 60s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
volumes:
|
||||
- ./db:/var/lib/postgresql/data
|
||||
environment:
|
||||
- POSTGRES_PASSWORD=drop
|
||||
- POSTGRES_USER=drop
|
||||
- POSTGRES_DB=drop
|
||||
drop:
|
||||
image: ghcr.io/drop-oss/drop:latest
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- 3000:3000
|
||||
volumes:
|
||||
- ./library:/library
|
||||
- ./data:/data
|
||||
environment:
|
||||
- DATABASE_URL=postgres://drop:drop@postgres:5432/drop
|
||||
- EXTERNAL_URL=http://localhost:3000
|
||||
```
|
||||
|
||||
## Option 2: From source (`nightly`)
|
||||
|
||||
You can alternatively run Drop from source, and use Docker for the database.
|
||||
|
||||
### Clone the GitHub repository
|
||||
|
||||
First, clone the Drop repository:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Drop-OSS/drop.git
|
||||
```
|
||||
|
||||
### Checkout your preferred branch
|
||||
|
||||
By default, Git selects the `develop` branch, which is what `nightly` is released from. You may want to checkout a tag (e.g. `v0.3.2`), or the `main` branch (latest stable).
|
||||
|
||||
### Install dependencies
|
||||
|
||||
We use the `yarn` package manager. Install dependencies with:
|
||||
|
||||
```bash
|
||||
yarn
|
||||
```
|
||||
|
||||
### Start the database
|
||||
|
||||
Open another shell (or the same one, if you're okay with running the database in the background), and `cd` into the `dev-tools` directory. Then, start the database:
|
||||
|
||||
```bash
|
||||
docker compose up [-d]
|
||||
```
|
||||
|
||||
(`-d` if you want to run it in the background)
|
||||
|
||||
### Start Drop
|
||||
|
||||
Then, move back to the root of the repository, and then start Drop:
|
||||
|
||||
```bash
|
||||
yarn dev
|
||||
```
|
||||
103
src/app/guides/web/page.mdx
Normal file
103
src/app/guides/web/page.mdx
Normal file
@@ -0,0 +1,103 @@
|
||||
export const metadata = {
|
||||
title: 'Web API',
|
||||
description:
|
||||
"On this page, we'll get started with the Web API and generating an API token.",
|
||||
}
|
||||
|
||||
# Web API
|
||||
|
||||
The Web API is used by the frontend to render and perform all its actions. Every function in the frontend is available through the Web API, except API token & client management for security reasons. {{ className: 'lead' }}
|
||||
|
||||
## Tokens
|
||||
|
||||
The Web API uses Bearer tokens to authenticate. They are called API tokens for this documentation. There are two kinds of API tokens:
|
||||
|
||||
- **System**: System tokens operate on the same level as "admins" in the Drop UI. They manage most things, can create users, and configure the instance. System API tokens can only be created by admins.
|
||||
- **User**: User tokens operate **as a user**, essentially impersonating them. User tokens generally have only read access, except to user-specific resources like libraries/collections.
|
||||
|
||||
Each token has a list of ACL (access control list) permissions. Each kind of token has different ACLs (for the different resources they can access). If system and user-level ACLs are referenced together, they are prefixed by `system:` and `user:`.
|
||||
|
||||
ACL permissions are defined by the user when they create the API token. As a third-party developer, you can either list the necessary ACLs in your documentation or application, or Drop supports pre-filling the API token creation form by creating a special URL.
|
||||
|
||||
### API token creation URL
|
||||
|
||||
The 'payload' for the API token creation is a Base64 encoded JSON object that encodes the following information:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "My Application's Token",
|
||||
"acls": [
|
||||
"read",
|
||||
"store:read",
|
||||
"object:read",
|
||||
"object:update",
|
||||
"object:delete",
|
||||
"clients:read",
|
||||
"clients:revoke",
|
||||
"..."
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
You can use the following script in your browser's console to generate the payload:
|
||||
|
||||
```javascript
|
||||
btoa(JSON.stringify({ ... }))
|
||||
```
|
||||
|
||||
With the payload, you can create a URL, depending on the kind of token you want to create:
|
||||
|
||||
- **System**: `http[s]://[Drop instance URL]/admin/settings/tokens?payload={ payload }`
|
||||
- **User**: `http[s]://[Drop instance URL]/account/tokens?payload={ ... }`
|
||||
|
||||
When the user visits the URL, it will automatically open the token generation modal with your values pre-filled.
|
||||
|
||||
## Authentication
|
||||
|
||||
To authenticate requests to the Drop API, use your token in the Authorization header, like so:
|
||||
|
||||
```
|
||||
Authorization: Bearer {token}
|
||||
```
|
||||
|
||||
## Checking your ACLs
|
||||
|
||||
<Row>
|
||||
<Col>
|
||||
|
||||
To verify the ACLs granted to your token, you can use the `/api/v1/token` endpoint. It returns a JSON array of all the ACLs granted to your token.
|
||||
|
||||
The ACLs are in global format, which means they're prefixed with `user:` or `system:`.
|
||||
|
||||
</Col>
|
||||
<Col sticky>
|
||||
|
||||
<CodeGroup title="Request" tag="GET" label="/api/v1/token">
|
||||
|
||||
```bash {{ title: 'cURL' }}
|
||||
curl -G http://localhost:3000/api/v1/token \
|
||||
-H "Authorization: Bearer {token}"
|
||||
```
|
||||
|
||||
```javascript
|
||||
const response = await fetch("http://localhost:3000/api/v1/token", {
|
||||
headers: {
|
||||
Authorization: "Bearer {token}"
|
||||
}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
console.log(data) // [ "user:read", "user:store:read" ]
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
```json {{ title: 'Response' }}
|
||||
[
|
||||
"user:read",
|
||||
"user:store:read"
|
||||
]
|
||||
```
|
||||
|
||||
</Col>
|
||||
</Row>
|
||||
42
src/app/layout.tsx
Normal file
42
src/app/layout.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import glob from 'fast-glob'
|
||||
import { type Metadata } from 'next'
|
||||
|
||||
import { Providers } from '@/app/providers'
|
||||
import { Layout } from '@/components/Layout'
|
||||
import { type Section } from '@/components/SectionProvider'
|
||||
|
||||
import '@/styles/tailwind.css'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
template: '%s - Drop API Reference',
|
||||
default: 'Drop API Reference',
|
||||
},
|
||||
}
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
let pages = await glob('**/*.mdx', { cwd: 'src/app' })
|
||||
let allSectionsEntries = (await Promise.all(
|
||||
pages.map(async (filename) => [
|
||||
'/' + filename.replace(/(^|\/)page\.mdx$/, ''),
|
||||
(await import(`./${filename}`)).sections,
|
||||
]),
|
||||
)) as Array<[string, Array<Section>]>
|
||||
let allSections = Object.fromEntries(allSectionsEntries)
|
||||
|
||||
return (
|
||||
<html lang="en" className="h-full" suppressHydrationWarning>
|
||||
<body className="flex min-h-full bg-white antialiased dark:bg-zinc-900">
|
||||
<Providers>
|
||||
<div className="w-full">
|
||||
<Layout allSections={allSections}>{children}</Layout>
|
||||
</div>
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
24
src/app/not-found.tsx
Normal file
24
src/app/not-found.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Button } from '@/components/Button'
|
||||
import { HeroPattern } from '@/components/HeroPattern'
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<>
|
||||
<HeroPattern />
|
||||
<div className="mx-auto flex h-full max-w-xl flex-col items-center justify-center py-16 text-center">
|
||||
<p className="text-sm font-semibold text-zinc-900 dark:text-white">
|
||||
404
|
||||
</p>
|
||||
<h1 className="mt-2 text-2xl font-bold text-zinc-900 dark:text-white">
|
||||
Page not found
|
||||
</h1>
|
||||
<p className="mt-2 text-base text-zinc-600 dark:text-zinc-400">
|
||||
Sorry, we couldn’t find the page you’re looking for.
|
||||
</p>
|
||||
<Button href="/" arrow="right" className="mt-8">
|
||||
Back to docs
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
48
src/app/page.mdx
Normal file
48
src/app/page.mdx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Guides } from '@/components/Guides'
|
||||
import { Resources } from '@/components/Resources'
|
||||
import { HeroPattern } from '@/components/HeroPattern'
|
||||
|
||||
export const metadata = {
|
||||
title: 'API Documentation',
|
||||
description:
|
||||
'Learn everything there is to know about the Protocol API and integrate Protocol into your product.',
|
||||
}
|
||||
|
||||
export const sections = [
|
||||
{ title: 'Guides', id: 'guides' },
|
||||
{ title: 'Resources', id: 'resources' },
|
||||
]
|
||||
|
||||
<HeroPattern />
|
||||
|
||||
# Drop API Documentation
|
||||
|
||||
Use the Drop API to interact with Drop-instances, on a per-instance level, to build third-party integrations and applications. Drop is built with an API-first development model, which means the API can do nearly anything users can, and more. {{ className: 'lead' }}
|
||||
|
||||
<div className="not-prose mt-6 mb-16 flex gap-3">
|
||||
<Button href="/quickstart" arrow="right">
|
||||
<>Quickstart</>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
## Getting started {{ anchor: false }}
|
||||
|
||||
To get started, you will need to decide on the type of application you are building. Drop has two sets of APIs:
|
||||
|
||||
- **Web API**: interaction with most of the application, used for management and integration.
|
||||
- **Client API**: a specific subset of APIs, plus additional APIs used to do client-only actions, like download content and networking features.
|
||||
|
||||
Then, use the associated authentication mechanism to authenticate with Drop.
|
||||
|
||||
<div className="not-prose mt-6 mb-16 flex gap-3">
|
||||
<Button href="/quickstart" arrow="right" variant="outline">
|
||||
<>Generate a Web API token</>
|
||||
</Button>
|
||||
<Button href="/quickstart" arrow="right" variant="outline">
|
||||
<>Authenticate as a client</>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Guides />
|
||||
|
||||
<Resources />
|
||||
37
src/app/providers.tsx
Normal file
37
src/app/providers.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
'use client'
|
||||
|
||||
import { ThemeProvider, useTheme } from 'next-themes'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
function ThemeWatcher() {
|
||||
let { resolvedTheme, setTheme } = useTheme()
|
||||
|
||||
useEffect(() => {
|
||||
let media = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
|
||||
function onMediaChange() {
|
||||
let systemTheme = media.matches ? 'dark' : 'light'
|
||||
if (resolvedTheme === systemTheme) {
|
||||
setTheme('system')
|
||||
}
|
||||
}
|
||||
|
||||
onMediaChange()
|
||||
media.addEventListener('change', onMediaChange)
|
||||
|
||||
return () => {
|
||||
media.removeEventListener('change', onMediaChange)
|
||||
}
|
||||
}, [resolvedTheme, setTheme])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<ThemeProvider attribute="class" disableTransitionOnChange>
|
||||
<ThemeWatcher />
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
3
src/app/web/objects/page.mdx
Normal file
3
src/app/web/objects/page.mdx
Normal file
@@ -0,0 +1,3 @@
|
||||
# Objects
|
||||
|
||||
Under construction.
|
||||
499
src/app/web/users/page.mdx
Normal file
499
src/app/web/users/page.mdx
Normal file
@@ -0,0 +1,499 @@
|
||||
export const metadata = {
|
||||
title: 'Users',
|
||||
description:
|
||||
"On this page, we'll dive into users and how to interact with them through the Web API.",
|
||||
}
|
||||
|
||||
# Users
|
||||
|
||||
Users, like most other web applications, are usually owned by a single person, and are used to categorize their activities and data. As Drop is intended to be a private platform, most requests need to be authenticated by a user first. {{ className: 'lead' }}
|
||||
|
||||
## The user model
|
||||
|
||||
The user model contains most of the profile information about a user, including their username and profile picture. User endpoints should be used to fetch, interact and manage users, and update information about them.
|
||||
|
||||
### Properties
|
||||
|
||||
<Properties>
|
||||
<Property name="id" type="string">
|
||||
UUID of the user. Used internally to refer to this user.
|
||||
</Property>
|
||||
<Property name="username" type="string">
|
||||
Unique alphanumeric username (with length limitations), used for
|
||||
character-restricted representations of users (like IGNs).
|
||||
</Property>
|
||||
<Property name="displayName" type="string">
|
||||
Unconstrained preferred name of this user. Used when possible.
|
||||
</Property>
|
||||
<Property name="email" type="string">
|
||||
Email of this user. Not currently used, but collected in case of future
|
||||
functionality.
|
||||
</Property>
|
||||
<Property name="profilePictureObjectId" type="string">
|
||||
The object ID of this user's profile picture. Check out the
|
||||
[Objects](../objects) API on how to fetch the image.
|
||||
</Property>
|
||||
<Property name="admin" type="boolean">
|
||||
Admin flag. If enabled, this user has access to system-level endpoints.
|
||||
</Property>
|
||||
<Property name="enabled" type="boolean">
|
||||
If this user is currently enabled, and can be access.
|
||||
</Property>
|
||||
</Properties>
|
||||
|
||||
---
|
||||
|
||||
## Fetch current user {{ tag: 'GET', label: '/api/v1/user', apilevel: "user", acl: "read" }}
|
||||
|
||||
<Row>
|
||||
<Col>
|
||||
|
||||
This endpoint fetches the current user. If you are authenticated, it returns the user. If you are not, it returns `null`.
|
||||
|
||||
</Col>
|
||||
<Col sticky>
|
||||
|
||||
<CodeGroup title="Request" tag="GET" label="/api/v1/user">
|
||||
|
||||
```bash {{ title: 'cURL' }}
|
||||
curl -G http://localhost:3000/api/v1/user \
|
||||
-H "Authorization: Bearer {token}"
|
||||
```
|
||||
|
||||
```js
|
||||
const response = await fetch("http://localhost:3000/api/v1/user", {
|
||||
headers: {
|
||||
Authorization: "Bearer {token}"
|
||||
}
|
||||
});
|
||||
|
||||
const user = await response.json();
|
||||
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
```json {{ title: 'Response' }}
|
||||
{
|
||||
"id": "d2b487be-f796-4259-83d9-6ec27ad2947e",
|
||||
"username": "exampleusername",
|
||||
"email": "example@example.com",
|
||||
"displayName": "Example Username",
|
||||
"profilePictureObjectId": "1d5f7aeb-df75-439c-a2e9-b875af4724d2",
|
||||
"admin": true,
|
||||
"enabled": true
|
||||
}
|
||||
```
|
||||
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
---
|
||||
|
||||
## List all users {{ tag: 'GET', label: '/api/v1/admin/users', apilevel: "system", acl: "user:read" }}
|
||||
|
||||
<Row>
|
||||
<Col>
|
||||
|
||||
This endpoint fetches all users on this instance.
|
||||
|
||||
</Col>
|
||||
<Col sticky>
|
||||
|
||||
<CodeGroup title="Request" tag="GET" label="/api/v1/admin/users">
|
||||
|
||||
```bash {{ title: 'cURL' }}
|
||||
curl -G http://localhost:3000/api/v1/admin/users \
|
||||
-H "Authorization: Bearer {token}"
|
||||
```
|
||||
|
||||
```js
|
||||
const response = await fetch("http://localhost:3000/api/v1/admin/users", {
|
||||
headers: {
|
||||
Authorization: "Bearer {token}"
|
||||
}
|
||||
});
|
||||
|
||||
const users = await response.json();
|
||||
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
```json {{ title: 'Response' }}
|
||||
[
|
||||
{
|
||||
"id": "d2b487be-f796-4259-83d9-6ec27ad2947e",
|
||||
"username": "exampleusername",
|
||||
"email": "example@example.com",
|
||||
"displayName": "Example Username",
|
||||
"profilePictureObjectId": "1d5f7aeb-df75-439c-a2e9-b875af4724d2",
|
||||
"admin": true,
|
||||
"enabled": true
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
---
|
||||
|
||||
## Fetch user {{ tag: 'GET', label: '/api/v1/admin/users/:id', apilevel: "system", acl: "user:read" }}
|
||||
|
||||
<Row>
|
||||
<Col>
|
||||
|
||||
This endpoint fetches a user by ID.
|
||||
|
||||
### Route parameters
|
||||
|
||||
<Properties>
|
||||
<Property name="id" type="string">
|
||||
ID of user you want to fetch.
|
||||
</Property>
|
||||
</Properties>
|
||||
|
||||
</Col>
|
||||
<Col sticky>
|
||||
|
||||
<CodeGroup title="Request" tag="GET" label="/api/v1/admin/users/:id">
|
||||
|
||||
```bash {{ title: 'cURL' }}
|
||||
curl -G http://localhost:3000/api/v1/admin/users/{id} \
|
||||
-H "Authorization: Bearer {token}"
|
||||
```
|
||||
|
||||
```js
|
||||
const response = await fetch("http://localhost:3000/api/v1/admin/users/{id}", {
|
||||
headers: {
|
||||
Authorization: "Bearer {token}"
|
||||
}
|
||||
});
|
||||
|
||||
const user = await response.json();
|
||||
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
```json {{ title: 'Response' }}
|
||||
{
|
||||
"id": "d2b487be-f796-4259-83d9-6ec27ad2947e",
|
||||
"username": "exampleusername",
|
||||
"email": "example@example.com",
|
||||
"displayName": "Example Username",
|
||||
"profilePictureObjectId": "1d5f7aeb-df75-439c-a2e9-b875af4724d2",
|
||||
"admin": true,
|
||||
"enabled": true
|
||||
}
|
||||
```
|
||||
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
---
|
||||
|
||||
## Delete user {{ tag: 'DELETE', label: '/api/v1/admin/users/:id', apilevel: "system", acl: "user:delete" }}
|
||||
|
||||
<Row>
|
||||
<Col>
|
||||
|
||||
This endpoint deletes a user by ID
|
||||
|
||||
### Route parameters
|
||||
|
||||
<Properties>
|
||||
<Property name="id" type="string">
|
||||
ID of user you want to delete.
|
||||
</Property>
|
||||
</Properties>
|
||||
|
||||
</Col>
|
||||
<Col sticky>
|
||||
|
||||
<CodeGroup title="Request" tag="DELETE" label="/api/v1/admin/users/:id">
|
||||
|
||||
```bash {{ title: 'cURL' }}
|
||||
curl -X DELETE http://localhost:3000/api/v1/admin/users/{id} \
|
||||
-H "Authorization: Bearer {token}"
|
||||
```
|
||||
|
||||
```js
|
||||
const response = await fetch("http://localhost:3000/api/v1/admin/users/{id}", {
|
||||
headers: {
|
||||
Authorization: "Bearer {token}"
|
||||
},
|
||||
method: "DELETE"
|
||||
});
|
||||
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
``` {{ title: 'Response' }}
|
||||
No response returned.
|
||||
```
|
||||
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
---
|
||||
|
||||
## Authentication mechanisms
|
||||
|
||||
With these endpoint above, you might be wondering, "well, how do you actually _create_ a user?". That job, in Drop, is given to authentication mechanism. Each user can multiple, but at least one, authentication "mechanism" attached to their account. These may be (non-exhaustive):
|
||||
|
||||
- **Simple**: username & password combination
|
||||
- **OIDC**: OpenID Connect, allowing for Single Sign On (SSO),
|
||||
|
||||
These authentication mechanisms are also responsible for _creating_ the user, since each user must have at least one mechanism active.
|
||||
|
||||
---
|
||||
|
||||
## Fetch authentication mechanisms {{ tag: 'GET', label: '/api/v1/admin/auth', apilevel: "system", acl: "auth:read" }}
|
||||
|
||||
<Row>
|
||||
<Col>
|
||||
|
||||
This endpoint returns all authentication mechanisms, and a configuration object for each if enabled, otherwise `undefined`. For mechanisms that require no authentication, the object is simply `true` or `false` depending on whether or not it's enabled.
|
||||
|
||||
</Col>
|
||||
<Col sticky>
|
||||
|
||||
<CodeGroup title="Request" tag="GET" label="/api/v1/admin/auth">
|
||||
|
||||
```bash {{ title: 'cURL' }}
|
||||
curl -G http://localhost:3000/api/v1/admin/auth \
|
||||
-H "Authorization: Bearer {token}"
|
||||
```
|
||||
|
||||
```js
|
||||
const response = await fetch("http://localhost:3000/api/v1/admin/auth", {
|
||||
headers: {
|
||||
Authorization: "Bearer {token}"
|
||||
}
|
||||
});
|
||||
|
||||
const authenticationMechanisms = await response.json();
|
||||
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
```json {{ title: 'Response' }}
|
||||
{
|
||||
"Simple": true,
|
||||
"OpenID": {
|
||||
"authorizationUrl": "https://my.oidc.app/application/o/authorize/",
|
||||
"scopes": "openid, email, profile, offline_access",
|
||||
"adminGroup": "admin",
|
||||
"usernameClaim": "preferred_username",
|
||||
"externalUrl": "http://localhost:3000"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
---
|
||||
|
||||
## Simple authentication
|
||||
|
||||
Simple authentication is the default and fallback in Drop. It relies on a simple username & password pair to log in. Users are created through a concept of invites.
|
||||
|
||||
---
|
||||
|
||||
## Fetch invitations {{ tag: 'GET', label: '/api/v1/admin/invitation', apilevel: "system", acl: "auth:simple:invitation:read" }}
|
||||
|
||||
<Row>
|
||||
<Col>
|
||||
|
||||
This endpoint is used to fetch all invitations for an instance.
|
||||
|
||||
### Response
|
||||
<Properties>
|
||||
<Property name="id" type="string">
|
||||
Invitation ID, used as a secret for the invite.
|
||||
</Property>
|
||||
<Property name="isAdmin" type="boolean">
|
||||
Whether or not this invite will create the user as an admin.
|
||||
</Property>
|
||||
<Property name="username" type="string">
|
||||
Optional, will enforce a username on the account.
|
||||
</Property>
|
||||
<Property name="email" type="string">
|
||||
Optional, will enforce an email on the account.
|
||||
</Property>
|
||||
<Property name="expires" type="timestamp">
|
||||
Timestamp for when this invite will expire. If "Never" is selected in the UI, the invitation actually expires in 100 years.
|
||||
</Property>
|
||||
<Property name="inviteUrl" type="timestamp">
|
||||
A link to the frontend page that will allow a user to register through a web browser. Uses the EXTERNAL_URL of the instance.
|
||||
</Property>
|
||||
</Properties>
|
||||
|
||||
</Col>
|
||||
<Col sticky>
|
||||
|
||||
<CodeGroup title="Request" tag="GET" label="/api/v1/admin/invitation">
|
||||
|
||||
```bash {{ title: 'cURL' }}
|
||||
curl -G http://localhost:3000/api/v1/admin/invitation \
|
||||
-H "Authorization: Bearer {token}"
|
||||
```
|
||||
|
||||
```js
|
||||
const response = await fetch("http://localhost:3000/api/v1/admin/invitation", {
|
||||
headers: {
|
||||
Authorization: "Bearer {token}"
|
||||
}
|
||||
});
|
||||
|
||||
const invitations = await response.json();
|
||||
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
```json {{ title: 'Response' }}
|
||||
[
|
||||
{
|
||||
"id": "418d2c3f-8029-409e-b47c-0c3b158be3e1",
|
||||
"isAdmin": true,
|
||||
"username": "testusername",
|
||||
"email": "example@example.com",
|
||||
"expires": "2025-09-23T06:37:19.047Z",
|
||||
"inviteUrl": "http://localhost:3000/auth/register?id=418d2c3f-8029-409e-b47c-0c3b158be3e1"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
---
|
||||
|
||||
## Create invitation {{ tag: 'POST', label: '/api/v1/admin/invitation', apilevel: "system", acl: "auth:simple:invitation:new" }}
|
||||
|
||||
<Row>
|
||||
<Col>
|
||||
|
||||
This endpoint is used to create an invitation.
|
||||
|
||||
### Required parameters
|
||||
<Properties>
|
||||
<Property name="expires" type="timestamp">
|
||||
Timestamp for when this invite will expire, in the format of an ISO timestamp. Typically, you can convert a JavaScript Date object.
|
||||
</Property>
|
||||
</Properties>
|
||||
|
||||
### Optional parameters
|
||||
<Properties>
|
||||
<Property name="username" type="string">
|
||||
Username to enforce on the invitation.
|
||||
</Property>
|
||||
<Property name="email" type="string">
|
||||
Email to enforce on the invitation.
|
||||
</Property>
|
||||
<Property name="isAdmin" type="boolean">
|
||||
Whether or not to make this an admin invitation, defaults to false.
|
||||
</Property>
|
||||
</Properties>
|
||||
|
||||
</Col>
|
||||
<Col sticky>
|
||||
|
||||
<CodeGroup title="Request" tag="POST" label="/api/v1/admin/invitation">
|
||||
|
||||
```bash {{ title: 'cURL' }}
|
||||
curl -X POST http://localhost:3000/api/v1/admin/invitation \
|
||||
-H "Authorization: Bearer {token}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{ ... }"
|
||||
```
|
||||
|
||||
```js
|
||||
const response = await fetch("http://localhost:3000/api/v1/admin/invitation", {
|
||||
headers: {
|
||||
Authorization: "Bearer {token}"
|
||||
},
|
||||
body: {
|
||||
expires: new Date(), // Will probably want longer than an expiry time of "now"
|
||||
isAdmin: true
|
||||
},
|
||||
method: "POST"
|
||||
});
|
||||
|
||||
const invitation = await response.json();
|
||||
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
```json {{ title: 'Response' }}
|
||||
[
|
||||
{
|
||||
"id": "418d2c3f-8029-409e-b47c-0c3b158be3e1",
|
||||
"isAdmin": true,
|
||||
"username": "testusername",
|
||||
"email": "example@example.com",
|
||||
"expires": "2025-09-23T06:37:19.047Z",
|
||||
"inviteUrl": "http://localhost:3000/auth/register?id=418d2c3f-8029-409e-b47c-0c3b158be3e1"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
---
|
||||
|
||||
## Delete an invitation {{ tag: 'DELETE', label: '/api/v1/admin/invitation', apilevel: "system", acl: "auth:simple:invitation:delete" }}
|
||||
|
||||
<Row>
|
||||
<Col>
|
||||
|
||||
This endpoint is used to delete an invitation.
|
||||
|
||||
### Required parameters
|
||||
<Properties>
|
||||
<Property name="id" type="string">
|
||||
ID of invitation to delete.
|
||||
</Property>
|
||||
</Properties>
|
||||
|
||||
</Col>
|
||||
<Col sticky>
|
||||
|
||||
<CodeGroup title="Request" tag="DELETE" label="/api/v1/admin/invitation">
|
||||
|
||||
```bash {{ title: 'cURL' }}
|
||||
curl -X DELETE http://localhost:3000/api/v1/admin/invitation \
|
||||
-H "Authorization: Bearer {token}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{ ... }"
|
||||
```
|
||||
|
||||
```js
|
||||
const response = await fetch("http://localhost:3000/api/v1/admin/invitation", {
|
||||
headers: {
|
||||
Authorization: "Bearer {token}"
|
||||
},
|
||||
body: {
|
||||
id: "..."
|
||||
},
|
||||
method: "POST"
|
||||
});
|
||||
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
``` {{ title: 'Response' }}
|
||||
No response.
|
||||
```
|
||||
|
||||
</Col>
|
||||
</Row>
|
||||
82
src/components/Button.tsx
Normal file
82
src/components/Button.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import clsx from 'clsx'
|
||||
import Link from 'next/link'
|
||||
|
||||
function ArrowIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true" {...props}>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="m11.5 6.5 3 3.5m0 0-3 3.5m3-3.5h-9"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
const variantStyles = {
|
||||
primary:
|
||||
'rounded-full bg-zinc-900 py-1 px-3 text-white hover:bg-zinc-700 dark:bg-blue-400/10 dark:text-blue-400 dark:ring-1 dark:ring-inset dark:ring-blue-400/20 dark:hover:bg-blue-400/10 dark:hover:text-blue-300 dark:hover:ring-blue-300',
|
||||
secondary:
|
||||
'rounded-full bg-zinc-100 py-1 px-3 text-zinc-900 hover:bg-zinc-200 dark:bg-zinc-800/40 dark:text-zinc-400 dark:ring-1 dark:ring-inset dark:ring-zinc-800 dark:hover:bg-zinc-800 dark:hover:text-zinc-300',
|
||||
filled:
|
||||
'rounded-full bg-zinc-900 py-1 px-3 text-white hover:bg-zinc-700 dark:bg-blue-500 dark:text-white dark:hover:bg-blue-400',
|
||||
outline:
|
||||
'rounded-full py-1 px-3 text-zinc-700 ring-1 ring-inset ring-zinc-900/10 hover:bg-zinc-900/2.5 hover:text-zinc-900 dark:text-zinc-400 dark:ring-white/10 dark:hover:bg-white/5 dark:hover:text-white',
|
||||
text: 'text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-500',
|
||||
}
|
||||
|
||||
type ButtonProps = {
|
||||
variant?: keyof typeof variantStyles
|
||||
arrow?: 'left' | 'right'
|
||||
} & (
|
||||
| React.ComponentPropsWithoutRef<typeof Link>
|
||||
| (React.ComponentPropsWithoutRef<'button'> & { href?: undefined })
|
||||
)
|
||||
|
||||
export function Button({
|
||||
variant = 'primary',
|
||||
className,
|
||||
children,
|
||||
arrow,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
className = clsx(
|
||||
'inline-flex gap-0.5 justify-center overflow-hidden text-sm font-medium transition',
|
||||
variantStyles[variant],
|
||||
className,
|
||||
)
|
||||
|
||||
let arrowIcon = (
|
||||
<ArrowIcon
|
||||
className={clsx(
|
||||
'mt-0.5 h-5 w-5',
|
||||
variant === 'text' && 'relative top-px',
|
||||
arrow === 'left' && '-ml-1 rotate-180',
|
||||
arrow === 'right' && '-mr-1',
|
||||
)}
|
||||
/>
|
||||
)
|
||||
|
||||
let inner = (
|
||||
<>
|
||||
{arrow === 'left' && arrowIcon}
|
||||
{children}
|
||||
{arrow === 'right' && arrowIcon}
|
||||
</>
|
||||
)
|
||||
|
||||
if (typeof props.href === 'undefined') {
|
||||
return (
|
||||
<button className={className} {...props}>
|
||||
{inner}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Link className={className} {...props}>
|
||||
{inner}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
387
src/components/Code.tsx
Normal file
387
src/components/Code.tsx
Normal file
@@ -0,0 +1,387 @@
|
||||
'use client'
|
||||
|
||||
import { Tab, TabGroup, TabList, TabPanel, TabPanels } from '@headlessui/react'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
Children,
|
||||
createContext,
|
||||
isValidElement,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { create } from 'zustand'
|
||||
|
||||
import { Tag } from '@/components/Tag'
|
||||
|
||||
const languageNames: Record<string, string> = {
|
||||
js: 'JavaScript',
|
||||
ts: 'TypeScript',
|
||||
javascript: 'JavaScript',
|
||||
typescript: 'TypeScript',
|
||||
php: 'PHP',
|
||||
python: 'Python',
|
||||
ruby: 'Ruby',
|
||||
go: 'Go',
|
||||
}
|
||||
|
||||
function getPanelTitle({
|
||||
title,
|
||||
language,
|
||||
}: {
|
||||
title?: string
|
||||
language?: string
|
||||
}) {
|
||||
if (title) {
|
||||
return title
|
||||
}
|
||||
if (language && language in languageNames) {
|
||||
return languageNames[language]
|
||||
}
|
||||
return 'Code'
|
||||
}
|
||||
|
||||
function ClipboardIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||
<path
|
||||
strokeWidth="0"
|
||||
d="M5.5 13.5v-5a2 2 0 0 1 2-2l.447-.894A2 2 0 0 1 9.737 4.5h.527a2 2 0 0 1 1.789 1.106l.447.894a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-5a2 2 0 0 1-2-2Z"
|
||||
/>
|
||||
<path
|
||||
fill="none"
|
||||
strokeLinejoin="round"
|
||||
d="M12.5 6.5a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-5a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2m5 0-.447-.894a2 2 0 0 0-1.79-1.106h-.527a2 2 0 0 0-1.789 1.106L7.5 6.5m5 0-1 1h-3l-1-1"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function CopyButton({ code }: { code: string }) {
|
||||
let [copyCount, setCopyCount] = useState(0)
|
||||
let copied = copyCount > 0
|
||||
|
||||
useEffect(() => {
|
||||
if (copyCount > 0) {
|
||||
let timeout = setTimeout(() => setCopyCount(0), 1000)
|
||||
return () => {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
}, [copyCount])
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(
|
||||
'group/button absolute top-3.5 right-4 overflow-hidden rounded-full py-1 pr-3 pl-2 text-2xs font-medium opacity-0 backdrop-blur-sm transition group-hover:opacity-100 focus:opacity-100',
|
||||
copied
|
||||
? 'bg-blue-400/10 ring-1 ring-blue-400/20 ring-inset'
|
||||
: 'bg-white/5 hover:bg-white/7.5 dark:bg-white/2.5 dark:hover:bg-white/5',
|
||||
)}
|
||||
onClick={() => {
|
||||
window.navigator.clipboard.writeText(code).then(() => {
|
||||
setCopyCount((count) => count + 1)
|
||||
})
|
||||
}}
|
||||
>
|
||||
<span
|
||||
aria-hidden={copied}
|
||||
className={clsx(
|
||||
'pointer-events-none flex items-center gap-0.5 text-zinc-400 transition duration-300',
|
||||
copied && '-translate-y-1.5 opacity-0',
|
||||
)}
|
||||
>
|
||||
<ClipboardIcon className="h-5 w-5 fill-zinc-500/20 stroke-zinc-500 transition-colors group-hover/button:stroke-zinc-400" />
|
||||
Copy
|
||||
</span>
|
||||
<span
|
||||
aria-hidden={!copied}
|
||||
className={clsx(
|
||||
'pointer-events-none absolute inset-0 flex items-center justify-center text-blue-400 transition duration-300',
|
||||
!copied && 'translate-y-1.5 opacity-0',
|
||||
)}
|
||||
>
|
||||
Copied!
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function CodePanelHeader({ tag, label }: { tag?: string; label?: string }) {
|
||||
if (!tag && !label) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-9 items-center gap-2 border-y border-t-transparent border-b-white/7.5 bg-white/2.5 bg-zinc-900 px-4 dark:border-b-white/5 dark:bg-white/1">
|
||||
{tag && (
|
||||
<div className="dark flex">
|
||||
<Tag variant="small">{tag}</Tag>
|
||||
</div>
|
||||
)}
|
||||
{tag && label && (
|
||||
<span className="h-0.5 w-0.5 rounded-full bg-zinc-500" />
|
||||
)}
|
||||
{label && (
|
||||
<span className="font-mono text-xs text-zinc-400">{label}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CodePanel({
|
||||
children,
|
||||
tag,
|
||||
label,
|
||||
code,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
tag?: string
|
||||
label?: string
|
||||
code?: string
|
||||
}) {
|
||||
let child = Children.only(children)
|
||||
|
||||
if (isValidElement(child)) {
|
||||
const props = child.props as { tag?: string; label?: string; code?: string }
|
||||
tag = props.tag ?? tag
|
||||
label = props.label ?? label
|
||||
code = props.code ?? code
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
throw new Error(
|
||||
'`CodePanel` requires a `code` prop, or a child with a `code` prop.',
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="group dark:bg-white/2.5">
|
||||
<CodePanelHeader tag={tag} label={label} />
|
||||
<div className="relative">
|
||||
<pre className="overflow-x-auto p-4 text-xs text-white">{children}</pre>
|
||||
<CopyButton code={code} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CodeGroupHeader({
|
||||
title,
|
||||
children,
|
||||
selectedIndex,
|
||||
}: {
|
||||
title: string
|
||||
children: React.ReactNode
|
||||
selectedIndex: number
|
||||
}) {
|
||||
let hasTabs = Children.count(children) > 1
|
||||
|
||||
if (!title && !hasTabs) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[calc(--spacing(12)+1px)] flex-wrap items-start gap-x-4 border-b border-zinc-700 bg-zinc-800 px-4 dark:border-zinc-800 dark:bg-transparent">
|
||||
{title && (
|
||||
<h3 className="mr-auto pt-3 text-xs font-semibold text-white">
|
||||
{title}
|
||||
</h3>
|
||||
)}
|
||||
{hasTabs && (
|
||||
<TabList className="-mb-px flex gap-4 text-xs font-medium">
|
||||
{Children.map(children, (child, childIndex) => (
|
||||
<Tab
|
||||
className={clsx(
|
||||
'border-b py-3 transition data-selected:not-data-focus:outline-hidden',
|
||||
childIndex === selectedIndex
|
||||
? 'border-blue-500 text-blue-400'
|
||||
: 'border-transparent text-zinc-400 hover:text-zinc-300',
|
||||
)}
|
||||
>
|
||||
{getPanelTitle(
|
||||
isValidElement(child)
|
||||
? (child.props as { title?: string })
|
||||
: {},
|
||||
)}
|
||||
</Tab>
|
||||
))}
|
||||
</TabList>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CodeGroupPanels({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof CodePanel>) {
|
||||
let hasTabs = Children.count(children) > 1
|
||||
|
||||
if (hasTabs) {
|
||||
return (
|
||||
<TabPanels>
|
||||
{Children.map(children, (child) => (
|
||||
<TabPanel>
|
||||
<CodePanel {...props}>{child}</CodePanel>
|
||||
</TabPanel>
|
||||
))}
|
||||
</TabPanels>
|
||||
)
|
||||
}
|
||||
|
||||
return <CodePanel {...props}>{children}</CodePanel>
|
||||
}
|
||||
|
||||
function usePreventLayoutShift() {
|
||||
let positionRef = useRef<HTMLElement>(null)
|
||||
let rafRef = useRef<number | undefined>(undefined)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (typeof rafRef.current !== 'undefined') {
|
||||
window.cancelAnimationFrame(rafRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
positionRef,
|
||||
preventLayoutShift(callback: () => void) {
|
||||
if (!positionRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
let initialTop = positionRef.current.getBoundingClientRect().top
|
||||
|
||||
callback()
|
||||
|
||||
rafRef.current = window.requestAnimationFrame(() => {
|
||||
let newTop =
|
||||
positionRef.current?.getBoundingClientRect().top ?? initialTop
|
||||
window.scrollBy(0, newTop - initialTop)
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const usePreferredLanguageStore = create<{
|
||||
preferredLanguages: Array<string>
|
||||
addPreferredLanguage: (language: string) => void
|
||||
}>()((set) => ({
|
||||
preferredLanguages: [],
|
||||
addPreferredLanguage: (language) =>
|
||||
set((state) => ({
|
||||
preferredLanguages: [
|
||||
...state.preferredLanguages.filter(
|
||||
(preferredLanguage) => preferredLanguage !== language,
|
||||
),
|
||||
language,
|
||||
],
|
||||
})),
|
||||
}))
|
||||
|
||||
function useTabGroupProps(availableLanguages: Array<string>) {
|
||||
let { preferredLanguages, addPreferredLanguage } = usePreferredLanguageStore()
|
||||
let [selectedIndex, setSelectedIndex] = useState(0)
|
||||
let activeLanguage = [...availableLanguages].sort(
|
||||
(a, z) => preferredLanguages.indexOf(z) - preferredLanguages.indexOf(a),
|
||||
)[0]
|
||||
let languageIndex = availableLanguages.indexOf(activeLanguage)
|
||||
let newSelectedIndex = languageIndex === -1 ? selectedIndex : languageIndex
|
||||
if (newSelectedIndex !== selectedIndex) {
|
||||
setSelectedIndex(newSelectedIndex)
|
||||
}
|
||||
|
||||
let { positionRef, preventLayoutShift } = usePreventLayoutShift()
|
||||
|
||||
return {
|
||||
as: 'div' as const,
|
||||
ref: positionRef,
|
||||
selectedIndex,
|
||||
onChange: (newSelectedIndex: number) => {
|
||||
preventLayoutShift(() =>
|
||||
addPreferredLanguage(availableLanguages[newSelectedIndex]),
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const CodeGroupContext = createContext(false)
|
||||
|
||||
export function CodeGroup({
|
||||
children,
|
||||
title,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof CodeGroupPanels> & { title: string }) {
|
||||
let languages =
|
||||
Children.map(children, (child) =>
|
||||
getPanelTitle(
|
||||
isValidElement(child) ? (child.props as { title?: string }) : {},
|
||||
),
|
||||
) ?? []
|
||||
let tabGroupProps = useTabGroupProps(languages)
|
||||
let hasTabs = Children.count(children) > 1
|
||||
|
||||
let containerClassName =
|
||||
'my-6 overflow-hidden rounded-2xl bg-zinc-900 shadow-md dark:ring-1 dark:ring-white/10'
|
||||
let header = (
|
||||
<CodeGroupHeader title={title} selectedIndex={tabGroupProps.selectedIndex}>
|
||||
{children}
|
||||
</CodeGroupHeader>
|
||||
)
|
||||
let panels = <CodeGroupPanels {...props}>{children}</CodeGroupPanels>
|
||||
|
||||
return (
|
||||
<CodeGroupContext.Provider value={true}>
|
||||
{hasTabs ? (
|
||||
<TabGroup {...tabGroupProps} className={containerClassName}>
|
||||
<div className="not-prose">
|
||||
{header}
|
||||
{panels}
|
||||
</div>
|
||||
</TabGroup>
|
||||
) : (
|
||||
<div className={containerClassName}>
|
||||
<div className="not-prose">
|
||||
{header}
|
||||
{panels}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CodeGroupContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function Code({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<'code'>) {
|
||||
let isGrouped = useContext(CodeGroupContext)
|
||||
|
||||
if (isGrouped) {
|
||||
if (typeof children !== 'string') {
|
||||
throw new Error(
|
||||
'`Code` children must be a string when nested inside a `CodeGroup`.',
|
||||
)
|
||||
}
|
||||
return <code {...props} dangerouslySetInnerHTML={{ __html: children }} />
|
||||
}
|
||||
|
||||
return <code {...props}>{children}</code>
|
||||
}
|
||||
|
||||
export function Pre({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof CodeGroup>) {
|
||||
let isGrouped = useContext(CodeGroupContext)
|
||||
|
||||
if (isGrouped) {
|
||||
return children
|
||||
}
|
||||
|
||||
return <CodeGroup {...props}>{children}</CodeGroup>
|
||||
}
|
||||
106
src/components/Feedback.tsx
Normal file
106
src/components/Feedback.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
'use client'
|
||||
|
||||
import { Transition } from '@headlessui/react'
|
||||
import clsx from 'clsx'
|
||||
import { forwardRef, useState } from 'react'
|
||||
|
||||
function CheckIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||
<circle cx="10" cy="10" r="10" strokeWidth="0" />
|
||||
<path
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
d="m6.75 10.813 2.438 2.437c1.218-4.469 4.062-6.5 4.062-6.5"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function FeedbackButton(
|
||||
props: Omit<React.ComponentPropsWithoutRef<'button'>, 'type' | 'className'>,
|
||||
) {
|
||||
return (
|
||||
<button
|
||||
type="submit"
|
||||
className="px-3 text-sm font-medium text-zinc-600 transition hover:bg-zinc-900/2.5 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-white/5 dark:hover:text-white"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const FeedbackForm = forwardRef<
|
||||
React.ElementRef<'form'>,
|
||||
React.ComponentPropsWithoutRef<'form'>
|
||||
>(function FeedbackForm({ onSubmit, className, ...props }, ref) {
|
||||
return (
|
||||
<form
|
||||
{...props}
|
||||
ref={ref}
|
||||
onSubmit={onSubmit}
|
||||
className={clsx(
|
||||
className,
|
||||
'absolute inset-0 flex items-center justify-center gap-6 md:justify-start',
|
||||
)}
|
||||
>
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-400">
|
||||
Was this page helpful?
|
||||
</p>
|
||||
<div className="group grid h-8 grid-cols-[1fr_1px_1fr] overflow-hidden rounded-full border border-zinc-900/10 dark:border-white/10">
|
||||
<FeedbackButton data-response="yes">Yes</FeedbackButton>
|
||||
<div className="bg-zinc-900/10 dark:bg-white/10" />
|
||||
<FeedbackButton data-response="no">No</FeedbackButton>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
})
|
||||
|
||||
const FeedbackThanks = forwardRef<
|
||||
React.ElementRef<'div'>,
|
||||
React.ComponentPropsWithoutRef<'div'>
|
||||
>(function FeedbackThanks({ className, ...props }, ref) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
ref={ref}
|
||||
className={clsx(
|
||||
className,
|
||||
'absolute inset-0 flex justify-center md:justify-start',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3 rounded-full bg-blue-50/50 py-1 pr-3 pl-1.5 text-sm text-blue-900 ring-1 ring-blue-500/20 ring-inset dark:bg-blue-500/5 dark:text-blue-200 dark:ring-blue-500/30">
|
||||
<CheckIcon className="h-5 w-5 flex-none fill-blue-500 stroke-white dark:fill-blue-200/20 dark:stroke-blue-200" />
|
||||
Thanks for your feedback!
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export function Feedback() {
|
||||
let [submitted, setSubmitted] = useState(false)
|
||||
|
||||
function onSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
|
||||
// event.nativeEvent.submitter.dataset.response
|
||||
// => "yes" or "no"
|
||||
|
||||
setSubmitted(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative h-8">
|
||||
<Transition show={!submitted}>
|
||||
<FeedbackForm
|
||||
className="duration-300 data-closed:opacity-0 data-leave:pointer-events-none"
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
</Transition>
|
||||
<Transition show={submitted}>
|
||||
<FeedbackThanks className="delay-150 duration-300 data-closed:opacity-0" />
|
||||
</Transition>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
145
src/components/Footer.tsx
Normal file
145
src/components/Footer.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
|
||||
import { Button } from '@/components/Button'
|
||||
import { navigation } from '@/components/Navigation'
|
||||
|
||||
function PageLink({
|
||||
label,
|
||||
page,
|
||||
previous = false,
|
||||
}: {
|
||||
label: string
|
||||
page: { href: string; title: string }
|
||||
previous?: boolean
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
href={page.href}
|
||||
aria-label={`${label}: ${page.title}`}
|
||||
variant="secondary"
|
||||
arrow={previous ? 'left' : 'right'}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
<Link
|
||||
href={page.href}
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
className="text-base font-semibold text-zinc-900 transition hover:text-zinc-600 dark:text-white dark:hover:text-zinc-300"
|
||||
>
|
||||
{page.title}
|
||||
</Link>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function PageNavigation() {
|
||||
let pathname = usePathname()
|
||||
let allPages = navigation.flatMap((group) => group.links)
|
||||
let currentPageIndex = allPages.findIndex((page) => page.href === pathname)
|
||||
|
||||
if (currentPageIndex === -1) {
|
||||
return null
|
||||
}
|
||||
|
||||
let previousPage = allPages[currentPageIndex - 1]
|
||||
let nextPage = allPages[currentPageIndex + 1]
|
||||
|
||||
if (!previousPage && !nextPage) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex">
|
||||
{previousPage && (
|
||||
<div className="flex flex-col items-start gap-3">
|
||||
<PageLink label="Previous" page={previousPage} previous />
|
||||
</div>
|
||||
)}
|
||||
{nextPage && (
|
||||
<div className="ml-auto flex flex-col items-end gap-3">
|
||||
<PageLink label="Next" page={nextPage} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function XIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||
<path d="M11.1527 8.92804L16.2525 3H15.044L10.6159 8.14724L7.07919 3H3L8.34821 10.7835L3 17H4.20855L8.88474 11.5643L12.6198 17H16.699L11.1524 8.92804H11.1527ZM9.49748 10.8521L8.95559 10.077L4.644 3.90978H6.50026L9.97976 8.88696L10.5216 9.66202L15.0446 16.1316H13.1883L9.49748 10.8524V10.8521Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function GitHubIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M10 1.667c-4.605 0-8.334 3.823-8.334 8.544 0 3.78 2.385 6.974 5.698 8.106.417.075.573-.182.573-.406 0-.203-.011-.875-.011-1.592-2.093.397-2.635-.522-2.802-1.002-.094-.246-.5-1.005-.854-1.207-.291-.16-.708-.556-.01-.567.656-.01 1.124.62 1.281.876.75 1.292 1.948.93 2.427.705.073-.555.291-.93.531-1.143-1.854-.213-3.791-.95-3.791-4.218 0-.929.322-1.698.854-2.296-.083-.214-.375-1.09.083-2.265 0 0 .698-.224 2.292.876a7.576 7.576 0 0 1 2.083-.288c.709 0 1.417.096 2.084.288 1.593-1.11 2.291-.875 2.291-.875.459 1.174.167 2.05.084 2.263.53.599.854 1.357.854 2.297 0 3.278-1.948 4.005-3.802 4.219.302.266.563.78.563 1.58 0 1.143-.011 2.061-.011 2.35 0 .224.156.491.573.405a8.365 8.365 0 0 0 4.11-3.116 8.707 8.707 0 0 0 1.567-4.99c0-4.721-3.73-8.545-8.334-8.545Z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function DiscordIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||
<path d="M16.238 4.515a14.842 14.842 0 0 0-3.664-1.136.055.055 0 0 0-.059.027 10.35 10.35 0 0 0-.456.938 13.702 13.702 0 0 0-4.115 0 9.479 9.479 0 0 0-.464-.938.058.058 0 0 0-.058-.027c-1.266.218-2.497.6-3.664 1.136a.052.052 0 0 0-.024.02C1.4 8.023.76 11.424 1.074 14.782a.062.062 0 0 0 .024.042 14.923 14.923 0 0 0 4.494 2.272.058.058 0 0 0 .064-.02c.346-.473.654-.972.92-1.496a.057.057 0 0 0-.032-.08 9.83 9.83 0 0 1-1.404-.669.058.058 0 0 1-.029-.046.058.058 0 0 1 .023-.05c.094-.07.189-.144.279-.218a.056.056 0 0 1 .058-.008c2.946 1.345 6.135 1.345 9.046 0a.056.056 0 0 1 .059.007c.09.074.184.149.28.22a.058.058 0 0 1 .023.049.059.059 0 0 1-.028.046 9.224 9.224 0 0 1-1.405.669.058.058 0 0 0-.033.033.056.056 0 0 0 .002.047c.27.523.58 1.022.92 1.495a.056.056 0 0 0 .062.021 14.878 14.878 0 0 0 4.502-2.272.055.055 0 0 0 .016-.018.056.056 0 0 0 .008-.023c.375-3.883-.63-7.256-2.662-10.246a.046.046 0 0 0-.023-.021Zm-9.223 8.221c-.887 0-1.618-.814-1.618-1.814s.717-1.814 1.618-1.814c.908 0 1.632.821 1.618 1.814 0 1-.717 1.814-1.618 1.814Zm5.981 0c-.887 0-1.618-.814-1.618-1.814s.717-1.814 1.618-1.814c.908 0 1.632.821 1.618 1.814 0 1-.71 1.814-1.618 1.814Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function SocialLink({
|
||||
href,
|
||||
icon: Icon,
|
||||
children,
|
||||
}: {
|
||||
href: string
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<Link href={href} className="group">
|
||||
<span className="sr-only">{children}</span>
|
||||
<Icon className="h-5 w-5 fill-zinc-700 transition group-hover:fill-zinc-900 dark:group-hover:fill-zinc-500" />
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
function SmallPrint() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-between gap-5 border-t border-zinc-900/5 pt-8 sm:flex-row dark:border-white/5">
|
||||
<p className="text-xs text-zinc-600 dark:text-zinc-400">
|
||||
© Copyright {new Date().getFullYear()}. All rights reserved.
|
||||
</p>
|
||||
<div className="flex gap-4">
|
||||
<SocialLink href="#" icon={XIcon}>
|
||||
Follow us on X
|
||||
</SocialLink>
|
||||
<SocialLink href="#" icon={GitHubIcon}>
|
||||
Follow us on GitHub
|
||||
</SocialLink>
|
||||
<SocialLink href="#" icon={DiscordIcon}>
|
||||
Join our Discord server
|
||||
</SocialLink>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Footer() {
|
||||
return (
|
||||
<footer className="mx-auto w-full max-w-2xl space-y-10 pb-16 lg:max-w-5xl">
|
||||
<PageNavigation />
|
||||
<SmallPrint />
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
55
src/components/GridPattern.tsx
Normal file
55
src/components/GridPattern.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useId } from 'react'
|
||||
|
||||
export function GridPattern({
|
||||
width,
|
||||
height,
|
||||
x,
|
||||
y,
|
||||
squares,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<'svg'> & {
|
||||
width: number
|
||||
height: number
|
||||
x: string | number
|
||||
y: string | number
|
||||
squares: Array<[x: number, y: number]>
|
||||
}) {
|
||||
let patternId = useId()
|
||||
|
||||
return (
|
||||
<svg aria-hidden="true" {...props}>
|
||||
<defs>
|
||||
<pattern
|
||||
id={patternId}
|
||||
width={width}
|
||||
height={height}
|
||||
patternUnits="userSpaceOnUse"
|
||||
x={x}
|
||||
y={y}
|
||||
>
|
||||
<path d={`M.5 ${height}V.5H${width}`} fill="none" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect
|
||||
width="100%"
|
||||
height="100%"
|
||||
strokeWidth={0}
|
||||
fill={`url(#${patternId})`}
|
||||
/>
|
||||
{squares && (
|
||||
<svg x={x} y={y} className="overflow-visible">
|
||||
{squares.map(([x, y]) => (
|
||||
<rect
|
||||
strokeWidth="0"
|
||||
key={`${x}-${y}`}
|
||||
width={width + 1}
|
||||
height={height + 1}
|
||||
x={x * width}
|
||||
y={y * height}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
)}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
49
src/components/Guides.tsx
Normal file
49
src/components/Guides.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Button } from '@/components/Button'
|
||||
import { Heading } from '@/components/Heading'
|
||||
|
||||
const guides = [
|
||||
{
|
||||
href: '/guides/web',
|
||||
name: 'Web API',
|
||||
description:
|
||||
'Learn how to use the Web API to manage and interact with the Drop server.',
|
||||
},
|
||||
{
|
||||
href: '/guides/client',
|
||||
name: 'Client API',
|
||||
description:
|
||||
'Learn how to use the Client API to download and interact with other clients.',
|
||||
},
|
||||
{
|
||||
href: '/guides/plugins',
|
||||
name: 'Plugins',
|
||||
description: 'Learn how to develop and publish plugins for Drop.',
|
||||
},
|
||||
]
|
||||
|
||||
export function Guides() {
|
||||
return (
|
||||
<div className="my-16 xl:max-w-none">
|
||||
<Heading level={2} id="guides">
|
||||
Guides
|
||||
</Heading>
|
||||
<div className="not-prose mt-4 grid grid-cols-1 gap-8 border-t border-zinc-900/5 pt-10 sm:grid-cols-2 xl:grid-cols-4 dark:border-white/5">
|
||||
{guides.map((guide) => (
|
||||
<div key={guide.href}>
|
||||
<h3 className="text-sm font-semibold text-zinc-900 dark:text-white">
|
||||
{guide.name}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
{guide.description}
|
||||
</p>
|
||||
<p className="mt-4">
|
||||
<Button href={guide.href} variant="text" arrow="right">
|
||||
Read more
|
||||
</Button>
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
97
src/components/Header.tsx
Normal file
97
src/components/Header.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import clsx from 'clsx'
|
||||
import { motion, useScroll, useTransform } from 'framer-motion'
|
||||
import Link from 'next/link'
|
||||
import { forwardRef } from 'react'
|
||||
|
||||
import { Button } from '@/components/Button'
|
||||
import { Logo } from '@/components/Logo'
|
||||
import {
|
||||
MobileNavigation,
|
||||
useIsInsideMobileNavigation,
|
||||
useMobileNavigationStore,
|
||||
} from '@/components/MobileNavigation'
|
||||
import { MobileSearch, Search } from '@/components/Search'
|
||||
import { ThemeToggle } from '@/components/ThemeToggle'
|
||||
import { CloseButton } from '@headlessui/react'
|
||||
|
||||
function TopLevelNavItem({
|
||||
href,
|
||||
children,
|
||||
}: {
|
||||
href: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<li>
|
||||
<Link
|
||||
href={href}
|
||||
className="text-sm/5 text-zinc-600 transition hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-white"
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
export const Header = forwardRef<
|
||||
React.ComponentRef<'div'>,
|
||||
React.ComponentPropsWithoutRef<typeof motion.div>
|
||||
>(function Header({ className, ...props }, ref) {
|
||||
let { isOpen: mobileNavIsOpen } = useMobileNavigationStore()
|
||||
let isInsideMobileNavigation = useIsInsideMobileNavigation()
|
||||
|
||||
let { scrollY } = useScroll()
|
||||
let bgOpacityLight = useTransform(scrollY, [0, 72], ['50%', '90%'])
|
||||
let bgOpacityDark = useTransform(scrollY, [0, 72], ['20%', '80%'])
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
{...props}
|
||||
ref={ref}
|
||||
className={clsx(
|
||||
className,
|
||||
'fixed inset-x-0 top-0 z-50 flex h-14 items-center justify-between gap-12 px-4 transition sm:px-6 lg:left-72 lg:z-30 lg:px-8 xl:left-80',
|
||||
!isInsideMobileNavigation &&
|
||||
'backdrop-blur-xs lg:left-72 xl:left-80 dark:backdrop-blur-sm',
|
||||
isInsideMobileNavigation
|
||||
? 'bg-white dark:bg-zinc-900'
|
||||
: 'bg-white/(--bg-opacity-light) dark:bg-zinc-900/(--bg-opacity-dark)',
|
||||
)}
|
||||
style={
|
||||
{
|
||||
'--bg-opacity-light': bgOpacityLight,
|
||||
'--bg-opacity-dark': bgOpacityDark,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
'absolute inset-x-0 top-full h-px transition',
|
||||
(isInsideMobileNavigation || !mobileNavIsOpen) &&
|
||||
'bg-zinc-900/7.5 dark:bg-white/7.5',
|
||||
)}
|
||||
/>
|
||||
<Search />
|
||||
<div className="flex items-center gap-5 lg:hidden">
|
||||
<MobileNavigation />
|
||||
<CloseButton as={Link} href="/" aria-label="Home">
|
||||
<Logo className="h-6" />
|
||||
</CloseButton>
|
||||
</div>
|
||||
<div className="flex items-center gap-5">
|
||||
<nav className="hidden md:block">
|
||||
<ul role="list" className="flex items-center gap-8">
|
||||
<TopLevelNavItem href="https://docs.droposs.org/">Documentation</TopLevelNavItem>
|
||||
<TopLevelNavItem href="https://github.com/Drop-OSS/">GitHub</TopLevelNavItem>
|
||||
<TopLevelNavItem href="https://discord.gg/ACq4qZp4a9">Discord</TopLevelNavItem>
|
||||
</ul>
|
||||
</nav>
|
||||
<div className="hidden md:block md:h-5 md:w-px md:bg-zinc-900/10 md:dark:bg-white/15" />
|
||||
<div className="flex gap-4">
|
||||
<MobileSearch />
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
})
|
||||
159
src/components/Heading.tsx
Normal file
159
src/components/Heading.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
'use client'
|
||||
|
||||
import { useInView } from 'framer-motion'
|
||||
import Link from 'next/link'
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
import { useSectionStore } from '@/components/SectionProvider'
|
||||
import { Tag } from '@/components/Tag'
|
||||
import { remToPx } from '@/lib/remToPx'
|
||||
|
||||
function AnchorIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path d="m6.5 11.5-.964-.964a3.535 3.535 0 1 1 5-5l.964.964m2 2 .964.964a3.536 3.536 0 0 1-5 5L8.5 13.5m0-5 3 3" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function Eyebrow({
|
||||
tag,
|
||||
label,
|
||||
apilevel,
|
||||
acl,
|
||||
}: {
|
||||
tag?: string
|
||||
label?: string
|
||||
apilevel?: string
|
||||
acl?: string
|
||||
}) {
|
||||
if (!tag && !label && !apilevel) {
|
||||
return null
|
||||
}
|
||||
|
||||
const apiLevelColor =
|
||||
apilevel?.toLowerCase() == 'user'
|
||||
? 'text-blue-400'
|
||||
: apilevel?.toLowerCase() == 'system'
|
||||
? 'text-yellow-600'
|
||||
: 'text-zinc-400'
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-x-3">
|
||||
{tag && <Tag>{tag}</Tag>}
|
||||
{tag && label && (
|
||||
<span className="h-0.5 w-0.5 rounded-full bg-zinc-300 dark:bg-zinc-600" />
|
||||
)}
|
||||
{label && (
|
||||
<span className="font-mono text-xs text-zinc-400">{label}</span>
|
||||
)}
|
||||
{(tag || label) && apilevel && (
|
||||
<span className="h-0.5 w-0.5 rounded-full bg-zinc-300 dark:bg-zinc-600" />
|
||||
)}
|
||||
{apilevel && (
|
||||
<span className={`font-mono text-xs font-bold ${apiLevelColor}`}>
|
||||
{apilevel.toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
{apilevel && acl && (
|
||||
<span className="h-0.5 w-0.5 rounded-full bg-zinc-300 dark:bg-zinc-600" />
|
||||
)}
|
||||
|
||||
{apilevel && acl && (
|
||||
<span className="font-mono text-xs text-green-300">
|
||||
ACL: <span className="text-zinc-900 dark:text-zinc-100">{acl}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Anchor({
|
||||
id,
|
||||
inView,
|
||||
children,
|
||||
}: {
|
||||
id: string
|
||||
inView: boolean
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={`#${id}`}
|
||||
className="group text-inherit no-underline hover:text-inherit"
|
||||
>
|
||||
{inView && (
|
||||
<div className="absolute mt-1 ml-[calc(-1*var(--width))] hidden w-(--width) opacity-0 transition [--width:calc(2.625rem+0.5px+50%-min(50%,calc(var(--container-lg)+(--spacing(8)))))] group-hover:opacity-100 group-focus:opacity-100 md:block lg:z-50 2xl:[--width:--spacing(10)]">
|
||||
<div className="group/anchor block h-5 w-5 rounded-lg bg-zinc-50 ring-1 ring-zinc-300 transition ring-inset hover:ring-zinc-500 dark:bg-zinc-800 dark:ring-zinc-700 dark:hover:bg-zinc-700 dark:hover:ring-zinc-600">
|
||||
<AnchorIcon className="h-5 w-5 stroke-zinc-500 transition dark:stroke-zinc-400 dark:group-hover/anchor:stroke-white" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export function Heading<Level extends 2 | 3>({
|
||||
children,
|
||||
tag,
|
||||
label,
|
||||
level,
|
||||
anchor = true,
|
||||
apilevel,
|
||||
acl,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<`h${Level}`> & {
|
||||
id: string
|
||||
tag?: string
|
||||
label?: string
|
||||
level?: Level
|
||||
anchor?: boolean
|
||||
apilevel?: string
|
||||
acl?: string
|
||||
}) {
|
||||
level = level ?? (2 as Level)
|
||||
let Component = `h${level}` as 'h2' | 'h3'
|
||||
let ref = useRef<HTMLHeadingElement>(null)
|
||||
let registerHeading = useSectionStore((s) => s.registerHeading)
|
||||
|
||||
let inView = useInView(ref, {
|
||||
margin: `${remToPx(-3.5)}px 0px 0px 0px`,
|
||||
amount: 'all',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (level === 2) {
|
||||
registerHeading({
|
||||
id: props.id,
|
||||
ref,
|
||||
offsetRem: tag || label ? 8 : 6,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<Eyebrow tag={tag} label={label} apilevel={apilevel} acl={acl} />
|
||||
<Component
|
||||
ref={ref}
|
||||
className={tag || label ? 'mt-2 scroll-mt-32' : 'scroll-mt-24'}
|
||||
{...props}
|
||||
>
|
||||
{anchor ? (
|
||||
<Anchor id={props.id} inView={inView}>
|
||||
{children}
|
||||
</Anchor>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</Component>
|
||||
</>
|
||||
)
|
||||
}
|
||||
32
src/components/HeroPattern.tsx
Normal file
32
src/components/HeroPattern.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { GridPattern } from '@/components/GridPattern'
|
||||
|
||||
export function HeroPattern() {
|
||||
return (
|
||||
<div className="absolute inset-0 -z-10 mx-0 max-w-none overflow-hidden">
|
||||
<div className="absolute top-0 left-1/2 -ml-152 h-100 w-325 dark:mask-[linear-gradient(white,transparent)]">
|
||||
<div className="absolute inset-0 bg-linear-to-r from-blue-100 to-blue-900 mask-[radial-gradient(farthest-side_at_top,white,transparent)] opacity-40 dark:from-blue-600/30 dark:to-blue-300/30 dark:opacity-100">
|
||||
<GridPattern
|
||||
width={72}
|
||||
height={56}
|
||||
x={-12}
|
||||
y={4}
|
||||
squares={[
|
||||
[4, 3],
|
||||
[2, 1],
|
||||
[7, 3],
|
||||
[10, 6],
|
||||
]}
|
||||
className="absolute inset-x-0 inset-y-[-50%] h-[200%] w-full skew-y-[-18deg] fill-black/40 stroke-black/50 mix-blend-overlay dark:fill-white/2.5 dark:stroke-white/5"
|
||||
/>
|
||||
</div>
|
||||
<svg
|
||||
viewBox="0 0 1113 440"
|
||||
aria-hidden="true"
|
||||
className="absolute top-0 left-1/2 -ml-76 w-278.25 fill-white blur-[26px] dark:hidden"
|
||||
>
|
||||
<path d="M.016 439.5s-9.5-300 434-300S882.516 20 882.516 20V0h230.004v439.5H.016Z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
46
src/components/Layout.tsx
Normal file
46
src/components/Layout.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'framer-motion'
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
|
||||
import { Footer } from '@/components/Footer'
|
||||
import { Header } from '@/components/Header'
|
||||
import { Logo } from '@/components/Logo'
|
||||
import { Navigation } from '@/components/Navigation'
|
||||
import { SectionProvider, type Section } from '@/components/SectionProvider'
|
||||
|
||||
export function Layout({
|
||||
children,
|
||||
allSections,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
allSections: Record<string, Array<Section>>
|
||||
}) {
|
||||
let pathname = usePathname()
|
||||
|
||||
return (
|
||||
<SectionProvider sections={allSections[pathname] ?? []}>
|
||||
<div className="h-full lg:ml-72 xl:ml-80">
|
||||
<motion.header
|
||||
layoutScroll
|
||||
className="contents lg:pointer-events-none lg:fixed lg:inset-0 lg:z-40 lg:flex"
|
||||
>
|
||||
<div className="contents lg:pointer-events-auto lg:block lg:w-72 lg:overflow-y-auto lg:border-r lg:border-zinc-900/10 lg:px-6 lg:pt-4 lg:pb-8 xl:w-80 lg:dark:border-white/10">
|
||||
<div className="hidden lg:flex">
|
||||
<Link href="/" aria-label="Home">
|
||||
<Logo className="h-6" />
|
||||
</Link>
|
||||
</div>
|
||||
<Header />
|
||||
<Navigation className="hidden lg:mt-10 lg:block" />
|
||||
</div>
|
||||
</motion.header>
|
||||
<div className="relative flex h-full flex-col px-4 pt-14 sm:px-6 lg:px-8">
|
||||
<main className="flex-auto">{children}</main>
|
||||
<Footer />
|
||||
</div>
|
||||
</div>
|
||||
</SectionProvider>
|
||||
)
|
||||
}
|
||||
82
src/components/Libraries.tsx
Normal file
82
src/components/Libraries.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import Image from 'next/image'
|
||||
|
||||
import { Button } from '@/components/Button'
|
||||
import { Heading } from '@/components/Heading'
|
||||
import logoGo from '@/images/logos/go.svg'
|
||||
import logoNode from '@/images/logos/node.svg'
|
||||
import logoPhp from '@/images/logos/php.svg'
|
||||
import logoPython from '@/images/logos/python.svg'
|
||||
import logoRuby from '@/images/logos/ruby.svg'
|
||||
|
||||
const libraries = [
|
||||
{
|
||||
href: '#',
|
||||
name: 'PHP',
|
||||
description:
|
||||
'A popular general-purpose scripting language that is especially suited to web development.',
|
||||
logo: logoPhp,
|
||||
},
|
||||
{
|
||||
href: '#',
|
||||
name: 'Ruby',
|
||||
description:
|
||||
'A dynamic, open source programming language with a focus on simplicity and productivity.',
|
||||
logo: logoRuby,
|
||||
},
|
||||
{
|
||||
href: '#',
|
||||
name: 'Node.js',
|
||||
description:
|
||||
'Node.js® is an open-source, cross-platform JavaScript runtime environment.',
|
||||
logo: logoNode,
|
||||
},
|
||||
{
|
||||
href: '#',
|
||||
name: 'Python',
|
||||
description:
|
||||
'Python is a programming language that lets you work quickly and integrate systems more effectively.',
|
||||
logo: logoPython,
|
||||
},
|
||||
{
|
||||
href: '#',
|
||||
name: 'Go',
|
||||
description:
|
||||
'An open-source programming language supported by Google with built-in concurrency.',
|
||||
logo: logoGo,
|
||||
},
|
||||
]
|
||||
|
||||
export function Libraries() {
|
||||
return (
|
||||
<div className="my-16 xl:max-w-none">
|
||||
<Heading level={2} id="official-libraries">
|
||||
Official libraries
|
||||
</Heading>
|
||||
<div className="not-prose mt-4 grid grid-cols-1 gap-x-6 gap-y-10 border-t border-zinc-900/5 pt-10 sm:grid-cols-2 xl:max-w-none xl:grid-cols-3 dark:border-white/5">
|
||||
{libraries.map((library) => (
|
||||
<div key={library.name} className="flex flex-row-reverse gap-6">
|
||||
<div className="flex-auto">
|
||||
<h3 className="text-sm font-semibold text-zinc-900 dark:text-white">
|
||||
{library.name}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
{library.description}
|
||||
</p>
|
||||
<p className="mt-4">
|
||||
<Button href={library.href} variant="text" arrow="right">
|
||||
Read more
|
||||
</Button>
|
||||
</p>
|
||||
</div>
|
||||
<Image
|
||||
src={library.logo}
|
||||
alt=""
|
||||
className="h-12 w-12"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
20
src/components/Logo.tsx
Normal file
20
src/components/Logo.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
export function Logo(props: React.ComponentPropsWithoutRef<'svg'>) {
|
||||
return (
|
||||
<div className="flex flex-row items-center gap-x-2 font-semibold">
|
||||
<svg
|
||||
aria-label="Drop Logo"
|
||||
className="mt-[1px] h-6 text-blue-400"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M4 13.5C4 11.0008 5.38798 8.76189 7.00766 7C8.43926 5.44272 10.0519 4.25811 11.0471 3.5959C11.6287 3.20893 12.3713 3.20893 12.9529 3.5959C13.9481 4.25811 15.5607 5.44272 16.9923 7C18.612 8.76189 20 11.0008 20 13.5C20 17.9183 16.4183 21.5 12 21.5C7.58172 21.5 4 17.9183 4 13.5Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</svg>
|
||||
Developer API
|
||||
</div>
|
||||
)
|
||||
}
|
||||
122
src/components/MobileNavigation.tsx
Normal file
122
src/components/MobileNavigation.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogBackdrop,
|
||||
DialogPanel,
|
||||
TransitionChild,
|
||||
} from '@headlessui/react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Suspense, createContext, useContext } from 'react'
|
||||
import { create } from 'zustand'
|
||||
|
||||
import { Header } from '@/components/Header'
|
||||
import { Navigation } from '@/components/Navigation'
|
||||
|
||||
function MenuIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 10 9"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path d="M.5 1h9M.5 8h9M.5 4.5h9" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function XIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 10 9"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path d="m1.5 1 7 7M8.5 1l-7 7" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
const IsInsideMobileNavigationContext = createContext(false)
|
||||
|
||||
function MobileNavigationDialog({
|
||||
isOpen,
|
||||
close,
|
||||
}: {
|
||||
isOpen: boolean
|
||||
close: () => void
|
||||
}) {
|
||||
return (
|
||||
<Dialog
|
||||
transition
|
||||
open={isOpen}
|
||||
onClose={close}
|
||||
className="fixed inset-0 z-50 lg:hidden"
|
||||
>
|
||||
<DialogBackdrop
|
||||
transition
|
||||
className="fixed inset-0 top-14 bg-zinc-400/20 backdrop-blur-xs data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in dark:bg-black/40"
|
||||
/>
|
||||
|
||||
<DialogPanel>
|
||||
<TransitionChild>
|
||||
<Header className="data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in" />
|
||||
</TransitionChild>
|
||||
|
||||
<TransitionChild>
|
||||
<motion.div
|
||||
layoutScroll
|
||||
className="fixed top-14 bottom-0 left-0 w-full overflow-y-auto bg-white px-4 pt-6 pb-4 shadow-lg ring-1 shadow-zinc-900/10 ring-zinc-900/7.5 duration-500 ease-in-out data-closed:-translate-x-full min-[416px]:max-w-sm sm:px-6 sm:pb-10 dark:bg-zinc-900 dark:ring-zinc-800"
|
||||
>
|
||||
<Navigation />
|
||||
</motion.div>
|
||||
</TransitionChild>
|
||||
</DialogPanel>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export function useIsInsideMobileNavigation() {
|
||||
return useContext(IsInsideMobileNavigationContext)
|
||||
}
|
||||
|
||||
export const useMobileNavigationStore = create<{
|
||||
isOpen: boolean
|
||||
open: () => void
|
||||
close: () => void
|
||||
toggle: () => void
|
||||
}>()((set) => ({
|
||||
isOpen: false,
|
||||
open: () => set({ isOpen: true }),
|
||||
close: () => set({ isOpen: false }),
|
||||
toggle: () => set((state) => ({ isOpen: !state.isOpen })),
|
||||
}))
|
||||
|
||||
export function MobileNavigation() {
|
||||
let isInsideMobileNavigation = useIsInsideMobileNavigation()
|
||||
let { isOpen, toggle, close } = useMobileNavigationStore()
|
||||
let ToggleIcon = isOpen ? XIcon : MenuIcon
|
||||
|
||||
return (
|
||||
<IsInsideMobileNavigationContext.Provider value={true}>
|
||||
<button
|
||||
type="button"
|
||||
className="relative flex size-6 items-center justify-center rounded-md transition hover:bg-zinc-900/5 dark:hover:bg-white/5"
|
||||
aria-label="Toggle navigation"
|
||||
onClick={toggle}
|
||||
>
|
||||
<span className="absolute size-12 pointer-fine:hidden" />
|
||||
<ToggleIcon className="w-2.5 stroke-zinc-900 dark:stroke-white" />
|
||||
</button>
|
||||
{!isInsideMobileNavigation && (
|
||||
<Suspense fallback={null}>
|
||||
<MobileNavigationDialog isOpen={isOpen} close={close} />
|
||||
</Suspense>
|
||||
)}
|
||||
</IsInsideMobileNavigationContext.Provider>
|
||||
)
|
||||
}
|
||||
294
src/components/Navigation.tsx
Normal file
294
src/components/Navigation.tsx
Normal file
@@ -0,0 +1,294 @@
|
||||
'use client'
|
||||
|
||||
import clsx from 'clsx'
|
||||
import { AnimatePresence, motion, useIsPresent } from 'framer-motion'
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useRef } from 'react'
|
||||
|
||||
import { useIsInsideMobileNavigation } from '@/components/MobileNavigation'
|
||||
import { useSectionStore } from '@/components/SectionProvider'
|
||||
import { Tag } from '@/components/Tag'
|
||||
import { remToPx } from '@/lib/remToPx'
|
||||
import { CloseButton } from '@headlessui/react'
|
||||
|
||||
interface NavGroup {
|
||||
title: string
|
||||
links: Array<{
|
||||
title: string
|
||||
href: string
|
||||
}>
|
||||
}
|
||||
|
||||
function useInitialValue<T>(value: T, condition = true) {
|
||||
let initialValue = useRef(value).current
|
||||
return condition ? initialValue : value
|
||||
}
|
||||
|
||||
function TopLevelNavItem({
|
||||
href,
|
||||
children,
|
||||
}: {
|
||||
href: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<li className="md:hidden">
|
||||
<CloseButton
|
||||
as={Link}
|
||||
href={href}
|
||||
className="block py-1 text-sm text-zinc-600 transition hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-white"
|
||||
>
|
||||
{children}
|
||||
</CloseButton>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function NavLink({
|
||||
href,
|
||||
children,
|
||||
tag,
|
||||
apilevel,
|
||||
active = false,
|
||||
isAnchorLink = false,
|
||||
}: {
|
||||
href: string
|
||||
children: React.ReactNode
|
||||
tag?: string
|
||||
active?: boolean
|
||||
isAnchorLink?: boolean
|
||||
apilevel?: string
|
||||
}) {
|
||||
const apiLevelColor =
|
||||
apilevel?.toLowerCase() == 'user'
|
||||
? 'text-blue-400/90 dark:text-blue-400/70'
|
||||
: apilevel?.toLowerCase() == 'system'
|
||||
? 'text-yellow-600/90 dark:text-yellow-400/70'
|
||||
: 'text-zinc-400 dark:text-zinc-500'
|
||||
|
||||
|
||||
return (
|
||||
<CloseButton
|
||||
as={Link}
|
||||
href={href}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
className={clsx(
|
||||
'flex justify-between gap-2 py-1 pr-3 text-sm transition',
|
||||
isAnchorLink ? 'pl-7' : 'pl-4',
|
||||
active
|
||||
? 'text-zinc-900 dark:text-white'
|
||||
: 'text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-white',
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{children}</span>
|
||||
<div className="inline-flex items-center gap-x-1">
|
||||
{apilevel && (
|
||||
<span
|
||||
className={`font-mono text-[0.625rem]/6 font-bold ${apiLevelColor}`}
|
||||
>
|
||||
{apilevel.toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
{tag && apilevel && (
|
||||
<span className="h-0.5 w-0.5 rounded-full bg-zinc-300 dark:bg-zinc-600" />
|
||||
)}
|
||||
{tag && (
|
||||
<Tag variant="small" color="zinc">
|
||||
{tag}
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
</CloseButton>
|
||||
)
|
||||
}
|
||||
|
||||
function VisibleSectionHighlight({
|
||||
group,
|
||||
pathname,
|
||||
}: {
|
||||
group: NavGroup
|
||||
pathname: string
|
||||
}) {
|
||||
let [sections, visibleSections] = useInitialValue(
|
||||
[
|
||||
useSectionStore((s) => s.sections),
|
||||
useSectionStore((s) => s.visibleSections),
|
||||
],
|
||||
useIsInsideMobileNavigation(),
|
||||
)
|
||||
|
||||
let isPresent = useIsPresent()
|
||||
let firstVisibleSectionIndex = Math.max(
|
||||
0,
|
||||
[{ id: '_top' }, ...sections].findIndex(
|
||||
(section) => section.id === visibleSections[0],
|
||||
),
|
||||
)
|
||||
let itemHeight = remToPx(2)
|
||||
let height = isPresent
|
||||
? Math.max(1, visibleSections.length) * itemHeight
|
||||
: itemHeight
|
||||
let top =
|
||||
group.links.findIndex((link) => link.href === pathname) * itemHeight +
|
||||
firstVisibleSectionIndex * itemHeight
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1, transition: { delay: 0.2 } }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="absolute inset-x-0 top-0 bg-zinc-800/2.5 will-change-transform dark:bg-white/2.5"
|
||||
style={{ borderRadius: 8, height, top }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ActivePageMarker({
|
||||
group,
|
||||
pathname,
|
||||
}: {
|
||||
group: NavGroup
|
||||
pathname: string
|
||||
}) {
|
||||
let itemHeight = remToPx(2)
|
||||
let offset = remToPx(0.25)
|
||||
let activePageIndex = group.links.findIndex((link) => link.href === pathname)
|
||||
let top = offset + activePageIndex * itemHeight
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
className="absolute left-2 h-6 w-px bg-blue-500"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1, transition: { delay: 0.2 } }}
|
||||
exit={{ opacity: 0 }}
|
||||
style={{ top }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function NavigationGroup({
|
||||
group,
|
||||
className,
|
||||
}: {
|
||||
group: NavGroup
|
||||
className?: string
|
||||
}) {
|
||||
// If this is the mobile navigation then we always render the initial
|
||||
// state, so that the state does not change during the close animation.
|
||||
// The state will still update when we re-open (re-render) the navigation.
|
||||
let isInsideMobileNavigation = useIsInsideMobileNavigation()
|
||||
let [pathname, sections] = useInitialValue(
|
||||
[usePathname(), useSectionStore((s) => s.sections)],
|
||||
isInsideMobileNavigation,
|
||||
)
|
||||
|
||||
let isActiveGroup =
|
||||
group.links.findIndex((link) => link.href === pathname) !== -1
|
||||
|
||||
return (
|
||||
<li className={clsx('relative mt-6', className)}>
|
||||
<motion.h2
|
||||
layout="position"
|
||||
className="text-xs font-semibold text-zinc-900 dark:text-white"
|
||||
>
|
||||
{group.title}
|
||||
</motion.h2>
|
||||
<div className="relative mt-3 pl-2">
|
||||
<AnimatePresence initial={!isInsideMobileNavigation}>
|
||||
{isActiveGroup && (
|
||||
<VisibleSectionHighlight group={group} pathname={pathname} />
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<motion.div
|
||||
layout
|
||||
className="absolute inset-y-0 left-2 w-px bg-zinc-900/10 dark:bg-white/5"
|
||||
/>
|
||||
<AnimatePresence initial={false}>
|
||||
{isActiveGroup && (
|
||||
<ActivePageMarker group={group} pathname={pathname} />
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<ul role="list" className="border-l border-transparent">
|
||||
{group.links.map((link) => (
|
||||
<motion.li key={link.href} layout="position" className="relative">
|
||||
<NavLink href={link.href} active={link.href === pathname}>
|
||||
{link.title}
|
||||
</NavLink>
|
||||
<AnimatePresence mode="popLayout" initial={false}>
|
||||
{link.href === pathname && sections.length > 0 && (
|
||||
<motion.ul
|
||||
role="list"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
transition: { delay: 0.1 },
|
||||
}}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
transition: { duration: 0.15 },
|
||||
}}
|
||||
>
|
||||
{sections.map((section) => (
|
||||
<li key={section.id}>
|
||||
<NavLink
|
||||
href={`${link.href}#${section.id}`}
|
||||
tag={section.tag}
|
||||
apilevel={section.apilevel}
|
||||
isAnchorLink
|
||||
>
|
||||
{section.title}
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
</motion.ul>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
export const navigation: Array<NavGroup> = [
|
||||
{
|
||||
title: 'Guides',
|
||||
links: [
|
||||
{ title: 'Introduction', href: '/' },
|
||||
{ title: 'Quickstart', href: '/guides/quickstart' },
|
||||
{ title: 'Web API', href: '/guides/web' },
|
||||
{ title: 'Client API', href: '/guides/client' },
|
||||
{ title: 'Plugins', href: '/guides/plugins' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Web API',
|
||||
links: [
|
||||
{ title: 'Users', href: '/web/users' },
|
||||
{ title: 'Objects', href: '/web/objects' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export function Navigation(props: React.ComponentPropsWithoutRef<'nav'>) {
|
||||
return (
|
||||
<nav {...props}>
|
||||
<ul role="list">
|
||||
<TopLevelNavItem href="/">API</TopLevelNavItem>
|
||||
<TopLevelNavItem href="#">Documentation</TopLevelNavItem>
|
||||
<TopLevelNavItem href="#">Support</TopLevelNavItem>
|
||||
{navigation.map((group, groupIndex) => (
|
||||
<NavigationGroup
|
||||
key={group.title}
|
||||
group={group}
|
||||
className={groupIndex === 0 ? 'md:mt-0' : ''}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
24
src/components/Prose.tsx
Normal file
24
src/components/Prose.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import clsx from 'clsx'
|
||||
|
||||
export function Prose<T extends React.ElementType = 'div'>({
|
||||
as,
|
||||
className,
|
||||
...props
|
||||
}: Omit<React.ComponentPropsWithoutRef<T>, 'as' | 'className'> & {
|
||||
as?: T
|
||||
className?: string
|
||||
}) {
|
||||
let Component = as ?? 'div'
|
||||
|
||||
return (
|
||||
<Component
|
||||
className={clsx(
|
||||
className,
|
||||
'prose dark:prose-invert',
|
||||
// `html :where(& > *)` is used to select all direct children without an increase in specificity like you'd get from just `& > *`
|
||||
'[html_:where(&>*)]:mx-auto [html_:where(&>*)]:max-w-2xl lg:[html_:where(&>*)]:mx-[calc(50%-min(50%,var(--container-lg)))] lg:[html_:where(&>*)]:max-w-3xl',
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
186
src/components/Resources.tsx
Normal file
186
src/components/Resources.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
motion,
|
||||
useMotionTemplate,
|
||||
useMotionValue,
|
||||
type MotionValue,
|
||||
} from 'framer-motion'
|
||||
import Link from 'next/link'
|
||||
|
||||
import { GridPattern } from '@/components/GridPattern'
|
||||
import { Heading } from '@/components/Heading'
|
||||
import { EnvelopeIcon } from '@/components/icons/EnvelopeIcon'
|
||||
import { UserIcon } from '@/components/icons/UserIcon'
|
||||
import { UsersIcon } from '@/components/icons/UsersIcon'
|
||||
import { DocumentIcon } from '@/components/icons/DocumentIcon'
|
||||
|
||||
interface Resource {
|
||||
href: string
|
||||
name: string
|
||||
description: string
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
pattern: Omit<
|
||||
React.ComponentPropsWithoutRef<typeof GridPattern>,
|
||||
'width' | 'height' | 'x'
|
||||
>
|
||||
}
|
||||
|
||||
const resources: Array<Resource> = [
|
||||
{
|
||||
href: '/web/users',
|
||||
name: 'Users',
|
||||
description:
|
||||
'Learn about the user model, authentication methods, and how to interact with them.',
|
||||
icon: UserIcon,
|
||||
pattern: {
|
||||
y: 16,
|
||||
squares: [
|
||||
[0, 1],
|
||||
[1, 3],
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
href: '/web/objects',
|
||||
name: 'Objects',
|
||||
description:
|
||||
'Learn how to access object data, and perform CRUD operations on it.',
|
||||
icon: DocumentIcon,
|
||||
pattern: {
|
||||
y: -6,
|
||||
squares: [
|
||||
[-1, 2],
|
||||
[1, 3],
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
href: '/messages',
|
||||
name: 'Messages',
|
||||
description:
|
||||
'Learn about the message model and how to create, retrieve, update, delete, and list messages.',
|
||||
icon: EnvelopeIcon,
|
||||
pattern: {
|
||||
y: 32,
|
||||
squares: [
|
||||
[0, 2],
|
||||
[1, 4],
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
href: '/groups',
|
||||
name: 'Groups',
|
||||
description:
|
||||
'Learn about the group model and how to create, retrieve, update, delete, and list groups.',
|
||||
icon: UsersIcon,
|
||||
pattern: {
|
||||
y: 22,
|
||||
squares: [[0, 1]],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
function ResourceIcon({ icon: Icon }: { icon: Resource['icon'] }) {
|
||||
return (
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-zinc-900/5 ring-1 ring-zinc-900/25 backdrop-blur-[2px] transition duration-300 group-hover:bg-white/50 group-hover:ring-zinc-900/25 dark:bg-white/7.5 dark:ring-white/15 dark:group-hover:bg-blue-300/10 dark:group-hover:ring-blue-400">
|
||||
<Icon className="h-5 w-5 fill-zinc-700/10 stroke-zinc-700 transition-colors duration-300 group-hover:stroke-zinc-900 dark:fill-white/10 dark:stroke-zinc-400 dark:group-hover:fill-blue-300/10 dark:group-hover:stroke-blue-400" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ResourcePattern({
|
||||
mouseX,
|
||||
mouseY,
|
||||
...gridProps
|
||||
}: Resource['pattern'] & {
|
||||
mouseX: MotionValue<number>
|
||||
mouseY: MotionValue<number>
|
||||
}) {
|
||||
let maskImage = useMotionTemplate`radial-gradient(180px at ${mouseX}px ${mouseY}px, white, transparent)`
|
||||
let style = { maskImage, WebkitMaskImage: maskImage }
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none">
|
||||
<div className="absolute inset-0 rounded-2xl mask-[linear-gradient(white,transparent)] transition duration-300 group-hover:opacity-50">
|
||||
<GridPattern
|
||||
width={72}
|
||||
height={56}
|
||||
x="50%"
|
||||
className="absolute inset-x-0 inset-y-[-30%] h-[160%] w-full skew-y-[-18deg] fill-black/2 stroke-black/5 dark:fill-white/1 dark:stroke-white/2.5"
|
||||
{...gridProps}
|
||||
/>
|
||||
</div>
|
||||
<motion.div
|
||||
className="absolute inset-0 rounded-2xl bg-linear-to-r from-blue-600/10 to-blue-400/10 opacity-0 transition duration-300 group-hover:opacity-100 dark:from-[#202D2E] dark:to-[#303428]"
|
||||
style={style}
|
||||
/>
|
||||
<motion.div
|
||||
className="absolute inset-0 rounded-2xl opacity-0 mix-blend-overlay transition duration-300 group-hover:opacity-100"
|
||||
style={style}
|
||||
>
|
||||
<GridPattern
|
||||
width={72}
|
||||
height={56}
|
||||
x="50%"
|
||||
className="absolute inset-x-0 inset-y-[-30%] h-[160%] w-full skew-y-[-18deg] fill-black/50 stroke-black/70 dark:fill-white/2.5 dark:stroke-white/10"
|
||||
{...gridProps}
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Resource({ resource }: { resource: Resource }) {
|
||||
let mouseX = useMotionValue(0)
|
||||
let mouseY = useMotionValue(0)
|
||||
|
||||
function onMouseMove({
|
||||
currentTarget,
|
||||
clientX,
|
||||
clientY,
|
||||
}: React.MouseEvent<HTMLDivElement>) {
|
||||
let { left, top } = currentTarget.getBoundingClientRect()
|
||||
mouseX.set(clientX - left)
|
||||
mouseY.set(clientY - top)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={resource.href}
|
||||
onMouseMove={onMouseMove}
|
||||
className="group relative flex rounded-2xl bg-zinc-50 transition-shadow hover:shadow-md hover:shadow-zinc-900/5 dark:bg-white/2.5 dark:hover:shadow-black/5"
|
||||
>
|
||||
<ResourcePattern {...resource.pattern} mouseX={mouseX} mouseY={mouseY} />
|
||||
<div className="absolute inset-0 rounded-2xl ring-1 ring-zinc-900/7.5 ring-inset group-hover:ring-zinc-900/10 dark:ring-white/10 dark:group-hover:ring-white/20" />
|
||||
<div className="relative rounded-2xl px-4 pt-16 pb-4">
|
||||
<ResourceIcon icon={resource.icon} />
|
||||
<h3 className="mt-4 text-sm/7 font-semibold text-zinc-900 dark:text-white">
|
||||
<Link href={resource.href}>
|
||||
<span className="absolute inset-0 rounded-2xl" />
|
||||
{resource.name}
|
||||
</Link>
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
{resource.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Resources() {
|
||||
return (
|
||||
<div className="my-16 xl:max-w-none">
|
||||
<Heading level={2} id="resources">
|
||||
Web API
|
||||
</Heading>
|
||||
<div className="not-prose mt-4 grid grid-cols-1 gap-8 border-t border-zinc-900/5 pt-10 sm:grid-cols-2 xl:grid-cols-4 dark:border-white/5">
|
||||
{resources.map((resource) => (
|
||||
<Resource key={resource.href} resource={resource} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
493
src/components/Search.tsx
Normal file
493
src/components/Search.tsx
Normal file
@@ -0,0 +1,493 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
createAutocomplete,
|
||||
type AutocompleteApi,
|
||||
type AutocompleteCollection,
|
||||
type AutocompleteState,
|
||||
} from '@algolia/autocomplete-core'
|
||||
import { Dialog, DialogBackdrop, DialogPanel } from '@headlessui/react'
|
||||
import clsx from 'clsx'
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
||||
import {
|
||||
Fragment,
|
||||
Suspense,
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import Highlighter from 'react-highlight-words'
|
||||
|
||||
import { navigation } from '@/components/Navigation'
|
||||
import { type Result } from '@/mdx/search.mjs'
|
||||
import { useMobileNavigationStore } from './MobileNavigation'
|
||||
|
||||
type EmptyObject = Record<string, never>
|
||||
|
||||
type Autocomplete = AutocompleteApi<
|
||||
Result,
|
||||
React.SyntheticEvent,
|
||||
React.MouseEvent,
|
||||
React.KeyboardEvent
|
||||
>
|
||||
|
||||
function useAutocomplete({ onNavigate }: { onNavigate: () => void }) {
|
||||
let id = useId()
|
||||
let router = useRouter()
|
||||
let [autocompleteState, setAutocompleteState] = useState<
|
||||
AutocompleteState<Result> | EmptyObject
|
||||
>({})
|
||||
|
||||
function navigate({ itemUrl }: { itemUrl?: string }) {
|
||||
if (itemUrl) {
|
||||
router.push(itemUrl)
|
||||
}
|
||||
|
||||
onNavigate()
|
||||
}
|
||||
|
||||
let [autocomplete] = useState<Autocomplete>(() =>
|
||||
createAutocomplete<
|
||||
Result,
|
||||
React.SyntheticEvent,
|
||||
React.MouseEvent,
|
||||
React.KeyboardEvent
|
||||
>({
|
||||
id,
|
||||
placeholder: 'Find something...',
|
||||
defaultActiveItemId: 0,
|
||||
onStateChange({ state }) {
|
||||
setAutocompleteState(state)
|
||||
},
|
||||
shouldPanelOpen({ state }) {
|
||||
return state.query !== ''
|
||||
},
|
||||
navigator: {
|
||||
navigate,
|
||||
},
|
||||
getSources({ query }) {
|
||||
return import('@/mdx/search.mjs').then(({ search }) => {
|
||||
return [
|
||||
{
|
||||
sourceId: 'documentation',
|
||||
getItems() {
|
||||
return search(query, { limit: 5 })
|
||||
},
|
||||
getItemUrl({ item }) {
|
||||
return item.url
|
||||
},
|
||||
onSelect: navigate,
|
||||
},
|
||||
]
|
||||
})
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
return { autocomplete, autocompleteState }
|
||||
}
|
||||
|
||||
function SearchIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true" {...props}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12.01 12a4.25 4.25 0 1 0-6.02-6 4.25 4.25 0 0 0 6.02 6Zm0 0 3.24 3.25"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function NoResultsIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true" {...props}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12.01 12a4.237 4.237 0 0 0 1.24-3c0-.62-.132-1.207-.37-1.738M12.01 12A4.237 4.237 0 0 1 9 13.25c-.635 0-1.237-.14-1.777-.388M12.01 12l3.24 3.25m-3.715-9.661a4.25 4.25 0 0 0-5.975 5.908M4.5 15.5l11-11"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function LoadingIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
||||
let id = useId()
|
||||
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true" {...props}>
|
||||
<circle cx="10" cy="10" r="5.5" strokeLinejoin="round" />
|
||||
<path
|
||||
stroke={`url(#${id})`}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15.5 10a5.5 5.5 0 1 0-5.5 5.5"
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id={id}
|
||||
x1="13"
|
||||
x2="9.5"
|
||||
y1="9"
|
||||
y2="15"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="currentColor" />
|
||||
<stop offset="1" stopColor="currentColor" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function HighlightQuery({ text, query }: { text: string; query: string }) {
|
||||
return (
|
||||
<Highlighter
|
||||
highlightClassName="underline bg-transparent text-blue-500"
|
||||
searchWords={[query]}
|
||||
autoEscape={true}
|
||||
textToHighlight={text}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SearchResult({
|
||||
result,
|
||||
resultIndex,
|
||||
autocomplete,
|
||||
collection,
|
||||
query,
|
||||
}: {
|
||||
result: Result
|
||||
resultIndex: number
|
||||
autocomplete: Autocomplete
|
||||
collection: AutocompleteCollection<Result>
|
||||
query: string
|
||||
}) {
|
||||
let id = useId()
|
||||
|
||||
let sectionTitle = navigation.find((section) =>
|
||||
section.links.find((link) => link.href === result.url.split('#')[0]),
|
||||
)?.title
|
||||
let hierarchy = [sectionTitle, result.pageTitle].filter(
|
||||
(x): x is string => typeof x === 'string',
|
||||
)
|
||||
|
||||
return (
|
||||
<li
|
||||
className={clsx(
|
||||
'group block cursor-default px-4 py-3 aria-selected:bg-zinc-50 dark:aria-selected:bg-zinc-800/50',
|
||||
resultIndex > 0 && 'border-t border-zinc-100 dark:border-zinc-800',
|
||||
)}
|
||||
aria-labelledby={`${id}-hierarchy ${id}-title`}
|
||||
{...autocomplete.getItemProps({
|
||||
item: result,
|
||||
source: collection.source,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
id={`${id}-title`}
|
||||
aria-hidden="true"
|
||||
className="text-sm font-medium text-zinc-900 group-aria-selected:text-blue-500 dark:text-white"
|
||||
>
|
||||
<HighlightQuery text={result.title} query={query} />
|
||||
</div>
|
||||
{hierarchy.length > 0 && (
|
||||
<div
|
||||
id={`${id}-hierarchy`}
|
||||
aria-hidden="true"
|
||||
className="mt-1 truncate text-2xs whitespace-nowrap text-zinc-500"
|
||||
>
|
||||
{hierarchy.map((item, itemIndex, items) => (
|
||||
<Fragment key={itemIndex}>
|
||||
<HighlightQuery text={item} query={query} />
|
||||
<span
|
||||
className={
|
||||
itemIndex === items.length - 1
|
||||
? 'sr-only'
|
||||
: 'mx-2 text-zinc-300 dark:text-zinc-700'
|
||||
}
|
||||
>
|
||||
/
|
||||
</span>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function SearchResults({
|
||||
autocomplete,
|
||||
query,
|
||||
collection,
|
||||
}: {
|
||||
autocomplete: Autocomplete
|
||||
query: string
|
||||
collection: AutocompleteCollection<Result>
|
||||
}) {
|
||||
if (collection.items.length === 0) {
|
||||
return (
|
||||
<div className="p-6 text-center">
|
||||
<NoResultsIcon className="mx-auto h-5 w-5 stroke-zinc-900 dark:stroke-zinc-600" />
|
||||
<p className="mt-2 text-xs text-zinc-700 dark:text-zinc-400">
|
||||
Nothing found for{' '}
|
||||
<strong className="font-semibold break-words text-zinc-900 dark:text-white">
|
||||
‘{query}’
|
||||
</strong>
|
||||
. Please try again.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ul {...autocomplete.getListProps()}>
|
||||
{collection.items.map((result, resultIndex) => (
|
||||
<SearchResult
|
||||
key={result.url}
|
||||
result={result}
|
||||
resultIndex={resultIndex}
|
||||
autocomplete={autocomplete}
|
||||
collection={collection}
|
||||
query={query}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
const SearchInput = forwardRef<
|
||||
React.ElementRef<'input'>,
|
||||
{
|
||||
autocomplete: Autocomplete
|
||||
autocompleteState: AutocompleteState<Result> | EmptyObject
|
||||
onClose: () => void
|
||||
}
|
||||
>(function SearchInput({ autocomplete, autocompleteState, onClose }, inputRef) {
|
||||
let inputProps = autocomplete.getInputProps({ inputElement: null })
|
||||
|
||||
return (
|
||||
<div className="group relative flex h-12">
|
||||
<SearchIcon className="pointer-events-none absolute top-0 left-3 h-full w-5 stroke-zinc-500" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
data-autofocus
|
||||
className={clsx(
|
||||
'flex-auto appearance-none bg-transparent pl-10 text-zinc-900 outline-hidden placeholder:text-zinc-500 focus:w-full focus:flex-none sm:text-sm dark:text-white [&::-webkit-search-cancel-button]:hidden [&::-webkit-search-decoration]:hidden [&::-webkit-search-results-button]:hidden [&::-webkit-search-results-decoration]:hidden',
|
||||
autocompleteState.status === 'stalled' ? 'pr-11' : 'pr-4',
|
||||
)}
|
||||
{...inputProps}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
event.key === 'Escape' &&
|
||||
!autocompleteState.isOpen &&
|
||||
autocompleteState.query === ''
|
||||
) {
|
||||
// In Safari, closing the dialog with the escape key can sometimes cause the scroll position to jump to the
|
||||
// bottom of the page. This is a workaround for that until we can figure out a proper fix in Headless UI.
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.blur()
|
||||
}
|
||||
|
||||
onClose()
|
||||
} else {
|
||||
inputProps.onKeyDown(event)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{autocompleteState.status === 'stalled' && (
|
||||
<div className="absolute inset-y-0 right-3 flex items-center">
|
||||
<LoadingIcon className="h-5 w-5 animate-spin stroke-zinc-200 text-zinc-900 dark:stroke-zinc-800 dark:text-blue-400" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
function SearchDialog({
|
||||
open,
|
||||
setOpen,
|
||||
className,
|
||||
onNavigate = () => {},
|
||||
}: {
|
||||
open: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
className?: string
|
||||
onNavigate?: () => void
|
||||
}) {
|
||||
let formRef = useRef<React.ElementRef<'form'>>(null)
|
||||
let panelRef = useRef<React.ElementRef<'div'>>(null)
|
||||
let inputRef = useRef<React.ElementRef<typeof SearchInput>>(null)
|
||||
let { autocomplete, autocompleteState } = useAutocomplete({
|
||||
onNavigate() {
|
||||
onNavigate()
|
||||
setOpen(false)
|
||||
},
|
||||
})
|
||||
let pathname = usePathname()
|
||||
let searchParams = useSearchParams()
|
||||
|
||||
useEffect(() => {
|
||||
setOpen(false)
|
||||
}, [pathname, searchParams, setOpen])
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
return
|
||||
}
|
||||
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'k' && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault()
|
||||
setOpen(true)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
}
|
||||
}, [open, setOpen])
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={() => {
|
||||
setOpen(false)
|
||||
autocomplete.setQuery('')
|
||||
}}
|
||||
className={clsx('fixed inset-0 z-50', className)}
|
||||
>
|
||||
<DialogBackdrop
|
||||
transition
|
||||
className="fixed inset-0 bg-zinc-400/25 backdrop-blur-xs data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in dark:bg-black/40"
|
||||
/>
|
||||
|
||||
<div className="fixed inset-0 overflow-y-auto px-4 py-4 sm:px-6 sm:py-20 md:py-32 lg:px-8 lg:py-[15vh]">
|
||||
<DialogPanel
|
||||
transition
|
||||
className="mx-auto transform-gpu overflow-hidden rounded-lg bg-zinc-50 shadow-xl ring-1 ring-zinc-900/7.5 data-closed:scale-95 data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in sm:max-w-xl dark:bg-zinc-900 dark:ring-zinc-800"
|
||||
>
|
||||
<div {...autocomplete.getRootProps({})}>
|
||||
<form
|
||||
ref={formRef}
|
||||
{...autocomplete.getFormProps({
|
||||
inputElement: inputRef.current,
|
||||
})}
|
||||
>
|
||||
<SearchInput
|
||||
ref={inputRef}
|
||||
autocomplete={autocomplete}
|
||||
autocompleteState={autocompleteState}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="border-t border-zinc-200 bg-white empty:hidden dark:border-zinc-100/5 dark:bg-white/2.5"
|
||||
{...autocomplete.getPanelProps({})}
|
||||
>
|
||||
{autocompleteState.isOpen && (
|
||||
<SearchResults
|
||||
autocomplete={autocomplete}
|
||||
query={autocompleteState.query}
|
||||
collection={autocompleteState.collections[0]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</DialogPanel>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function useSearchProps() {
|
||||
let buttonRef = useRef<React.ElementRef<'button'>>(null)
|
||||
let [open, setOpen] = useState(false)
|
||||
|
||||
return {
|
||||
buttonProps: {
|
||||
ref: buttonRef,
|
||||
onClick() {
|
||||
setOpen(true)
|
||||
},
|
||||
},
|
||||
dialogProps: {
|
||||
open,
|
||||
setOpen: useCallback(
|
||||
(open: boolean) => {
|
||||
let { width = 0, height = 0 } =
|
||||
buttonRef.current?.getBoundingClientRect() ?? {}
|
||||
if (!open || (width !== 0 && height !== 0)) {
|
||||
setOpen(open)
|
||||
}
|
||||
},
|
||||
[setOpen],
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function Search() {
|
||||
let [modifierKey, setModifierKey] = useState<string>()
|
||||
let { buttonProps, dialogProps } = useSearchProps()
|
||||
|
||||
useEffect(() => {
|
||||
setModifierKey(
|
||||
/(Mac|iPhone|iPod|iPad)/i.test(navigator.platform) ? '⌘' : 'Ctrl ',
|
||||
)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="hidden lg:block lg:max-w-md lg:flex-auto">
|
||||
<button
|
||||
type="button"
|
||||
className="hidden h-8 w-full items-center gap-2 rounded-full bg-white pr-3 pl-2 text-sm text-zinc-500 ring-1 ring-zinc-900/10 transition hover:ring-zinc-900/20 lg:flex dark:bg-white/5 dark:text-zinc-400 dark:ring-white/10 dark:ring-inset dark:hover:ring-white/20"
|
||||
{...buttonProps}
|
||||
>
|
||||
<SearchIcon className="h-5 w-5 stroke-current" />
|
||||
Find something...
|
||||
<kbd className="ml-auto text-2xs text-zinc-400 dark:text-zinc-500">
|
||||
<kbd className="font-sans">{modifierKey}</kbd>
|
||||
<kbd className="font-sans">K</kbd>
|
||||
</kbd>
|
||||
</button>
|
||||
<Suspense fallback={null}>
|
||||
<SearchDialog className="hidden lg:block" {...dialogProps} />
|
||||
</Suspense>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function MobileSearch() {
|
||||
let { close } = useMobileNavigationStore()
|
||||
let { buttonProps, dialogProps } = useSearchProps()
|
||||
|
||||
return (
|
||||
<div className="contents lg:hidden">
|
||||
<button
|
||||
type="button"
|
||||
className="relative flex size-6 items-center justify-center rounded-md transition hover:bg-zinc-900/5 lg:hidden dark:hover:bg-white/5"
|
||||
aria-label="Find something..."
|
||||
{...buttonProps}
|
||||
>
|
||||
<span className="absolute size-12 pointer-fine:hidden" />
|
||||
<SearchIcon className="h-5 w-5 stroke-zinc-900 dark:stroke-white" />
|
||||
</button>
|
||||
<Suspense fallback={null}>
|
||||
<SearchDialog
|
||||
className="lg:hidden"
|
||||
onNavigate={close}
|
||||
{...dialogProps}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
154
src/components/SectionProvider.tsx
Normal file
154
src/components/SectionProvider.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { createStore, useStore, type StoreApi } from 'zustand'
|
||||
|
||||
import { remToPx } from '@/lib/remToPx'
|
||||
|
||||
export interface Section {
|
||||
id: string
|
||||
title: string
|
||||
offsetRem?: number
|
||||
tag?: string
|
||||
apilevel?: string
|
||||
headingRef?: React.RefObject<HTMLHeadingElement | null>
|
||||
}
|
||||
|
||||
interface SectionState {
|
||||
sections: Array<Section>
|
||||
visibleSections: Array<string>
|
||||
setVisibleSections: (visibleSections: Array<string>) => void
|
||||
registerHeading: ({
|
||||
id,
|
||||
ref,
|
||||
offsetRem,
|
||||
}: {
|
||||
id: string
|
||||
ref: React.RefObject<HTMLHeadingElement | null>
|
||||
offsetRem: number
|
||||
}) => void
|
||||
}
|
||||
|
||||
function createSectionStore(sections: Array<Section>) {
|
||||
return createStore<SectionState>()((set) => ({
|
||||
sections,
|
||||
visibleSections: [],
|
||||
setVisibleSections: (visibleSections) =>
|
||||
set((state) =>
|
||||
state.visibleSections.join() === visibleSections.join()
|
||||
? {}
|
||||
: { visibleSections },
|
||||
),
|
||||
registerHeading: ({ id, ref, offsetRem }) =>
|
||||
set((state) => {
|
||||
return {
|
||||
sections: state.sections.map((section) => {
|
||||
if (section.id === id) {
|
||||
return {
|
||||
...section,
|
||||
headingRef: ref,
|
||||
offsetRem,
|
||||
}
|
||||
}
|
||||
return section
|
||||
}),
|
||||
}
|
||||
}),
|
||||
}))
|
||||
}
|
||||
|
||||
function useVisibleSections(sectionStore: StoreApi<SectionState>) {
|
||||
let setVisibleSections = useStore(sectionStore, (s) => s.setVisibleSections)
|
||||
let sections = useStore(sectionStore, (s) => s.sections)
|
||||
|
||||
useEffect(() => {
|
||||
function checkVisibleSections() {
|
||||
let { innerHeight, scrollY } = window
|
||||
let newVisibleSections = []
|
||||
|
||||
for (
|
||||
let sectionIndex = 0;
|
||||
sectionIndex < sections.length;
|
||||
sectionIndex++
|
||||
) {
|
||||
let { id, headingRef, offsetRem = 0 } = sections[sectionIndex]
|
||||
|
||||
if (!headingRef?.current) {
|
||||
continue
|
||||
}
|
||||
|
||||
let offset = remToPx(offsetRem)
|
||||
let top = headingRef.current.getBoundingClientRect().top + scrollY
|
||||
|
||||
if (sectionIndex === 0 && top - offset > scrollY) {
|
||||
newVisibleSections.push('_top')
|
||||
}
|
||||
|
||||
let nextSection = sections[sectionIndex + 1]
|
||||
let bottom =
|
||||
(nextSection?.headingRef?.current?.getBoundingClientRect().top ??
|
||||
Infinity) +
|
||||
scrollY -
|
||||
remToPx(nextSection?.offsetRem ?? 0)
|
||||
|
||||
if (
|
||||
(top > scrollY && top < scrollY + innerHeight) ||
|
||||
(bottom > scrollY && bottom < scrollY + innerHeight) ||
|
||||
(top <= scrollY && bottom >= scrollY + innerHeight)
|
||||
) {
|
||||
newVisibleSections.push(id)
|
||||
}
|
||||
}
|
||||
|
||||
setVisibleSections(newVisibleSections)
|
||||
}
|
||||
|
||||
let raf = window.requestAnimationFrame(() => checkVisibleSections())
|
||||
window.addEventListener('scroll', checkVisibleSections, { passive: true })
|
||||
window.addEventListener('resize', checkVisibleSections)
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(raf)
|
||||
window.removeEventListener('scroll', checkVisibleSections)
|
||||
window.removeEventListener('resize', checkVisibleSections)
|
||||
}
|
||||
}, [setVisibleSections, sections])
|
||||
}
|
||||
|
||||
const SectionStoreContext = createContext<StoreApi<SectionState> | null>(null)
|
||||
|
||||
const useIsomorphicLayoutEffect =
|
||||
typeof window === 'undefined' ? useEffect : useLayoutEffect
|
||||
|
||||
export function SectionProvider({
|
||||
sections,
|
||||
children,
|
||||
}: {
|
||||
sections: Array<Section>
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
let [sectionStore] = useState(() => createSectionStore(sections))
|
||||
|
||||
useVisibleSections(sectionStore)
|
||||
|
||||
useIsomorphicLayoutEffect(() => {
|
||||
sectionStore.setState({ sections })
|
||||
}, [sectionStore, sections])
|
||||
|
||||
return (
|
||||
<SectionStoreContext.Provider value={sectionStore}>
|
||||
{children}
|
||||
</SectionStoreContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useSectionStore<T>(selector: (state: SectionState) => T) {
|
||||
let store = useContext(SectionStoreContext)
|
||||
return useStore(store!, selector)
|
||||
}
|
||||
63
src/components/Tag.tsx
Normal file
63
src/components/Tag.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import clsx from 'clsx'
|
||||
|
||||
const variantStyles = {
|
||||
small: '',
|
||||
medium: 'rounded-lg px-1.5 ring-1 ring-inset',
|
||||
}
|
||||
|
||||
const colorStyles = {
|
||||
blue: {
|
||||
small: 'text-blue-500 dark:text-blue-400',
|
||||
medium:
|
||||
'ring-blue-300 dark:ring-blue-400/30 bg-blue-400/10 text-blue-500 dark:text-blue-400',
|
||||
},
|
||||
emerald: {
|
||||
small: 'text-emerald-500',
|
||||
medium:
|
||||
'ring-emerald-300 bg-emerald-400/10 text-emerald-500 dark:ring-emerald-400/30 dark:bg-emerald-400/10 dark:text-emerald-400',
|
||||
},
|
||||
amber: {
|
||||
small: 'text-amber-500',
|
||||
medium:
|
||||
'ring-amber-300 bg-amber-400/10 text-amber-500 dark:ring-amber-400/30 dark:bg-amber-400/10 dark:text-amber-400',
|
||||
},
|
||||
rose: {
|
||||
small: 'text-red-500 dark:text-rose-500',
|
||||
medium:
|
||||
'ring-rose-200 bg-rose-50 text-red-500 dark:ring-rose-500/20 dark:bg-rose-400/10 dark:text-rose-400',
|
||||
},
|
||||
zinc: {
|
||||
small: 'text-zinc-400 dark:text-zinc-500',
|
||||
medium:
|
||||
'ring-zinc-200 bg-zinc-50 text-zinc-500 dark:ring-zinc-500/20 dark:bg-zinc-400/10 dark:text-zinc-400',
|
||||
},
|
||||
}
|
||||
|
||||
const valueColorMap = {
|
||||
GET: 'blue',
|
||||
POST: 'emerald',
|
||||
PUT: 'amber',
|
||||
DELETE: 'rose',
|
||||
} as Record<string, keyof typeof colorStyles>
|
||||
|
||||
export function Tag({
|
||||
children,
|
||||
variant = 'medium',
|
||||
color = valueColorMap[children] ?? 'blue',
|
||||
}: {
|
||||
children: keyof typeof valueColorMap & (string | {})
|
||||
variant?: keyof typeof variantStyles
|
||||
color?: keyof typeof colorStyles
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={clsx(
|
||||
'font-mono text-[0.625rem]/6 font-semibold',
|
||||
variantStyles[variant],
|
||||
colorStyles[color][variant],
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
45
src/components/ThemeToggle.tsx
Normal file
45
src/components/ThemeToggle.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { useTheme } from 'next-themes'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
function SunIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true" {...props}>
|
||||
<path d="M12.5 10a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0Z" />
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
d="M10 5.5v-1M13.182 6.818l.707-.707M14.5 10h1M13.182 13.182l.707.707M10 15.5v-1M6.11 13.889l.708-.707M4.5 10h1M6.11 6.111l.708.707"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function MoonIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true" {...props}>
|
||||
<path d="M15.224 11.724a5.5 5.5 0 0 1-6.949-6.949 5.5 5.5 0 1 0 6.949 6.949Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function ThemeToggle() {
|
||||
let { resolvedTheme, setTheme } = useTheme()
|
||||
let otherTheme = resolvedTheme === 'dark' ? 'light' : 'dark'
|
||||
let [mounted, setMounted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-6 items-center justify-center rounded-md transition hover:bg-zinc-900/5 dark:hover:bg-white/5"
|
||||
aria-label={mounted ? `Switch to ${otherTheme} theme` : 'Toggle theme'}
|
||||
onClick={() => setTheme(otherTheme)}
|
||||
>
|
||||
<span className="absolute size-12 pointer-fine:hidden" />
|
||||
<SunIcon className="h-5 w-5 stroke-zinc-900 dark:hidden" />
|
||||
<MoonIcon className="hidden h-5 w-5 stroke-white dark:block" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
17
src/components/icons/BellIcon.tsx
Normal file
17
src/components/icons/BellIcon.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
export function BellIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M4.438 8.063a5.563 5.563 0 0 1 11.125 0v2.626c0 1.182.34 2.34.982 3.332L17.5 15.5h-15l.955-1.479c.641-.993.982-2.15.982-3.332V8.062Z"
|
||||
/>
|
||||
<path
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M7.5 15.5v0a2 2 0 0 0 2 2h1a2 2 0 0 0 2-2v0"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
11
src/components/icons/BoltIcon.tsx
Normal file
11
src/components/icons/BoltIcon.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
export function BoltIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M4.5 11.5 10 2v5.5a1 1 0 0 0 1 1h4.5L10 18v-5.5a1 1 0 0 0-1-1H4.5Z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
17
src/components/icons/BookIcon.tsx
Normal file
17
src/components/icons/BookIcon.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
export function BookIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||
<path
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="m10 5.5-7.5-3v12l7.5 3m0-12 7.5-3v12l-7.5 3m0-12v12"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="m17.5 2.5-7.5 3v12l7.5-3v-12Z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
23
src/components/icons/CalendarIcon.tsx
Normal file
23
src/components/icons/CalendarIcon.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
export function CalendarIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||
<path
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M2.5 6.5a2 2 0 0 1 2-2h11a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-11a2 2 0 0 1-2-2v-9Z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M2.5 6.5a2 2 0 0 1 2-2h11a2 2 0 0 1 2 2v2h-15v-2Z"
|
||||
/>
|
||||
<path
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M5.5 5.5v-3M14.5 5.5v-3"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
15
src/components/icons/CartIcon.tsx
Normal file
15
src/components/icons/CartIcon.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
export function CartIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||
<path
|
||||
strokeWidth="0"
|
||||
d="M5.98 11.288 3.5 5.5h14l-2.48 5.788A2 2 0 0 1 13.18 12.5H7.82a2 2 0 0 1-1.838-1.212Z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="m3.5 5.5 2.48 5.788A2 2 0 0 0 7.82 12.5h5.362a2 2 0 0 0 1.839-1.212L17.5 5.5h-14Zm0 0-1-2M6.5 14.5a1 1 0 1 1 0 2 1 1 0 0 1 0-2ZM14.5 14.5a1 1 0 1 1 0 2 1 1 0 0 1 0-2Z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
17
src/components/icons/ChatBubbleIcon.tsx
Normal file
17
src/components/icons/ChatBubbleIcon.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
export function ChatBubbleIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M10 16.5c4.142 0 7.5-3.134 7.5-7s-3.358-7-7.5-7c-4.142 0-7.5 3.134-7.5 7 0 1.941.846 3.698 2.214 4.966L3.5 17.5c2.231 0 3.633-.553 4.513-1.248A8.014 8.014 0 0 0 10 16.5Z"
|
||||
/>
|
||||
<path
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M7.5 8.5h5M8.5 11.5h3"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
17
src/components/icons/CheckIcon.tsx
Normal file
17
src/components/icons/CheckIcon.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
export function CheckIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M10 1.5a8.5 8.5 0 1 1 0 17 8.5 8.5 0 0 1 0-17Z"
|
||||
/>
|
||||
<path
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="m7.5 10.5 2 2c1-3.5 3-5 3-5"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
19
src/components/icons/ChevronRightLeftIcon.tsx
Normal file
19
src/components/icons/ChevronRightLeftIcon.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
export function ChevronRightLeftIcon(
|
||||
props: React.ComponentPropsWithoutRef<'svg'>,
|
||||
) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M1.5 10A6.5 6.5 0 0 1 8 3.5h4a6.5 6.5 0 1 1 0 13H8A6.5 6.5 0 0 1 1.5 10Z"
|
||||
/>
|
||||
<path
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="m7.5 7.5-3 2.5 3 2.5M12.5 7.5l3 2.5-3 2.5"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
17
src/components/icons/ClipboardIcon.tsx
Normal file
17
src/components/icons/ClipboardIcon.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
export function ClipboardIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||
<path
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M3.5 6v10a2 2 0 0 0 2 2h9a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2h-1l-.447.894A2 2 0 0 1 11.263 6H8.737a2 2 0 0 1-1.789-1.106L6.5 4h-1a2 2 0 0 0-2 2Z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="m13.5 4-.447.894A2 2 0 0 1 11.263 6H8.737a2 2 0 0 1-1.789-1.106L6.5 4l.724-1.447A1 1 0 0 1 8.118 2h3.764a1 1 0 0 1 .894.553L13.5 4Z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
19
src/components/icons/CogIcon.tsx
Normal file
19
src/components/icons/CogIcon.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
export function CogIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||
<path
|
||||
strokeWidth="0"
|
||||
fillRule="evenodd"
|
||||
d="M11.063 1.5H8.937l-.14 1.128c-.086.682-.61 1.22-1.246 1.484-.634.264-1.37.247-1.912-.175l-.898-.699-1.503 1.503.699.898c.422.543.44 1.278.175 1.912-.264.635-.802 1.16-1.484 1.245L1.5 8.938v2.124l1.128.142c.682.085 1.22.61 1.484 1.244.264.635.247 1.37-.175 1.913l-.699.898 1.503 1.503.898-.699c.543-.422 1.278-.44 1.912-.175.635.264 1.16.801 1.245 1.484l.142 1.128h2.124l.142-1.128c.085-.683.61-1.22 1.244-1.484.635-.264 1.37-.247 1.913.175l.898.699 1.503-1.503-.699-.898c-.422-.543-.44-1.278-.175-1.913.264-.634.801-1.16 1.484-1.245l1.128-.14V8.937l-1.128-.14c-.683-.086-1.22-.611-1.484-1.246-.264-.634-.247-1.37.175-1.912l.699-.898-1.503-1.503-.898.699c-.543.422-1.278.44-1.913.175-.634-.264-1.16-.802-1.244-1.484L11.062 1.5ZM10 12.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
<path
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M8.938 1.5h2.124l.142 1.128c.085.682.61 1.22 1.244 1.484v0c.635.264 1.37.247 1.913-.175l.898-.699 1.503 1.503-.699.898c-.422.543-.44 1.278-.175 1.912v0c.264.635.801 1.16 1.484 1.245l1.128.142v2.124l-1.128.142c-.683.085-1.22.61-1.484 1.244v0c-.264.635-.247 1.37.175 1.913l.699.898-1.503 1.503-.898-.699c-.543-.422-1.278-.44-1.913-.175v0c-.634.264-1.16.801-1.245 1.484l-.14 1.128H8.937l-.14-1.128c-.086-.683-.611-1.22-1.246-1.484v0c-.634-.264-1.37-.247-1.912.175l-.898.699-1.503-1.503.699-.898c.422-.543.44-1.278.175-1.913v0c-.264-.634-.802-1.16-1.484-1.245l-1.128-.14V8.937l1.128-.14c.682-.086 1.22-.61 1.484-1.246v0c.264-.634.247-1.37-.175-1.912l-.699-.898 1.503-1.503.898.699c.543.422 1.278.44 1.912.175v0c.635-.264 1.16-.802 1.245-1.484L8.938 1.5Z"
|
||||
/>
|
||||
<circle cx="10" cy="10" r="2.5" fill="none" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
17
src/components/icons/CopyIcon.tsx
Normal file
17
src/components/icons/CopyIcon.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
export function CopyIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||
<path
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M14.5 5.5v-1a2 2 0 0 0-2-2h-8a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h1"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M5.5 7.5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-8a2 2 0 0 1-2-2v-8Z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
17
src/components/icons/DocumentIcon.tsx
Normal file
17
src/components/icons/DocumentIcon.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
export function DocumentIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M3.5 4.5v11a2 2 0 0 0 2 2h9a2 2 0 0 0 2-2v-8h-5v-5h-6a2 2 0 0 0-2 2Z"
|
||||
/>
|
||||
<path
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="m11.5 2.5 5 5"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
17
src/components/icons/EnvelopeIcon.tsx
Normal file
17
src/components/icons/EnvelopeIcon.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
export function EnvelopeIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||
<path
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M2.5 5.5a2 2 0 0 1 2-2h11a2 2 0 0 1 2 2v8a3 3 0 0 1-3 3h-9a3 3 0 0 1-3-3v-8Z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M10 10 4.526 5.256c-.7-.607-.271-1.756.655-1.756h9.638c.926 0 1.355 1.15.655 1.756L10 10Z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
17
src/components/icons/FaceSmileIcon.tsx
Normal file
17
src/components/icons/FaceSmileIcon.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
export function FaceSmileIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M10 1.5a8.5 8.5 0 1 1 0 17 8.5 8.5 0 0 1 0-17Z"
|
||||
/>
|
||||
<path
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M7.5 6.5v2M12.5 6.5v2M5.5 11.5s1 3 4.5 3 4.5-3 4.5-3"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
22
src/components/icons/FolderIcon.tsx
Normal file
22
src/components/icons/FolderIcon.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
export function FolderIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||
<path
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M17.5 15.5v-8a2 2 0 0 0-2-2h-2.93a2 2 0 0 1-1.664-.89l-.812-1.22A2 2 0 0 0 8.43 2.5H4.5a2 2 0 0 0-2 2v11a2 2 0 0 0 2 2h11a2 2 0 0 0 2-2Z"
|
||||
/>
|
||||
<path
|
||||
strokeWidth="0"
|
||||
d="M8.43 2.5H4.5a2 2 0 0 0-2 2v1h9l-1.406-2.11A2 2 0 0 0 8.43 2.5Z"
|
||||
/>
|
||||
<path
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="m11.5 5.5-1.406-2.11A2 2 0 0 0 8.43 2.5H4.5a2 2 0 0 0-2 2v1h9Zm0 0h2"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
12
src/components/icons/LinkIcon.tsx
Normal file
12
src/components/icons/LinkIcon.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
export function LinkIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||
<path
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="m5.056 11.5-1.221-1.222a4.556 4.556 0 0 1 6.443-6.443L11.5 5.056M7.5 7.5l5 5m2.444-4 1.222 1.222a4.556 4.556 0 0 1-6.444 6.444L8.5 14.944"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
17
src/components/icons/ListIcon.tsx
Normal file
17
src/components/icons/ListIcon.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
export function ListIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M2.5 4.5a2 2 0 0 1 2-2h11a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2h-11a2 2 0 0 1-2-2v-11Z"
|
||||
/>
|
||||
<path
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M6.5 6.5h7M6.5 13.5h7M6.5 10h7"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
15
src/components/icons/MagnifyingGlassIcon.tsx
Normal file
15
src/components/icons/MagnifyingGlassIcon.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
export function MagnifyingGlassIcon(
|
||||
props: React.ComponentPropsWithoutRef<'svg'>,
|
||||
) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||
<path strokeWidth="0" d="M2.5 8.5a6 6 0 1 1 12 0 6 6 0 0 1-12 0Z" />
|
||||
<path
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="m13 13 4.5 4.5m-9-3a6 6 0 1 1 0-12 6 6 0 0 1 0 12Z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
19
src/components/icons/MapPinIcon.tsx
Normal file
19
src/components/icons/MapPinIcon.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
export function MapPinIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||
<path
|
||||
strokeWidth="0"
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M10 2.5A5.5 5.5 0 0 0 4.5 8c0 3.038 5.5 9.5 5.5 9.5s5.5-6.462 5.5-9.5A5.5 5.5 0 0 0 10 2.5Zm0 7a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"
|
||||
/>
|
||||
<path
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M4.5 8a5.5 5.5 0 1 1 11 0c0 3.038-5.5 9.5-5.5 9.5S4.5 11.038 4.5 8Z"
|
||||
/>
|
||||
<circle cx="10" cy="8" r="1.5" fill="none" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
16
src/components/icons/PackageIcon.tsx
Normal file
16
src/components/icons/PackageIcon.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
export function PackageIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||
<path
|
||||
strokeWidth="0"
|
||||
d="m10 9.5-7.5-4v9l7.5 4v-9ZM10 9.5l7.5-4v9l-7.5 4v-9Z"
|
||||
/>
|
||||
<path
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="m2.5 5.5 7.5 4m-7.5-4v9l7.5 4m-7.5-13 7.5-4 7.5 4m-7.5 4v9m0-9 7.5-4m-7.5 13 7.5-4v-9m-11 6 .028-3.852L13.5 3.5"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
19
src/components/icons/PaperAirplaneIcon.tsx
Normal file
19
src/components/icons/PaperAirplaneIcon.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
export function PaperAirplaneIcon(
|
||||
props: React.ComponentPropsWithoutRef<'svg'>,
|
||||
) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||
<path
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M17 3L1 9L8 12M17 3L11 19L8 12M17 3L8 12"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M11 19L8 12L17 3L11 19Z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
12
src/components/icons/PaperClipIcon.tsx
Normal file
12
src/components/icons/PaperClipIcon.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
export function PaperClipIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||
<path
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="m15.56 7.375-3.678-3.447c-2.032-1.904-5.326-1.904-7.358 0s-2.032 4.99 0 6.895l6.017 5.639c1.477 1.384 3.873 1.384 5.35 0 1.478-1.385 1.478-3.63 0-5.015L10.21 6.122a1.983 1.983 0 0 0-2.676 0 1.695 1.695 0 0 0 0 2.507l4.013 3.76"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
17
src/components/icons/ShapesIcon.tsx
Normal file
17
src/components/icons/ShapesIcon.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
export function ShapesIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||
<path
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M2.5 7.5v-4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1ZM11.5 16.5v-4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1Z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="m2.5 17.5 3-6 3 6h-6ZM14.5 2.5a3 3 0 1 1 0 6 3 3 0 0 1 0-6Z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
11
src/components/icons/ShirtIcon.tsx
Normal file
11
src/components/icons/ShirtIcon.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
export function ShirtIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12.5 1.5s0 2-2.5 2-2.5-2-2.5-2h-2L2.207 4.793a1 1 0 0 0 0 1.414L4.5 8.5v10h11v-10l2.293-2.293a1 1 0 0 0 0-1.414L14.5 1.5h-2Z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
17
src/components/icons/SquaresPlusIcon.tsx
Normal file
17
src/components/icons/SquaresPlusIcon.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
export function SquaresPlusIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M8.5 4.5v2a2 2 0 0 1-2 2h-2a2 2 0 0 1-2-2v-2a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2ZM8.5 13.5v2a2 2 0 0 1-2 2h-2a2 2 0 0 1-2-2v-2a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2ZM17.5 4.5v2a2 2 0 0 1-2 2h-2a2 2 0 0 1-2-2v-2a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2Z"
|
||||
/>
|
||||
<path
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M14.5 11.5v6M17.5 14.5h-6"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
19
src/components/icons/TagIcon.tsx
Normal file
19
src/components/icons/TagIcon.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
export function TagIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||
<path
|
||||
strokeWidth="0"
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M3 8.69499V3H8.69499C9.18447 3 9.65389 3.19444 10 3.54055L16.4594 10C17.1802 10.7207 17.1802 11.8893 16.4594 12.61L12.61 16.4594C11.8893 17.1802 10.7207 17.1802 10 16.4594L3.54055 10C3.19444 9.65389 3 9.18447 3 8.69499ZM7 8.5C7.82843 8.5 8.5 7.82843 8.5 7C8.5 6.17157 7.82843 5.5 7 5.5C6.17157 5.5 5.5 6.17157 5.5 7C5.5 7.82843 6.17157 8.5 7 8.5Z"
|
||||
/>
|
||||
<path
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M3 3V8.69499C3 9.18447 3.19444 9.65389 3.54055 10L10 16.4594C10.7207 17.1802 11.8893 17.1802 12.61 16.4594L16.4594 12.61C17.1802 11.8893 17.1802 10.7207 16.4594 10L10 3.54055C9.65389 3.19444 9.18447 3 8.69499 3H3Z"
|
||||
/>
|
||||
<circle cx="7" cy="7" r="1.5" fill="none" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
24
src/components/icons/UserIcon.tsx
Normal file
24
src/components/icons/UserIcon.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
export function UserIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||
<path
|
||||
strokeWidth="0"
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M10 .5a9.5 9.5 0 0 1 5.598 17.177C14.466 15.177 12.383 13.5 10 13.5s-4.466 1.677-5.598 4.177A9.5 9.5 0 0 1 10 .5ZM12.5 8a2.5 2.5 0 1 0-5 0 2.5 2.5 0 0 0 5 0Z"
|
||||
/>
|
||||
<path
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M10 .5a9.5 9.5 0 0 1 5.598 17.177A9.458 9.458 0 0 1 10 19.5a9.458 9.458 0 0 1-5.598-1.823A9.5 9.5 0 0 1 10 .5Z"
|
||||
/>
|
||||
<path
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M4.402 17.677C5.534 15.177 7.617 13.5 10 13.5s4.466 1.677 5.598 4.177M10 5.5a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5Z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
28
src/components/icons/UsersIcon.tsx
Normal file
28
src/components/icons/UsersIcon.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
export function UsersIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
|
||||
<path
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M10.046 16H1.955a.458.458 0 0 1-.455-.459C1.5 13.056 3.515 11 6 11h.5"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M7.5 15.454C7.5 12.442 9.988 10 13 10s5.5 2.442 5.5 5.454a.545.545 0 0 1-.546.546H8.045a.545.545 0 0 1-.545-.546Z"
|
||||
/>
|
||||
<path
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M6.5 4a2 2 0 1 1 0 4 2 2 0 0 1 0-4Z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M13 2a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5Z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
127
src/components/mdx.tsx
Normal file
127
src/components/mdx.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import clsx from 'clsx'
|
||||
import Link from 'next/link'
|
||||
|
||||
import { Feedback } from '@/components/Feedback'
|
||||
import { Heading } from '@/components/Heading'
|
||||
import { Prose } from '@/components/Prose'
|
||||
|
||||
export const a = Link
|
||||
export { Button } from '@/components/Button'
|
||||
export { CodeGroup, Code as code, Pre as pre } from '@/components/Code'
|
||||
|
||||
export function wrapper({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<article className="flex h-full flex-col pt-16 pb-10">
|
||||
<Prose className="flex-auto">{children}</Prose>
|
||||
<footer className="mx-auto mt-16 w-full max-w-2xl lg:max-w-5xl">
|
||||
<Feedback />
|
||||
</footer>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
export const h2 = function H2(
|
||||
props: Omit<React.ComponentPropsWithoutRef<typeof Heading>, 'level'>,
|
||||
) {
|
||||
return <Heading level={2} {...props} />
|
||||
}
|
||||
|
||||
function InfoIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true" {...props}>
|
||||
<circle cx="8" cy="8" r="8" strokeWidth="0" />
|
||||
<path
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
d="M6.75 7.75h1.5v3.5"
|
||||
/>
|
||||
<circle cx="8" cy="4" r=".5" fill="none" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function Note({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="my-6 flex gap-2.5 rounded-2xl border border-blue-500/20 bg-blue-50/50 p-4 text-sm/6 text-blue-900 dark:border-blue-500/30 dark:bg-blue-500/5 dark:text-blue-200 dark:[--tw-prose-links-hover:var(--color-blue-300)] dark:[--tw-prose-links:var(--color-white)]">
|
||||
<InfoIcon className="mt-1 h-4 w-4 flex-none fill-blue-500 stroke-white dark:fill-blue-200/20 dark:stroke-blue-200" />
|
||||
<div className="[&>:first-child]:mt-0 [&>:last-child]:mb-0">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Row({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 items-start gap-x-16 gap-y-10 xl:max-w-none xl:grid-cols-2">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Col({
|
||||
children,
|
||||
sticky = false,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
sticky?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'[&>:first-child]:mt-0 [&>:last-child]:mb-0',
|
||||
sticky && 'xl:sticky xl:top-24',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Properties({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="my-6">
|
||||
<ul
|
||||
role="list"
|
||||
className="m-0 max-w-[calc(var(--container-lg)-(--spacing(8)))] list-none divide-y divide-zinc-900/5 p-0 dark:divide-white/5"
|
||||
>
|
||||
{children}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Property({
|
||||
name,
|
||||
children,
|
||||
type,
|
||||
}: {
|
||||
name: string
|
||||
children: React.ReactNode
|
||||
type?: string
|
||||
}) {
|
||||
return (
|
||||
<li className="m-0 px-0 py-4 first:pt-0 last:pb-0">
|
||||
<dl className="m-0 flex flex-wrap items-center gap-x-3 gap-y-2">
|
||||
<dt className="sr-only">Name</dt>
|
||||
<dd>
|
||||
<code>{name}</code>
|
||||
</dd>
|
||||
{type && (
|
||||
<>
|
||||
<dt className="sr-only">Type</dt>
|
||||
<dd className="font-mono text-xs text-zinc-400 dark:text-zinc-500">
|
||||
{type}
|
||||
</dd>
|
||||
</>
|
||||
)}
|
||||
<dt className="sr-only">Description</dt>
|
||||
<dd className="w-full flex-none [&>:first-child]:mt-0 [&>:last-child]:mb-0">
|
||||
{children}
|
||||
</dd>
|
||||
</dl>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
14
src/images/logos/go.svg
Normal file
14
src/images/logos/go.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="none">
|
||||
<g fill="#00ACD7" clip-path="url(#a)">
|
||||
<path fill-rule="evenodd"
|
||||
d="M5.8 19.334c-.08 0-.093-.054-.067-.107l.4-.533a.421.421 0 0 1 .227-.08h6.893c.08 0 .094.053.067.106l-.334.507c-.04.053-.133.12-.2.12L5.8 19.32v.014Zm-2.92 1.773c-.08 0-.093-.04-.053-.107l.4-.52c.04-.053.133-.093.213-.093h8.8c.093 0 .133.053.107.12l-.16.453c-.014.08-.094.134-.174.134H2.88v.013Zm4.68 1.773c-.08 0-.107-.053-.067-.12l.267-.48c.053-.053.133-.12.2-.12h3.866c.08 0 .12.067.12.134l-.04.466c0 .08-.08.134-.133.134L7.56 22.88Zm20.053-3.906-3.24.853c-.293.08-.32.093-.56-.2-.293-.32-.506-.533-.92-.733a3.36 3.36 0 0 0-3.493.293 4.107 4.107 0 0 0-1.973 3.667 3.027 3.027 0 0 0 2.613 3.04c1.306.173 2.413-.294 3.28-1.28l.533-.707H20.12c-.4 0-.507-.267-.373-.587.253-.6.72-1.6.986-2.106a.533.533 0 0 1 .48-.307h7.04c-.04.533-.04 1.04-.12 1.573-.213 1.387-.733 2.667-1.586 3.787a8.053 8.053 0 0 1-5.507 3.28 6.839 6.839 0 0 1-5.2-1.28A6.065 6.065 0 0 1 13.386 24c-.24-2.106.374-4 1.654-5.666A8.573 8.573 0 0 1 20.44 15a6.667 6.667 0 0 1 5.12.934c1.027.666 1.76 1.6 2.253 2.733.107.173.027.267-.2.32v-.013Z"
|
||||
clip-rule="evenodd" />
|
||||
<path
|
||||
d="M34 29.667a7.253 7.253 0 0 1-4.707-1.707 6.066 6.066 0 0 1-2.08-3.733 7.373 7.373 0 0 1 1.56-5.827 8.107 8.107 0 0 1 5.413-3.226 7.173 7.173 0 0 1 5.507.986 6.015 6.015 0 0 1 2.72 4.307 7.467 7.467 0 0 1-2.227 6.547 8.854 8.854 0 0 1-4.626 2.48c-.534.093-1.054.106-1.547.173H34Zm4.613-7.813c-.027-.254-.027-.44-.067-.64a3.186 3.186 0 0 0-3.933-2.547 4.227 4.227 0 0 0-3.387 3.36A3.187 3.187 0 0 0 33 25.68c1.066.454 2.133.4 3.16-.133a4.227 4.227 0 0 0 2.453-3.68v-.013Z" />
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="a">
|
||||
<path fill="#fff" d="M4 4h40v40H4z" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
4
src/images/logos/node.svg
Normal file
4
src/images/logos/node.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="none">
|
||||
<path fill="#89D42C"
|
||||
d="M23.675 39.82a2.48 2.48 0 0 1-1.19-.313l-3.764-2.236c-.568-.31-.285-.425-.114-.48.765-.256.906-.313 1.698-.765.086-.057.198-.029.284.028l2.888 1.727c.113.057.254.057.34 0l11.296-6.54c.113-.057.17-.17.17-.312v-13.05c0-.143-.057-.256-.17-.313l-11.296-6.51c-.114-.057-.256-.057-.34 0L12.18 17.567c-.114.057-.17.198-.17.311V30.93c0 .114.056.255.17.312l3.087 1.784c1.67.849 2.717-.143 2.717-1.133V19.01a.344.344 0 0 1 .34-.34h1.443a.344.344 0 0 1 .341.34v12.882c0 2.237-1.218 3.539-3.342 3.539-.65 0-1.16 0-2.604-.708l-2.975-1.698A2.39 2.39 0 0 1 10 30.959V17.904c0-.849.452-1.642 1.189-2.066l11.296-6.54a2.527 2.527 0 0 1 2.379 0l11.297 6.54a2.39 2.39 0 0 1 1.188 2.066V30.96c0 .85-.452 1.642-1.188 2.066l-11.297 6.54a2.896 2.896 0 0 1-1.189.256v-.001Zm3.482-8.976c-4.954 0-5.973-2.264-5.973-4.19a.344.344 0 0 1 .34-.34h1.472c.169 0 .311.114.311.284.226 1.5.878 2.236 3.879 2.236 2.378 0 3.397-.538 3.397-1.812 0-.736-.283-1.274-3.992-1.642-3.086-.311-5.012-.99-5.012-3.454 0-2.293 1.926-3.652 5.154-3.652 3.623 0 5.407 1.246 5.634 3.963a.459.459 0 0 1-.086.256c-.056.056-.141.113-.226.113h-1.472a.332.332 0 0 1-.311-.256c-.34-1.555-1.217-2.066-3.539-2.066-2.605 0-2.916.906-2.916 1.585 0 .821.368 1.076 3.878 1.53 3.483.452 5.124 1.104 5.124 3.539-.027 2.491-2.066 3.907-5.662 3.907Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
10
src/images/logos/php.svg
Normal file
10
src/images/logos/php.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="none">
|
||||
<path fill="#6181B6" fill-rule="evenodd"
|
||||
d="M14.643 21.762h-1.77l-.964 4.965h1.57c1.043 0 1.82-.198 2.33-.59.51-.393.853-1.047 1.03-1.966.173-.882.095-1.503-.232-1.866-.328-.362-.98-.543-1.962-.543h-.002Z"
|
||||
clip-rule="evenodd" />
|
||||
<path fill="#6181B6"
|
||||
d="M24 13.29c-12.426 0-22.5 5.3-22.5 11.835 0 6.535 10.074 11.837 22.5 11.837s22.5-5.3 22.5-11.837S36.426 13.29 24 13.29Zm-6.113 13.971a4.55 4.55 0 0 1-1.718 1.032c-.63.203-1.434.308-2.41.308h-2.215l-.612 3.152H8.346l2.307-11.861h4.968c1.494 0 2.585.391 3.27 1.177.687.785.893 1.88.618 3.285a5.34 5.34 0 0 1-.57 1.588c-.28.493-.634.938-1.053 1.319h.002Zm7.546 1.34 1.018-5.247c.119-.598.073-1.005-.128-1.221-.2-.218-.63-.328-1.288-.328h-2.047l-1.32 6.799h-2.566L21.41 16.74h2.561l-.611 3.155h2.282c1.439 0 2.429.25 2.975.75.546.499.708 1.314.492 2.437l-1.073 5.52h-2.604V28.6Zm14.243-4.245a5.215 5.215 0 0 1-.571 1.586 5.356 5.356 0 0 1-1.051 1.319c-.49.467-1.078.82-1.721 1.032-.63.203-1.434.308-2.41.308H31.71l-.614 3.154h-2.581l2.305-11.862h4.968c1.495 0 2.584.393 3.27 1.177.686.784.895 1.878.62 3.285h-.002Z" />
|
||||
<path fill="#6181B6" fill-rule="evenodd"
|
||||
d="M34.81 21.762h-1.765l-.968 4.965h1.571c1.044 0 1.821-.198 2.33-.59.51-.393.852-1.047 1.032-1.966.172-.882.093-1.503-.234-1.866-.326-.362-.983-.543-1.964-.543h-.002Z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
13
src/images/logos/python.svg
Normal file
13
src/images/logos/python.svg
Normal file
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="none">
|
||||
<g clip-path="url(#a)">
|
||||
<path fill="#3372A7"
|
||||
d="M23.429 9.008c-7.882 0-7.39 3.418-7.39 3.418l.01 3.541h7.52v1.063H13.062s-5.043-.572-5.043 7.38c0 7.954 4.402 7.671 4.402 7.671h2.627v-3.69s-.142-4.402 4.331-4.402h7.46s4.191.068 4.191-4.05v-6.81s.637-4.12-7.6-4.12Zm-4.147 2.382a1.353 1.353 0 1 1 .001 2.706 1.353 1.353 0 0 1-.001-2.706Z" />
|
||||
<path fill="#FFD235"
|
||||
d="M23.653 39.894c7.881 0 7.39-3.418 7.39-3.418l-.01-3.541h-7.52v-1.063H34.02s5.043.572 5.043-7.381-4.402-7.67-4.402-7.67h-2.627v3.69s.142 4.402-4.332 4.402h-7.46s-4.19-.068-4.19 4.05v6.81s-.637 4.12 7.6 4.12Zm4.147-2.381a1.353 1.353 0 1 1-.002-2.707 1.353 1.353 0 0 1 .002 2.706Z" />
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="a">
|
||||
<path fill="#fff" d="M8 9h31.122v31H8z" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 864 B |
4
src/images/logos/ruby.svg
Normal file
4
src/images/logos/ruby.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="none">
|
||||
<path fill="#D91505"
|
||||
d="M33.735 10.41c3.376.585 4.334 2.893 4.262 5.311l.017-.035-1.519 19.912-19.752 1.352h.017c-1.639-.069-5.294-.218-5.46-5.328l1.83-3.34 3.139 7.331.56 1.306L19.95 26.74l-.032.007.017-.034 10.302 3.29-1.555-6.044-1.101-4.341 9.817-.634-.684-.567-7.048-5.746 4.073-2.272-.004.012v-.001ZM17.01 15.966c3.963-3.932 9.079-6.256 11.044-4.274 1.96 1.98-.118 6.796-4.089 10.726-3.966 3.931-9.02 6.382-10.98 4.405-1.967-1.98.05-6.921 4.02-10.853l.005-.004Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 561 B |
8
src/lib/remToPx.ts
Normal file
8
src/lib/remToPx.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export function remToPx(remValue: number) {
|
||||
let rootFontSize =
|
||||
typeof window === 'undefined'
|
||||
? 16
|
||||
: parseFloat(window.getComputedStyle(document.documentElement).fontSize)
|
||||
|
||||
return remValue * rootFontSize
|
||||
}
|
||||
3
src/mdx/recma.mjs
Normal file
3
src/mdx/recma.mjs
Normal file
@@ -0,0 +1,3 @@
|
||||
import { mdxAnnotations } from 'mdx-annotations'
|
||||
|
||||
export const recmaPlugins = [mdxAnnotations.recma]
|
||||
123
src/mdx/rehype.mjs
Normal file
123
src/mdx/rehype.mjs
Normal file
@@ -0,0 +1,123 @@
|
||||
import { slugifyWithCounter } from '@sindresorhus/slugify'
|
||||
import * as acorn from 'acorn'
|
||||
import { toString } from 'mdast-util-to-string'
|
||||
import { mdxAnnotations } from 'mdx-annotations'
|
||||
import shiki from 'shiki'
|
||||
import { visit } from 'unist-util-visit'
|
||||
|
||||
function rehypeParseCodeBlocks() {
|
||||
return (tree) => {
|
||||
visit(tree, 'element', (node, _nodeIndex, parentNode) => {
|
||||
if (node.tagName === 'code') {
|
||||
parentNode.properties.language = node.properties.className
|
||||
? node.properties?.className[0]?.replace(/^language-/, '')
|
||||
: 'txt'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let highlighter
|
||||
|
||||
function rehypeShiki() {
|
||||
return async (tree) => {
|
||||
highlighter =
|
||||
highlighter ?? (await shiki.getHighlighter({ theme: 'css-variables' }))
|
||||
|
||||
visit(tree, 'element', (node) => {
|
||||
if (node.tagName === 'pre' && node.children[0]?.tagName === 'code') {
|
||||
let codeNode = node.children[0]
|
||||
let textNode = codeNode.children[0]
|
||||
|
||||
node.properties.code = textNode.value
|
||||
|
||||
if (node.properties.language) {
|
||||
let tokens = highlighter.codeToThemedTokens(
|
||||
textNode.value,
|
||||
node.properties.language,
|
||||
)
|
||||
|
||||
textNode.value = shiki.renderToHtml(tokens, {
|
||||
elements: {
|
||||
pre: ({ children }) => children,
|
||||
code: ({ children }) => children,
|
||||
line: ({ children }) => `<span>${children}</span>`,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function rehypeSlugify() {
|
||||
return (tree) => {
|
||||
let slugify = slugifyWithCounter()
|
||||
visit(tree, 'element', (node) => {
|
||||
if (node.tagName === 'h2' && !node.properties.id) {
|
||||
node.properties.id = slugify(toString(node))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function rehypeAddMDXExports(getExports) {
|
||||
return (tree) => {
|
||||
let exports = Object.entries(getExports(tree))
|
||||
|
||||
for (let [name, value] of exports) {
|
||||
for (let node of tree.children) {
|
||||
if (
|
||||
node.type === 'mdxjsEsm' &&
|
||||
new RegExp(`export\\s+const\\s+${name}\\s*=`).test(node.value)
|
||||
) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
let exportStr = `export const ${name} = ${value}`
|
||||
|
||||
tree.children.push({
|
||||
type: 'mdxjsEsm',
|
||||
value: exportStr,
|
||||
data: {
|
||||
estree: acorn.parse(exportStr, {
|
||||
sourceType: 'module',
|
||||
ecmaVersion: 'latest',
|
||||
}),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getSections(node) {
|
||||
let sections = []
|
||||
|
||||
for (let child of node.children ?? []) {
|
||||
if (child.type === 'element' && child.tagName === 'h2') {
|
||||
sections.push(`{
|
||||
title: ${JSON.stringify(toString(child))},
|
||||
id: ${JSON.stringify(child.properties.id)},
|
||||
...${child.properties.annotation}
|
||||
}`)
|
||||
} else if (child.children) {
|
||||
sections.push(...getSections(child))
|
||||
}
|
||||
}
|
||||
|
||||
return sections
|
||||
}
|
||||
|
||||
export const rehypePlugins = [
|
||||
mdxAnnotations.rehype,
|
||||
rehypeParseCodeBlocks,
|
||||
rehypeShiki,
|
||||
rehypeSlugify,
|
||||
[
|
||||
rehypeAddMDXExports,
|
||||
(tree) => ({
|
||||
sections: `[${getSections(tree).join()}]`,
|
||||
}),
|
||||
],
|
||||
]
|
||||
4
src/mdx/remark.mjs
Normal file
4
src/mdx/remark.mjs
Normal file
@@ -0,0 +1,4 @@
|
||||
import { mdxAnnotations } from 'mdx-annotations'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
|
||||
export const remarkPlugins = [mdxAnnotations.remark, remarkGfm]
|
||||
135
src/mdx/search.mjs
Normal file
135
src/mdx/search.mjs
Normal file
@@ -0,0 +1,135 @@
|
||||
import { slugifyWithCounter } from '@sindresorhus/slugify'
|
||||
import glob from 'fast-glob'
|
||||
import * as fs from 'fs'
|
||||
import { toString } from 'mdast-util-to-string'
|
||||
import * as path from 'path'
|
||||
import { remark } from 'remark'
|
||||
import remarkMdx from 'remark-mdx'
|
||||
import { createLoader } from 'simple-functional-loader'
|
||||
import { filter } from 'unist-util-filter'
|
||||
import { SKIP, visit } from 'unist-util-visit'
|
||||
import * as url from 'url'
|
||||
|
||||
const __filename = url.fileURLToPath(import.meta.url)
|
||||
const processor = remark().use(remarkMdx).use(extractSections)
|
||||
const slugify = slugifyWithCounter()
|
||||
|
||||
function isObjectExpression(node) {
|
||||
return (
|
||||
node.type === 'mdxTextExpression' &&
|
||||
node.data?.estree?.body?.[0]?.expression?.type === 'ObjectExpression'
|
||||
)
|
||||
}
|
||||
|
||||
function excludeObjectExpressions(tree) {
|
||||
return filter(tree, (node) => !isObjectExpression(node))
|
||||
}
|
||||
|
||||
function extractSections() {
|
||||
return (tree, { sections }) => {
|
||||
slugify.reset()
|
||||
|
||||
visit(tree, (node) => {
|
||||
if (node.type === 'heading' || node.type === 'paragraph') {
|
||||
let content = toString(excludeObjectExpressions(node))
|
||||
if (node.type === 'heading' && node.depth <= 2) {
|
||||
let hash = node.depth === 1 ? null : slugify(content)
|
||||
sections.push([content, hash, []])
|
||||
} else {
|
||||
sections.at(-1)?.[2].push(content)
|
||||
}
|
||||
return SKIP
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default function Search(nextConfig = {}) {
|
||||
let cache = new Map()
|
||||
|
||||
return Object.assign({}, nextConfig, {
|
||||
webpack(config, options) {
|
||||
config.module.rules.push({
|
||||
test: __filename,
|
||||
use: [
|
||||
createLoader(function () {
|
||||
let appDir = path.resolve('./src/app')
|
||||
this.addContextDependency(appDir)
|
||||
|
||||
let files = glob.sync('**/*.mdx', { cwd: appDir })
|
||||
let data = files.map((file) => {
|
||||
let url = '/' + file.replace(/(^|\/)page\.mdx$/, '')
|
||||
let mdx = fs.readFileSync(path.join(appDir, file), 'utf8')
|
||||
|
||||
let sections = []
|
||||
|
||||
if (cache.get(file)?.[0] === mdx) {
|
||||
sections = cache.get(file)[1]
|
||||
} else {
|
||||
let vfile = { value: mdx, sections }
|
||||
processor.runSync(processor.parse(vfile), vfile)
|
||||
cache.set(file, [mdx, sections])
|
||||
}
|
||||
|
||||
return { url, sections }
|
||||
})
|
||||
|
||||
// When this file is imported within the application
|
||||
// the following module is loaded:
|
||||
return `
|
||||
import FlexSearch from 'flexsearch'
|
||||
|
||||
let sectionIndex = new FlexSearch.Document({
|
||||
tokenize: 'full',
|
||||
document: {
|
||||
id: 'url',
|
||||
index: 'content',
|
||||
store: ['title', 'pageTitle'],
|
||||
},
|
||||
context: {
|
||||
resolution: 9,
|
||||
depth: 2,
|
||||
bidirectional: true
|
||||
}
|
||||
})
|
||||
|
||||
let data = ${JSON.stringify(data)}
|
||||
|
||||
for (let { url, sections } of data) {
|
||||
for (let [title, hash, content] of sections) {
|
||||
sectionIndex.add({
|
||||
url: url + (hash ? ('#' + hash) : ''),
|
||||
title,
|
||||
content: [title, ...content].join('\\n'),
|
||||
pageTitle: hash ? sections[0][0] : undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function search(query, options = {}) {
|
||||
let result = sectionIndex.search(query, {
|
||||
...options,
|
||||
enrich: true,
|
||||
})
|
||||
if (result.length === 0) {
|
||||
return []
|
||||
}
|
||||
return result[0].result.map((item) => ({
|
||||
url: item.id,
|
||||
title: item.doc.title,
|
||||
pageTitle: item.doc.pageTitle,
|
||||
}))
|
||||
}
|
||||
`
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
if (typeof nextConfig.webpack === 'function') {
|
||||
return nextConfig.webpack(config, options)
|
||||
}
|
||||
|
||||
return config
|
||||
},
|
||||
})
|
||||
}
|
||||
60
src/styles/tailwind.css
Normal file
60
src/styles/tailwind.css
Normal file
@@ -0,0 +1,60 @@
|
||||
@import 'tailwindcss';
|
||||
@plugin '@tailwindcss/typography';
|
||||
@config '../../typography.ts';
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@theme {
|
||||
--text-2xs: 0.75rem;
|
||||
--text-2xs--line-height: 1.25rem;
|
||||
--text-xs: 0.8125rem;
|
||||
--text-xs--line-height: 1.5rem;
|
||||
--text-sm: 0.875rem;
|
||||
--text-sm--line-height: 1.5rem;
|
||||
--text-base: 1rem;
|
||||
--text-base--line-height: 1.75rem;
|
||||
--text-lg: 1.125rem;
|
||||
--text-lg--line-height: 1.75rem;
|
||||
--text-xl: 1.25rem;
|
||||
--text-xl--line-height: 1.75rem;
|
||||
--text-2xl: 1.5rem;
|
||||
--text-2xl--line-height: 2rem;
|
||||
--text-3xl: 1.875rem;
|
||||
--text-3xl--line-height: 2.25rem;
|
||||
--text-4xl: 2.25rem;
|
||||
--text-4xl--line-height: 2.5rem;
|
||||
--text-5xl: 3rem;
|
||||
--text-5xl--line-height: 1;
|
||||
--text-6xl: 3.75rem;
|
||||
--text-6xl--line-height: 1;
|
||||
--text-7xl: 4.5rem;
|
||||
--text-7xl--line-height: 1;
|
||||
--text-8xl: 6rem;
|
||||
--text-8xl--line-height: 1;
|
||||
--text-9xl: 8rem;
|
||||
--text-9xl--line-height: 1;
|
||||
|
||||
--shadow-glow: 0 0 4px rgb(0 0 0 / 0.1);
|
||||
|
||||
--container-lg: 33rem;
|
||||
--container-2xl: 40rem;
|
||||
--container-3xl: 50rem;
|
||||
--container-5xl: 66rem;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--shiki-color-text: var(--color-white);
|
||||
--shiki-token-constant: var(--color-emerald-300);
|
||||
--shiki-token-string: var(--color-emerald-300);
|
||||
--shiki-token-comment: var(--color-zinc-500);
|
||||
--shiki-token-keyword: var(--color-sky-300);
|
||||
--shiki-token-parameter: var(--color-pink-300);
|
||||
--shiki-token-function: var(--color-violet-300);
|
||||
--shiki-token-string-expression: var(--color-emerald-300);
|
||||
--shiki-token-punctuation: var(--color-zinc-200);
|
||||
}
|
||||
|
||||
[inert] ::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
28
tsconfig.json
Normal file
28
tsconfig.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es6",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
11
types.d.ts
vendored
Normal file
11
types.d.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
import { type SearchOptions } from 'flexsearch'
|
||||
|
||||
declare module '@/mdx/search.mjs' {
|
||||
export type Result = {
|
||||
url: string
|
||||
title: string
|
||||
pageTitle?: string
|
||||
}
|
||||
|
||||
export function search(query: string, options?: SearchOptions): Array<Result>
|
||||
}
|
||||
354
typography.ts
Normal file
354
typography.ts
Normal file
@@ -0,0 +1,354 @@
|
||||
import { type Config } from 'tailwindcss'
|
||||
|
||||
export default {
|
||||
theme: {
|
||||
typography: ({ theme }) => ({
|
||||
DEFAULT: {
|
||||
css: {
|
||||
'--tw-prose-body': theme('colors.zinc.700'),
|
||||
'--tw-prose-headings': theme('colors.zinc.900'),
|
||||
'--tw-prose-links': theme('colors.blue.500'),
|
||||
'--tw-prose-links-hover': theme('colors.blue.600'),
|
||||
'--tw-prose-links-underline': theme('colors.blue.500 / 0.3'),
|
||||
'--tw-prose-bold': theme('colors.zinc.900'),
|
||||
'--tw-prose-counters': theme('colors.zinc.500'),
|
||||
'--tw-prose-bullets': theme('colors.zinc.300'),
|
||||
'--tw-prose-hr': theme('colors.zinc.900 / 0.05'),
|
||||
'--tw-prose-quotes': theme('colors.zinc.900'),
|
||||
'--tw-prose-quote-borders': theme('colors.zinc.200'),
|
||||
'--tw-prose-captions': theme('colors.zinc.500'),
|
||||
'--tw-prose-code': theme('colors.zinc.900'),
|
||||
'--tw-prose-code-bg': theme('colors.zinc.100'),
|
||||
'--tw-prose-code-ring': theme('colors.zinc.300'),
|
||||
'--tw-prose-th-borders': theme('colors.zinc.300'),
|
||||
'--tw-prose-td-borders': theme('colors.zinc.200'),
|
||||
|
||||
'--tw-prose-invert-body': theme('colors.zinc.400'),
|
||||
'--tw-prose-invert-headings': theme('colors.white'),
|
||||
'--tw-prose-invert-links': theme('colors.blue.400'),
|
||||
'--tw-prose-invert-links-hover': theme('colors.blue.500'),
|
||||
'--tw-prose-invert-links-underline': theme(
|
||||
'colors.blue.500 / 0.3',
|
||||
),
|
||||
'--tw-prose-invert-bold': theme('colors.white'),
|
||||
'--tw-prose-invert-counters': theme('colors.zinc.400'),
|
||||
'--tw-prose-invert-bullets': theme('colors.zinc.600'),
|
||||
'--tw-prose-invert-hr': theme('colors.white / 0.05'),
|
||||
'--tw-prose-invert-quotes': theme('colors.zinc.100'),
|
||||
'--tw-prose-invert-quote-borders': theme('colors.zinc.700'),
|
||||
'--tw-prose-invert-captions': theme('colors.zinc.400'),
|
||||
'--tw-prose-invert-code': theme('colors.white'),
|
||||
'--tw-prose-invert-code-bg': theme('colors.zinc.700 / 0.15'),
|
||||
'--tw-prose-invert-code-ring': theme('colors.white / 0.1'),
|
||||
'--tw-prose-invert-th-borders': theme('colors.zinc.600'),
|
||||
'--tw-prose-invert-td-borders': theme('colors.zinc.700'),
|
||||
|
||||
// Base
|
||||
color: 'var(--tw-prose-body)',
|
||||
fontSize: theme('fontSize.sm')[0],
|
||||
lineHeight: theme('lineHeight.7'),
|
||||
|
||||
// Text
|
||||
p: {
|
||||
marginTop: theme('spacing.6'),
|
||||
marginBottom: theme('spacing.6'),
|
||||
},
|
||||
'[class~="lead"]': {
|
||||
fontSize: theme('fontSize.base')[0],
|
||||
...theme('fontSize.base')[1],
|
||||
},
|
||||
|
||||
// Lists
|
||||
ol: {
|
||||
listStyleType: 'decimal',
|
||||
marginTop: theme('spacing.5'),
|
||||
marginBottom: theme('spacing.5'),
|
||||
paddingLeft: '1.625rem',
|
||||
},
|
||||
'ol[type="A"]': {
|
||||
listStyleType: 'upper-alpha',
|
||||
},
|
||||
'ol[type="a"]': {
|
||||
listStyleType: 'lower-alpha',
|
||||
},
|
||||
'ol[type="A" s]': {
|
||||
listStyleType: 'upper-alpha',
|
||||
},
|
||||
'ol[type="a" s]': {
|
||||
listStyleType: 'lower-alpha',
|
||||
},
|
||||
'ol[type="I"]': {
|
||||
listStyleType: 'upper-roman',
|
||||
},
|
||||
'ol[type="i"]': {
|
||||
listStyleType: 'lower-roman',
|
||||
},
|
||||
'ol[type="I" s]': {
|
||||
listStyleType: 'upper-roman',
|
||||
},
|
||||
'ol[type="i" s]': {
|
||||
listStyleType: 'lower-roman',
|
||||
},
|
||||
'ol[type="1"]': {
|
||||
listStyleType: 'decimal',
|
||||
},
|
||||
ul: {
|
||||
listStyleType: 'disc',
|
||||
marginTop: theme('spacing.5'),
|
||||
marginBottom: theme('spacing.5'),
|
||||
paddingLeft: '1.625rem',
|
||||
},
|
||||
li: {
|
||||
marginTop: theme('spacing.2'),
|
||||
marginBottom: theme('spacing.2'),
|
||||
},
|
||||
':is(ol, ul) > li': {
|
||||
paddingLeft: theme('spacing[1.5]'),
|
||||
},
|
||||
'ol > li::marker': {
|
||||
fontWeight: '400',
|
||||
color: 'var(--tw-prose-counters)',
|
||||
},
|
||||
'ul > li::marker': {
|
||||
color: 'var(--tw-prose-bullets)',
|
||||
},
|
||||
'> ul > li p': {
|
||||
marginTop: theme('spacing.3'),
|
||||
marginBottom: theme('spacing.3'),
|
||||
},
|
||||
'> ul > li > *:first-child': {
|
||||
marginTop: theme('spacing.5'),
|
||||
},
|
||||
'> ul > li > *:last-child': {
|
||||
marginBottom: theme('spacing.5'),
|
||||
},
|
||||
'> ol > li > *:first-child': {
|
||||
marginTop: theme('spacing.5'),
|
||||
},
|
||||
'> ol > li > *:last-child': {
|
||||
marginBottom: theme('spacing.5'),
|
||||
},
|
||||
'ul ul, ul ol, ol ul, ol ol': {
|
||||
marginTop: theme('spacing.3'),
|
||||
marginBottom: theme('spacing.3'),
|
||||
},
|
||||
|
||||
// Horizontal rules
|
||||
hr: {
|
||||
borderColor: 'var(--tw-prose-hr)',
|
||||
borderTopWidth: 1,
|
||||
marginTop: theme('spacing.16'),
|
||||
marginBottom: theme('spacing.16'),
|
||||
maxWidth: 'none',
|
||||
marginLeft: `calc(-1 * ${theme('spacing.4')})`,
|
||||
marginRight: `calc(-1 * ${theme('spacing.4')})`,
|
||||
'@screen sm': {
|
||||
marginLeft: `calc(-1 * ${theme('spacing.6')})`,
|
||||
marginRight: `calc(-1 * ${theme('spacing.6')})`,
|
||||
},
|
||||
'@screen lg': {
|
||||
marginLeft: `calc(-1 * ${theme('spacing.8')})`,
|
||||
marginRight: `calc(-1 * ${theme('spacing.8')})`,
|
||||
},
|
||||
},
|
||||
|
||||
// Quotes
|
||||
blockquote: {
|
||||
fontWeight: '500',
|
||||
fontStyle: 'italic',
|
||||
color: 'var(--tw-prose-quotes)',
|
||||
borderLeftWidth: '0.25rem',
|
||||
borderLeftColor: 'var(--tw-prose-quote-borders)',
|
||||
quotes: '"\\201C""\\201D""\\2018""\\2019"',
|
||||
marginTop: theme('spacing.8'),
|
||||
marginBottom: theme('spacing.8'),
|
||||
paddingLeft: theme('spacing.5'),
|
||||
},
|
||||
'blockquote p:first-of-type::before': {
|
||||
content: 'open-quote',
|
||||
},
|
||||
'blockquote p:last-of-type::after': {
|
||||
content: 'close-quote',
|
||||
},
|
||||
|
||||
// Headings
|
||||
h1: {
|
||||
color: 'var(--tw-prose-headings)',
|
||||
fontWeight: '700',
|
||||
fontSize: theme('fontSize.2xl')[0],
|
||||
...theme('fontSize.2xl')[1],
|
||||
marginBottom: theme('spacing.2'),
|
||||
},
|
||||
h2: {
|
||||
color: 'var(--tw-prose-headings)',
|
||||
fontWeight: '600',
|
||||
fontSize: theme('fontSize.lg')[0],
|
||||
...theme('fontSize.lg')[1],
|
||||
marginTop: theme('spacing.16'),
|
||||
marginBottom: theme('spacing.2'),
|
||||
},
|
||||
h3: {
|
||||
color: 'var(--tw-prose-headings)',
|
||||
fontSize: theme('fontSize.base')[0],
|
||||
...theme('fontSize.base')[1],
|
||||
fontWeight: '600',
|
||||
marginTop: theme('spacing.10'),
|
||||
marginBottom: theme('spacing.2'),
|
||||
},
|
||||
|
||||
// Media
|
||||
'img, video, figure': {
|
||||
marginTop: theme('spacing.8'),
|
||||
marginBottom: theme('spacing.8'),
|
||||
},
|
||||
'figure > *': {
|
||||
marginTop: '0',
|
||||
marginBottom: '0',
|
||||
},
|
||||
figcaption: {
|
||||
color: 'var(--tw-prose-captions)',
|
||||
fontSize: theme('fontSize.xs')[0],
|
||||
...theme('fontSize.xs')[1],
|
||||
marginTop: theme('spacing.2'),
|
||||
},
|
||||
|
||||
// Tables
|
||||
table: {
|
||||
width: '100%',
|
||||
tableLayout: 'auto',
|
||||
textAlign: 'left',
|
||||
marginTop: theme('spacing.8'),
|
||||
marginBottom: theme('spacing.8'),
|
||||
lineHeight: theme('lineHeight.6'),
|
||||
},
|
||||
thead: {
|
||||
borderBottomWidth: '1px',
|
||||
borderBottomColor: 'var(--tw-prose-th-borders)',
|
||||
},
|
||||
'thead th': {
|
||||
color: 'var(--tw-prose-headings)',
|
||||
fontWeight: '600',
|
||||
verticalAlign: 'bottom',
|
||||
paddingRight: theme('spacing.2'),
|
||||
paddingBottom: theme('spacing.2'),
|
||||
paddingLeft: theme('spacing.2'),
|
||||
},
|
||||
'thead th:first-child': {
|
||||
paddingLeft: '0',
|
||||
},
|
||||
'thead th:last-child': {
|
||||
paddingRight: '0',
|
||||
},
|
||||
'tbody tr': {
|
||||
borderBottomWidth: '1px',
|
||||
borderBottomColor: 'var(--tw-prose-td-borders)',
|
||||
},
|
||||
'tbody tr:last-child': {
|
||||
borderBottomWidth: '0',
|
||||
},
|
||||
'tbody td': {
|
||||
verticalAlign: 'baseline',
|
||||
},
|
||||
tfoot: {
|
||||
borderTopWidth: '1px',
|
||||
borderTopColor: 'var(--tw-prose-th-borders)',
|
||||
},
|
||||
'tfoot td': {
|
||||
verticalAlign: 'top',
|
||||
},
|
||||
':is(tbody, tfoot) td': {
|
||||
paddingTop: theme('spacing.2'),
|
||||
paddingRight: theme('spacing.2'),
|
||||
paddingBottom: theme('spacing.2'),
|
||||
paddingLeft: theme('spacing.2'),
|
||||
},
|
||||
':is(tbody, tfoot) td:first-child': {
|
||||
paddingLeft: '0',
|
||||
},
|
||||
':is(tbody, tfoot) td:last-child': {
|
||||
paddingRight: '0',
|
||||
},
|
||||
|
||||
// Inline elements
|
||||
a: {
|
||||
color: 'var(--tw-prose-links)',
|
||||
textDecoration: 'underline transparent',
|
||||
fontWeight: '500',
|
||||
transitionProperty: 'color, text-decoration-color',
|
||||
transitionDuration: theme('transitionDuration.DEFAULT'),
|
||||
transitionTimingFunction: theme('transitionTimingFunction.DEFAULT'),
|
||||
'&:hover': {
|
||||
color: 'var(--tw-prose-links-hover)',
|
||||
textDecorationColor: 'var(--tw-prose-links-underline)',
|
||||
},
|
||||
},
|
||||
':is(h1, h2, h3) a': {
|
||||
fontWeight: 'inherit',
|
||||
},
|
||||
strong: {
|
||||
color: 'var(--tw-prose-bold)',
|
||||
fontWeight: '600',
|
||||
},
|
||||
':is(a, blockquote, thead th) strong': {
|
||||
color: 'inherit',
|
||||
},
|
||||
code: {
|
||||
color: 'var(--tw-prose-code)',
|
||||
borderRadius: theme('borderRadius.lg'),
|
||||
paddingTop: theme('padding.1'),
|
||||
paddingRight: theme('padding[1.5]'),
|
||||
paddingBottom: theme('padding.1'),
|
||||
paddingLeft: theme('padding[1.5]'),
|
||||
boxShadow: 'inset 0 0 0 1px var(--tw-prose-code-ring)',
|
||||
backgroundColor: 'var(--tw-prose-code-bg)',
|
||||
fontSize: theme('fontSize.2xs')[0],
|
||||
},
|
||||
':is(a, h1, h2, h3, blockquote, thead th) code': {
|
||||
color: 'inherit',
|
||||
},
|
||||
'h2 code': {
|
||||
fontSize: theme('fontSize.base')[0],
|
||||
fontWeight: 'inherit',
|
||||
},
|
||||
'h3 code': {
|
||||
fontSize: theme('fontSize.sm')[0],
|
||||
fontWeight: 'inherit',
|
||||
},
|
||||
|
||||
// Overrides
|
||||
':is(h1, h2, h3) + *': {
|
||||
marginTop: '0',
|
||||
},
|
||||
'> :first-child': {
|
||||
marginTop: '0 !important',
|
||||
},
|
||||
'> :last-child': {
|
||||
marginBottom: '0 !important',
|
||||
},
|
||||
},
|
||||
},
|
||||
invert: {
|
||||
css: {
|
||||
'--tw-prose-body': 'var(--tw-prose-invert-body)',
|
||||
'--tw-prose-headings': 'var(--tw-prose-invert-headings)',
|
||||
'--tw-prose-links': 'var(--tw-prose-invert-links)',
|
||||
'--tw-prose-links-hover': 'var(--tw-prose-invert-links-hover)',
|
||||
'--tw-prose-links-underline':
|
||||
'var(--tw-prose-invert-links-underline)',
|
||||
'--tw-prose-bold': 'var(--tw-prose-invert-bold)',
|
||||
'--tw-prose-counters': 'var(--tw-prose-invert-counters)',
|
||||
'--tw-prose-bullets': 'var(--tw-prose-invert-bullets)',
|
||||
'--tw-prose-hr': 'var(--tw-prose-invert-hr)',
|
||||
'--tw-prose-quotes': 'var(--tw-prose-invert-quotes)',
|
||||
'--tw-prose-quote-borders': 'var(--tw-prose-invert-quote-borders)',
|
||||
'--tw-prose-captions': 'var(--tw-prose-invert-captions)',
|
||||
'--tw-prose-code': 'var(--tw-prose-invert-code)',
|
||||
'--tw-prose-code-bg': 'var(--tw-prose-invert-code-bg)',
|
||||
'--tw-prose-code-ring': 'var(--tw-prose-invert-code-ring)',
|
||||
'--tw-prose-th-borders': 'var(--tw-prose-invert-th-borders)',
|
||||
'--tw-prose-td-borders': 'var(--tw-prose-invert-td-borders)',
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
} satisfies Config
|
||||
Reference in New Issue
Block a user