최초 생성
6
.eslintrc.json
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"extends": "next/core-web-vitals",
|
||||||
|
"rules": {
|
||||||
|
"@next/next/no-img-element": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
95
.gitignore
vendored
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.js
|
||||||
|
.yarn/install-state.gz
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# ---------------------------------------------------
|
||||||
|
# Node.js & Dependencies
|
||||||
|
# ---------------------------------------------------
|
||||||
|
node_modules/
|
||||||
|
.pnp/
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# ---------------------------------------------------
|
||||||
|
# Next.js Specific
|
||||||
|
# ---------------------------------------------------
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
build/
|
||||||
|
.vercel/
|
||||||
|
next-env.d.ts
|
||||||
|
|
||||||
|
# ---------------------------------------------------
|
||||||
|
# Environment Variables
|
||||||
|
# ---------------------------------------------------
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
# ---------------------------------------------------
|
||||||
|
# macOS & OS Specific
|
||||||
|
# ---------------------------------------------------
|
||||||
|
.DS_Store
|
||||||
|
.AppleDouble
|
||||||
|
.LSOverride
|
||||||
|
Icon\r
|
||||||
|
._*
|
||||||
|
.Spotlight-V100
|
||||||
|
.Trashes
|
||||||
|
|
||||||
|
# ---------------------------------------------------
|
||||||
|
# VS Code & IDEs
|
||||||
|
# ---------------------------------------------------
|
||||||
|
.vscode/
|
||||||
|
.history/ # VS Code 로컬 수정 히스토리 확장 프로그램 폴더
|
||||||
|
*.code-workspace
|
||||||
|
.idea/ # WebStorm 등 JetBrains IDE 사용 시
|
||||||
|
|
||||||
|
# ---------------------------------------------------
|
||||||
|
# Logs & Git/History
|
||||||
|
# ---------------------------------------------------
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
# 기타 캐시 파일
|
||||||
|
.npm/
|
||||||
|
.eslintcache
|
||||||
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.
|
||||||
15
README.md
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
# Catalyst Demo
|
||||||
|
|
||||||
|
To run the Catalyst demo, 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.
|
||||||
4
next.config.mjs
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {}
|
||||||
|
|
||||||
|
export default nextConfig
|
||||||
6542
package-lock.json
generated
Normal file
34
package.json
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "catalyst-demo",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@headlessui/react": "^2.2.6",
|
||||||
|
"@heroicons/react": "^2.2.0",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"motion": "^12.23.11",
|
||||||
|
"next": "^15",
|
||||||
|
"react": "^19",
|
||||||
|
"react-dom": "^19"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4.1.15",
|
||||||
|
"@types/node": "^24.1.0",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "^15",
|
||||||
|
"postcss": "^8",
|
||||||
|
"prettier": "^3.6.2",
|
||||||
|
"prettier-plugin-organize-imports": "^4.2.0",
|
||||||
|
"prettier-plugin-tailwindcss": "^0.7.1",
|
||||||
|
"tailwindcss": "^4.1.15",
|
||||||
|
"typescript": "^5.8.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
8
postcss.config.mjs
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
/** @type {import('postcss-load-config').Config} */
|
||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
'@tailwindcss/postcss': {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default config
|
||||||
12
prettier.config.mjs
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
/** @type {import("prettier").Config} */
|
||||||
|
const config = {
|
||||||
|
semi: false,
|
||||||
|
singleQuote: true,
|
||||||
|
printWidth: 120,
|
||||||
|
trailingComma: 'es5',
|
||||||
|
tailwindFunctions: ['clsx', 'tw'],
|
||||||
|
plugins: ['prettier-plugin-organize-imports', 'prettier-plugin-tailwindcss'],
|
||||||
|
tailwindStylesheet: './src/styles/tailwind.css',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default config
|
||||||
BIN
public/events/bear-hug-thumb.jpg
Normal file
|
After Width: | Height: | Size: 8.0 KiB |
BIN
public/events/bear-hug.jpg
Normal file
|
After Width: | Height: | Size: 114 KiB |
BIN
public/events/bear-hug.webp
Normal file
|
After Width: | Height: | Size: 315 KiB |
BIN
public/events/six-fingers-thumb.jpg
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
BIN
public/events/six-fingers.jpg
Normal file
|
After Width: | Height: | Size: 108 KiB |
BIN
public/events/six-fingers.webp
Normal file
|
After Width: | Height: | Size: 658 KiB |
BIN
public/events/viking-people-thumb.jpg
Normal file
|
After Width: | Height: | Size: 9.9 KiB |
BIN
public/events/viking-people.jpg
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
public/events/viking-people.webp
Normal file
|
After Width: | Height: | Size: 280 KiB |
BIN
public/events/we-all-look-the-same-thumb.jpg
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
public/events/we-all-look-the-same.jpg
Normal file
|
After Width: | Height: | Size: 127 KiB |
BIN
public/events/we-all-look-the-same.webp
Normal file
|
After Width: | Height: | Size: 467 KiB |
12
public/flags/ca.svg
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<svg width="16" height="12" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 12" fill="none">
|
||||||
|
<g clip-path="url(#clip0_270_55097)">
|
||||||
|
<path fill="#fff" d="M0 0H16V12H0z"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M4 0h8.5v12H4V0z" fill="#F7FCFF"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.164 4.201L7.987 3 8 10h-.343l.21-1.732s-2.305.423-2.115.21c.191-.214.3-.606.3-.606L4 6.474s.324-.004.587-.164c.264-.16-.263-1.109-.263-1.109l1.036.154.392-.435.782.836h.352l-.352-1.914.63.36zM8 10V3l.836 1.201.63-.359-.352 1.914h.352l.782-.836.392.435 1.036-.154s-.527.949-.263 1.109c.263.16.587.164.587.164L9.947 7.872s.11.392.3.606c.191.213-2.115-.21-2.115-.21L8.342 10H8zM12 0h4v12h-4V0zM0 0h4v12H0V0z" fill="#E31D1C"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_270_55097">
|
||||||
|
<path fill="#fff" d="M0 0H16V12H0z"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 874 B |
21
public/flags/mx.svg
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
<svg width="16" height="15" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 12" fill="none">
|
||||||
|
<g clip-path="url(#clip0_270_55118)">
|
||||||
|
<path fill="#fff" d="M0 0H16V12H0z"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M11 0h5v12h-5V0z" fill="#D9071E"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 0h6v12H0V0z" fill="#006923"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M5 0h6v12H5V0z" fill="#fff"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.192 3.957s-.343.266-.279.55c.064.284.74 0 .66-.275-.083-.275-.38-.275-.38-.275z" fill="#FCCA3D"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M6.668 4.634c-.257 0-.364-.214-.311-.442.028-.121.102-.276.222-.476l.43.257a2.861 2.861 0 00-.11.198.96.96 0 01.25.113c.234.154.32.428.07.631-.05.04-.128.173-.171.302.12.007.204.025.283.071.221.13.2.37.032.553-.12.13-.278.237-.429.29-.25.091-.516.057-.516-.263v-.002c0-.013-.002-.036.11-.038h.022-.022c-.19-.003-.231-.104-.255-.283a1.853 1.853 0 01-.012-.182l-.002-.06-.002-.06-.001-.013c-.004-.058-.007-.097.123-.097h-.29v-.5h.29c.164 0 .255.055.307.17.03-.052.06-.101.094-.145a.386.386 0 00-.112-.024zm.318.878l.008-.008-.002.004-.006.004zm-.218.149zm0-.189v.008-.015.007z" fill="#A8AC71"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.633 6.201s.463-1.37-.272-2.08c-.736-.71-1.585-.662-1.585-.662s-.245.182 0 .32c.245.14.142.272.142.272s-.41-.416-.682-.173c-.272.243.254.206.199.39-.056.182-.292.995.053 1.405.344.41-.326.321-.19.321.137 0 .62.096.62 0 0-.095.168.371.312.371s.255-.164.255-.164.201.164.324.164.552-.107.552-.107l-.81-.682s.049-.301-.066-.366.92.577 1.002.784c.08.207.146.207.146.207z" fill="#8F4620"/>
|
||||||
|
<path d="M5.533 5.863s.075-.239.156-.256c.07-.014.216.115.216.115.26 1.445.889 2.01 1.933 2.01 1.055 0 1.695-.43 2.069-1.762 0 0 .198-.203.268-.18.077.024.172.292.172.292-.095 1.559-1.22 2.482-2.492 2.482-1.284 0-2.333-1.035-2.322-2.7z" fill="#9FAB2F"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M6.667 6.828s1.502.319 2.095.319c.593 0-.52.484-.954.484-.435 0-1.14-.803-1.14-.803z" fill="#2FC2DC"/>
|
||||||
|
<rect x="7.33337" y="7.05" width="1" height="0.535714" rx="0.0833333" fill="#F9AA51"/>
|
||||||
|
<path d="M6.088 6.068l.41-.287c.532.76 1.328 1.009 2.443.748l.114.487c-1.303.305-2.31-.01-2.967-.948z" fill="#259485"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.296 6.175s-.607.32-.405.32c.202 0 .994.16.84 0-.154-.16-.435-.32-.435-.32zM7.578 5.794s-.115-.333-.355-.333-.172.273-.288.273c-.115 0 .108.193.207.193.1 0 .436-.133.436-.133z" fill="#FCCA3D"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_270_55118">
|
||||||
|
<path fill="#fff" d="M0 0H16V12H0z"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.6 KiB |
14
public/flags/us.svg
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<svg width="16" height="12" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 12" fill="none">
|
||||||
|
<g clip-path="url(#clip0_270_55071)">
|
||||||
|
<path fill="#fff" d="M0 0H16V12H0z"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 0h16v12H0V0z" fill="#E31D1C"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 1v1h16V1H0zm0 2v1h16V3H0zm0 3V5h16v1H0zm0 1v1h16V7H0zm0 3V9h16v1H0zm0 2v-1h16v1H0z" fill="#F7FCFF"/>
|
||||||
|
<path fill="#2E42A5" d="M0 0H9V7H0z"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M1.04 2.174l.53-.37.411.297h-.233l.471.416-.159.584h-.249l-.242-.536-.206.536H.748l.471.416-.179.657.53-.37.411.297h-.233l.471.416-.159.584h-.249l-.242-.536-.206.536H.748l.471.416-.179.657.53-.37.513.37-.16-.657.412-.416h-.19l.425-.296.411.296h-.233l.471.416-.179.657.53-.37.513.37-.16-.657.412-.416h-.19l.425-.296.411.296h-.233l.471.416-.179.657.53-.37.513.37-.16-.657.412-.416h-.19l.425-.296.411.296h-.233l.471.416-.179.657.53-.37.513.37-.16-.657.412-.416h-.524l-.242-.536-.206.536h-.298l-.142-.584.412-.416h-.19l.425-.296.513.369-.16-.657.412-.416h-.524l-.242-.536-.206.536h-.298l-.142-.584.412-.416h-.19l.425-.296.513.369-.16-.657.412-.416h-.524L7.569.565l-.206.536h-.615l.471.416-.159.584h-.249l-.242-.536-.206.536h-.298l-.142-.584.412-.416h-.524L5.569.565l-.206.536h-.615l.471.416-.159.584h-.249l-.242-.536-.206.536h-.298l-.142-.584.412-.416h-.524L3.569.565l-.206.536h-.615l.471.416-.159.584h-.249l-.242-.536-.206.536h-.298l-.142-.584.412-.416h-.524L1.569.565l-.206.536H.748l.471.416-.179.657zM7.06 4.1l.159-.584-.47-.416h.232l-.411-.296-.425.296h.19l-.412.416.142.584h.298l.206-.536.242.536h.249zm-1.079 0l-.411-.296-.425.296h.19l-.412.416.142.584h.298l.206-.536.242.536h.249l.159-.584-.47-.416h.232zm-1.762.416L4.06 5.1h-.249l-.242-.536-.206.536h-.298l-.142-.584.412-.416h-.19l.425-.296.411.296h-.233l.471.416zm.144-.416h-.298l-.142-.584.412-.416h-.19l.425-.296.411.296h-.233l.471.416-.159.584h-.249l-.242-.536-.206.536zm-1.303 0l.159-.584-.47-.416h.232l-.411-.296-.425.296h.19l-.412.416.142.584h.298l.206-.536.242.536h.249zm3.159-1.584L6.06 3.1h-.249l-.242-.536-.206.536h-.298l-.142-.584.412-.416h-.19l.425-.296.411.296h-.233l.471.416zM3.981 2.1l-.411-.296-.425.296h.19l-.412.416.142.584h.298l.206-.536.242.536h.249l.159-.584-.47-.416h.232z" fill="#F7FCFF"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_270_55071">
|
||||||
|
<path fill="#fff" d="M0 0H16V12H0z"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.4 KiB |
5
public/teams/catalyst.svg
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="32" height="32" fill="#18181B"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.5715 7.74275L11.0003 7.5H21.0008L21.3543 8.35355L19.2709 10.437L18.9173 10.5834H12.9422L9.30034 16.3337L12.9422 22.0841H18.9173L19.2709 22.2305L21.3543 24.3139L21.0008 25.1675H11.0003L10.5715 24.9247L5.57129 16.591V16.0765L10.5715 7.74275Z" fill="white"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M20.4121 12.6466L22.4347 10.624L23.217 10.7203L26.4305 16.0764V16.5909L23.217 21.947L22.4347 22.0433L20.4121 20.0207L20.3433 19.3997L22.2848 16.3337L20.3433 13.2677L20.4121 12.6466Z" fill="white"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 705 B |
BIN
public/users/erica.jpg
Normal file
|
After Width: | Height: | Size: 9.6 KiB |
171
src/app/(app)/application-layout.tsx
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Avatar } from '@/components/avatar'
|
||||||
|
import {
|
||||||
|
Dropdown,
|
||||||
|
DropdownButton,
|
||||||
|
DropdownDivider,
|
||||||
|
DropdownItem,
|
||||||
|
DropdownLabel,
|
||||||
|
DropdownMenu,
|
||||||
|
} from '@/components/dropdown'
|
||||||
|
import { Navbar, NavbarItem, NavbarSection, NavbarSpacer } from '@/components/navbar'
|
||||||
|
import {
|
||||||
|
Sidebar,
|
||||||
|
SidebarBody,
|
||||||
|
SidebarFooter,
|
||||||
|
SidebarHeader,
|
||||||
|
SidebarHeading,
|
||||||
|
SidebarItem,
|
||||||
|
SidebarLabel,
|
||||||
|
SidebarSection,
|
||||||
|
SidebarSpacer,
|
||||||
|
} from '@/components/sidebar'
|
||||||
|
import { SidebarLayout } from '@/components/sidebar-layout'
|
||||||
|
import { getProducts } from '@/data'
|
||||||
|
import {
|
||||||
|
ArrowRightStartOnRectangleIcon,
|
||||||
|
ChevronUpIcon,
|
||||||
|
NoSymbolIcon,
|
||||||
|
ShieldCheckIcon,
|
||||||
|
UserCircleIcon,
|
||||||
|
UsersIcon,
|
||||||
|
} from '@heroicons/react/16/solid'
|
||||||
|
import {
|
||||||
|
CubeTransparentIcon,
|
||||||
|
HomeIcon,
|
||||||
|
QuestionMarkCircleIcon,
|
||||||
|
Square2StackIcon,
|
||||||
|
TicketIcon,
|
||||||
|
} from '@heroicons/react/20/solid'
|
||||||
|
import { usePathname } from 'next/navigation'
|
||||||
|
|
||||||
|
function AccountDropdownMenu({ anchor }: { anchor: 'top start' | 'bottom end' }) {
|
||||||
|
return (
|
||||||
|
<DropdownMenu className="min-w-64" anchor={anchor}>
|
||||||
|
<DropdownItem href="#">
|
||||||
|
<UserCircleIcon />
|
||||||
|
<DropdownLabel>My account</DropdownLabel>
|
||||||
|
</DropdownItem>
|
||||||
|
<DropdownDivider />
|
||||||
|
<DropdownItem href="#">
|
||||||
|
<ShieldCheckIcon />
|
||||||
|
<DropdownLabel>Privacy policy</DropdownLabel>
|
||||||
|
</DropdownItem>
|
||||||
|
<DropdownDivider />
|
||||||
|
<DropdownItem href="/login">
|
||||||
|
<ArrowRightStartOnRectangleIcon />
|
||||||
|
<DropdownLabel>Sign out</DropdownLabel>
|
||||||
|
</DropdownItem>
|
||||||
|
</DropdownMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ApplicationLayout({
|
||||||
|
products,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
products: Awaited<ReturnType<typeof getProducts>>
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
let pathname = usePathname()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarLayout
|
||||||
|
navbar={
|
||||||
|
<Navbar>
|
||||||
|
<NavbarSpacer />
|
||||||
|
<NavbarSection>
|
||||||
|
<Dropdown>
|
||||||
|
<DropdownButton as={NavbarItem}>
|
||||||
|
<Avatar src="/users/erica.jpg" square />
|
||||||
|
</DropdownButton>
|
||||||
|
<AccountDropdownMenu anchor="bottom end" />
|
||||||
|
</Dropdown>
|
||||||
|
</NavbarSection>
|
||||||
|
</Navbar>
|
||||||
|
}
|
||||||
|
sidebar={
|
||||||
|
<Sidebar>
|
||||||
|
<SidebarHeader>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<CubeTransparentIcon className="w-7" />
|
||||||
|
<SidebarLabel></SidebarLabel>
|
||||||
|
</div>
|
||||||
|
</SidebarHeader>
|
||||||
|
|
||||||
|
<SidebarBody>
|
||||||
|
<SidebarSection>
|
||||||
|
<SidebarItem href="/" current={pathname === '/'}>
|
||||||
|
<HomeIcon />
|
||||||
|
<SidebarLabel>Home</SidebarLabel>
|
||||||
|
</SidebarItem>
|
||||||
|
<SidebarItem href="/users" current={pathname.startsWith('/users')}>
|
||||||
|
<UsersIcon />
|
||||||
|
<SidebarLabel>Users</SidebarLabel>
|
||||||
|
</SidebarItem>
|
||||||
|
<SidebarItem href="/products" current={pathname.startsWith('/products')}>
|
||||||
|
<Square2StackIcon />
|
||||||
|
<SidebarLabel>Products</SidebarLabel>
|
||||||
|
</SidebarItem>
|
||||||
|
<SidebarItem href="/orders" current={pathname.startsWith('/orders')}>
|
||||||
|
<TicketIcon />
|
||||||
|
<SidebarLabel>Orders</SidebarLabel>
|
||||||
|
</SidebarItem>
|
||||||
|
<SidebarItem href="/cancellation" current={pathname.startsWith('/cancellation')}>
|
||||||
|
<NoSymbolIcon />
|
||||||
|
<SidebarLabel>Cancellation</SidebarLabel>
|
||||||
|
</SidebarItem>
|
||||||
|
{/* <SidebarItem href="/settings" current={pathname.startsWith('/settings')}>
|
||||||
|
<Cog6ToothIcon />
|
||||||
|
<SidebarLabel>Settings</SidebarLabel>
|
||||||
|
</SidebarItem> */}
|
||||||
|
</SidebarSection>
|
||||||
|
|
||||||
|
<SidebarSection className="max-lg:hidden">
|
||||||
|
<SidebarHeading>Recently sold items</SidebarHeading>
|
||||||
|
{products.map((products) => (
|
||||||
|
<SidebarItem key={products.id} href={products.url}>
|
||||||
|
{products.name}
|
||||||
|
</SidebarItem>
|
||||||
|
))}
|
||||||
|
</SidebarSection>
|
||||||
|
|
||||||
|
<SidebarSpacer />
|
||||||
|
|
||||||
|
<SidebarSection>
|
||||||
|
<SidebarItem href="#">
|
||||||
|
<QuestionMarkCircleIcon />
|
||||||
|
<SidebarLabel>Support</SidebarLabel>
|
||||||
|
</SidebarItem>
|
||||||
|
{/* <SidebarItem href="#">
|
||||||
|
<SparklesIcon />
|
||||||
|
<SidebarLabel>Changelog</SidebarLabel>
|
||||||
|
</SidebarItem> */}
|
||||||
|
</SidebarSection>
|
||||||
|
</SidebarBody>
|
||||||
|
|
||||||
|
<SidebarFooter className="max-lg:hidden">
|
||||||
|
<Dropdown>
|
||||||
|
<DropdownButton as={SidebarItem}>
|
||||||
|
<span className="flex min-w-0 items-center gap-3">
|
||||||
|
<Avatar src="/users/erica.jpg" className="size-10" square alt="" />
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block truncate text-sm/5 font-medium text-zinc-950 dark:text-white">Erica</span>
|
||||||
|
<span className="block truncate text-xs/5 font-normal text-zinc-500 dark:text-zinc-400">
|
||||||
|
erica@example.com
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<ChevronUpIcon />
|
||||||
|
</DropdownButton>
|
||||||
|
<AccountDropdownMenu anchor="top start" />
|
||||||
|
</Dropdown>
|
||||||
|
</SidebarFooter>
|
||||||
|
</Sidebar>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SidebarLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
148
src/app/(app)/cancellation/[id]/page.tsx
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
import { Avatar } from '@/components/avatar'
|
||||||
|
import { Badge } from '@/components/badge'
|
||||||
|
import { DescriptionDetails, DescriptionList, DescriptionTerm } from '@/components/description-list'
|
||||||
|
import { Divider } from '@/components/divider'
|
||||||
|
import { Heading, Subheading } from '@/components/heading'
|
||||||
|
import { Link } from '@/components/link'
|
||||||
|
import { getOrder } from '@/data'
|
||||||
|
import { BanknotesIcon, CalendarIcon, ChevronLeftIcon, CreditCardIcon } from '@heroicons/react/16/solid'
|
||||||
|
import type { Metadata } from 'next'
|
||||||
|
import { notFound } from 'next/navigation'
|
||||||
|
import { RefundOrder } from './refund'
|
||||||
|
import { RejectRefund } from './reject'
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
|
||||||
|
let { id } = await params
|
||||||
|
let order = await getOrder(id)
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: order && `Order #${order.id}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function Order({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
let { id } = await params
|
||||||
|
let order = await getOrder(id)
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
notFound()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="max-lg:hidden">
|
||||||
|
<Link
|
||||||
|
href="/cancellation"
|
||||||
|
className="inline-flex items-center gap-2 text-sm/6 text-zinc-500 dark:text-zinc-400"
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon className="size-4 fill-zinc-400 dark:fill-zinc-500" />
|
||||||
|
Cancellation
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 lg:mt-8">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Heading>Order #{order.id}</Heading>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge
|
||||||
|
color={
|
||||||
|
order.cancellationStatus === '취소 완료'
|
||||||
|
? 'zinc'
|
||||||
|
: order.cancellationStatus === '요청 대기'
|
||||||
|
? 'amber'
|
||||||
|
: 'rose'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{order.cancellationStatus}
|
||||||
|
</Badge>
|
||||||
|
{/* 상태가 대기가 아니고 처리일자가 존재하면 날짜를 표시합니다 */}
|
||||||
|
{order.cancellationStatus !== '요청 대기' && order.cancellationResolvedDate && (
|
||||||
|
<span className="text-sm font-medium text-zinc-500">{order.cancellationResolvedDate}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="isolate mt-2.5 flex flex-wrap justify-between gap-x-6 gap-y-4">
|
||||||
|
<div className="flex flex-wrap gap-x-10 gap-y-4 py-1.5">
|
||||||
|
<span className="flex items-center gap-3 text-base/6 text-zinc-950 sm:text-sm/6 dark:text-white">
|
||||||
|
<BanknotesIcon className="size-4 shrink-0 fill-zinc-400 dark:fill-zinc-500" />
|
||||||
|
<span>{order.amount.krw}</span>
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-3 text-base/6 text-zinc-950 sm:text-sm/6 dark:text-white">
|
||||||
|
<CreditCardIcon className="size-4 shrink-0 fill-zinc-400 dark:fill-zinc-500" />
|
||||||
|
<span className="inline-flex gap-3">
|
||||||
|
{order.payment.card.type}{' '}
|
||||||
|
<span>
|
||||||
|
<span aria-hidden="true">••••</span> {order.payment.card.number}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-3 text-base/6 text-zinc-950 sm:text-sm/6 dark:text-white">
|
||||||
|
<CalendarIcon className="size-4 shrink-0 fill-zinc-400 dark:fill-zinc-500" />
|
||||||
|
<span>{order.date}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* '요청 대기' 상태일 때만 처리 버튼들이 나타납니다 */}
|
||||||
|
{order.cancellationStatus === '요청 대기' && (
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<RefundOrder outline amount={order.amount.krw} cancellationReason={order.cancellationReason}>
|
||||||
|
취소 진행
|
||||||
|
</RefundOrder>
|
||||||
|
|
||||||
|
<RejectRefund amount={order.amount.krw} cancellationReason={order.cancellationReason}>
|
||||||
|
취소 불가
|
||||||
|
</RejectRefund>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-12">
|
||||||
|
<Subheading>Summary</Subheading>
|
||||||
|
<Divider className="mt-4" />
|
||||||
|
<DescriptionList>
|
||||||
|
<DescriptionTerm>Customer</DescriptionTerm>
|
||||||
|
<DescriptionDetails>{order.customer.name}</DescriptionDetails>
|
||||||
|
<DescriptionTerm>Product</DescriptionTerm>
|
||||||
|
<DescriptionDetails>
|
||||||
|
<Link href={order.product.url} className="flex items-center gap-2">
|
||||||
|
<Avatar src={order.product.thumbUrl} className="size-6" />
|
||||||
|
<span>{order.product.name}</span>
|
||||||
|
</Link>
|
||||||
|
</DescriptionDetails>
|
||||||
|
<DescriptionTerm>Amount</DescriptionTerm>
|
||||||
|
<DescriptionDetails>{order.amount.krw}</DescriptionDetails>
|
||||||
|
</DescriptionList>
|
||||||
|
</div>
|
||||||
|
<div className="mt-12">
|
||||||
|
<Subheading>Payment method</Subheading>
|
||||||
|
<Divider className="mt-4" />
|
||||||
|
<DescriptionList>
|
||||||
|
<DescriptionTerm>Transaction ID</DescriptionTerm>
|
||||||
|
<DescriptionDetails>{order.payment.transactionId}</DescriptionDetails>
|
||||||
|
<DescriptionTerm>Card number</DescriptionTerm>
|
||||||
|
<DescriptionDetails>•••• {order.payment.card.number}</DescriptionDetails>
|
||||||
|
<DescriptionTerm>Card type</DescriptionTerm>
|
||||||
|
<DescriptionDetails>{order.payment.card.type}</DescriptionDetails>
|
||||||
|
<DescriptionTerm>Card expiry</DescriptionTerm>
|
||||||
|
<DescriptionDetails>{order.payment.card.expiry}</DescriptionDetails>
|
||||||
|
<DescriptionTerm>Owner</DescriptionTerm>
|
||||||
|
<DescriptionDetails>{order.customer.name}</DescriptionDetails>
|
||||||
|
<DescriptionTerm>Email address</DescriptionTerm>
|
||||||
|
<DescriptionDetails>{order.customer.email}</DescriptionDetails>
|
||||||
|
<DescriptionTerm>Address</DescriptionTerm>
|
||||||
|
<DescriptionDetails>{order.customer.address}</DescriptionDetails>
|
||||||
|
<DescriptionTerm>Country</DescriptionTerm>
|
||||||
|
<DescriptionDetails>
|
||||||
|
<span className="inline-flex gap-3">
|
||||||
|
<img src={order.customer.countryFlagUrl} alt={order.customer.country} />
|
||||||
|
{order.customer.country}
|
||||||
|
</span>
|
||||||
|
</DescriptionDetails>
|
||||||
|
<DescriptionTerm>CVC</DescriptionTerm>
|
||||||
|
<DescriptionDetails>
|
||||||
|
<Badge color="lime">Passed successfully</Badge>
|
||||||
|
</DescriptionDetails>
|
||||||
|
</DescriptionList>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
63
src/app/(app)/cancellation/[id]/refund.tsx
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Button } from '@/components/button'
|
||||||
|
import { Checkbox, CheckboxField } from '@/components/checkbox'
|
||||||
|
import { Dialog, DialogActions, DialogBody, DialogDescription, DialogTitle } from '@/components/dialog'
|
||||||
|
import { Description, Field, FieldGroup, Label } from '@/components/fieldset'
|
||||||
|
import { Input } from '@/components/input'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
export function RefundOrder({
|
||||||
|
amount,
|
||||||
|
cancellationReason,
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
amount: string
|
||||||
|
cancellationReason?: string // 옵셔널 스트링으로 타입 정의
|
||||||
|
} & React.ComponentPropsWithoutRef<typeof Button>) {
|
||||||
|
let [isOpen, setIsOpen] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button type="button" onClick={() => setIsOpen(true)} {...props} />
|
||||||
|
<Dialog open={isOpen} onClose={setIsOpen}>
|
||||||
|
<DialogTitle>Approve Refund</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
환불 처리가 완료되면 영업일 기준 2~3일 내에 고객의 결제 수단으로 환불 금액이 반영됩니다. 알림 옵션을 선택하면
|
||||||
|
고객에게 이메일이 발송됩니다.
|
||||||
|
</DialogDescription>
|
||||||
|
<DialogBody>
|
||||||
|
<FieldGroup>
|
||||||
|
<Field>
|
||||||
|
<Label>Amount (환불 금액)</Label>
|
||||||
|
<Input name="amount" defaultValue={amount} placeholder="$0.00" autoFocus />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<Label>Cancellation Reason (고객 취소 사유)</Label>
|
||||||
|
<Input
|
||||||
|
name="reason"
|
||||||
|
defaultValue={cancellationReason || '사유 없음'}
|
||||||
|
readOnly
|
||||||
|
className="bg-zinc-50 text-zinc-500"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<CheckboxField>
|
||||||
|
{/* 기본으로 체크된 상태로 설정 */}
|
||||||
|
<Checkbox name="notify" defaultChecked />
|
||||||
|
<Label>Notify customer</Label>
|
||||||
|
<Description>환불 완료 이메일 알림이 고객에게 발송됩니다.</Description>
|
||||||
|
</CheckboxField>
|
||||||
|
</FieldGroup>
|
||||||
|
</DialogBody>
|
||||||
|
<DialogActions>
|
||||||
|
<Button plain onClick={() => setIsOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => setIsOpen(false)}>Refund</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
69
src/app/(app)/cancellation/[id]/reject.tsx
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Button } from '@/components/button'
|
||||||
|
import { Checkbox, CheckboxField } from '@/components/checkbox'
|
||||||
|
import { Dialog, DialogActions, DialogBody, DialogDescription, DialogTitle } from '@/components/dialog'
|
||||||
|
import { Description, Field, FieldGroup, Label } from '@/components/fieldset'
|
||||||
|
import { Input } from '@/components/input'
|
||||||
|
import { Textarea } from '@/components/textarea'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
export function RejectRefund({
|
||||||
|
amount,
|
||||||
|
cancellationReason,
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
amount: string
|
||||||
|
cancellationReason?: string
|
||||||
|
} & React.ComponentPropsWithoutRef<typeof Button>) {
|
||||||
|
let [isOpen, setIsOpen] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button type="button" onClick={() => setIsOpen(true)} {...props} />
|
||||||
|
<Dialog open={isOpen} onClose={setIsOpen}>
|
||||||
|
<DialogTitle>Reject Refund</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
고객의 취소 요청을 거부하는 사유를 입력해 주세요. 알림 옵션을 선택하면 고객에게 이메일이 발송됩니다.
|
||||||
|
</DialogDescription>
|
||||||
|
<DialogBody>
|
||||||
|
<FieldGroup>
|
||||||
|
<Field>
|
||||||
|
<Label>Amount</Label>
|
||||||
|
<Input name="amount" defaultValue={amount} placeholder="$0.00" autoFocus />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<Label>Cancellation Reason (고객 취소 사유)</Label>
|
||||||
|
<Input
|
||||||
|
name="reason"
|
||||||
|
defaultValue={cancellationReason || '사유 없음'}
|
||||||
|
readOnly
|
||||||
|
className="bg-zinc-50 text-zinc-500"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{/* 관리자 거부 사유 입력란 추가 */}
|
||||||
|
<Field>
|
||||||
|
<Label>Reject Reason (취소 거부 사유)</Label>
|
||||||
|
<Textarea name="reject_reason" placeholder="관리자 거부/반려 사유를 상세히 입력해 주세요..." rows={4} />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<CheckboxField>
|
||||||
|
<Checkbox name="notify" defaultChecked />
|
||||||
|
<Label>Notify customer</Label>
|
||||||
|
<Description>이메일 알림이 고객에게 발송됩니다.</Description>
|
||||||
|
</CheckboxField>
|
||||||
|
</FieldGroup>
|
||||||
|
</DialogBody>
|
||||||
|
<DialogActions>
|
||||||
|
<Button plain onClick={() => setIsOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
{/* 완료 버튼 텍스트를 Refund에서 Reject로 변경 */}
|
||||||
|
<Button onClick={() => setIsOpen(false)}>Reject</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
129
src/app/(app)/cancellation/page.tsx
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
import { Avatar } from '@/components/avatar'
|
||||||
|
import { Badge } from '@/components/badge'
|
||||||
|
import { Heading } from '@/components/heading'
|
||||||
|
import { Input, InputGroup } from '@/components/input'
|
||||||
|
import { Pagination, PaginationList, PaginationNext, PaginationPage, PaginationPrevious } from '@/components/pagination'
|
||||||
|
import { Select } from '@/components/select'
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/table'
|
||||||
|
import { getOrders } from '@/data'
|
||||||
|
import { MagnifyingGlassIcon } from '@heroicons/react/16/solid'
|
||||||
|
import type { Metadata } from 'next'
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: '취소 관리',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function CancellationPage() {
|
||||||
|
const allOrders = await getOrders()
|
||||||
|
const cancelledOrders = allOrders.filter((order) => order.isCancelled === true)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="mt-14 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||||
|
<Heading>Cancellation</Heading>
|
||||||
|
|
||||||
|
<div className="flex w-full flex-col gap-2 sm:max-w-2xl sm:flex-row sm:items-center sm:justify-end">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="flex-1 sm:w-40 sm:flex-none">
|
||||||
|
<Select name="cancel_status">
|
||||||
|
<option value="all">전체 취소 상태</option>
|
||||||
|
<option value="요청 대기">요청 대기</option>
|
||||||
|
<option value="취소 완료">취소 완료</option>
|
||||||
|
<option value="취소 불가">취소 불가</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 sm:w-32 sm:flex-none">
|
||||||
|
<Select name="product_type">
|
||||||
|
<option value="all">전체 유형</option>
|
||||||
|
<option value="애셋">애셋</option>
|
||||||
|
<option value="텍스처">텍스처</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<InputGroup className="w-full sm:flex-1">
|
||||||
|
<MagnifyingGlassIcon />
|
||||||
|
<Input name="search" placeholder="취소 주문 검색…" aria-label="Search" />
|
||||||
|
</InputGroup>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Table className="mt-8 [--gutter:--spacing(6)] lg:[--gutter:--spacing(10)]">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableHeader>주문 번호</TableHeader>
|
||||||
|
{/* <TableHeader>주문 일자</TableHeader> */}
|
||||||
|
<TableHeader>취소 요청 일자</TableHeader>
|
||||||
|
<TableHeader>처리 일자</TableHeader>
|
||||||
|
<TableHeader>주문자</TableHeader>
|
||||||
|
<TableHeader>상품 유형</TableHeader>
|
||||||
|
<TableHeader>상품명</TableHeader>
|
||||||
|
<TableHeader className="text-right">결제 금액</TableHeader>
|
||||||
|
<TableHeader>상태</TableHeader>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{cancelledOrders.length > 0 ? (
|
||||||
|
cancelledOrders.map((order) => (
|
||||||
|
<TableRow key={order.id} href={order.cancellationUrl || order.url} title={`Order #${order.id} 취소 상세`}>
|
||||||
|
<TableCell className="font-medium text-zinc-950">{order.id}</TableCell>
|
||||||
|
{/* <TableCell className="text-zinc-500">{order.date}</TableCell> */}
|
||||||
|
<TableCell className="font-medium text-zinc-500">{order.cancellationDate}</TableCell>
|
||||||
|
{/* 처리 일자 추가 (값이 없으면 '-' 출력) */}
|
||||||
|
<TableCell className="text-zinc-500">
|
||||||
|
{order.cancellationResolvedDate ? order.cancellationResolvedDate : '-'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Avatar src={undefined} initials={order.customer.name[0]} className="size-6" />
|
||||||
|
<span className="text-zinc-950">{order.customer.name}</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-zinc-500">{order.event.productType}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Avatar src={order.event.thumbUrl} className="size-6" />
|
||||||
|
<span className="max-w-[150px] truncate text-zinc-950">{order.event.name}</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-semibold text-zinc-950">{order.amount.krw}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge
|
||||||
|
color={
|
||||||
|
order.cancellationStatus === '취소 완료'
|
||||||
|
? 'zinc'
|
||||||
|
: order.cancellationStatus === '요청 대기'
|
||||||
|
? 'amber'
|
||||||
|
: 'rose'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{order.cancellationStatus}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={9} className="py-10 text-center text-zinc-500">
|
||||||
|
취소 요청 내역이 없습니다.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
{cancelledOrders.length > 0 && (
|
||||||
|
<Pagination className="mt-10">
|
||||||
|
<PaginationPrevious href="?page=1" />
|
||||||
|
<PaginationList>
|
||||||
|
<PaginationPage href="?page=1" current>
|
||||||
|
1
|
||||||
|
</PaginationPage>
|
||||||
|
</PaginationList>
|
||||||
|
<PaginationNext href="?page=1" />
|
||||||
|
</Pagination>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
90
src/app/(app)/events/[id]/page.tsx
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
import { Stat } from '@/app/stat'
|
||||||
|
import { Badge } from '@/components/badge'
|
||||||
|
import { Button } from '@/components/button'
|
||||||
|
import { Heading, Subheading } from '@/components/heading'
|
||||||
|
import { Link } from '@/components/link'
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/table'
|
||||||
|
import { getEvent, getEventOrders } from '@/data'
|
||||||
|
import { ChevronLeftIcon } from '@heroicons/react/16/solid'
|
||||||
|
import type { Metadata } from 'next'
|
||||||
|
import { notFound } from 'next/navigation'
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
|
||||||
|
let { id } = await params
|
||||||
|
let event = await getEvent(id)
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: event?.name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function Event({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
let { id } = await params
|
||||||
|
let event = await getEvent(id)
|
||||||
|
let orders = await getEventOrders(id)
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
notFound()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="max-lg:hidden">
|
||||||
|
<Link href="/events" className="inline-flex items-center gap-2 text-sm/6 text-zinc-500 dark:text-zinc-400">
|
||||||
|
<ChevronLeftIcon className="size-4 fill-zinc-400 dark:fill-zinc-500" />
|
||||||
|
Events
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex flex-wrap items-end justify-between gap-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-6">
|
||||||
|
<div className="w-32 shrink-0">
|
||||||
|
<img className="aspect-3/2 rounded-lg shadow-sm" src={event.imgUrl} alt="" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||||
|
<Heading>{event.name}</Heading>
|
||||||
|
<Badge color={event.status === 'On Sale' ? 'lime' : 'zinc'}>{event.status}</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-sm/6 text-zinc-500">
|
||||||
|
{event.date} at {event.time} <span aria-hidden="true">·</span> {event.location}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<Button outline>Edit</Button>
|
||||||
|
<Button>View</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-8 grid gap-8 sm:grid-cols-3">
|
||||||
|
<Stat title="Total revenue" value={event.totalRevenue} change={event.totalRevenueChange} />
|
||||||
|
<Stat
|
||||||
|
title="Tickets sold"
|
||||||
|
value={`${event.ticketsSold}/${event.ticketsAvailable}`}
|
||||||
|
change={event.ticketsSoldChange}
|
||||||
|
/>
|
||||||
|
<Stat title="Pageviews" value={event.pageViews} change={event.pageViewsChange} />
|
||||||
|
</div>
|
||||||
|
<Subheading className="mt-12">Recent orders</Subheading>
|
||||||
|
<Table className="mt-4 [--gutter:--spacing(6)] lg:[--gutter:--spacing(10)]">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableHeader>Order number</TableHeader>
|
||||||
|
<TableHeader>Purchase date</TableHeader>
|
||||||
|
<TableHeader>Customer</TableHeader>
|
||||||
|
<TableHeader className="text-right">Amount</TableHeader>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{orders.map((order) => (
|
||||||
|
<TableRow key={order.id} href={order.url} title={`Order #${order.id}`}>
|
||||||
|
<TableCell>{order.id}</TableCell>
|
||||||
|
<TableCell className="text-zinc-500">{order.date}</TableCell>
|
||||||
|
<TableCell>{order.customer.name}</TableCell>
|
||||||
|
<TableCell className="text-right">US{order.amount.usd}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
87
src/app/(app)/events/page.tsx
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import { Badge } from '@/components/badge'
|
||||||
|
import { Button } from '@/components/button'
|
||||||
|
import { Divider } from '@/components/divider'
|
||||||
|
import { Dropdown, DropdownButton, DropdownItem, DropdownMenu } from '@/components/dropdown'
|
||||||
|
import { Heading } from '@/components/heading'
|
||||||
|
import { Input, InputGroup } from '@/components/input'
|
||||||
|
import { Link } from '@/components/link'
|
||||||
|
import { Select } from '@/components/select'
|
||||||
|
import { getEvents } from '@/data'
|
||||||
|
import { EllipsisVerticalIcon, MagnifyingGlassIcon } from '@heroicons/react/16/solid'
|
||||||
|
import type { Metadata } from 'next'
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Events',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function Events() {
|
||||||
|
let events = await getEvents()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||||
|
<div className="max-sm:w-full sm:flex-1">
|
||||||
|
<Heading>Events</Heading>
|
||||||
|
<div className="mt-4 flex max-w-xl gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<InputGroup>
|
||||||
|
<MagnifyingGlassIcon />
|
||||||
|
<Input name="search" placeholder="Search events…" />
|
||||||
|
</InputGroup>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Select name="sort_by">
|
||||||
|
<option value="name">Sort by name</option>
|
||||||
|
<option value="date">Sort by date</option>
|
||||||
|
<option value="status">Sort by status</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button>Create event</Button>
|
||||||
|
</div>
|
||||||
|
<ul className="mt-10">
|
||||||
|
{events.map((event, index) => (
|
||||||
|
<li key={event.id}>
|
||||||
|
<Divider soft={index > 0} />
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div key={event.id} className="flex gap-6 py-6">
|
||||||
|
<div className="w-32 shrink-0">
|
||||||
|
<Link href={event.url} aria-hidden="true">
|
||||||
|
<img className="aspect-3/2 rounded-lg shadow-sm" src={event.imgUrl} alt="" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="text-base/6 font-semibold">
|
||||||
|
<Link href={event.url}>{event.name}</Link>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs/6 text-zinc-500">
|
||||||
|
{event.date} at {event.time} <span aria-hidden="true">·</span> {event.location}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs/6 text-zinc-600">
|
||||||
|
{event.ticketsSold}/{event.ticketsAvailable} tickets sold
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Badge className="max-sm:hidden" color={event.status === 'On Sale' ? 'lime' : 'zinc'}>
|
||||||
|
{event.status}
|
||||||
|
</Badge>
|
||||||
|
<Dropdown>
|
||||||
|
<DropdownButton plain aria-label="More options">
|
||||||
|
<EllipsisVerticalIcon />
|
||||||
|
</DropdownButton>
|
||||||
|
<DropdownMenu anchor="bottom end">
|
||||||
|
<DropdownItem href={event.url}>View</DropdownItem>
|
||||||
|
<DropdownItem>Edit</DropdownItem>
|
||||||
|
<DropdownItem>Delete</DropdownItem>
|
||||||
|
</DropdownMenu>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
8
src/app/(app)/layout.tsx
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { getProducts } from '@/data'
|
||||||
|
import { ApplicationLayout } from './application-layout'
|
||||||
|
|
||||||
|
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
let products = await getProducts()
|
||||||
|
|
||||||
|
return <ApplicationLayout products={products}>{children}</ApplicationLayout>
|
||||||
|
}
|
||||||
120
src/app/(app)/orders/[id]/page.tsx
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
import { Avatar } from '@/components/avatar'
|
||||||
|
import { Badge } from '@/components/badge'
|
||||||
|
import { DescriptionDetails, DescriptionList, DescriptionTerm } from '@/components/description-list'
|
||||||
|
import { Divider } from '@/components/divider'
|
||||||
|
import { Heading, Subheading } from '@/components/heading'
|
||||||
|
import { Link } from '@/components/link'
|
||||||
|
import { getOrder } from '@/data'
|
||||||
|
import { BanknotesIcon, CalendarIcon, ChevronLeftIcon, CreditCardIcon } from '@heroicons/react/16/solid'
|
||||||
|
import type { Metadata } from 'next'
|
||||||
|
import { notFound } from 'next/navigation'
|
||||||
|
import { RefundOrder } from './refund'
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
|
||||||
|
let { id } = await params
|
||||||
|
let order = await getOrder(id)
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: order && `Order #${order.id}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function Order({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
let { id } = await params
|
||||||
|
let order = await getOrder(id)
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
notFound()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="max-lg:hidden">
|
||||||
|
<Link href="/orders" className="inline-flex items-center gap-2 text-sm/6 text-zinc-500 dark:text-zinc-400">
|
||||||
|
<ChevronLeftIcon className="size-4 fill-zinc-400 dark:fill-zinc-500" />
|
||||||
|
Orders
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 lg:mt-8">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Heading>Order #{order.id}</Heading>
|
||||||
|
<Badge color="lime">Successful</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="isolate mt-2.5 flex flex-wrap justify-between gap-x-6 gap-y-4">
|
||||||
|
<div className="flex flex-wrap gap-x-10 gap-y-4 py-1.5">
|
||||||
|
<span className="flex items-center gap-3 text-base/6 text-zinc-950 sm:text-sm/6 dark:text-white">
|
||||||
|
<BanknotesIcon className="size-4 shrink-0 fill-zinc-400 dark:fill-zinc-500" />
|
||||||
|
<span>{order.amount.krw}</span>
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-3 text-base/6 text-zinc-950 sm:text-sm/6 dark:text-white">
|
||||||
|
<CreditCardIcon className="size-4 shrink-0 fill-zinc-400 dark:fill-zinc-500" />
|
||||||
|
<span className="inline-flex gap-3">
|
||||||
|
{order.payment.card.type}{' '}
|
||||||
|
<span>
|
||||||
|
<span aria-hidden="true">••••</span> {order.payment.card.number}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-3 text-base/6 text-zinc-950 sm:text-sm/6 dark:text-white">
|
||||||
|
<CalendarIcon className="size-4 shrink-0 fill-zinc-400 dark:fill-zinc-500" />
|
||||||
|
<span>{order.date}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<RefundOrder outline amount={order.amount.krw}>
|
||||||
|
주문 취소
|
||||||
|
</RefundOrder>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-12">
|
||||||
|
<Subheading>Summary</Subheading>
|
||||||
|
<Divider className="mt-4" />
|
||||||
|
<DescriptionList>
|
||||||
|
<DescriptionTerm>Customer</DescriptionTerm>
|
||||||
|
<DescriptionDetails>{order.customer.name}</DescriptionDetails>
|
||||||
|
<DescriptionTerm>Product</DescriptionTerm>
|
||||||
|
<DescriptionDetails>
|
||||||
|
<Link href={order.event.url} className="flex items-center gap-2">
|
||||||
|
<Avatar src={order.event.thumbUrl} className="size-6" />
|
||||||
|
<span>{order.event.name}</span>
|
||||||
|
</Link>
|
||||||
|
</DescriptionDetails>
|
||||||
|
<DescriptionTerm>Amount</DescriptionTerm>
|
||||||
|
<DescriptionDetails>{order.amount.krw}</DescriptionDetails>
|
||||||
|
</DescriptionList>
|
||||||
|
</div>
|
||||||
|
<div className="mt-12">
|
||||||
|
<Subheading>Payment method</Subheading>
|
||||||
|
<Divider className="mt-4" />
|
||||||
|
<DescriptionList>
|
||||||
|
<DescriptionTerm>Transaction ID</DescriptionTerm>
|
||||||
|
<DescriptionDetails>{order.payment.transactionId}</DescriptionDetails>
|
||||||
|
<DescriptionTerm>Card number</DescriptionTerm>
|
||||||
|
<DescriptionDetails>•••• {order.payment.card.number}</DescriptionDetails>
|
||||||
|
<DescriptionTerm>Card type</DescriptionTerm>
|
||||||
|
<DescriptionDetails>{order.payment.card.type}</DescriptionDetails>
|
||||||
|
<DescriptionTerm>Card expiry</DescriptionTerm>
|
||||||
|
<DescriptionDetails>{order.payment.card.expiry}</DescriptionDetails>
|
||||||
|
<DescriptionTerm>Owner</DescriptionTerm>
|
||||||
|
<DescriptionDetails>{order.customer.name}</DescriptionDetails>
|
||||||
|
<DescriptionTerm>Email address</DescriptionTerm>
|
||||||
|
<DescriptionDetails>{order.customer.email}</DescriptionDetails>
|
||||||
|
<DescriptionTerm>Address</DescriptionTerm>
|
||||||
|
<DescriptionDetails>{order.customer.address}</DescriptionDetails>
|
||||||
|
<DescriptionTerm>Country</DescriptionTerm>
|
||||||
|
<DescriptionDetails>
|
||||||
|
<span className="inline-flex gap-3">
|
||||||
|
<img src={order.customer.countryFlagUrl} alt={order.customer.country} />
|
||||||
|
{order.customer.country}
|
||||||
|
</span>
|
||||||
|
</DescriptionDetails>
|
||||||
|
<DescriptionTerm>CVC</DescriptionTerm>
|
||||||
|
<DescriptionDetails>
|
||||||
|
<Badge color="lime">Passed successfully</Badge>
|
||||||
|
</DescriptionDetails>
|
||||||
|
</DescriptionList>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
56
src/app/(app)/orders/[id]/refund.tsx
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Button } from '@/components/button'
|
||||||
|
import { Checkbox, CheckboxField } from '@/components/checkbox'
|
||||||
|
import { Dialog, DialogActions, DialogBody, DialogDescription, DialogTitle } from '@/components/dialog'
|
||||||
|
import { Description, Field, FieldGroup, Label } from '@/components/fieldset'
|
||||||
|
import { Input } from '@/components/input'
|
||||||
|
import { Select } from '@/components/select'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
export function RefundOrder({ amount, ...props }: { amount: string } & React.ComponentPropsWithoutRef<typeof Button>) {
|
||||||
|
let [isOpen, setIsOpen] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button type="button" onClick={() => setIsOpen(true)} {...props} />
|
||||||
|
<Dialog open={isOpen} onClose={setIsOpen}>
|
||||||
|
<DialogTitle>Refund payment</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
The refund will be reflected in the customer’s bank account 2 to 3 business days after processing.
|
||||||
|
</DialogDescription>
|
||||||
|
<DialogBody>
|
||||||
|
<FieldGroup>
|
||||||
|
<Field>
|
||||||
|
<Label>Amount</Label>
|
||||||
|
<Input name="amount" defaultValue={amount} placeholder="$0.00" autoFocus />
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<Label>Reason</Label>
|
||||||
|
<Select name="reason" defaultValue="">
|
||||||
|
<option value="" disabled>
|
||||||
|
Select a reason…
|
||||||
|
</option>
|
||||||
|
<option value="duplicate">Duplicate</option>
|
||||||
|
<option value="fraudulent">Fraudulent</option>
|
||||||
|
<option value="requested_by_customer">Requested by customer</option>
|
||||||
|
<option value="other">Other</option>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
<CheckboxField>
|
||||||
|
<Checkbox name="notify" />
|
||||||
|
<Label>Notify customer</Label>
|
||||||
|
<Description>An email notification will be sent to this customer.</Description>
|
||||||
|
</CheckboxField>
|
||||||
|
</FieldGroup>
|
||||||
|
</DialogBody>
|
||||||
|
<DialogActions>
|
||||||
|
<Button plain onClick={() => setIsOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => setIsOpen(false)}>Refund</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
112
src/app/(app)/orders/page.tsx
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
import { Stat } from '@/app/stat'
|
||||||
|
import { Avatar } from '@/components/avatar'
|
||||||
|
import { Divider } from '@/components/divider'
|
||||||
|
import { Heading, Subheading } from '@/components/heading'
|
||||||
|
import { Input, InputGroup } from '@/components/input'
|
||||||
|
import {
|
||||||
|
Pagination,
|
||||||
|
PaginationGap,
|
||||||
|
PaginationList,
|
||||||
|
PaginationNext,
|
||||||
|
PaginationPage,
|
||||||
|
PaginationPrevious,
|
||||||
|
} from '@/components/pagination'
|
||||||
|
import { Select } from '@/components/select'
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/table'
|
||||||
|
import { getOrders } from '@/data'
|
||||||
|
import { MagnifyingGlassIcon } from '@heroicons/react/16/solid'
|
||||||
|
import type { Metadata } from 'next'
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Orders',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function Orders() {
|
||||||
|
let orders = await getOrders()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="mt-8 flex items-end justify-between">
|
||||||
|
<Subheading>Overview</Subheading>
|
||||||
|
<div>
|
||||||
|
<Select name="period">
|
||||||
|
<option value="last_week">최근 1주일</option>
|
||||||
|
<option value="last_month">최근 1개월</option>
|
||||||
|
<option value="last_quarter">최근 3개월</option>
|
||||||
|
<option value="last_year">최근 1년</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 grid gap-8 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-4">
|
||||||
|
<Stat title="총 판매금액(원)" value="2,600,000" change="+3.5%" />
|
||||||
|
<Stat title="애셋 판매(원)" value="1,200,000" change="-0.5%" />
|
||||||
|
<Stat title="텍스처 판매(원)" value="1,400,000" change="+4.5%" />
|
||||||
|
<Stat title="총 주문(건)" value="1,030" change="+1.5%" />
|
||||||
|
</div>
|
||||||
|
<Divider className="mt-10" />
|
||||||
|
<div className="mt-14 flex items-end justify-between gap-4">
|
||||||
|
<Heading>Orders</Heading>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<Select name="order_type">
|
||||||
|
<option value="all">전체 유형</option>
|
||||||
|
<option value="assets">애셋</option>
|
||||||
|
<option value="textures">텍스처</option>
|
||||||
|
</Select>
|
||||||
|
<InputGroup>
|
||||||
|
<MagnifyingGlassIcon />
|
||||||
|
<Input name="search" placeholder="검색…" aria-label="Search" />
|
||||||
|
</InputGroup>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Table className="mt-8 [--gutter:--spacing(6)] lg:[--gutter:--spacing(10)]">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableHeader>주문 번호</TableHeader>
|
||||||
|
<TableHeader>주문 일자</TableHeader>
|
||||||
|
<TableHeader>주문자</TableHeader>
|
||||||
|
<TableHeader>상품 유형</TableHeader>
|
||||||
|
<TableHeader>상품명</TableHeader>
|
||||||
|
<TableHeader className="text-right">결제 금액</TableHeader>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{orders.map((order) => (
|
||||||
|
<TableRow key={order.id} href={order.url} title={`Order #${order.id}`}>
|
||||||
|
<TableCell>{order.id}</TableCell>
|
||||||
|
<TableCell className="text-zinc-500">{order.date}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Avatar src={order.event.thumbUser} className="size-6" />
|
||||||
|
<span>{order.customer.name}</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-zinc-500">{order.event.productType}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Avatar src={order.event.thumbUrl} className="size-6" />
|
||||||
|
<span>{order.event.name}</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">{order.amount.krw}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
<Pagination className="mt-10">
|
||||||
|
<PaginationPrevious href="?page=2" />
|
||||||
|
<PaginationList>
|
||||||
|
<PaginationPage href="?page=1">1</PaginationPage>
|
||||||
|
<PaginationPage href="?page=2">2</PaginationPage>
|
||||||
|
<PaginationPage href="?page=3" current>
|
||||||
|
3
|
||||||
|
</PaginationPage>
|
||||||
|
<PaginationPage href="?page=4">4</PaginationPage>
|
||||||
|
<PaginationGap />
|
||||||
|
<PaginationPage href="?page=65">65</PaginationPage>
|
||||||
|
<PaginationPage href="?page=66">66</PaginationPage>
|
||||||
|
</PaginationList>
|
||||||
|
<PaginationNext href="?page=4" />
|
||||||
|
</Pagination>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
71
src/app/(app)/page.tsx
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import { Stat } from '@/app/stat'
|
||||||
|
import { Divider } from '@/components/divider'
|
||||||
|
import { Heading, Subheading } from '@/components/heading'
|
||||||
|
import { Select } from '@/components/select'
|
||||||
|
import { getRecentOrders } from '@/data'
|
||||||
|
|
||||||
|
export default async function Home() {
|
||||||
|
let orders = await getRecentOrders()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Heading>Overview</Heading>
|
||||||
|
<div className="mt-18 flex items-end justify-between">
|
||||||
|
<Subheading>Orders Overview</Subheading>
|
||||||
|
<div>
|
||||||
|
<Select name="period">
|
||||||
|
<option value="last_week">최근 1주일</option>
|
||||||
|
<option value="last_month">최근 1개월</option>
|
||||||
|
<option value="last_quarter">최근 3개월</option>
|
||||||
|
<option value="last_year">최근 1년</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 grid gap-8 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-4">
|
||||||
|
<Stat title="총 판매금액(원)" value="2,600,000" change="+3.5%" />
|
||||||
|
<Stat title="애셋 판매(원)" value="1,200,000" change="-0.5%" />
|
||||||
|
<Stat title="텍스처 판매(원)" value="1,400,000" change="+4.5%" />
|
||||||
|
<Stat title="총 주문(건)" value="1,030" change="+1.5%" />
|
||||||
|
</div>
|
||||||
|
{/* <Divider className="mt-10 mb-10" /> */}
|
||||||
|
|
||||||
|
<div className="mt-18 flex items-end justify-between">
|
||||||
|
<Subheading>Products Overview</Subheading>
|
||||||
|
<div>
|
||||||
|
<Select name="period">
|
||||||
|
<option value="last_week">최근 1주일</option>
|
||||||
|
<option value="last_month">최근 1개월</option>
|
||||||
|
<option value="last_quarter">최근 3개월</option>
|
||||||
|
<option value="last_year">최근 1년</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 grid gap-8 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-4">
|
||||||
|
<Stat title="애셋" value="21,200" change="-0.5%" />
|
||||||
|
<Stat title="텍스처" value="121,400" change="+4.5%" />
|
||||||
|
<Stat title="판매" value="231,030" change="+1.5%" />
|
||||||
|
<Stat title="미판매" value="352,630" change="+3.5%" />
|
||||||
|
</div>
|
||||||
|
{/* <Divider className="mt-10 mb-10" /> */}
|
||||||
|
|
||||||
|
<div className="mt-18 flex items-end justify-between">
|
||||||
|
<Subheading>Users Overview</Subheading>
|
||||||
|
<div>
|
||||||
|
<Select name="period">
|
||||||
|
<option value="last_week">최근 1주일</option>
|
||||||
|
<option value="last_month">최근 1개월</option>
|
||||||
|
<option value="last_quarter">최근 3개월</option>
|
||||||
|
<option value="last_year">최근 1년</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 grid gap-8 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-4">
|
||||||
|
<Stat title="일반" value="3,630" change="+3.5%" />
|
||||||
|
<Stat title="기관" value="1,200" change="-0.5%" />
|
||||||
|
<Stat title="기업" value="1,400" change="+4.5%" />
|
||||||
|
<Stat title="교육" value="1,030" change="+1.5%" />
|
||||||
|
</div>
|
||||||
|
{/* <Divider className="mt-10 mb-10" /> */}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
165
src/app/(app)/products/ProductsClient.tsx
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Badge } from '@/components/badge'
|
||||||
|
import { Button } from '@/components/button'
|
||||||
|
import { Dialog, DialogActions, DialogBody, DialogDescription, DialogTitle } from '@/components/dialog'
|
||||||
|
import { Divider } from '@/components/divider'
|
||||||
|
import { Dropdown, DropdownButton, DropdownItem, DropdownMenu } from '@/components/dropdown'
|
||||||
|
import { Field, Label } from '@/components/fieldset'
|
||||||
|
import { Heading } from '@/components/heading'
|
||||||
|
import { Input, InputGroup } from '@/components/input'
|
||||||
|
import { Link } from '@/components/link'
|
||||||
|
import {
|
||||||
|
Pagination,
|
||||||
|
PaginationGap,
|
||||||
|
PaginationList,
|
||||||
|
PaginationNext,
|
||||||
|
PaginationPage,
|
||||||
|
PaginationPrevious,
|
||||||
|
} from '@/components/pagination'
|
||||||
|
import { Select } from '@/components/select'
|
||||||
|
import { EllipsisVerticalIcon, MagnifyingGlassIcon } from '@heroicons/react/16/solid'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
export default function ProductsClient({ products }: { products: any[] }) {
|
||||||
|
let [isOpen, setIsOpen] = useState(false)
|
||||||
|
let [selectedProduct, setSelectedProduct] = useState<any>(null)
|
||||||
|
|
||||||
|
const handleOpenDialog = (product: any) => {
|
||||||
|
setSelectedProduct(product)
|
||||||
|
setIsOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="mt-14 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||||
|
<Heading>Products</Heading>
|
||||||
|
|
||||||
|
<div className="flex w-full flex-col gap-2 sm:max-w-4xl sm:flex-row sm:items-center sm:justify-end">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="flex-1 sm:w-32 sm:flex-none">
|
||||||
|
<Select name="sort_by_types">
|
||||||
|
<option value="all">전체 유형</option>
|
||||||
|
<option value="assets">애셋</option>
|
||||||
|
<option value="textures">텍스처</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 sm:w-32 sm:flex-none">
|
||||||
|
<Select name="sort_by_sale" defaultValue="on_sale">
|
||||||
|
<option value="all">전체 판매 상태</option>
|
||||||
|
<option value="on_sale">판매 중</option>
|
||||||
|
<option value="non_sale">미판매</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-none sm:w-40">
|
||||||
|
<Select name="sort_by_license" defaultValue="license_04">
|
||||||
|
<option value="all">전체 라이선스</option>
|
||||||
|
<option value="license_01">단순 읽기</option>
|
||||||
|
<option value="license_02">부분 추출</option>
|
||||||
|
<option value="license_03">전체 추출</option>
|
||||||
|
<option value="license_04">부분 변경</option>
|
||||||
|
<option value="license_05">전체 변경</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<InputGroup className="w-full sm:flex-1">
|
||||||
|
<MagnifyingGlassIcon />
|
||||||
|
<Input name="search" placeholder="상품명 검색…" aria-label="Search" />
|
||||||
|
</InputGroup>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 상품 리스트 */}
|
||||||
|
<ul className="mt-10">
|
||||||
|
{products.map((product, index) => (
|
||||||
|
<li key={product.id}>
|
||||||
|
<Divider soft={index > 0} />
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex gap-6 py-6">
|
||||||
|
<div className="w-32 shrink-0">
|
||||||
|
<Link href={product.url} aria-hidden="true">
|
||||||
|
<img
|
||||||
|
className="aspect-3/2 rounded-lg object-cover shadow-sm"
|
||||||
|
src={product.imgUrl}
|
||||||
|
alt={product.name}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="text-xs/6 text-zinc-600">
|
||||||
|
<Badge color="lime">{product.productType}</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="text-base/6 font-semibold">
|
||||||
|
<Link href={product.url}>{product.name}</Link>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-xs/6 text-zinc-500">{product.licenseType} 라이선스</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Badge
|
||||||
|
className="max-sm:hidden"
|
||||||
|
color={product.status === '판매 중' ? 'sky' : product.status === '판매 보류' ? 'amber' : 'zinc'}
|
||||||
|
>
|
||||||
|
{product.status}
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
<Dropdown>
|
||||||
|
<DropdownButton plain aria-label="More options">
|
||||||
|
<EllipsisVerticalIcon />
|
||||||
|
</DropdownButton>
|
||||||
|
<DropdownMenu anchor="bottom end">
|
||||||
|
{product.status === '판매 보류' ? (
|
||||||
|
<DropdownItem onClick={() => {}}>보류 해제</DropdownItem>
|
||||||
|
) : (
|
||||||
|
<DropdownItem onClick={() => handleOpenDialog(product)}>판매 보류 등록</DropdownItem>
|
||||||
|
)}
|
||||||
|
</DropdownMenu>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{/* 판매 보류 다이얼로그 */}
|
||||||
|
<Dialog open={isOpen} onClose={setIsOpen}>
|
||||||
|
<DialogTitle>판매 보류 등록</DialogTitle>
|
||||||
|
<DialogDescription>{selectedProduct?.name} 상품의 판매 보류 지정 사유를 입력하세요.</DialogDescription>
|
||||||
|
<DialogBody>
|
||||||
|
<Field>
|
||||||
|
<Label>등록 사유</Label>
|
||||||
|
<Input name="ban" placeholder="사유를 입력해주세요..." />
|
||||||
|
</Field>
|
||||||
|
</DialogBody>
|
||||||
|
<DialogActions>
|
||||||
|
<Button plain onClick={() => setIsOpen(false)}>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => setIsOpen(false)}>등록</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 페이지네이션 */}
|
||||||
|
<Pagination className="mt-10">
|
||||||
|
<PaginationPrevious href="?page=2" />
|
||||||
|
<PaginationList>
|
||||||
|
<PaginationPage href="?page=1">1</PaginationPage>
|
||||||
|
<PaginationPage href="?page=2">2</PaginationPage>
|
||||||
|
<PaginationPage href="?page=3" current>
|
||||||
|
3
|
||||||
|
</PaginationPage>
|
||||||
|
<PaginationPage href="?page=4">4</PaginationPage>
|
||||||
|
<PaginationGap />
|
||||||
|
<PaginationPage href="?page=65">65</PaginationPage>
|
||||||
|
<PaginationPage href="?page=66">66</PaginationPage>
|
||||||
|
</PaginationList>
|
||||||
|
<PaginationNext href="?page=4" />
|
||||||
|
</Pagination>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
105
src/app/(app)/products/[id]/page.tsx
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
import { Stat } from '@/app/stat'
|
||||||
|
import { Badge } from '@/components/badge'
|
||||||
|
import { Button } from '@/components/button'
|
||||||
|
import { Heading, Subheading } from '@/components/heading'
|
||||||
|
import { Link } from '@/components/link'
|
||||||
|
import { Select } from '@/components/select'
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/table'
|
||||||
|
import { getProductOrders, getProducts } from '@/data'
|
||||||
|
import { ChevronLeftIcon } from '@heroicons/react/16/solid'
|
||||||
|
import type { Metadata } from 'next'
|
||||||
|
import { notFound } from 'next/navigation'
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
|
||||||
|
let { id } = await params
|
||||||
|
let products = await getProducts()
|
||||||
|
let product = products.find((p: any) => p.id.toString() === id)
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: product?.name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
let { id } = await params
|
||||||
|
|
||||||
|
let products = await getProducts()
|
||||||
|
let product = products.find((p: any) => p.id.toString() === id)
|
||||||
|
|
||||||
|
let orders = await getProductOrders(id)
|
||||||
|
|
||||||
|
if (!product) {
|
||||||
|
notFound()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="max-lg:hidden">
|
||||||
|
<Link href="/products" className="inline-flex items-center gap-2 text-sm/6 text-zinc-500 dark:text-zinc-400">
|
||||||
|
<ChevronLeftIcon className="size-4 fill-zinc-400 dark:fill-zinc-500" />
|
||||||
|
Products
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex flex-wrap items-end justify-between gap-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-6">
|
||||||
|
<div className="w-32 shrink-0">
|
||||||
|
<img className="aspect-3/2 rounded-lg object-cover shadow-sm" src={product.imgUrl} alt="" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||||
|
<Heading>{product.name}</Heading>
|
||||||
|
<Badge color={product.status === '판매 중' ? 'sky' : product.status === '판매 보류' ? 'amber' : 'zinc'}>
|
||||||
|
{product.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-sm/6 text-zinc-500">
|
||||||
|
{product.date} <span aria-hidden="true">·</span> {product.productType}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<Button>제품 상세 보기</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-12 flex items-end justify-between">
|
||||||
|
<Subheading>Orders Overview</Subheading>
|
||||||
|
<div>
|
||||||
|
<Select name="period">
|
||||||
|
<option value="last_week">최근 1주일</option>
|
||||||
|
<option value="last_month">최근 1개월</option>
|
||||||
|
<option value="last_quarter">최근 3개월</option>
|
||||||
|
<option value="last_year">최근 1년</option>
|
||||||
|
<option value="all">전체 기간</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-8 grid gap-8 sm:grid-cols-3">
|
||||||
|
<Stat title="총 판매(원)" value={product.totalRevenue} change={product.totalRevenueChange} />
|
||||||
|
<Stat title="총 판매(건)" value={`${product.ticketsSold}`} change={product.ticketsSoldChange} />
|
||||||
|
<Stat title="조회수" value={product.pageViews} change={product.pageViewsChange} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Subheading className="mt-12">Recent orders</Subheading>
|
||||||
|
<Table className="mt-4 [--gutter:--spacing(6)] lg:[--gutter:--spacing(10)]">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableHeader>주문번호</TableHeader>
|
||||||
|
<TableHeader>주문일</TableHeader>
|
||||||
|
<TableHeader>구매자</TableHeader>
|
||||||
|
<TableHeader className="text-right">가격</TableHeader>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{orders.map((order: any) => (
|
||||||
|
<TableRow key={order.id} href={order.url} title={`Order #${order.id}`}>
|
||||||
|
<TableCell>#{order.id}</TableCell>
|
||||||
|
<TableCell className="text-zinc-500">{order.date}</TableCell>
|
||||||
|
<TableCell>{order.customer.name}</TableCell>
|
||||||
|
<TableCell className="text-right font-medium">{order.amount.krw || `US${order.amount.usd}`}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
13
src/app/(app)/products/page.tsx
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { getProducts } from '@/data'
|
||||||
|
import type { Metadata } from 'next'
|
||||||
|
import ProductsClient from './ProductsClient'
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Events',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function EventsPage() {
|
||||||
|
const products = await getProducts()
|
||||||
|
|
||||||
|
return <ProductsClient products={products} />
|
||||||
|
}
|
||||||
48
src/app/(app)/settings/address.tsx
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Input } from '@/components/input'
|
||||||
|
import { Listbox, ListboxLabel, ListboxOption } from '@/components/listbox'
|
||||||
|
import { getCountries } from '@/data'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
export function Address() {
|
||||||
|
let countries = getCountries()
|
||||||
|
let [country, setCountry] = useState(countries[0])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 gap-6">
|
||||||
|
<Input
|
||||||
|
aria-label="Street Address"
|
||||||
|
name="address"
|
||||||
|
placeholder="Street Address"
|
||||||
|
defaultValue="147 Catalyst Ave"
|
||||||
|
className="col-span-2"
|
||||||
|
/>
|
||||||
|
<Input aria-label="City" name="city" placeholder="City" defaultValue="Toronto" className="col-span-2" />
|
||||||
|
<Listbox aria-label="Region" name="region" placeholder="Region" defaultValue="Ontario">
|
||||||
|
{country.regions.map((region) => (
|
||||||
|
<ListboxOption key={region} value={region}>
|
||||||
|
<ListboxLabel>{region}</ListboxLabel>
|
||||||
|
</ListboxOption>
|
||||||
|
))}
|
||||||
|
</Listbox>
|
||||||
|
<Input aria-label="Postal code" name="postal_code" placeholder="Postal Code" defaultValue="A1A 1A1" />
|
||||||
|
<Listbox
|
||||||
|
aria-label="Country"
|
||||||
|
name="country"
|
||||||
|
placeholder="Country"
|
||||||
|
by="code"
|
||||||
|
value={country}
|
||||||
|
onChange={(country) => setCountry(country)}
|
||||||
|
className="col-span-2"
|
||||||
|
>
|
||||||
|
{countries.map((country) => (
|
||||||
|
<ListboxOption key={country.code} value={country}>
|
||||||
|
<img className="w-5 sm:w-4" src={country.flagUrl} alt="" />
|
||||||
|
<ListboxLabel>{country.name}</ListboxLabel>
|
||||||
|
</ListboxOption>
|
||||||
|
))}
|
||||||
|
</Listbox>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
96
src/app/(app)/settings/page.tsx
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import { Button } from '@/components/button'
|
||||||
|
import { Checkbox, CheckboxField } from '@/components/checkbox'
|
||||||
|
import { Divider } from '@/components/divider'
|
||||||
|
import { Label } from '@/components/fieldset'
|
||||||
|
import { Heading, Subheading } from '@/components/heading'
|
||||||
|
import { Input } from '@/components/input'
|
||||||
|
import { Select } from '@/components/select'
|
||||||
|
import { Text } from '@/components/text'
|
||||||
|
import { Textarea } from '@/components/textarea'
|
||||||
|
import type { Metadata } from 'next'
|
||||||
|
import { Address } from './address'
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Settings',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Settings() {
|
||||||
|
return (
|
||||||
|
<form method="post" className="mx-auto max-w-4xl">
|
||||||
|
<Heading>Settings</Heading>
|
||||||
|
<Divider className="my-10 mt-6" />
|
||||||
|
|
||||||
|
<section className="grid gap-x-8 gap-y-6 sm:grid-cols-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Subheading>Organization Name</Subheading>
|
||||||
|
<Text>This will be displayed on your public profile.</Text>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Input aria-label="Organization Name" name="name" defaultValue="Catalyst" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Divider className="my-10" soft />
|
||||||
|
|
||||||
|
<section className="grid gap-x-8 gap-y-6 sm:grid-cols-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Subheading>Organization Bio</Subheading>
|
||||||
|
<Text>This will be displayed on your public profile. Maximum 240 characters.</Text>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Textarea aria-label="Organization Bio" name="bio" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Divider className="my-10" soft />
|
||||||
|
|
||||||
|
<section className="grid gap-x-8 gap-y-6 sm:grid-cols-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Subheading>Organization Email</Subheading>
|
||||||
|
<Text>This is how customers can contact you for support.</Text>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Input type="email" aria-label="Organization Email" name="email" defaultValue="info@example.com" />
|
||||||
|
<CheckboxField>
|
||||||
|
<Checkbox name="email_is_public" defaultChecked />
|
||||||
|
<Label>Show email on public profile</Label>
|
||||||
|
</CheckboxField>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Divider className="my-10" soft />
|
||||||
|
|
||||||
|
<section className="grid gap-x-8 gap-y-6 sm:grid-cols-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Subheading>Address</Subheading>
|
||||||
|
<Text>This is where your organization is registered.</Text>
|
||||||
|
</div>
|
||||||
|
<Address />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Divider className="my-10" soft />
|
||||||
|
|
||||||
|
<section className="grid gap-x-8 gap-y-6 sm:grid-cols-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Subheading>Currency</Subheading>
|
||||||
|
<Text>The currency that your organization will be collecting.</Text>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Select aria-label="Currency" name="currency" defaultValue="cad">
|
||||||
|
<option value="cad">CAD - Canadian Dollar</option>
|
||||||
|
<option value="usd">USD - United States Dollar</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Divider className="my-10" soft />
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-4">
|
||||||
|
<Button type="reset" plain>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
<Button type="submit">Save changes</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
213
src/app/(app)/users/UsersClient.tsx
Normal file
@ -0,0 +1,213 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Stat } from '@/app/stat'
|
||||||
|
import { Avatar } from '@/components/avatar'
|
||||||
|
import { Badge } from '@/components/badge'
|
||||||
|
import { Divider } from '@/components/divider'
|
||||||
|
import {
|
||||||
|
Dropdown,
|
||||||
|
DropdownButton,
|
||||||
|
DropdownHeading,
|
||||||
|
DropdownItem,
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownSection,
|
||||||
|
} from '@/components/dropdown'
|
||||||
|
import { Heading, Subheading } from '@/components/heading'
|
||||||
|
import { Input, InputGroup } from '@/components/input'
|
||||||
|
import { Link } from '@/components/link'
|
||||||
|
import {
|
||||||
|
Pagination,
|
||||||
|
PaginationGap,
|
||||||
|
PaginationList,
|
||||||
|
PaginationNext,
|
||||||
|
PaginationPage,
|
||||||
|
PaginationPrevious,
|
||||||
|
} from '@/components/pagination'
|
||||||
|
import { Select } from '@/components/select'
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/table'
|
||||||
|
import { EllipsisHorizontalIcon, MagnifyingGlassIcon } from '@heroicons/react/16/solid'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
export default function UsersClient({ initialUsers }: { initialUsers: any[] }) {
|
||||||
|
const [users, setUsers] = useState(initialUsers)
|
||||||
|
|
||||||
|
// 계정 상태 업데이트
|
||||||
|
const updateUserStatus = (userId: number, newStatus: string) => {
|
||||||
|
setUsers((prev) => prev.map((user) => (user.id === userId ? { ...user, accountStatus: newStatus } : user)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 인증 상태 업데이트
|
||||||
|
const updateVerificationStatus = (userId: number, newStatus: string) => {
|
||||||
|
setUsers((prev) => prev.map((user) => (user.id === userId ? { ...user, verificationStatus: newStatus } : user)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="mt-8 flex items-end justify-between">
|
||||||
|
<Subheading>Overview</Subheading>
|
||||||
|
<div>
|
||||||
|
<Select name="period">
|
||||||
|
<option value="last_week">최근 1주일</option>
|
||||||
|
<option value="last_month">최근 1개월</option>
|
||||||
|
<option value="last_year">최근 1년</option>
|
||||||
|
<option value="last_year">전체 기간</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 grid gap-8 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-4">
|
||||||
|
<Stat title="일반" value="3,630" change="+3.5%" />
|
||||||
|
<Stat title="기관" value="1,200" change="-0.5%" />
|
||||||
|
<Stat title="기업" value="1,400" change="+4.5%" />
|
||||||
|
<Stat title="교육" value="1,030" change="+1.5%" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider className="mt-10" />
|
||||||
|
|
||||||
|
<div className="mt-14 flex items-end justify-between gap-4">
|
||||||
|
<Heading>Users</Heading>
|
||||||
|
<div className="flex max-w-xl justify-end gap-2">
|
||||||
|
<Select name="user_type" className="flex-1">
|
||||||
|
<option value="all">전체 유형</option>
|
||||||
|
<option value="normal">일반</option>
|
||||||
|
<option value="corp">기업</option>
|
||||||
|
<option value="org">기관</option>
|
||||||
|
<option value="edu">교육</option>ㄴ
|
||||||
|
</Select>
|
||||||
|
<InputGroup className="flex-2">
|
||||||
|
<MagnifyingGlassIcon />
|
||||||
|
<Input name="search" placeholder="사용자 검색…" aria-label="Search" />
|
||||||
|
</InputGroup>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Table className="mt-8 [--gutter:--spacing(6)] lg:[--gutter:--spacing(10)]">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableHeader>사용자</TableHeader>
|
||||||
|
<TableHeader>사용자 유형</TableHeader>
|
||||||
|
<TableHeader>이메일</TableHeader>
|
||||||
|
<TableHeader>전화번호</TableHeader>
|
||||||
|
<TableHeader>소속 명칭</TableHeader>
|
||||||
|
<TableHeader>인증 상태</TableHeader>
|
||||||
|
<TableHeader>인증 자료</TableHeader>
|
||||||
|
<TableHeader>계정 상태</TableHeader>
|
||||||
|
<TableHeader className="relative w-0 px-0">
|
||||||
|
<span className="sr-only">Actions</span>
|
||||||
|
</TableHeader>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{users.map((user) => (
|
||||||
|
<TableRow key={user.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* 에리카는 이미지가 나오고, 나머지는 이니셜(Fallback)이 나옵니다. */}
|
||||||
|
<Avatar
|
||||||
|
src={user.avatar || undefined}
|
||||||
|
initials={!user.avatar ? user.name[0] : undefined}
|
||||||
|
className="size-8"
|
||||||
|
/>
|
||||||
|
<span className="font-medium text-zinc-950">{user.name}</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{user.type}
|
||||||
|
{user.subType ? `(${user.subType})` : ''}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-zinc-500">{user.email}</TableCell>
|
||||||
|
<TableCell className="text-zinc-500">{user.phone}</TableCell>
|
||||||
|
<TableCell>{user.organization}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{user.verificationStatus === '-' ? (
|
||||||
|
<span className="text-zinc-400">-</span>
|
||||||
|
) : (
|
||||||
|
<Badge
|
||||||
|
color={
|
||||||
|
user.verificationStatus === '인증 완료'
|
||||||
|
? 'lime'
|
||||||
|
: user.verificationStatus === '인증 대기'
|
||||||
|
? 'amber'
|
||||||
|
: 'rose'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{user.verificationStatus}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{user.verificationDocUrl ? (
|
||||||
|
<Link href={user.verificationDocUrl} className="text-xs font-medium text-sky-600 hover:underline">
|
||||||
|
다운로드
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="text-zinc-400">-</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge color={user.accountStatus === '활동' ? 'emerald' : 'zinc'}>{user.accountStatus}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Dropdown>
|
||||||
|
<DropdownButton plain aria-label="More options">
|
||||||
|
<EllipsisHorizontalIcon className="size-5 text-zinc-500" />
|
||||||
|
</DropdownButton>
|
||||||
|
<DropdownMenu anchor="bottom end">
|
||||||
|
{user.type !== '일반' && (
|
||||||
|
<DropdownSection>
|
||||||
|
<DropdownHeading>인증 상태 관리</DropdownHeading>
|
||||||
|
{user.verificationStatus !== '인증 완료' && (
|
||||||
|
<DropdownItem onClick={() => updateVerificationStatus(user.id, '인증 완료')}>
|
||||||
|
인증 승인
|
||||||
|
</DropdownItem>
|
||||||
|
)}
|
||||||
|
{user.verificationStatus !== '인증 반려' && (
|
||||||
|
<DropdownItem onClick={() => updateVerificationStatus(user.id, '인증 반려')}>
|
||||||
|
인증 반려
|
||||||
|
</DropdownItem>
|
||||||
|
)}
|
||||||
|
{user.verificationStatus !== '인증 대기' && (
|
||||||
|
<DropdownItem onClick={() => updateVerificationStatus(user.id, '인증 대기')}>
|
||||||
|
대기로 변경
|
||||||
|
</DropdownItem>
|
||||||
|
)}
|
||||||
|
</DropdownSection>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{user.type !== '일반' && <Divider className="my-1" />}
|
||||||
|
|
||||||
|
<DropdownSection>
|
||||||
|
<DropdownHeading>계정 상태 관리</DropdownHeading>
|
||||||
|
{user.accountStatus === '정지' ? (
|
||||||
|
<DropdownItem onClick={() => updateUserStatus(user.id, '활동')}>활동으로 변경</DropdownItem>
|
||||||
|
) : (
|
||||||
|
<DropdownItem onClick={() => updateUserStatus(user.id, '정지')}>정지 처리</DropdownItem>
|
||||||
|
)}
|
||||||
|
</DropdownSection>
|
||||||
|
</DropdownMenu>
|
||||||
|
</Dropdown>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
<Pagination className="mt-10">
|
||||||
|
<PaginationPrevious href="?page=2" />
|
||||||
|
<PaginationList>
|
||||||
|
<PaginationPage href="?page=1">1</PaginationPage>
|
||||||
|
<PaginationPage href="?page=2">2</PaginationPage>
|
||||||
|
<PaginationPage href="?page=3" current>
|
||||||
|
3
|
||||||
|
</PaginationPage>
|
||||||
|
<PaginationPage href="?page=4">4</PaginationPage>
|
||||||
|
<PaginationGap />
|
||||||
|
<PaginationPage href="?page=65">65</PaginationPage>
|
||||||
|
<PaginationPage href="?page=66">66</PaginationPage>
|
||||||
|
</PaginationList>
|
||||||
|
<PaginationNext href="?page=4" />
|
||||||
|
</Pagination>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
130
src/app/(app)/users/[id]/page.tsx
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
import { Avatar } from '@/components/avatar'
|
||||||
|
import { Badge } from '@/components/badge'
|
||||||
|
import { Button } from '@/components/button'
|
||||||
|
import { DescriptionDetails, DescriptionList, DescriptionTerm } from '@/components/description-list'
|
||||||
|
import { Divider } from '@/components/divider'
|
||||||
|
import { Heading, Subheading } from '@/components/heading'
|
||||||
|
import { Link } from '@/components/link'
|
||||||
|
import { getOrder } from '@/data'
|
||||||
|
import { BanknotesIcon, CalendarIcon, ChevronLeftIcon, CreditCardIcon } from '@heroicons/react/16/solid'
|
||||||
|
import type { Metadata } from 'next'
|
||||||
|
import { notFound } from 'next/navigation'
|
||||||
|
import { RefundOrder } from './refund'
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
|
||||||
|
let { id } = await params
|
||||||
|
let order = await getOrder(id)
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: order && `Order #${order.id}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function Order({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
let { id } = await params
|
||||||
|
let order = await getOrder(id)
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
notFound()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="max-lg:hidden">
|
||||||
|
<Link href="/orders" className="inline-flex items-center gap-2 text-sm/6 text-zinc-500 dark:text-zinc-400">
|
||||||
|
<ChevronLeftIcon className="size-4 fill-zinc-400 dark:fill-zinc-500" />
|
||||||
|
Orders
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 lg:mt-8">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Heading>Order #{order.id}</Heading>
|
||||||
|
<Badge color="lime">Successful</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="isolate mt-2.5 flex flex-wrap justify-between gap-x-6 gap-y-4">
|
||||||
|
<div className="flex flex-wrap gap-x-10 gap-y-4 py-1.5">
|
||||||
|
<span className="flex items-center gap-3 text-base/6 text-zinc-950 sm:text-sm/6 dark:text-white">
|
||||||
|
<BanknotesIcon className="size-4 shrink-0 fill-zinc-400 dark:fill-zinc-500" />
|
||||||
|
<span>US{order.amount.usd}</span>
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-3 text-base/6 text-zinc-950 sm:text-sm/6 dark:text-white">
|
||||||
|
<CreditCardIcon className="size-4 shrink-0 fill-zinc-400 dark:fill-zinc-500" />
|
||||||
|
<span className="inline-flex gap-3">
|
||||||
|
{order.payment.card.type}{' '}
|
||||||
|
<span>
|
||||||
|
<span aria-hidden="true">••••</span> {order.payment.card.number}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-3 text-base/6 text-zinc-950 sm:text-sm/6 dark:text-white">
|
||||||
|
<CalendarIcon className="size-4 shrink-0 fill-zinc-400 dark:fill-zinc-500" />
|
||||||
|
<span>{order.date}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<RefundOrder outline amount={order.amount.usd}>
|
||||||
|
Refund
|
||||||
|
</RefundOrder>
|
||||||
|
<Button>Resend Invoice</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-12">
|
||||||
|
<Subheading>Summary</Subheading>
|
||||||
|
<Divider className="mt-4" />
|
||||||
|
<DescriptionList>
|
||||||
|
<DescriptionTerm>Customer</DescriptionTerm>
|
||||||
|
<DescriptionDetails>{order.customer.name}</DescriptionDetails>
|
||||||
|
<DescriptionTerm>Event</DescriptionTerm>
|
||||||
|
<DescriptionDetails>
|
||||||
|
<Link href={order.event.url} className="flex items-center gap-2">
|
||||||
|
<Avatar src={order.event.thumbUrl} className="size-6" />
|
||||||
|
<span>{order.event.name}</span>
|
||||||
|
</Link>
|
||||||
|
</DescriptionDetails>
|
||||||
|
<DescriptionTerm>Amount</DescriptionTerm>
|
||||||
|
<DescriptionDetails>US{order.amount.usd}</DescriptionDetails>
|
||||||
|
<DescriptionTerm>Amount after exchange rate</DescriptionTerm>
|
||||||
|
<DescriptionDetails>
|
||||||
|
US{order.amount.usd} → CA{order.amount.cad}
|
||||||
|
</DescriptionDetails>
|
||||||
|
<DescriptionTerm>Fee</DescriptionTerm>
|
||||||
|
<DescriptionDetails>CA{order.amount.fee}</DescriptionDetails>
|
||||||
|
<DescriptionTerm>Net</DescriptionTerm>
|
||||||
|
<DescriptionDetails>CA{order.amount.net}</DescriptionDetails>
|
||||||
|
</DescriptionList>
|
||||||
|
</div>
|
||||||
|
<div className="mt-12">
|
||||||
|
<Subheading>Payment method</Subheading>
|
||||||
|
<Divider className="mt-4" />
|
||||||
|
<DescriptionList>
|
||||||
|
<DescriptionTerm>Transaction ID</DescriptionTerm>
|
||||||
|
<DescriptionDetails>{order.payment.transactionId}</DescriptionDetails>
|
||||||
|
<DescriptionTerm>Card number</DescriptionTerm>
|
||||||
|
<DescriptionDetails>•••• {order.payment.card.number}</DescriptionDetails>
|
||||||
|
<DescriptionTerm>Card type</DescriptionTerm>
|
||||||
|
<DescriptionDetails>{order.payment.card.type}</DescriptionDetails>
|
||||||
|
<DescriptionTerm>Card expiry</DescriptionTerm>
|
||||||
|
<DescriptionDetails>{order.payment.card.expiry}</DescriptionDetails>
|
||||||
|
<DescriptionTerm>Owner</DescriptionTerm>
|
||||||
|
<DescriptionDetails>{order.customer.name}</DescriptionDetails>
|
||||||
|
<DescriptionTerm>Email address</DescriptionTerm>
|
||||||
|
<DescriptionDetails>{order.customer.email}</DescriptionDetails>
|
||||||
|
<DescriptionTerm>Address</DescriptionTerm>
|
||||||
|
<DescriptionDetails>{order.customer.address}</DescriptionDetails>
|
||||||
|
<DescriptionTerm>Country</DescriptionTerm>
|
||||||
|
<DescriptionDetails>
|
||||||
|
<span className="inline-flex gap-3">
|
||||||
|
<img src={order.customer.countryFlagUrl} alt={order.customer.country} />
|
||||||
|
{order.customer.country}
|
||||||
|
</span>
|
||||||
|
</DescriptionDetails>
|
||||||
|
<DescriptionTerm>CVC</DescriptionTerm>
|
||||||
|
<DescriptionDetails>
|
||||||
|
<Badge color="lime">Passed successfully</Badge>
|
||||||
|
</DescriptionDetails>
|
||||||
|
</DescriptionList>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
56
src/app/(app)/users/[id]/refund.tsx
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Button } from '@/components/button'
|
||||||
|
import { Checkbox, CheckboxField } from '@/components/checkbox'
|
||||||
|
import { Dialog, DialogActions, DialogBody, DialogDescription, DialogTitle } from '@/components/dialog'
|
||||||
|
import { Description, Field, FieldGroup, Label } from '@/components/fieldset'
|
||||||
|
import { Input } from '@/components/input'
|
||||||
|
import { Select } from '@/components/select'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
export function RefundOrder({ amount, ...props }: { amount: string } & React.ComponentPropsWithoutRef<typeof Button>) {
|
||||||
|
let [isOpen, setIsOpen] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button type="button" onClick={() => setIsOpen(true)} {...props} />
|
||||||
|
<Dialog open={isOpen} onClose={setIsOpen}>
|
||||||
|
<DialogTitle>Refund payment</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
The refund will be reflected in the customer’s bank account 2 to 3 business days after processing.
|
||||||
|
</DialogDescription>
|
||||||
|
<DialogBody>
|
||||||
|
<FieldGroup>
|
||||||
|
<Field>
|
||||||
|
<Label>Amount</Label>
|
||||||
|
<Input name="amount" defaultValue={amount} placeholder="$0.00" autoFocus />
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<Label>Reason</Label>
|
||||||
|
<Select name="reason" defaultValue="">
|
||||||
|
<option value="" disabled>
|
||||||
|
Select a reason…
|
||||||
|
</option>
|
||||||
|
<option value="duplicate">Duplicate</option>
|
||||||
|
<option value="fraudulent">Fraudulent</option>
|
||||||
|
<option value="requested_by_customer">Requested by customer</option>
|
||||||
|
<option value="other">Other</option>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
<CheckboxField>
|
||||||
|
<Checkbox name="notify" />
|
||||||
|
<Label>Notify customer</Label>
|
||||||
|
<Description>An email notification will be sent to this customer.</Description>
|
||||||
|
</CheckboxField>
|
||||||
|
</FieldGroup>
|
||||||
|
</DialogBody>
|
||||||
|
<DialogActions>
|
||||||
|
<Button plain onClick={() => setIsOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => setIsOpen(false)}>Refund</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
13
src/app/(app)/users/page.tsx
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { getUsers } from '@/data'
|
||||||
|
import type { Metadata } from 'next'
|
||||||
|
import UsersClient from './UsersClient'
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Users',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function UsersPage() {
|
||||||
|
const users = await getUsers()
|
||||||
|
|
||||||
|
return <UsersClient initialUsers={users} />
|
||||||
|
}
|
||||||
34
src/app/(auth)/forgot-password/page.tsx
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import { Logo } from '@/app/logo'
|
||||||
|
import { Button } from '@/components/button'
|
||||||
|
import { Field, Label } from '@/components/fieldset'
|
||||||
|
import { Heading } from '@/components/heading'
|
||||||
|
import { Input } from '@/components/input'
|
||||||
|
import { Strong, Text, TextLink } from '@/components/text'
|
||||||
|
import type { Metadata } from 'next'
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Forgot password',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
return (
|
||||||
|
<form action="" method="POST" className="grid w-full max-w-sm grid-cols-1 gap-8">
|
||||||
|
<Logo className="h-6 text-zinc-950 dark:text-white forced-colors:text-[CanvasText]" />
|
||||||
|
<Heading>Reset your password</Heading>
|
||||||
|
<Text>Enter your email and we’ll send you a link to reset your password.</Text>
|
||||||
|
<Field>
|
||||||
|
<Label>Email</Label>
|
||||||
|
<Input type="email" name="email" />
|
||||||
|
</Field>
|
||||||
|
<Button type="submit" className="w-full">
|
||||||
|
Reset password
|
||||||
|
</Button>
|
||||||
|
<Text>
|
||||||
|
Don’t have an account?{' '}
|
||||||
|
<TextLink href="/register">
|
||||||
|
<Strong>Sign up</Strong>
|
||||||
|
</TextLink>
|
||||||
|
</Text>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
5
src/app/(auth)/layout.tsx
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { AuthLayout } from '@/components/auth-layout'
|
||||||
|
|
||||||
|
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return <AuthLayout>{children}</AuthLayout>
|
||||||
|
}
|
||||||
49
src/app/(auth)/login/page.tsx
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import { Logo } from '@/app/logo'
|
||||||
|
import { Button } from '@/components/button'
|
||||||
|
import { Checkbox, CheckboxField } from '@/components/checkbox'
|
||||||
|
import { Field, Label } from '@/components/fieldset'
|
||||||
|
import { Heading } from '@/components/heading'
|
||||||
|
import { Input } from '@/components/input'
|
||||||
|
import { Strong, Text, TextLink } from '@/components/text'
|
||||||
|
import type { Metadata } from 'next'
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Login',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
return (
|
||||||
|
<form action="" method="POST" className="grid w-full max-w-sm grid-cols-1 gap-8">
|
||||||
|
<Logo className="h-6 text-zinc-950 dark:text-white forced-colors:text-[CanvasText]" />
|
||||||
|
<Heading>Sign in to your account</Heading>
|
||||||
|
<Field>
|
||||||
|
<Label>Email</Label>
|
||||||
|
<Input type="email" name="email" />
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<Label>Password</Label>
|
||||||
|
<Input type="password" name="password" />
|
||||||
|
</Field>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CheckboxField>
|
||||||
|
<Checkbox name="remember" />
|
||||||
|
<Label>Remember me</Label>
|
||||||
|
</CheckboxField>
|
||||||
|
<Text>
|
||||||
|
<TextLink href="/forgot-password">
|
||||||
|
<Strong>Forgot password?</Strong>
|
||||||
|
</TextLink>
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" className="w-full">
|
||||||
|
Login
|
||||||
|
</Button>
|
||||||
|
<Text>
|
||||||
|
Don’t have an account?{' '}
|
||||||
|
<TextLink href="/register">
|
||||||
|
<Strong>Sign up</Strong>
|
||||||
|
</TextLink>
|
||||||
|
</Text>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
55
src/app/(auth)/register/page.tsx
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import { Logo } from '@/app/logo'
|
||||||
|
import { Button } from '@/components/button'
|
||||||
|
import { Checkbox, CheckboxField } from '@/components/checkbox'
|
||||||
|
import { Field, Label } from '@/components/fieldset'
|
||||||
|
import { Heading } from '@/components/heading'
|
||||||
|
import { Input } from '@/components/input'
|
||||||
|
import { Select } from '@/components/select'
|
||||||
|
import { Strong, Text, TextLink } from '@/components/text'
|
||||||
|
import type { Metadata } from 'next'
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Register',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
return (
|
||||||
|
<form action="" method="POST" className="grid w-full max-w-sm grid-cols-1 gap-8">
|
||||||
|
<Logo className="h-6 text-zinc-950 dark:text-white forced-colors:text-[CanvasText]" />
|
||||||
|
<Heading>Create your account</Heading>
|
||||||
|
<Field>
|
||||||
|
<Label>Email</Label>
|
||||||
|
<Input type="email" name="email" />
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<Label>Full name</Label>
|
||||||
|
<Input name="name" />
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<Label>Password</Label>
|
||||||
|
<Input type="password" name="password" autoComplete="new-password" />
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<Label>Country</Label>
|
||||||
|
<Select name="country">
|
||||||
|
<option>Canada</option>
|
||||||
|
<option>Mexico</option>
|
||||||
|
<option>United States</option>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
<CheckboxField>
|
||||||
|
<Checkbox name="remember" />
|
||||||
|
<Label>Get emails about product updates and news.</Label>
|
||||||
|
</CheckboxField>
|
||||||
|
<Button type="submit" className="w-full">
|
||||||
|
Create account
|
||||||
|
</Button>
|
||||||
|
<Text>
|
||||||
|
Already have an account?{' '}
|
||||||
|
<TextLink href="/login">
|
||||||
|
<Strong>Sign in</Strong>
|
||||||
|
</TextLink>
|
||||||
|
</Text>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
BIN
src/app/favicon.ico
Normal file
|
After Width: | Height: | Size: 6.4 KiB |
25
src/app/layout.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import '@/styles/tailwind.css'
|
||||||
|
import type { Metadata } from 'next'
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: {
|
||||||
|
template: '%s - Catalyst',
|
||||||
|
default: 'Catalyst',
|
||||||
|
},
|
||||||
|
description: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<html
|
||||||
|
lang="en"
|
||||||
|
className="text-zinc-950 antialiased lg:bg-zinc-100 dark:bg-zinc-900 dark:text-white dark:lg:bg-zinc-950"
|
||||||
|
>
|
||||||
|
<head>
|
||||||
|
<link rel="preconnect" href="https://rsms.me/" />
|
||||||
|
<link rel="stylesheet" href="https://rsms.me/inter/inter.css" />
|
||||||
|
</head>
|
||||||
|
<body>{children}</body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
||||||
17
src/app/logo.tsx
Normal file
16
src/app/stat.tsx
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { Badge } from '@/components/badge'
|
||||||
|
import { Divider } from '@/components/divider'
|
||||||
|
|
||||||
|
export function Stat({ title, value, change }: { title: string; value: string; change: string }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Divider />
|
||||||
|
<div className="mt-6 text-lg/6 font-medium sm:text-sm/6">{title}</div>
|
||||||
|
<div className="mt-3 text-3xl/8 font-semibold sm:text-2xl/8">{value}</div>
|
||||||
|
<div className="mt-3 text-sm/6 sm:text-xs/6">
|
||||||
|
<Badge color={change.startsWith('+') ? 'lime' : 'pink'}>{change}</Badge>{' '}
|
||||||
|
<span className="text-zinc-500">최근 1주일</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
95
src/components/alert.tsx
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
import * as Headless from '@headlessui/react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import type React from 'react'
|
||||||
|
import { Text } from './text'
|
||||||
|
|
||||||
|
const sizes = {
|
||||||
|
xs: 'sm:max-w-xs',
|
||||||
|
sm: 'sm:max-w-sm',
|
||||||
|
md: 'sm:max-w-md',
|
||||||
|
lg: 'sm:max-w-lg',
|
||||||
|
xl: 'sm:max-w-xl',
|
||||||
|
'2xl': 'sm:max-w-2xl',
|
||||||
|
'3xl': 'sm:max-w-3xl',
|
||||||
|
'4xl': 'sm:max-w-4xl',
|
||||||
|
'5xl': 'sm:max-w-5xl',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Alert({
|
||||||
|
size = 'md',
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: { size?: keyof typeof sizes; className?: string; children: React.ReactNode } & Omit<
|
||||||
|
Headless.DialogProps,
|
||||||
|
'as' | 'className'
|
||||||
|
>) {
|
||||||
|
return (
|
||||||
|
<Headless.Dialog {...props}>
|
||||||
|
<Headless.DialogBackdrop
|
||||||
|
transition
|
||||||
|
className="fixed inset-0 flex w-screen justify-center overflow-y-auto bg-zinc-950/15 px-2 py-2 transition duration-100 focus:outline-0 data-closed:opacity-0 data-enter:ease-out data-leave:ease-in sm:px-6 sm:py-8 lg:px-8 lg:py-16 dark:bg-zinc-950/50"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="fixed inset-0 w-screen overflow-y-auto pt-6 sm:pt-0">
|
||||||
|
<div className="grid min-h-full grid-rows-[1fr_auto_1fr] justify-items-center p-8 sm:grid-rows-[1fr_auto_3fr] sm:p-4">
|
||||||
|
<Headless.DialogPanel
|
||||||
|
transition
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
sizes[size],
|
||||||
|
'row-start-2 w-full rounded-2xl bg-white p-8 shadow-lg ring-1 ring-zinc-950/10 sm:rounded-2xl sm:p-6 dark:bg-zinc-900 dark:ring-white/10 forced-colors:outline',
|
||||||
|
'transition duration-100 will-change-transform data-closed:opacity-0 data-enter:ease-out data-closed:data-enter:scale-95 data-leave:ease-in'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Headless.DialogPanel>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Headless.Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AlertTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: { className?: string } & Omit<Headless.DialogTitleProps, 'as' | 'className'>) {
|
||||||
|
return (
|
||||||
|
<Headless.DialogTitle
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
'text-center text-base/6 font-semibold text-balance text-zinc-950 sm:text-left sm:text-sm/6 sm:text-wrap dark:text-white'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AlertDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: { className?: string } & Omit<Headless.DescriptionProps<typeof Text>, 'as' | 'className'>) {
|
||||||
|
return (
|
||||||
|
<Headless.Description
|
||||||
|
as={Text}
|
||||||
|
{...props}
|
||||||
|
className={clsx(className, 'mt-2 text-center text-pretty sm:text-left')}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AlertBody({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
|
||||||
|
return <div {...props} className={clsx(className, 'mt-4')} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AlertActions({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
'mt-6 flex flex-col-reverse items-center justify-end gap-3 *:w-full sm:mt-4 sm:flex-row sm:*:w-auto'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
11
src/components/auth-layout.tsx
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import type React from 'react'
|
||||||
|
|
||||||
|
export function AuthLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<main className="flex min-h-dvh flex-col p-2">
|
||||||
|
<div className="flex grow items-center justify-center p-6 lg:rounded-lg lg:bg-white lg:p-10 lg:shadow-xs lg:ring-1 lg:ring-zinc-950/5 dark:lg:bg-zinc-900 dark:lg:ring-white/10">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
87
src/components/avatar.tsx
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import * as Headless from '@headlessui/react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import React, { forwardRef } from 'react'
|
||||||
|
import { TouchTarget } from './button'
|
||||||
|
import { Link } from './link'
|
||||||
|
|
||||||
|
type AvatarProps = {
|
||||||
|
src?: string | null
|
||||||
|
square?: boolean
|
||||||
|
initials?: string
|
||||||
|
alt?: string
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Avatar({
|
||||||
|
src = null,
|
||||||
|
square = false,
|
||||||
|
initials,
|
||||||
|
alt = '',
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: AvatarProps & React.ComponentPropsWithoutRef<'span'>) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="avatar"
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
// Basic layout
|
||||||
|
'inline-grid shrink-0 align-middle [--avatar-radius:20%] *:col-start-1 *:row-start-1',
|
||||||
|
'outline -outline-offset-1 outline-black/10 dark:outline-white/10',
|
||||||
|
// Border radius
|
||||||
|
square ? 'rounded-(--avatar-radius) *:rounded-(--avatar-radius)' : 'rounded-full *:rounded-full'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{initials && (
|
||||||
|
<svg
|
||||||
|
className="size-full fill-current p-[5%] text-[48px] font-medium uppercase select-none"
|
||||||
|
viewBox="0 0 100 100"
|
||||||
|
aria-hidden={alt ? undefined : 'true'}
|
||||||
|
>
|
||||||
|
{alt && <title>{alt}</title>}
|
||||||
|
<text x="50%" y="50%" alignmentBaseline="middle" dominantBaseline="middle" textAnchor="middle" dy=".125em">
|
||||||
|
{initials}
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{src && <img className="size-full" src={src} alt={alt} />}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AvatarButton = forwardRef(function AvatarButton(
|
||||||
|
{
|
||||||
|
src,
|
||||||
|
square = false,
|
||||||
|
initials,
|
||||||
|
alt,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: AvatarProps &
|
||||||
|
(
|
||||||
|
| ({ href?: never } & Omit<Headless.ButtonProps, 'as' | 'className'>)
|
||||||
|
| ({ href: string } & Omit<React.ComponentPropsWithoutRef<typeof Link>, 'className'>)
|
||||||
|
),
|
||||||
|
ref: React.ForwardedRef<HTMLButtonElement>
|
||||||
|
) {
|
||||||
|
let classes = clsx(
|
||||||
|
className,
|
||||||
|
square ? 'rounded-[20%]' : 'rounded-full',
|
||||||
|
'relative inline-grid focus:not-data-focus:outline-hidden data-focus:outline-2 data-focus:outline-offset-2 data-focus:outline-blue-500'
|
||||||
|
)
|
||||||
|
|
||||||
|
return typeof props.href === 'string' ? (
|
||||||
|
<Link {...props} className={classes} ref={ref as React.ForwardedRef<HTMLAnchorElement>}>
|
||||||
|
<TouchTarget>
|
||||||
|
<Avatar src={src} square={square} initials={initials} alt={alt} />
|
||||||
|
</TouchTarget>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<Headless.Button {...props} className={classes} ref={ref}>
|
||||||
|
<TouchTarget>
|
||||||
|
<Avatar src={src} square={square} initials={initials} alt={alt} />
|
||||||
|
</TouchTarget>
|
||||||
|
</Headless.Button>
|
||||||
|
)
|
||||||
|
})
|
||||||
82
src/components/badge.tsx
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import * as Headless from '@headlessui/react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import React, { forwardRef } from 'react'
|
||||||
|
import { TouchTarget } from './button'
|
||||||
|
import { Link } from './link'
|
||||||
|
|
||||||
|
const colors = {
|
||||||
|
red: 'bg-red-500/15 text-red-700 group-data-hover:bg-red-500/25 dark:bg-red-500/10 dark:text-red-400 dark:group-data-hover:bg-red-500/20',
|
||||||
|
orange:
|
||||||
|
'bg-orange-500/15 text-orange-700 group-data-hover:bg-orange-500/25 dark:bg-orange-500/10 dark:text-orange-400 dark:group-data-hover:bg-orange-500/20',
|
||||||
|
amber:
|
||||||
|
'bg-amber-400/20 text-amber-700 group-data-hover:bg-amber-400/30 dark:bg-amber-400/10 dark:text-amber-400 dark:group-data-hover:bg-amber-400/15',
|
||||||
|
yellow:
|
||||||
|
'bg-yellow-400/20 text-yellow-700 group-data-hover:bg-yellow-400/30 dark:bg-yellow-400/10 dark:text-yellow-300 dark:group-data-hover:bg-yellow-400/15',
|
||||||
|
lime: 'bg-lime-400/20 text-lime-700 group-data-hover:bg-lime-400/30 dark:bg-lime-400/10 dark:text-lime-300 dark:group-data-hover:bg-lime-400/15',
|
||||||
|
green:
|
||||||
|
'bg-green-500/15 text-green-700 group-data-hover:bg-green-500/25 dark:bg-green-500/10 dark:text-green-400 dark:group-data-hover:bg-green-500/20',
|
||||||
|
emerald:
|
||||||
|
'bg-emerald-500/15 text-emerald-700 group-data-hover:bg-emerald-500/25 dark:bg-emerald-500/10 dark:text-emerald-400 dark:group-data-hover:bg-emerald-500/20',
|
||||||
|
teal: 'bg-teal-500/15 text-teal-700 group-data-hover:bg-teal-500/25 dark:bg-teal-500/10 dark:text-teal-300 dark:group-data-hover:bg-teal-500/20',
|
||||||
|
cyan: 'bg-cyan-400/20 text-cyan-700 group-data-hover:bg-cyan-400/30 dark:bg-cyan-400/10 dark:text-cyan-300 dark:group-data-hover:bg-cyan-400/15',
|
||||||
|
sky: 'bg-sky-500/15 text-sky-700 group-data-hover:bg-sky-500/25 dark:bg-sky-500/10 dark:text-sky-300 dark:group-data-hover:bg-sky-500/20',
|
||||||
|
blue: 'bg-blue-500/15 text-blue-700 group-data-hover:bg-blue-500/25 dark:text-blue-400 dark:group-data-hover:bg-blue-500/25',
|
||||||
|
indigo:
|
||||||
|
'bg-indigo-500/15 text-indigo-700 group-data-hover:bg-indigo-500/25 dark:text-indigo-400 dark:group-data-hover:bg-indigo-500/20',
|
||||||
|
violet:
|
||||||
|
'bg-violet-500/15 text-violet-700 group-data-hover:bg-violet-500/25 dark:text-violet-400 dark:group-data-hover:bg-violet-500/20',
|
||||||
|
purple:
|
||||||
|
'bg-purple-500/15 text-purple-700 group-data-hover:bg-purple-500/25 dark:text-purple-400 dark:group-data-hover:bg-purple-500/20',
|
||||||
|
fuchsia:
|
||||||
|
'bg-fuchsia-400/15 text-fuchsia-700 group-data-hover:bg-fuchsia-400/25 dark:bg-fuchsia-400/10 dark:text-fuchsia-400 dark:group-data-hover:bg-fuchsia-400/20',
|
||||||
|
pink: 'bg-pink-400/15 text-pink-700 group-data-hover:bg-pink-400/25 dark:bg-pink-400/10 dark:text-pink-400 dark:group-data-hover:bg-pink-400/20',
|
||||||
|
rose: 'bg-rose-400/15 text-rose-700 group-data-hover:bg-rose-400/25 dark:bg-rose-400/10 dark:text-rose-400 dark:group-data-hover:bg-rose-400/20',
|
||||||
|
zinc: 'bg-zinc-600/10 text-zinc-700 group-data-hover:bg-zinc-600/20 dark:bg-white/5 dark:text-zinc-400 dark:group-data-hover:bg-white/10',
|
||||||
|
}
|
||||||
|
|
||||||
|
type BadgeProps = { color?: keyof typeof colors }
|
||||||
|
|
||||||
|
export function Badge({ color = 'zinc', className, ...props }: BadgeProps & React.ComponentPropsWithoutRef<'span'>) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
'inline-flex items-center gap-x-1.5 rounded-md px-1.5 py-0.5 text-sm/5 font-medium sm:text-xs/5 forced-colors:outline',
|
||||||
|
colors[color]
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BadgeButton = forwardRef(function BadgeButton(
|
||||||
|
{
|
||||||
|
color = 'zinc',
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: BadgeProps & { className?: string; children: React.ReactNode } & (
|
||||||
|
| ({ href?: never } & Omit<Headless.ButtonProps, 'as' | 'className'>)
|
||||||
|
| ({ href: string } & Omit<React.ComponentPropsWithoutRef<typeof Link>, 'className'>)
|
||||||
|
),
|
||||||
|
ref: React.ForwardedRef<HTMLElement>
|
||||||
|
) {
|
||||||
|
let classes = clsx(
|
||||||
|
className,
|
||||||
|
'group relative inline-flex rounded-md focus:not-data-focus:outline-hidden data-focus:outline-2 data-focus:outline-offset-2 data-focus:outline-blue-500'
|
||||||
|
)
|
||||||
|
|
||||||
|
return typeof props.href === 'string' ? (
|
||||||
|
<Link {...props} className={classes} ref={ref as React.ForwardedRef<HTMLAnchorElement>}>
|
||||||
|
<TouchTarget>
|
||||||
|
<Badge color={color}>{children}</Badge>
|
||||||
|
</TouchTarget>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<Headless.Button {...props} className={classes} ref={ref}>
|
||||||
|
<TouchTarget>
|
||||||
|
<Badge color={color}>{children}</Badge>
|
||||||
|
</TouchTarget>
|
||||||
|
</Headless.Button>
|
||||||
|
)
|
||||||
|
})
|
||||||
204
src/components/button.tsx
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
import * as Headless from '@headlessui/react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import React, { forwardRef } from 'react'
|
||||||
|
import { Link } from './link'
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
base: [
|
||||||
|
// Base
|
||||||
|
'relative isolate inline-flex items-baseline justify-center gap-x-2 rounded-lg border text-base/6 font-semibold',
|
||||||
|
// Sizing
|
||||||
|
'px-[calc(--spacing(3.5)-1px)] py-[calc(--spacing(2.5)-1px)] sm:px-[calc(--spacing(3)-1px)] sm:py-[calc(--spacing(1.5)-1px)] sm:text-sm/6',
|
||||||
|
// Focus
|
||||||
|
'focus:not-data-focus:outline-hidden data-focus:outline-2 data-focus:outline-offset-2 data-focus:outline-blue-500',
|
||||||
|
// Disabled
|
||||||
|
'data-disabled:opacity-50',
|
||||||
|
// Icon
|
||||||
|
'*:data-[slot=icon]:-mx-0.5 *:data-[slot=icon]:my-0.5 *:data-[slot=icon]:size-5 *:data-[slot=icon]:shrink-0 *:data-[slot=icon]:self-center *:data-[slot=icon]:text-(--btn-icon) sm:*:data-[slot=icon]:my-1 sm:*:data-[slot=icon]:size-4 forced-colors:[--btn-icon:ButtonText] forced-colors:data-hover:[--btn-icon:ButtonText]',
|
||||||
|
],
|
||||||
|
solid: [
|
||||||
|
// Optical border, implemented as the button background to avoid corner artifacts
|
||||||
|
'border-transparent bg-(--btn-border)',
|
||||||
|
// Dark mode: border is rendered on `after` so background is set to button background
|
||||||
|
'dark:bg-(--btn-bg)',
|
||||||
|
// Button background, implemented as foreground layer to stack on top of pseudo-border layer
|
||||||
|
'before:absolute before:inset-0 before:-z-10 before:rounded-[calc(var(--radius-lg)-1px)] before:bg-(--btn-bg)',
|
||||||
|
// Drop shadow, applied to the inset `before` layer so it blends with the border
|
||||||
|
'before:shadow-sm',
|
||||||
|
// Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo
|
||||||
|
'dark:before:hidden',
|
||||||
|
// Dark mode: Subtle white outline is applied using a border
|
||||||
|
'dark:border-white/5',
|
||||||
|
// Shim/overlay, inset to match button foreground and used for hover state + highlight shadow
|
||||||
|
'after:absolute after:inset-0 after:-z-10 after:rounded-[calc(var(--radius-lg)-1px)]',
|
||||||
|
// Inner highlight shadow
|
||||||
|
'after:shadow-[inset_0_1px_--theme(--color-white/15%)]',
|
||||||
|
// White overlay on hover
|
||||||
|
'data-active:after:bg-(--btn-hover-overlay) data-hover:after:bg-(--btn-hover-overlay)',
|
||||||
|
// Dark mode: `after` layer expands to cover entire button
|
||||||
|
'dark:after:-inset-px dark:after:rounded-lg',
|
||||||
|
// Disabled
|
||||||
|
'data-disabled:before:shadow-none data-disabled:after:shadow-none',
|
||||||
|
],
|
||||||
|
outline: [
|
||||||
|
// Base
|
||||||
|
'border-zinc-950/10 text-zinc-950 data-active:bg-zinc-950/2.5 data-hover:bg-zinc-950/2.5',
|
||||||
|
// Dark mode
|
||||||
|
'dark:border-white/15 dark:text-white dark:[--btn-bg:transparent] dark:data-active:bg-white/5 dark:data-hover:bg-white/5',
|
||||||
|
// Icon
|
||||||
|
'[--btn-icon:var(--color-zinc-500)] data-active:[--btn-icon:var(--color-zinc-700)] data-hover:[--btn-icon:var(--color-zinc-700)] dark:data-active:[--btn-icon:var(--color-zinc-400)] dark:data-hover:[--btn-icon:var(--color-zinc-400)]',
|
||||||
|
],
|
||||||
|
plain: [
|
||||||
|
// Base
|
||||||
|
'border-transparent text-zinc-950 data-active:bg-zinc-950/5 data-hover:bg-zinc-950/5',
|
||||||
|
// Dark mode
|
||||||
|
'dark:text-white dark:data-active:bg-white/10 dark:data-hover:bg-white/10',
|
||||||
|
// Icon
|
||||||
|
'[--btn-icon:var(--color-zinc-500)] data-active:[--btn-icon:var(--color-zinc-700)] data-hover:[--btn-icon:var(--color-zinc-700)] dark:[--btn-icon:var(--color-zinc-500)] dark:data-active:[--btn-icon:var(--color-zinc-400)] dark:data-hover:[--btn-icon:var(--color-zinc-400)]',
|
||||||
|
],
|
||||||
|
colors: {
|
||||||
|
'dark/zinc': [
|
||||||
|
'text-white [--btn-bg:var(--color-zinc-900)] [--btn-border:var(--color-zinc-950)]/90 [--btn-hover-overlay:var(--color-white)]/10',
|
||||||
|
'dark:text-white dark:[--btn-bg:var(--color-zinc-600)] dark:[--btn-hover-overlay:var(--color-white)]/5',
|
||||||
|
'[--btn-icon:var(--color-zinc-400)] data-active:[--btn-icon:var(--color-zinc-300)] data-hover:[--btn-icon:var(--color-zinc-300)]',
|
||||||
|
],
|
||||||
|
light: [
|
||||||
|
'text-zinc-950 [--btn-bg:white] [--btn-border:var(--color-zinc-950)]/10 [--btn-hover-overlay:var(--color-zinc-950)]/2.5 data-active:[--btn-border:var(--color-zinc-950)]/15 data-hover:[--btn-border:var(--color-zinc-950)]/15',
|
||||||
|
'dark:text-white dark:[--btn-hover-overlay:var(--color-white)]/5 dark:[--btn-bg:var(--color-zinc-800)]',
|
||||||
|
'[--btn-icon:var(--color-zinc-500)] data-active:[--btn-icon:var(--color-zinc-700)] data-hover:[--btn-icon:var(--color-zinc-700)] dark:[--btn-icon:var(--color-zinc-500)] dark:data-active:[--btn-icon:var(--color-zinc-400)] dark:data-hover:[--btn-icon:var(--color-zinc-400)]',
|
||||||
|
],
|
||||||
|
'dark/white': [
|
||||||
|
'text-white [--btn-bg:var(--color-zinc-900)] [--btn-border:var(--color-zinc-950)]/90 [--btn-hover-overlay:var(--color-white)]/10',
|
||||||
|
'dark:text-zinc-950 dark:[--btn-bg:white] dark:[--btn-hover-overlay:var(--color-zinc-950)]/5',
|
||||||
|
'[--btn-icon:var(--color-zinc-400)] data-active:[--btn-icon:var(--color-zinc-300)] data-hover:[--btn-icon:var(--color-zinc-300)] dark:[--btn-icon:var(--color-zinc-500)] dark:data-active:[--btn-icon:var(--color-zinc-400)] dark:data-hover:[--btn-icon:var(--color-zinc-400)]',
|
||||||
|
],
|
||||||
|
dark: [
|
||||||
|
'text-white [--btn-bg:var(--color-zinc-900)] [--btn-border:var(--color-zinc-950)]/90 [--btn-hover-overlay:var(--color-white)]/10',
|
||||||
|
'dark:[--btn-hover-overlay:var(--color-white)]/5 dark:[--btn-bg:var(--color-zinc-800)]',
|
||||||
|
'[--btn-icon:var(--color-zinc-400)] data-active:[--btn-icon:var(--color-zinc-300)] data-hover:[--btn-icon:var(--color-zinc-300)]',
|
||||||
|
],
|
||||||
|
white: [
|
||||||
|
'text-zinc-950 [--btn-bg:white] [--btn-border:var(--color-zinc-950)]/10 [--btn-hover-overlay:var(--color-zinc-950)]/2.5 data-active:[--btn-border:var(--color-zinc-950)]/15 data-hover:[--btn-border:var(--color-zinc-950)]/15',
|
||||||
|
'dark:[--btn-hover-overlay:var(--color-zinc-950)]/5',
|
||||||
|
'[--btn-icon:var(--color-zinc-400)] data-active:[--btn-icon:var(--color-zinc-500)] data-hover:[--btn-icon:var(--color-zinc-500)]',
|
||||||
|
],
|
||||||
|
zinc: [
|
||||||
|
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-zinc-600)] [--btn-border:var(--color-zinc-700)]/90',
|
||||||
|
'dark:[--btn-hover-overlay:var(--color-white)]/5',
|
||||||
|
'[--btn-icon:var(--color-zinc-400)] data-active:[--btn-icon:var(--color-zinc-300)] data-hover:[--btn-icon:var(--color-zinc-300)]',
|
||||||
|
],
|
||||||
|
indigo: [
|
||||||
|
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-indigo-500)] [--btn-border:var(--color-indigo-600)]/90',
|
||||||
|
'[--btn-icon:var(--color-indigo-300)] data-active:[--btn-icon:var(--color-indigo-200)] data-hover:[--btn-icon:var(--color-indigo-200)]',
|
||||||
|
],
|
||||||
|
cyan: [
|
||||||
|
'text-cyan-950 [--btn-bg:var(--color-cyan-300)] [--btn-border:var(--color-cyan-400)]/80 [--btn-hover-overlay:var(--color-white)]/25',
|
||||||
|
'[--btn-icon:var(--color-cyan-500)]',
|
||||||
|
],
|
||||||
|
red: [
|
||||||
|
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-red-600)] [--btn-border:var(--color-red-700)]/90',
|
||||||
|
'[--btn-icon:var(--color-red-300)] data-active:[--btn-icon:var(--color-red-200)] data-hover:[--btn-icon:var(--color-red-200)]',
|
||||||
|
],
|
||||||
|
orange: [
|
||||||
|
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-orange-500)] [--btn-border:var(--color-orange-600)]/90',
|
||||||
|
'[--btn-icon:var(--color-orange-300)] data-active:[--btn-icon:var(--color-orange-200)] data-hover:[--btn-icon:var(--color-orange-200)]',
|
||||||
|
],
|
||||||
|
amber: [
|
||||||
|
'text-amber-950 [--btn-hover-overlay:var(--color-white)]/25 [--btn-bg:var(--color-amber-400)] [--btn-border:var(--color-amber-500)]/80',
|
||||||
|
'[--btn-icon:var(--color-amber-600)]',
|
||||||
|
],
|
||||||
|
yellow: [
|
||||||
|
'text-yellow-950 [--btn-hover-overlay:var(--color-white)]/25 [--btn-bg:var(--color-yellow-300)] [--btn-border:var(--color-yellow-400)]/80',
|
||||||
|
'[--btn-icon:var(--color-yellow-600)] data-active:[--btn-icon:var(--color-yellow-700)] data-hover:[--btn-icon:var(--color-yellow-700)]',
|
||||||
|
],
|
||||||
|
lime: [
|
||||||
|
'text-lime-950 [--btn-hover-overlay:var(--color-white)]/25 [--btn-bg:var(--color-lime-300)] [--btn-border:var(--color-lime-400)]/80',
|
||||||
|
'[--btn-icon:var(--color-lime-600)] data-active:[--btn-icon:var(--color-lime-700)] data-hover:[--btn-icon:var(--color-lime-700)]',
|
||||||
|
],
|
||||||
|
green: [
|
||||||
|
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-green-600)] [--btn-border:var(--color-green-700)]/90',
|
||||||
|
'[--btn-icon:var(--color-white)]/60 data-active:[--btn-icon:var(--color-white)]/80 data-hover:[--btn-icon:var(--color-white)]/80',
|
||||||
|
],
|
||||||
|
emerald: [
|
||||||
|
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-emerald-600)] [--btn-border:var(--color-emerald-700)]/90',
|
||||||
|
'[--btn-icon:var(--color-white)]/60 data-active:[--btn-icon:var(--color-white)]/80 data-hover:[--btn-icon:var(--color-white)]/80',
|
||||||
|
],
|
||||||
|
teal: [
|
||||||
|
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-teal-600)] [--btn-border:var(--color-teal-700)]/90',
|
||||||
|
'[--btn-icon:var(--color-white)]/60 data-active:[--btn-icon:var(--color-white)]/80 data-hover:[--btn-icon:var(--color-white)]/80',
|
||||||
|
],
|
||||||
|
sky: [
|
||||||
|
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-sky-500)] [--btn-border:var(--color-sky-600)]/80',
|
||||||
|
'[--btn-icon:var(--color-white)]/60 data-active:[--btn-icon:var(--color-white)]/80 data-hover:[--btn-icon:var(--color-white)]/80',
|
||||||
|
],
|
||||||
|
blue: [
|
||||||
|
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-blue-600)] [--btn-border:var(--color-blue-700)]/90',
|
||||||
|
'[--btn-icon:var(--color-blue-400)] data-active:[--btn-icon:var(--color-blue-300)] data-hover:[--btn-icon:var(--color-blue-300)]',
|
||||||
|
],
|
||||||
|
violet: [
|
||||||
|
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-violet-500)] [--btn-border:var(--color-violet-600)]/90',
|
||||||
|
'[--btn-icon:var(--color-violet-300)] data-active:[--btn-icon:var(--color-violet-200)] data-hover:[--btn-icon:var(--color-violet-200)]',
|
||||||
|
],
|
||||||
|
purple: [
|
||||||
|
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-purple-500)] [--btn-border:var(--color-purple-600)]/90',
|
||||||
|
'[--btn-icon:var(--color-purple-300)] data-active:[--btn-icon:var(--color-purple-200)] data-hover:[--btn-icon:var(--color-purple-200)]',
|
||||||
|
],
|
||||||
|
fuchsia: [
|
||||||
|
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-fuchsia-500)] [--btn-border:var(--color-fuchsia-600)]/90',
|
||||||
|
'[--btn-icon:var(--color-fuchsia-300)] data-active:[--btn-icon:var(--color-fuchsia-200)] data-hover:[--btn-icon:var(--color-fuchsia-200)]',
|
||||||
|
],
|
||||||
|
pink: [
|
||||||
|
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-pink-500)] [--btn-border:var(--color-pink-600)]/90',
|
||||||
|
'[--btn-icon:var(--color-pink-300)] data-active:[--btn-icon:var(--color-pink-200)] data-hover:[--btn-icon:var(--color-pink-200)]',
|
||||||
|
],
|
||||||
|
rose: [
|
||||||
|
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-rose-500)] [--btn-border:var(--color-rose-600)]/90',
|
||||||
|
'[--btn-icon:var(--color-rose-300)] data-active:[--btn-icon:var(--color-rose-200)] data-hover:[--btn-icon:var(--color-rose-200)]',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
type ButtonProps = (
|
||||||
|
| { color?: keyof typeof styles.colors; outline?: never; plain?: never }
|
||||||
|
| { color?: never; outline: true; plain?: never }
|
||||||
|
| { color?: never; outline?: never; plain: true }
|
||||||
|
) & { className?: string; children: React.ReactNode } & (
|
||||||
|
| ({ href?: never } & Omit<Headless.ButtonProps, 'as' | 'className'>)
|
||||||
|
| ({ href: string } & Omit<React.ComponentPropsWithoutRef<typeof Link>, 'className'>)
|
||||||
|
)
|
||||||
|
|
||||||
|
export const Button = forwardRef(function Button(
|
||||||
|
{ color, outline, plain, className, children, ...props }: ButtonProps,
|
||||||
|
ref: React.ForwardedRef<HTMLElement>
|
||||||
|
) {
|
||||||
|
let classes = clsx(
|
||||||
|
className,
|
||||||
|
styles.base,
|
||||||
|
outline ? styles.outline : plain ? styles.plain : clsx(styles.solid, styles.colors[color ?? 'dark/zinc'])
|
||||||
|
)
|
||||||
|
|
||||||
|
return typeof props.href === 'string' ? (
|
||||||
|
<Link {...props} className={classes} ref={ref as React.ForwardedRef<HTMLAnchorElement>}>
|
||||||
|
<TouchTarget>{children}</TouchTarget>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<Headless.Button {...props} className={clsx(classes, 'cursor-default')} ref={ref}>
|
||||||
|
<TouchTarget>{children}</TouchTarget>
|
||||||
|
</Headless.Button>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expand the hit area to at least 44×44px on touch devices
|
||||||
|
*/
|
||||||
|
export function TouchTarget({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className="absolute top-1/2 left-1/2 size-[max(100%,2.75rem)] -translate-x-1/2 -translate-y-1/2 pointer-fine:hidden"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{children}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
157
src/components/checkbox.tsx
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
import * as Headless from '@headlessui/react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import type React from 'react'
|
||||||
|
|
||||||
|
export function CheckboxGroup({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="control"
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
// Basic groups
|
||||||
|
'space-y-3',
|
||||||
|
// With descriptions
|
||||||
|
'has-data-[slot=description]:space-y-6 has-data-[slot=description]:**:data-[slot=label]:font-medium'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CheckboxField({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: { className?: string } & Omit<Headless.FieldProps, 'as' | 'className'>) {
|
||||||
|
return (
|
||||||
|
<Headless.Field
|
||||||
|
data-slot="field"
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
// Base layout
|
||||||
|
'grid grid-cols-[1.125rem_1fr] gap-x-4 gap-y-1 sm:grid-cols-[1rem_1fr]',
|
||||||
|
// Control layout
|
||||||
|
'*:data-[slot=control]:col-start-1 *:data-[slot=control]:row-start-1 *:data-[slot=control]:mt-0.75 sm:*:data-[slot=control]:mt-1',
|
||||||
|
// Label layout
|
||||||
|
'*:data-[slot=label]:col-start-2 *:data-[slot=label]:row-start-1',
|
||||||
|
// Description layout
|
||||||
|
'*:data-[slot=description]:col-start-2 *:data-[slot=description]:row-start-2',
|
||||||
|
// With description
|
||||||
|
'has-data-[slot=description]:**:data-[slot=label]:font-medium'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = [
|
||||||
|
// Basic layout
|
||||||
|
'relative isolate flex size-4.5 items-center justify-center rounded-[0.3125rem] sm:size-4',
|
||||||
|
// Background color + shadow applied to inset pseudo element, so shadow blends with border in light mode
|
||||||
|
'before:absolute before:inset-0 before:-z-10 before:rounded-[calc(0.3125rem-1px)] before:bg-white before:shadow-sm',
|
||||||
|
// Background color when checked
|
||||||
|
'group-data-checked:before:bg-(--checkbox-checked-bg)',
|
||||||
|
// Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo
|
||||||
|
'dark:before:hidden',
|
||||||
|
// Background color applied to control in dark mode
|
||||||
|
'dark:bg-white/5 dark:group-data-checked:bg-(--checkbox-checked-bg)',
|
||||||
|
// Border
|
||||||
|
'border border-zinc-950/15 group-data-checked:border-transparent group-data-hover:group-data-checked:border-transparent group-data-hover:border-zinc-950/30 group-data-checked:bg-(--checkbox-checked-border)',
|
||||||
|
'dark:border-white/15 dark:group-data-checked:border-white/5 dark:group-data-hover:group-data-checked:border-white/5 dark:group-data-hover:border-white/30',
|
||||||
|
// Inner highlight shadow
|
||||||
|
'after:absolute after:inset-0 after:rounded-[calc(0.3125rem-1px)] after:shadow-[inset_0_1px_--theme(--color-white/15%)]',
|
||||||
|
'dark:after:-inset-px dark:after:hidden dark:after:rounded-[0.3125rem] dark:group-data-checked:after:block',
|
||||||
|
// Focus ring
|
||||||
|
'group-data-focus:outline-2 group-data-focus:outline-offset-2 group-data-focus:outline-blue-500',
|
||||||
|
// Disabled state
|
||||||
|
'group-data-disabled:opacity-50',
|
||||||
|
'group-data-disabled:border-zinc-950/25 group-data-disabled:bg-zinc-950/5 group-data-disabled:[--checkbox-check:var(--color-zinc-950)]/50 group-data-disabled:before:bg-transparent',
|
||||||
|
'dark:group-data-disabled:border-white/20 dark:group-data-disabled:bg-white/2.5 dark:group-data-disabled:[--checkbox-check:var(--color-white)]/50 dark:group-data-checked:group-data-disabled:after:hidden',
|
||||||
|
// Forced colors mode
|
||||||
|
'forced-colors:[--checkbox-check:HighlightText] forced-colors:[--checkbox-checked-bg:Highlight] forced-colors:group-data-disabled:[--checkbox-check:Highlight]',
|
||||||
|
'dark:forced-colors:[--checkbox-check:HighlightText] dark:forced-colors:[--checkbox-checked-bg:Highlight] dark:forced-colors:group-data-disabled:[--checkbox-check:Highlight]',
|
||||||
|
]
|
||||||
|
|
||||||
|
const colors = {
|
||||||
|
'dark/zinc': [
|
||||||
|
'[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-zinc-900)] [--checkbox-checked-border:var(--color-zinc-950)]/90',
|
||||||
|
'dark:[--checkbox-checked-bg:var(--color-zinc-600)]',
|
||||||
|
],
|
||||||
|
'dark/white': [
|
||||||
|
'[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-zinc-900)] [--checkbox-checked-border:var(--color-zinc-950)]/90',
|
||||||
|
'dark:[--checkbox-check:var(--color-zinc-900)] dark:[--checkbox-checked-bg:var(--color-white)] dark:[--checkbox-checked-border:var(--color-zinc-950)]/15',
|
||||||
|
],
|
||||||
|
white:
|
||||||
|
'[--checkbox-check:var(--color-zinc-900)] [--checkbox-checked-bg:var(--color-white)] [--checkbox-checked-border:var(--color-zinc-950)]/15',
|
||||||
|
dark: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-zinc-900)] [--checkbox-checked-border:var(--color-zinc-950)]/90',
|
||||||
|
zinc: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-zinc-600)] [--checkbox-checked-border:var(--color-zinc-700)]/90',
|
||||||
|
red: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-red-600)] [--checkbox-checked-border:var(--color-red-700)]/90',
|
||||||
|
orange:
|
||||||
|
'[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-orange-500)] [--checkbox-checked-border:var(--color-orange-600)]/90',
|
||||||
|
amber:
|
||||||
|
'[--checkbox-check:var(--color-amber-950)] [--checkbox-checked-bg:var(--color-amber-400)] [--checkbox-checked-border:var(--color-amber-500)]/80',
|
||||||
|
yellow:
|
||||||
|
'[--checkbox-check:var(--color-yellow-950)] [--checkbox-checked-bg:var(--color-yellow-300)] [--checkbox-checked-border:var(--color-yellow-400)]/80',
|
||||||
|
lime: '[--checkbox-check:var(--color-lime-950)] [--checkbox-checked-bg:var(--color-lime-300)] [--checkbox-checked-border:var(--color-lime-400)]/80',
|
||||||
|
green:
|
||||||
|
'[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-green-600)] [--checkbox-checked-border:var(--color-green-700)]/90',
|
||||||
|
emerald:
|
||||||
|
'[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-emerald-600)] [--checkbox-checked-border:var(--color-emerald-700)]/90',
|
||||||
|
teal: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-teal-600)] [--checkbox-checked-border:var(--color-teal-700)]/90',
|
||||||
|
cyan: '[--checkbox-check:var(--color-cyan-950)] [--checkbox-checked-bg:var(--color-cyan-300)] [--checkbox-checked-border:var(--color-cyan-400)]/80',
|
||||||
|
sky: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-sky-500)] [--checkbox-checked-border:var(--color-sky-600)]/80',
|
||||||
|
blue: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-blue-600)] [--checkbox-checked-border:var(--color-blue-700)]/90',
|
||||||
|
indigo:
|
||||||
|
'[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-indigo-500)] [--checkbox-checked-border:var(--color-indigo-600)]/90',
|
||||||
|
violet:
|
||||||
|
'[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-violet-500)] [--checkbox-checked-border:var(--color-violet-600)]/90',
|
||||||
|
purple:
|
||||||
|
'[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-purple-500)] [--checkbox-checked-border:var(--color-purple-600)]/90',
|
||||||
|
fuchsia:
|
||||||
|
'[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-fuchsia-500)] [--checkbox-checked-border:var(--color-fuchsia-600)]/90',
|
||||||
|
pink: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-pink-500)] [--checkbox-checked-border:var(--color-pink-600)]/90',
|
||||||
|
rose: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-rose-500)] [--checkbox-checked-border:var(--color-rose-600)]/90',
|
||||||
|
}
|
||||||
|
|
||||||
|
type Color = keyof typeof colors
|
||||||
|
|
||||||
|
export function Checkbox({
|
||||||
|
color = 'dark/zinc',
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
color?: Color
|
||||||
|
className?: string
|
||||||
|
} & Omit<Headless.CheckboxProps, 'as' | 'className'>) {
|
||||||
|
return (
|
||||||
|
<Headless.Checkbox
|
||||||
|
data-slot="control"
|
||||||
|
{...props}
|
||||||
|
className={clsx(className, 'group inline-flex focus:outline-hidden')}
|
||||||
|
>
|
||||||
|
<span className={clsx([base, colors[color]])}>
|
||||||
|
<svg
|
||||||
|
className="size-4 stroke-(--checkbox-check) opacity-0 group-data-checked:opacity-100 sm:h-3.5 sm:w-3.5"
|
||||||
|
viewBox="0 0 14 14"
|
||||||
|
fill="none"
|
||||||
|
>
|
||||||
|
{/* Checkmark icon */}
|
||||||
|
<path
|
||||||
|
className="opacity-100 group-data-indeterminate:opacity-0"
|
||||||
|
d="M3 8L6 11L11 3.5"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
{/* Indeterminate icon */}
|
||||||
|
<path
|
||||||
|
className="opacity-0 group-data-indeterminate:opacity-100"
|
||||||
|
d="M3 7H11"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</Headless.Checkbox>
|
||||||
|
)
|
||||||
|
}
|
||||||
188
src/components/combobox.tsx
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as Headless from '@headlessui/react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
export function Combobox<T>({
|
||||||
|
options,
|
||||||
|
displayValue,
|
||||||
|
filter,
|
||||||
|
anchor = 'bottom',
|
||||||
|
className,
|
||||||
|
placeholder,
|
||||||
|
autoFocus,
|
||||||
|
'aria-label': ariaLabel,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
options: T[]
|
||||||
|
displayValue: (value: T | null) => string | undefined
|
||||||
|
filter?: (value: T, query: string) => boolean
|
||||||
|
className?: string
|
||||||
|
placeholder?: string
|
||||||
|
autoFocus?: boolean
|
||||||
|
'aria-label'?: string
|
||||||
|
children: (value: NonNullable<T>) => React.ReactElement
|
||||||
|
} & Omit<Headless.ComboboxProps<T, false>, 'as' | 'multiple' | 'children'> & { anchor?: 'top' | 'bottom' }) {
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
|
||||||
|
const filteredOptions =
|
||||||
|
query === ''
|
||||||
|
? options
|
||||||
|
: options.filter((option) =>
|
||||||
|
filter ? filter(option, query) : displayValue(option)?.toLowerCase().includes(query.toLowerCase())
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Headless.Combobox {...props} multiple={false} virtual={{ options: filteredOptions }} onClose={() => setQuery('')}>
|
||||||
|
<span
|
||||||
|
data-slot="control"
|
||||||
|
className={clsx([
|
||||||
|
className,
|
||||||
|
// Basic layout
|
||||||
|
'relative block w-full',
|
||||||
|
// Background color + shadow applied to inset pseudo element, so shadow blends with border in light mode
|
||||||
|
'before:absolute before:inset-px before:rounded-[calc(var(--radius-lg)-1px)] before:bg-white before:shadow-sm',
|
||||||
|
// Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo
|
||||||
|
'dark:before:hidden',
|
||||||
|
// Focus ring
|
||||||
|
'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-transparent after:ring-inset sm:focus-within:after:ring-2 sm:focus-within:after:ring-blue-500',
|
||||||
|
// Disabled state
|
||||||
|
'has-data-disabled:opacity-50 has-data-disabled:before:bg-zinc-950/5 has-data-disabled:before:shadow-none',
|
||||||
|
// Invalid state
|
||||||
|
'has-data-invalid:before:shadow-red-500/10',
|
||||||
|
])}
|
||||||
|
>
|
||||||
|
<Headless.ComboboxInput
|
||||||
|
autoFocus={autoFocus}
|
||||||
|
data-slot="control"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
displayValue={(option: T) => displayValue(option) ?? ''}
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className={clsx([
|
||||||
|
className,
|
||||||
|
// Basic layout
|
||||||
|
'relative block w-full appearance-none rounded-lg py-[calc(--spacing(2.5)-1px)] sm:py-[calc(--spacing(1.5)-1px)]',
|
||||||
|
// Horizontal padding
|
||||||
|
'pr-[calc(--spacing(10)-1px)] pl-[calc(--spacing(3.5)-1px)] sm:pr-[calc(--spacing(9)-1px)] sm:pl-[calc(--spacing(3)-1px)]',
|
||||||
|
// Typography
|
||||||
|
'text-base/6 text-zinc-950 placeholder:text-zinc-500 sm:text-sm/6 dark:text-white',
|
||||||
|
// Border
|
||||||
|
'border border-zinc-950/10 data-hover:border-zinc-950/20 dark:border-white/10 dark:data-hover:border-white/20',
|
||||||
|
// Background color
|
||||||
|
'bg-transparent dark:bg-white/5',
|
||||||
|
// Hide default focus styles
|
||||||
|
'focus:outline-hidden',
|
||||||
|
// Invalid state
|
||||||
|
'data-invalid:border-red-500 data-invalid:data-hover:border-red-500 dark:data-invalid:border-red-500 dark:data-invalid:data-hover:border-red-500',
|
||||||
|
// Disabled state
|
||||||
|
'data-disabled:border-zinc-950/20 dark:data-disabled:border-white/15 dark:data-disabled:bg-white/2.5 dark:data-hover:data-disabled:border-white/15',
|
||||||
|
// System icons
|
||||||
|
'dark:scheme-dark',
|
||||||
|
])}
|
||||||
|
/>
|
||||||
|
<Headless.ComboboxButton className="group absolute inset-y-0 right-0 flex items-center px-2">
|
||||||
|
<svg
|
||||||
|
className="size-5 stroke-zinc-500 group-data-disabled:stroke-zinc-600 group-data-hover:stroke-zinc-700 sm:size-4 dark:stroke-zinc-400 dark:group-data-hover:stroke-zinc-300 forced-colors:stroke-[CanvasText]"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
aria-hidden="true"
|
||||||
|
fill="none"
|
||||||
|
>
|
||||||
|
<path d="M5.75 10.75L8 13L10.25 10.75" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
<path d="M10.25 5.25L8 3L5.75 5.25" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</Headless.ComboboxButton>
|
||||||
|
</span>
|
||||||
|
<Headless.ComboboxOptions
|
||||||
|
transition
|
||||||
|
anchor={anchor}
|
||||||
|
className={clsx(
|
||||||
|
// Anchor positioning
|
||||||
|
'[--anchor-gap:--spacing(2)] [--anchor-padding:--spacing(4)] sm:data-[anchor~=start]:[--anchor-offset:-4px]',
|
||||||
|
// Base styles,
|
||||||
|
'isolate min-w-[calc(var(--input-width)+8px)] scroll-py-1 rounded-xl p-1 select-none empty:invisible',
|
||||||
|
// Invisible border that is only visible in `forced-colors` mode for accessibility purposes
|
||||||
|
'outline outline-transparent focus:outline-hidden',
|
||||||
|
// Handle scrolling when menu won't fit in viewport
|
||||||
|
'overflow-y-scroll overscroll-contain',
|
||||||
|
// Popover background
|
||||||
|
'bg-white/75 backdrop-blur-xl dark:bg-zinc-800/75',
|
||||||
|
// Shadows
|
||||||
|
'shadow-lg ring-1 ring-zinc-950/10 dark:ring-white/10 dark:ring-inset',
|
||||||
|
// Transitions
|
||||||
|
'transition-opacity duration-100 ease-in data-closed:data-leave:opacity-0 data-transition:pointer-events-none'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{({ option }) => children(option)}
|
||||||
|
</Headless.ComboboxOptions>
|
||||||
|
</Headless.Combobox>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ComboboxOption<T>({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: { className?: string; children?: React.ReactNode } & Omit<
|
||||||
|
Headless.ComboboxOptionProps<'div', T>,
|
||||||
|
'as' | 'className'
|
||||||
|
>) {
|
||||||
|
let sharedClasses = clsx(
|
||||||
|
// Base
|
||||||
|
'flex min-w-0 items-center',
|
||||||
|
// Icons
|
||||||
|
'*:data-[slot=icon]:size-5 *:data-[slot=icon]:shrink-0 sm:*:data-[slot=icon]:size-4',
|
||||||
|
'*:data-[slot=icon]:text-zinc-500 group-data-focus/option:*:data-[slot=icon]:text-white dark:*:data-[slot=icon]:text-zinc-400',
|
||||||
|
'forced-colors:*:data-[slot=icon]:text-[CanvasText] forced-colors:group-data-focus/option:*:data-[slot=icon]:text-[Canvas]',
|
||||||
|
// Avatars
|
||||||
|
'*:data-[slot=avatar]:-mx-0.5 *:data-[slot=avatar]:size-6 sm:*:data-[slot=avatar]:size-5'
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Headless.ComboboxOption
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
// Basic layout
|
||||||
|
'group/option grid w-full cursor-default grid-cols-[1fr_--spacing(5)] items-baseline gap-x-2 rounded-lg py-2.5 pr-2 pl-3.5 sm:grid-cols-[1fr_--spacing(4)] sm:py-1.5 sm:pr-2 sm:pl-3',
|
||||||
|
// Typography
|
||||||
|
'text-base/6 text-zinc-950 sm:text-sm/6 dark:text-white forced-colors:text-[CanvasText]',
|
||||||
|
// Focus
|
||||||
|
'outline-hidden data-focus:bg-blue-500 data-focus:text-white',
|
||||||
|
// Forced colors mode
|
||||||
|
'forced-color-adjust-none forced-colors:data-focus:bg-[Highlight] forced-colors:data-focus:text-[HighlightText]',
|
||||||
|
// Disabled
|
||||||
|
'data-disabled:opacity-50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className={clsx(className, sharedClasses)}>{children}</span>
|
||||||
|
<svg
|
||||||
|
className="relative col-start-2 hidden size-5 self-center stroke-current group-data-selected/option:inline sm:size-4"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M4 8.5l3 3L12 4" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</Headless.ComboboxOption>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ComboboxLabel({ className, ...props }: React.ComponentPropsWithoutRef<'span'>) {
|
||||||
|
return <span {...props} className={clsx(className, 'ml-2.5 truncate first:ml-0 sm:ml-2 sm:first:ml-0')} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ComboboxDescription({ className, children, ...props }: React.ComponentPropsWithoutRef<'span'>) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
'flex flex-1 overflow-hidden text-zinc-500 group-data-focus/option:text-white before:w-2 before:min-w-0 before:shrink dark:text-zinc-400'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="flex-1 truncate">{children}</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
37
src/components/description-list.tsx
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import clsx from 'clsx'
|
||||||
|
|
||||||
|
export function DescriptionList({ className, ...props }: React.ComponentPropsWithoutRef<'dl'>) {
|
||||||
|
return (
|
||||||
|
<dl
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
'grid grid-cols-1 text-base/6 sm:grid-cols-[min(50%,--spacing(80))_auto] sm:text-sm/6'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DescriptionTerm({ className, ...props }: React.ComponentPropsWithoutRef<'dt'>) {
|
||||||
|
return (
|
||||||
|
<dt
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
'col-start-1 border-t border-zinc-950/5 pt-3 text-zinc-500 first:border-none sm:border-t sm:border-zinc-950/5 sm:py-3 dark:border-white/5 dark:text-zinc-400 sm:dark:border-white/5'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DescriptionDetails({ className, ...props }: React.ComponentPropsWithoutRef<'dd'>) {
|
||||||
|
return (
|
||||||
|
<dd
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
'pt-1 pb-3 text-zinc-950 sm:border-t sm:border-zinc-950/5 sm:py-3 sm:nth-2:border-none dark:text-white dark:sm:border-white/5'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
86
src/components/dialog.tsx
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
import * as Headless from '@headlessui/react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import type React from 'react'
|
||||||
|
import { Text } from './text'
|
||||||
|
|
||||||
|
const sizes = {
|
||||||
|
xs: 'sm:max-w-xs',
|
||||||
|
sm: 'sm:max-w-sm',
|
||||||
|
md: 'sm:max-w-md',
|
||||||
|
lg: 'sm:max-w-lg',
|
||||||
|
xl: 'sm:max-w-xl',
|
||||||
|
'2xl': 'sm:max-w-2xl',
|
||||||
|
'3xl': 'sm:max-w-3xl',
|
||||||
|
'4xl': 'sm:max-w-4xl',
|
||||||
|
'5xl': 'sm:max-w-5xl',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Dialog({
|
||||||
|
size = 'lg',
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: { size?: keyof typeof sizes; className?: string; children: React.ReactNode } & Omit<
|
||||||
|
Headless.DialogProps,
|
||||||
|
'as' | 'className'
|
||||||
|
>) {
|
||||||
|
return (
|
||||||
|
<Headless.Dialog {...props}>
|
||||||
|
<Headless.DialogBackdrop
|
||||||
|
transition
|
||||||
|
className="fixed inset-0 flex w-screen justify-center overflow-y-auto bg-zinc-950/25 px-2 py-2 transition duration-100 focus:outline-0 data-closed:opacity-0 data-enter:ease-out data-leave:ease-in sm:px-6 sm:py-8 lg:px-8 lg:py-16 dark:bg-zinc-950/50"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="fixed inset-0 w-screen overflow-y-auto pt-6 sm:pt-0">
|
||||||
|
<div className="grid min-h-full grid-rows-[1fr_auto] justify-items-center sm:grid-rows-[1fr_auto_3fr] sm:p-4">
|
||||||
|
<Headless.DialogPanel
|
||||||
|
transition
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
sizes[size],
|
||||||
|
'row-start-2 w-full min-w-0 rounded-t-3xl bg-white p-(--gutter) shadow-lg ring-1 ring-zinc-950/10 [--gutter:--spacing(8)] sm:mb-auto sm:rounded-2xl dark:bg-zinc-900 dark:ring-white/10 forced-colors:outline',
|
||||||
|
'transition duration-100 will-change-transform data-closed:translate-y-12 data-closed:opacity-0 data-enter:ease-out data-leave:ease-in sm:data-closed:translate-y-0 sm:data-closed:data-enter:scale-95'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Headless.DialogPanel>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Headless.Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: { className?: string } & Omit<Headless.DialogTitleProps, 'as' | 'className'>) {
|
||||||
|
return (
|
||||||
|
<Headless.DialogTitle
|
||||||
|
{...props}
|
||||||
|
className={clsx(className, 'text-lg/6 font-semibold text-balance text-zinc-950 sm:text-base/6 dark:text-white')}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: { className?: string } & Omit<Headless.DescriptionProps<typeof Text>, 'as' | 'className'>) {
|
||||||
|
return <Headless.Description as={Text} {...props} className={clsx(className, 'mt-2 text-pretty')} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DialogBody({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
|
||||||
|
return <div {...props} className={clsx(className, 'mt-6')} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DialogActions({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
'mt-8 flex flex-col-reverse items-center justify-end gap-3 *:w-full sm:flex-row sm:*:w-auto'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
20
src/components/divider.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import clsx from 'clsx'
|
||||||
|
|
||||||
|
export function Divider({
|
||||||
|
soft = false,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: { soft?: boolean } & React.ComponentPropsWithoutRef<'hr'>) {
|
||||||
|
return (
|
||||||
|
<hr
|
||||||
|
role="presentation"
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
'w-full border-t',
|
||||||
|
soft && 'border-zinc-950/5 dark:border-white/5',
|
||||||
|
!soft && 'border-zinc-950/10 dark:border-white/10'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
183
src/components/dropdown.tsx
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as Headless from '@headlessui/react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import type React from 'react'
|
||||||
|
import { Button } from './button'
|
||||||
|
import { Link } from './link'
|
||||||
|
|
||||||
|
export function Dropdown(props: Headless.MenuProps) {
|
||||||
|
return <Headless.Menu {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DropdownButton<T extends React.ElementType = typeof Button>({
|
||||||
|
as = Button,
|
||||||
|
...props
|
||||||
|
}: { className?: string } & Omit<Headless.MenuButtonProps<T>, 'className'>) {
|
||||||
|
return <Headless.MenuButton as={as} {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DropdownMenu({
|
||||||
|
anchor = 'bottom',
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: { className?: string } & Omit<Headless.MenuItemsProps, 'as' | 'className'>) {
|
||||||
|
return (
|
||||||
|
<Headless.MenuItems
|
||||||
|
{...props}
|
||||||
|
transition
|
||||||
|
anchor={anchor}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
// Anchor positioning
|
||||||
|
'[--anchor-gap:--spacing(2)] [--anchor-padding:--spacing(1)] data-[anchor~=end]:[--anchor-offset:6px] data-[anchor~=start]:[--anchor-offset:-6px] sm:data-[anchor~=end]:[--anchor-offset:4px] sm:data-[anchor~=start]:[--anchor-offset:-4px]',
|
||||||
|
// Base styles
|
||||||
|
'isolate w-max rounded-xl p-1',
|
||||||
|
// Invisible border that is only visible in `forced-colors` mode for accessibility purposes
|
||||||
|
'outline outline-transparent focus:outline-hidden',
|
||||||
|
// Handle scrolling when menu won't fit in viewport
|
||||||
|
'overflow-y-auto',
|
||||||
|
// Popover background
|
||||||
|
'bg-white/75 backdrop-blur-xl dark:bg-zinc-800/75',
|
||||||
|
// Shadows
|
||||||
|
'shadow-lg ring-1 ring-zinc-950/10 dark:ring-white/10 dark:ring-inset',
|
||||||
|
// Define grid at the menu level if subgrid is supported
|
||||||
|
'supports-[grid-template-columns:subgrid]:grid supports-[grid-template-columns:subgrid]:grid-cols-[auto_1fr_1.5rem_0.5rem_auto]',
|
||||||
|
// Transitions
|
||||||
|
'transition data-leave:duration-100 data-leave:ease-in data-closed:data-leave:opacity-0'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DropdownItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: { className?: string } & (
|
||||||
|
| ({ href?: never } & Omit<Headless.MenuItemProps<'button'>, 'as' | 'className'>)
|
||||||
|
| ({ href: string } & Omit<Headless.MenuItemProps<typeof Link>, 'as' | 'className'>)
|
||||||
|
)) {
|
||||||
|
let classes = clsx(
|
||||||
|
className,
|
||||||
|
// Base styles
|
||||||
|
'group cursor-default rounded-lg px-3.5 py-2.5 focus:outline-hidden sm:px-3 sm:py-1.5',
|
||||||
|
// Text styles
|
||||||
|
'text-left text-base/6 text-zinc-950 sm:text-sm/6 dark:text-white forced-colors:text-[CanvasText]',
|
||||||
|
// Focus
|
||||||
|
'data-focus:bg-blue-500 data-focus:text-white',
|
||||||
|
// Disabled state
|
||||||
|
'data-disabled:opacity-50',
|
||||||
|
// Forced colors mode
|
||||||
|
'forced-color-adjust-none forced-colors:data-focus:bg-[Highlight] forced-colors:data-focus:text-[HighlightText] forced-colors:data-focus:*:data-[slot=icon]:text-[HighlightText]',
|
||||||
|
// Use subgrid when available but fallback to an explicit grid layout if not
|
||||||
|
'col-span-full grid grid-cols-[auto_1fr_1.5rem_0.5rem_auto] items-center supports-[grid-template-columns:subgrid]:grid-cols-subgrid',
|
||||||
|
// Icons
|
||||||
|
'*:data-[slot=icon]:col-start-1 *:data-[slot=icon]:row-start-1 *:data-[slot=icon]:mr-2.5 *:data-[slot=icon]:-ml-0.5 *:data-[slot=icon]:size-5 sm:*:data-[slot=icon]:mr-2 sm:*:data-[slot=icon]:size-4',
|
||||||
|
'*:data-[slot=icon]:text-zinc-500 data-focus:*:data-[slot=icon]:text-white dark:*:data-[slot=icon]:text-zinc-400 dark:data-focus:*:data-[slot=icon]:text-white',
|
||||||
|
// Avatar
|
||||||
|
'*:data-[slot=avatar]:mr-2.5 *:data-[slot=avatar]:-ml-1 *:data-[slot=avatar]:size-6 sm:*:data-[slot=avatar]:mr-2 sm:*:data-[slot=avatar]:size-5'
|
||||||
|
)
|
||||||
|
|
||||||
|
return typeof props.href === 'string' ? (
|
||||||
|
<Headless.MenuItem as={Link} {...props} className={classes} />
|
||||||
|
) : (
|
||||||
|
<Headless.MenuItem as="button" type="button" {...props} className={classes} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DropdownHeader({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
|
||||||
|
return <div {...props} className={clsx(className, 'col-span-5 px-3.5 pt-2.5 pb-1 sm:px-3')} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DropdownSection({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: { className?: string } & Omit<Headless.MenuSectionProps, 'as' | 'className'>) {
|
||||||
|
return (
|
||||||
|
<Headless.MenuSection
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
// Define grid at the section level instead of the item level if subgrid is supported
|
||||||
|
'col-span-full supports-[grid-template-columns:subgrid]:grid supports-[grid-template-columns:subgrid]:grid-cols-[auto_1fr_1.5rem_0.5rem_auto]'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DropdownHeading({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: { className?: string } & Omit<Headless.MenuHeadingProps, 'as' | 'className'>) {
|
||||||
|
return (
|
||||||
|
<Headless.MenuHeading
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
'col-span-full grid grid-cols-[1fr_auto] gap-x-12 px-3.5 pt-2 pb-1 text-sm/5 font-medium text-zinc-500 sm:px-3 sm:text-xs/5 dark:text-zinc-400'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DropdownDivider({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: { className?: string } & Omit<Headless.MenuSeparatorProps, 'as' | 'className'>) {
|
||||||
|
return (
|
||||||
|
<Headless.MenuSeparator
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
'col-span-full mx-3.5 my-1 h-px border-0 bg-zinc-950/5 sm:mx-3 dark:bg-white/10 forced-colors:bg-[CanvasText]'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DropdownLabel({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
|
||||||
|
return <div {...props} data-slot="label" className={clsx(className, 'col-start-2 row-start-1')} {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DropdownDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: { className?: string } & Omit<Headless.DescriptionProps, 'as' | 'className'>) {
|
||||||
|
return (
|
||||||
|
<Headless.Description
|
||||||
|
data-slot="description"
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
'col-span-2 col-start-2 row-start-2 text-sm/5 text-zinc-500 group-data-focus:text-white sm:text-xs/5 dark:text-zinc-400 forced-colors:group-data-focus:text-[HighlightText]'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DropdownShortcut({
|
||||||
|
keys,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: { keys: string | string[]; className?: string } & Omit<Headless.DescriptionProps<'kbd'>, 'as' | 'className'>) {
|
||||||
|
return (
|
||||||
|
<Headless.Description
|
||||||
|
as="kbd"
|
||||||
|
{...props}
|
||||||
|
className={clsx(className, 'col-start-5 row-start-1 flex justify-self-end')}
|
||||||
|
>
|
||||||
|
{(Array.isArray(keys) ? keys : keys.split('')).map((char, index) => (
|
||||||
|
<kbd
|
||||||
|
key={index}
|
||||||
|
className={clsx([
|
||||||
|
'min-w-[2ch] text-center font-sans text-zinc-400 capitalize group-data-focus:text-white forced-colors:group-data-focus:text-[HighlightText]',
|
||||||
|
// Make sure key names that are longer than one character (like "Tab") have extra space
|
||||||
|
index > 0 && char.length > 1 && 'pl-1',
|
||||||
|
])}
|
||||||
|
>
|
||||||
|
{char}
|
||||||
|
</kbd>
|
||||||
|
))}
|
||||||
|
</Headless.Description>
|
||||||
|
)
|
||||||
|
}
|
||||||
91
src/components/fieldset.tsx
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import * as Headless from '@headlessui/react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import type React from 'react'
|
||||||
|
|
||||||
|
export function Fieldset({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: { className?: string } & Omit<Headless.FieldsetProps, 'as' | 'className'>) {
|
||||||
|
return (
|
||||||
|
<Headless.Fieldset
|
||||||
|
{...props}
|
||||||
|
className={clsx(className, '*:data-[slot=text]:mt-1 [&>*+[data-slot=control]]:mt-6')}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Legend({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: { className?: string } & Omit<Headless.LegendProps, 'as' | 'className'>) {
|
||||||
|
return (
|
||||||
|
<Headless.Legend
|
||||||
|
data-slot="legend"
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
'text-base/6 font-semibold text-zinc-950 data-disabled:opacity-50 sm:text-sm/6 dark:text-white'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FieldGroup({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
|
||||||
|
return <div data-slot="control" {...props} className={clsx(className, 'space-y-8')} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Field({ className, ...props }: { className?: string } & Omit<Headless.FieldProps, 'as' | 'className'>) {
|
||||||
|
return (
|
||||||
|
<Headless.Field
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
'[&>[data-slot=label]+[data-slot=control]]:mt-3',
|
||||||
|
'[&>[data-slot=label]+[data-slot=description]]:mt-1',
|
||||||
|
'[&>[data-slot=description]+[data-slot=control]]:mt-3',
|
||||||
|
'[&>[data-slot=control]+[data-slot=description]]:mt-3',
|
||||||
|
'[&>[data-slot=control]+[data-slot=error]]:mt-3',
|
||||||
|
'*:data-[slot=label]:font-medium'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Label({ className, ...props }: { className?: string } & Omit<Headless.LabelProps, 'as' | 'className'>) {
|
||||||
|
return (
|
||||||
|
<Headless.Label
|
||||||
|
data-slot="label"
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
'text-base/6 text-zinc-950 select-none data-disabled:opacity-50 sm:text-sm/6 dark:text-white'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Description({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: { className?: string } & Omit<Headless.DescriptionProps, 'as' | 'className'>) {
|
||||||
|
return (
|
||||||
|
<Headless.Description
|
||||||
|
data-slot="description"
|
||||||
|
{...props}
|
||||||
|
className={clsx(className, 'text-base/6 text-zinc-500 data-disabled:opacity-50 sm:text-sm/6 dark:text-zinc-400')}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ErrorMessage({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: { className?: string } & Omit<Headless.DescriptionProps, 'as' | 'className'>) {
|
||||||
|
return (
|
||||||
|
<Headless.Description
|
||||||
|
data-slot="error"
|
||||||
|
{...props}
|
||||||
|
className={clsx(className, 'text-base/6 text-red-600 data-disabled:opacity-50 sm:text-sm/6 dark:text-red-500')}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
27
src/components/heading.tsx
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import clsx from 'clsx'
|
||||||
|
|
||||||
|
type HeadingProps = { level?: 1 | 2 | 3 | 4 | 5 | 6 } & React.ComponentPropsWithoutRef<
|
||||||
|
'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
|
||||||
|
>
|
||||||
|
|
||||||
|
export function Heading({ className, level = 1, ...props }: HeadingProps) {
|
||||||
|
let Element: `h${typeof level}` = `h${level}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Element
|
||||||
|
{...props}
|
||||||
|
className={clsx(className, 'text-2xl/8 font-semibold text-zinc-950 sm:text-xl/8 dark:text-white')}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Subheading({ className, level = 2, ...props }: HeadingProps) {
|
||||||
|
let Element: `h${typeof level}` = `h${level}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Element
|
||||||
|
{...props}
|
||||||
|
className={clsx(className, 'text-base/7 font-semibold text-zinc-950 sm:text-sm/6 dark:text-white')}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
92
src/components/input.tsx
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import * as Headless from '@headlessui/react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import React, { forwardRef } from 'react'
|
||||||
|
|
||||||
|
export function InputGroup({ children }: React.ComponentPropsWithoutRef<'span'>) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="control"
|
||||||
|
className={clsx(
|
||||||
|
'relative isolate block',
|
||||||
|
'has-[[data-slot=icon]:first-child]:[&_input]:pl-10 has-[[data-slot=icon]:last-child]:[&_input]:pr-10 sm:has-[[data-slot=icon]:first-child]:[&_input]:pl-8 sm:has-[[data-slot=icon]:last-child]:[&_input]:pr-8',
|
||||||
|
'*:data-[slot=icon]:pointer-events-none *:data-[slot=icon]:absolute *:data-[slot=icon]:top-3 *:data-[slot=icon]:z-10 *:data-[slot=icon]:size-5 sm:*:data-[slot=icon]:top-2.5 sm:*:data-[slot=icon]:size-4',
|
||||||
|
'[&>[data-slot=icon]:first-child]:left-3 sm:[&>[data-slot=icon]:first-child]:left-2.5 [&>[data-slot=icon]:last-child]:right-3 sm:[&>[data-slot=icon]:last-child]:right-2.5',
|
||||||
|
'*:data-[slot=icon]:text-zinc-500 dark:*:data-[slot=icon]:text-zinc-400'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateTypes = ['date', 'datetime-local', 'month', 'time', 'week']
|
||||||
|
type DateType = (typeof dateTypes)[number]
|
||||||
|
|
||||||
|
export const Input = forwardRef(function Input(
|
||||||
|
{
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
className?: string
|
||||||
|
type?: 'email' | 'number' | 'password' | 'search' | 'tel' | 'text' | 'url' | DateType
|
||||||
|
} & Omit<Headless.InputProps, 'as' | 'className'>,
|
||||||
|
ref: React.ForwardedRef<HTMLInputElement>
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="control"
|
||||||
|
className={clsx([
|
||||||
|
className,
|
||||||
|
// Basic layout
|
||||||
|
'relative block w-full',
|
||||||
|
// Background color + shadow applied to inset pseudo element, so shadow blends with border in light mode
|
||||||
|
'before:absolute before:inset-px before:rounded-[calc(var(--radius-lg)-1px)] before:bg-white before:shadow-sm',
|
||||||
|
// Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo
|
||||||
|
'dark:before:hidden',
|
||||||
|
// Focus ring
|
||||||
|
'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-transparent after:ring-inset sm:focus-within:after:ring-2 sm:focus-within:after:ring-blue-500',
|
||||||
|
// Disabled state
|
||||||
|
'has-data-disabled:opacity-50 has-data-disabled:before:bg-zinc-950/5 has-data-disabled:before:shadow-none',
|
||||||
|
])}
|
||||||
|
>
|
||||||
|
<Headless.Input
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
className={clsx([
|
||||||
|
// Date classes
|
||||||
|
props.type &&
|
||||||
|
dateTypes.includes(props.type) && [
|
||||||
|
'[&::-webkit-datetime-edit-fields-wrapper]:p-0',
|
||||||
|
'[&::-webkit-date-and-time-value]:min-h-[1.5em]',
|
||||||
|
'[&::-webkit-datetime-edit]:inline-flex',
|
||||||
|
'[&::-webkit-datetime-edit]:p-0',
|
||||||
|
'[&::-webkit-datetime-edit-year-field]:p-0',
|
||||||
|
'[&::-webkit-datetime-edit-month-field]:p-0',
|
||||||
|
'[&::-webkit-datetime-edit-day-field]:p-0',
|
||||||
|
'[&::-webkit-datetime-edit-hour-field]:p-0',
|
||||||
|
'[&::-webkit-datetime-edit-minute-field]:p-0',
|
||||||
|
'[&::-webkit-datetime-edit-second-field]:p-0',
|
||||||
|
'[&::-webkit-datetime-edit-millisecond-field]:p-0',
|
||||||
|
'[&::-webkit-datetime-edit-meridiem-field]:p-0',
|
||||||
|
],
|
||||||
|
// Basic layout
|
||||||
|
'relative block w-full appearance-none rounded-lg px-[calc(--spacing(3.5)-1px)] py-[calc(--spacing(2.5)-1px)] sm:px-[calc(--spacing(3)-1px)] sm:py-[calc(--spacing(1.5)-1px)]',
|
||||||
|
// Typography
|
||||||
|
'text-base/6 text-zinc-950 placeholder:text-zinc-500 sm:text-sm/6 dark:text-white',
|
||||||
|
// Border
|
||||||
|
'border border-zinc-950/10 data-hover:border-zinc-950/20 dark:border-white/10 dark:data-hover:border-white/20',
|
||||||
|
// Background color
|
||||||
|
'bg-transparent dark:bg-white/5',
|
||||||
|
// Hide default focus styles
|
||||||
|
'focus:outline-hidden',
|
||||||
|
// Invalid state
|
||||||
|
'data-invalid:border-red-500 data-invalid:data-hover:border-red-500 dark:data-invalid:border-red-600 dark:data-invalid:data-hover:border-red-600',
|
||||||
|
// Disabled state
|
||||||
|
'data-disabled:border-zinc-950/20 dark:data-disabled:border-white/15 dark:data-disabled:bg-white/2.5 dark:data-hover:data-disabled:border-white/15',
|
||||||
|
// System icons
|
||||||
|
'dark:scheme-dark',
|
||||||
|
])}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})
|
||||||
14
src/components/link.tsx
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import * as Headless from '@headlessui/react'
|
||||||
|
import NextLink, { type LinkProps } from 'next/link'
|
||||||
|
import React, { forwardRef } from 'react'
|
||||||
|
|
||||||
|
export const Link = forwardRef(function Link(
|
||||||
|
props: LinkProps & React.ComponentPropsWithoutRef<'a'>,
|
||||||
|
ref: React.ForwardedRef<HTMLAnchorElement>
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<Headless.DataInteractive>
|
||||||
|
<NextLink {...props} ref={ref} />
|
||||||
|
</Headless.DataInteractive>
|
||||||
|
)
|
||||||
|
})
|
||||||
177
src/components/listbox.tsx
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as Headless from '@headlessui/react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import { Fragment } from 'react'
|
||||||
|
|
||||||
|
export function Listbox<T>({
|
||||||
|
className,
|
||||||
|
placeholder,
|
||||||
|
autoFocus,
|
||||||
|
'aria-label': ariaLabel,
|
||||||
|
children: options,
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
className?: string
|
||||||
|
placeholder?: React.ReactNode
|
||||||
|
autoFocus?: boolean
|
||||||
|
'aria-label'?: string
|
||||||
|
children?: React.ReactNode
|
||||||
|
} & Omit<Headless.ListboxProps<typeof Fragment, T>, 'as' | 'multiple'>) {
|
||||||
|
return (
|
||||||
|
<Headless.Listbox {...props} multiple={false}>
|
||||||
|
<Headless.ListboxButton
|
||||||
|
autoFocus={autoFocus}
|
||||||
|
data-slot="control"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
className={clsx([
|
||||||
|
className,
|
||||||
|
// Basic layout
|
||||||
|
'group relative block w-full',
|
||||||
|
// Background color + shadow applied to inset pseudo element, so shadow blends with border in light mode
|
||||||
|
'before:absolute before:inset-px before:rounded-[calc(var(--radius-lg)-1px)] before:bg-white before:shadow-sm',
|
||||||
|
// Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo
|
||||||
|
'dark:before:hidden',
|
||||||
|
// Hide default focus styles
|
||||||
|
'focus:outline-hidden',
|
||||||
|
// Focus ring
|
||||||
|
'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-transparent after:ring-inset data-focus:after:ring-2 data-focus:after:ring-blue-500',
|
||||||
|
// Disabled state
|
||||||
|
'data-disabled:opacity-50 data-disabled:before:bg-zinc-950/5 data-disabled:before:shadow-none',
|
||||||
|
])}
|
||||||
|
>
|
||||||
|
<Headless.ListboxSelectedOption
|
||||||
|
as="span"
|
||||||
|
options={options}
|
||||||
|
placeholder={placeholder && <span className="block truncate text-zinc-500">{placeholder}</span>}
|
||||||
|
className={clsx([
|
||||||
|
// Basic layout
|
||||||
|
'relative block w-full appearance-none rounded-lg py-[calc(--spacing(2.5)-1px)] sm:py-[calc(--spacing(1.5)-1px)]',
|
||||||
|
// Set minimum height for when no value is selected
|
||||||
|
'min-h-11 sm:min-h-9',
|
||||||
|
// Horizontal padding
|
||||||
|
'pr-[calc(--spacing(7)-1px)] pl-[calc(--spacing(3.5)-1px)] sm:pl-[calc(--spacing(3)-1px)]',
|
||||||
|
// Typography
|
||||||
|
'text-left text-base/6 text-zinc-950 placeholder:text-zinc-500 sm:text-sm/6 dark:text-white forced-colors:text-[CanvasText]',
|
||||||
|
// Border
|
||||||
|
'border border-zinc-950/10 group-data-active:border-zinc-950/20 group-data-hover:border-zinc-950/20 dark:border-white/10 dark:group-data-active:border-white/20 dark:group-data-hover:border-white/20',
|
||||||
|
// Background color
|
||||||
|
'bg-transparent dark:bg-white/5',
|
||||||
|
// Invalid state
|
||||||
|
'group-data-invalid:border-red-500 group-data-hover:group-data-invalid:border-red-500 dark:group-data-invalid:border-red-600 dark:data-hover:group-data-invalid:border-red-600',
|
||||||
|
// Disabled state
|
||||||
|
'group-data-disabled:border-zinc-950/20 group-data-disabled:opacity-100 dark:group-data-disabled:border-white/15 dark:group-data-disabled:bg-white/2.5 dark:group-data-disabled:data-hover:border-white/15',
|
||||||
|
])}
|
||||||
|
/>
|
||||||
|
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||||
|
<svg
|
||||||
|
className="size-5 stroke-zinc-500 group-data-disabled:stroke-zinc-600 sm:size-4 dark:stroke-zinc-400 forced-colors:stroke-[CanvasText]"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
aria-hidden="true"
|
||||||
|
fill="none"
|
||||||
|
>
|
||||||
|
<path d="M5.75 10.75L8 13L10.25 10.75" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
<path d="M10.25 5.25L8 3L5.75 5.25" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</Headless.ListboxButton>
|
||||||
|
<Headless.ListboxOptions
|
||||||
|
transition
|
||||||
|
anchor="selection start"
|
||||||
|
className={clsx(
|
||||||
|
// Anchor positioning
|
||||||
|
'[--anchor-offset:-1.625rem] [--anchor-padding:--spacing(4)] sm:[--anchor-offset:-1.375rem]',
|
||||||
|
// Base styles
|
||||||
|
'isolate w-max min-w-[calc(var(--button-width)+1.75rem)] scroll-py-1 rounded-xl p-1 select-none',
|
||||||
|
// Invisible border that is only visible in `forced-colors` mode for accessibility purposes
|
||||||
|
'outline outline-transparent focus:outline-hidden',
|
||||||
|
// Handle scrolling when menu won't fit in viewport
|
||||||
|
'overflow-y-scroll overscroll-contain',
|
||||||
|
// Popover background
|
||||||
|
'bg-white/75 backdrop-blur-xl dark:bg-zinc-800/75',
|
||||||
|
// Shadows
|
||||||
|
'shadow-lg ring-1 ring-zinc-950/10 dark:ring-white/10 dark:ring-inset',
|
||||||
|
// Transitions
|
||||||
|
'transition-opacity duration-100 ease-in data-closed:data-leave:opacity-0 data-transition:pointer-events-none'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{options}
|
||||||
|
</Headless.ListboxOptions>
|
||||||
|
</Headless.Listbox>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ListboxOption<T>({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: { className?: string; children?: React.ReactNode } & Omit<
|
||||||
|
Headless.ListboxOptionProps<'div', T>,
|
||||||
|
'as' | 'className'
|
||||||
|
>) {
|
||||||
|
let sharedClasses = clsx(
|
||||||
|
// Base
|
||||||
|
'flex min-w-0 items-center',
|
||||||
|
// Icons
|
||||||
|
'*:data-[slot=icon]:size-5 *:data-[slot=icon]:shrink-0 sm:*:data-[slot=icon]:size-4',
|
||||||
|
'*:data-[slot=icon]:text-zinc-500 group-data-focus/option:*:data-[slot=icon]:text-white dark:*:data-[slot=icon]:text-zinc-400',
|
||||||
|
'forced-colors:*:data-[slot=icon]:text-[CanvasText] forced-colors:group-data-focus/option:*:data-[slot=icon]:text-[Canvas]',
|
||||||
|
// Avatars
|
||||||
|
'*:data-[slot=avatar]:-mx-0.5 *:data-[slot=avatar]:size-6 sm:*:data-[slot=avatar]:size-5'
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Headless.ListboxOption as={Fragment} {...props}>
|
||||||
|
{({ selectedOption }) => {
|
||||||
|
if (selectedOption) {
|
||||||
|
return <div className={clsx(className, sharedClasses)}>{children}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
// Basic layout
|
||||||
|
'group/option grid cursor-default grid-cols-[--spacing(5)_1fr] items-baseline gap-x-2 rounded-lg py-2.5 pr-3.5 pl-2 sm:grid-cols-[--spacing(4)_1fr] sm:py-1.5 sm:pr-3 sm:pl-1.5',
|
||||||
|
// Typography
|
||||||
|
'text-base/6 text-zinc-950 sm:text-sm/6 dark:text-white forced-colors:text-[CanvasText]',
|
||||||
|
// Focus
|
||||||
|
'outline-hidden data-focus:bg-blue-500 data-focus:text-white',
|
||||||
|
// Forced colors mode
|
||||||
|
'forced-color-adjust-none forced-colors:data-focus:bg-[Highlight] forced-colors:data-focus:text-[HighlightText]',
|
||||||
|
// Disabled
|
||||||
|
'data-disabled:opacity-50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="relative hidden size-5 self-center stroke-current group-data-selected/option:inline sm:size-4"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M4 8.5l3 3L12 4" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
<span className={clsx(className, sharedClasses, 'col-start-2')}>{children}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Headless.ListboxOption>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ListboxLabel({ className, ...props }: React.ComponentPropsWithoutRef<'span'>) {
|
||||||
|
return <span {...props} className={clsx(className, 'ml-2.5 truncate first:ml-0 sm:ml-2 sm:first:ml-0')} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ListboxDescription({ className, children, ...props }: React.ComponentPropsWithoutRef<'span'>) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
'flex flex-1 overflow-hidden text-zinc-500 group-data-focus/option:text-white before:w-2 before:min-w-0 before:shrink dark:text-zinc-400'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="flex-1 truncate">{children}</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
96
src/components/navbar.tsx
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as Headless from '@headlessui/react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import { LayoutGroup, motion } from 'motion/react'
|
||||||
|
import React, { forwardRef, useId } from 'react'
|
||||||
|
import { TouchTarget } from './button'
|
||||||
|
import { Link } from './link'
|
||||||
|
|
||||||
|
export function Navbar({ className, ...props }: React.ComponentPropsWithoutRef<'nav'>) {
|
||||||
|
return <nav {...props} className={clsx(className, 'flex flex-1 items-center gap-4 py-2.5')} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NavbarDivider({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
|
||||||
|
return <div aria-hidden="true" {...props} className={clsx(className, 'h-6 w-px bg-zinc-950/10 dark:bg-white/10')} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NavbarSection({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
|
||||||
|
let id = useId()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LayoutGroup id={id}>
|
||||||
|
<div {...props} className={clsx(className, 'flex items-center gap-3')} />
|
||||||
|
</LayoutGroup>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NavbarSpacer({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
|
||||||
|
return <div aria-hidden="true" {...props} className={clsx(className, '-ml-4 flex-1')} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NavbarItem = forwardRef(function NavbarItem(
|
||||||
|
{
|
||||||
|
current,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: { current?: boolean; className?: string; children: React.ReactNode } & (
|
||||||
|
| ({ href?: never } & Omit<Headless.ButtonProps, 'as' | 'className'>)
|
||||||
|
| ({ href: string } & Omit<React.ComponentPropsWithoutRef<typeof Link>, 'className'>)
|
||||||
|
),
|
||||||
|
ref: React.ForwardedRef<HTMLAnchorElement | HTMLButtonElement>
|
||||||
|
) {
|
||||||
|
let classes = clsx(
|
||||||
|
// Base
|
||||||
|
'relative flex min-w-0 items-center gap-3 rounded-lg p-2 text-left text-base/6 font-medium text-zinc-950 sm:text-sm/5',
|
||||||
|
// Leading icon/icon-only
|
||||||
|
'*:data-[slot=icon]:size-6 *:data-[slot=icon]:shrink-0 *:data-[slot=icon]:fill-zinc-500 sm:*:data-[slot=icon]:size-5',
|
||||||
|
// Trailing icon (down chevron or similar)
|
||||||
|
'*:not-nth-2:last:data-[slot=icon]:ml-auto *:not-nth-2:last:data-[slot=icon]:size-5 sm:*:not-nth-2:last:data-[slot=icon]:size-4',
|
||||||
|
// Avatar
|
||||||
|
'*:data-[slot=avatar]:-m-0.5 *:data-[slot=avatar]:size-7 *:data-[slot=avatar]:[--avatar-radius:var(--radius-md)] sm:*:data-[slot=avatar]:size-6',
|
||||||
|
// Hover
|
||||||
|
'data-hover:bg-zinc-950/5 data-hover:*:data-[slot=icon]:fill-zinc-950',
|
||||||
|
// Active
|
||||||
|
'data-active:bg-zinc-950/5 data-active:*:data-[slot=icon]:fill-zinc-950',
|
||||||
|
// Dark mode
|
||||||
|
'dark:text-white dark:*:data-[slot=icon]:fill-zinc-400',
|
||||||
|
'dark:data-hover:bg-white/5 dark:data-hover:*:data-[slot=icon]:fill-white',
|
||||||
|
'dark:data-active:bg-white/5 dark:data-active:*:data-[slot=icon]:fill-white'
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={clsx(className, 'relative')}>
|
||||||
|
{current && (
|
||||||
|
<motion.span
|
||||||
|
layoutId="current-indicator"
|
||||||
|
className="absolute inset-x-2 -bottom-2.5 h-0.5 rounded-full bg-zinc-950 dark:bg-white"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{typeof props.href === 'string' ? (
|
||||||
|
<Link
|
||||||
|
{...props}
|
||||||
|
className={classes}
|
||||||
|
data-current={current ? 'true' : undefined}
|
||||||
|
ref={ref as React.ForwardedRef<HTMLAnchorElement>}
|
||||||
|
>
|
||||||
|
<TouchTarget>{children}</TouchTarget>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<Headless.Button
|
||||||
|
{...props}
|
||||||
|
className={clsx('cursor-default', classes)}
|
||||||
|
data-current={current ? 'true' : undefined}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<TouchTarget>{children}</TouchTarget>
|
||||||
|
</Headless.Button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export function NavbarLabel({ className, ...props }: React.ComponentPropsWithoutRef<'span'>) {
|
||||||
|
return <span {...props} className={clsx(className, 'truncate')} />
|
||||||
|
}
|
||||||
98
src/components/pagination.tsx
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
import clsx from 'clsx'
|
||||||
|
import type React from 'react'
|
||||||
|
import { Button } from './button'
|
||||||
|
|
||||||
|
export function Pagination({
|
||||||
|
'aria-label': ariaLabel = 'Page navigation',
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentPropsWithoutRef<'nav'>) {
|
||||||
|
return <nav aria-label={ariaLabel} {...props} className={clsx(className, 'flex gap-x-2')} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PaginationPrevious({
|
||||||
|
href = null,
|
||||||
|
className,
|
||||||
|
children = '이전',
|
||||||
|
}: React.PropsWithChildren<{ href?: string | null; className?: string }>) {
|
||||||
|
return (
|
||||||
|
<span className={clsx(className, 'grow basis-0')}>
|
||||||
|
<Button {...(href === null ? { disabled: true } : { href })} plain aria-label="이전 페이지">
|
||||||
|
<svg className="stroke-current" data-slot="icon" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M2.75 8H13.25M2.75 8L5.25 5.5M2.75 8L5.25 10.5"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{children}
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PaginationNext({
|
||||||
|
href = null,
|
||||||
|
className,
|
||||||
|
children = '다음',
|
||||||
|
}: React.PropsWithChildren<{ href?: string | null; className?: string }>) {
|
||||||
|
return (
|
||||||
|
<span className={clsx(className, 'flex grow basis-0 justify-end')}>
|
||||||
|
<Button {...(href === null ? { disabled: true } : { href })} plain aria-label="다음 페이지">
|
||||||
|
{children}
|
||||||
|
<svg className="stroke-current" data-slot="icon" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M13.25 8L2.75 8M13.25 8L10.75 10.5M13.25 8L10.75 5.5"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PaginationList({ className, ...props }: React.ComponentPropsWithoutRef<'span'>) {
|
||||||
|
return <span {...props} className={clsx(className, 'hidden items-baseline gap-x-2 sm:flex')} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PaginationPage({
|
||||||
|
href,
|
||||||
|
className,
|
||||||
|
current = false,
|
||||||
|
children,
|
||||||
|
}: React.PropsWithChildren<{ href: string; className?: string; current?: boolean }>) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
href={href}
|
||||||
|
plain
|
||||||
|
aria-label={`Page ${children}`}
|
||||||
|
aria-current={current ? 'page' : undefined}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
'min-w-9 before:absolute before:-inset-px before:rounded-lg',
|
||||||
|
current && 'before:bg-zinc-950/5 dark:before:bg-white/10'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="-mx-0.5">{children}</span>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PaginationGap({
|
||||||
|
className,
|
||||||
|
children = <>…</>,
|
||||||
|
...props
|
||||||
|
}: React.ComponentPropsWithoutRef<'span'>) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
{...props}
|
||||||
|
className={clsx(className, 'w-9 text-center text-sm/6 font-semibold text-zinc-950 select-none dark:text-white')}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
142
src/components/radio.tsx
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
import * as Headless from '@headlessui/react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
|
||||||
|
export function RadioGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: { className?: string } & Omit<Headless.RadioGroupProps, 'as' | 'className'>) {
|
||||||
|
return (
|
||||||
|
<Headless.RadioGroup
|
||||||
|
data-slot="control"
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
// Basic groups
|
||||||
|
'space-y-3 **:data-[slot=label]:font-normal',
|
||||||
|
// With descriptions
|
||||||
|
'has-data-[slot=description]:space-y-6 has-data-[slot=description]:**:data-[slot=label]:font-medium'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RadioField({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: { className?: string } & Omit<Headless.FieldProps, 'as' | 'className'>) {
|
||||||
|
return (
|
||||||
|
<Headless.Field
|
||||||
|
data-slot="field"
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
// Base layout
|
||||||
|
'grid grid-cols-[1.125rem_1fr] gap-x-4 gap-y-1 sm:grid-cols-[1rem_1fr]',
|
||||||
|
// Control layout
|
||||||
|
'*:data-[slot=control]:col-start-1 *:data-[slot=control]:row-start-1 *:data-[slot=control]:mt-0.75 sm:*:data-[slot=control]:mt-1',
|
||||||
|
// Label layout
|
||||||
|
'*:data-[slot=label]:col-start-2 *:data-[slot=label]:row-start-1',
|
||||||
|
// Description layout
|
||||||
|
'*:data-[slot=description]:col-start-2 *:data-[slot=description]:row-start-2',
|
||||||
|
// With description
|
||||||
|
'has-data-[slot=description]:**:data-[slot=label]:font-medium'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = [
|
||||||
|
// Basic layout
|
||||||
|
'relative isolate flex size-4.75 shrink-0 rounded-full sm:size-4.25',
|
||||||
|
// Background color + shadow applied to inset pseudo element, so shadow blends with border in light mode
|
||||||
|
'before:absolute before:inset-0 before:-z-10 before:rounded-full before:bg-white before:shadow-sm',
|
||||||
|
// Background color when checked
|
||||||
|
'group-data-checked:before:bg-(--radio-checked-bg)',
|
||||||
|
// Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo
|
||||||
|
'dark:before:hidden',
|
||||||
|
// Background color applied to control in dark mode
|
||||||
|
'dark:bg-white/5 dark:group-data-checked:bg-(--radio-checked-bg)',
|
||||||
|
// Border
|
||||||
|
'border border-zinc-950/15 group-data-checked:border-transparent group-data-hover:group-data-checked:border-transparent group-data-hover:border-zinc-950/30 group-data-checked:bg-(--radio-checked-border)',
|
||||||
|
'dark:border-white/15 dark:group-data-checked:border-white/5 dark:group-data-hover:group-data-checked:border-white/5 dark:group-data-hover:border-white/30',
|
||||||
|
// Inner highlight shadow
|
||||||
|
'after:absolute after:inset-0 after:rounded-full after:shadow-[inset_0_1px_--theme(--color-white/15%)]',
|
||||||
|
'dark:after:-inset-px dark:after:hidden dark:after:rounded-full dark:group-data-checked:after:block',
|
||||||
|
// Indicator color (light mode)
|
||||||
|
'[--radio-indicator:transparent] group-data-checked:[--radio-indicator:var(--radio-checked-indicator)] group-data-hover:group-data-checked:[--radio-indicator:var(--radio-checked-indicator)] group-data-hover:[--radio-indicator:var(--color-zinc-900)]/10',
|
||||||
|
// Indicator color (dark mode)
|
||||||
|
'dark:group-data-hover:group-data-checked:[--radio-indicator:var(--radio-checked-indicator)] dark:group-data-hover:[--radio-indicator:var(--color-zinc-700)]',
|
||||||
|
// Focus ring
|
||||||
|
'group-data-focus:outline group-data-focus:outline-2 group-data-focus:outline-offset-2 group-data-focus:outline-blue-500',
|
||||||
|
// Disabled state
|
||||||
|
'group-data-disabled:opacity-50',
|
||||||
|
'group-data-disabled:border-zinc-950/25 group-data-disabled:bg-zinc-950/5 group-data-disabled:[--radio-checked-indicator:var(--color-zinc-950)]/50 group-data-disabled:before:bg-transparent',
|
||||||
|
'dark:group-data-disabled:border-white/20 dark:group-data-disabled:bg-white/2.5 dark:group-data-disabled:[--radio-checked-indicator:var(--color-white)]/50 dark:group-data-checked:group-data-disabled:after:hidden',
|
||||||
|
]
|
||||||
|
|
||||||
|
const colors = {
|
||||||
|
'dark/zinc': [
|
||||||
|
'[--radio-checked-bg:var(--color-zinc-900)] [--radio-checked-border:var(--color-zinc-950)]/90 [--radio-checked-indicator:var(--color-white)]',
|
||||||
|
'dark:[--radio-checked-bg:var(--color-zinc-600)]',
|
||||||
|
],
|
||||||
|
'dark/white': [
|
||||||
|
'[--radio-checked-bg:var(--color-zinc-900)] [--radio-checked-border:var(--color-zinc-950)]/90 [--radio-checked-indicator:var(--color-white)]',
|
||||||
|
'dark:[--radio-checked-bg:var(--color-white)] dark:[--radio-checked-border:var(--color-zinc-950)]/15 dark:[--radio-checked-indicator:var(--color-zinc-900)]',
|
||||||
|
],
|
||||||
|
white:
|
||||||
|
'[--radio-checked-bg:var(--color-white)] [--radio-checked-border:var(--color-zinc-950)]/15 [--radio-checked-indicator:var(--color-zinc-900)]',
|
||||||
|
dark: '[--radio-checked-bg:var(--color-zinc-900)] [--radio-checked-border:var(--color-zinc-950)]/90 [--radio-checked-indicator:var(--color-white)]',
|
||||||
|
zinc: '[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-zinc-600)] [--radio-checked-border:var(--color-zinc-700)]/90',
|
||||||
|
red: '[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-red-600)] [--radio-checked-border:var(--color-red-700)]/90',
|
||||||
|
orange:
|
||||||
|
'[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-orange-500)] [--radio-checked-border:var(--color-orange-600)]/90',
|
||||||
|
amber:
|
||||||
|
'[--radio-checked-bg:var(--color-amber-400)] [--radio-checked-border:var(--color-amber-500)]/80 [--radio-checked-indicator:var(--color-amber-950)]',
|
||||||
|
yellow:
|
||||||
|
'[--radio-checked-bg:var(--color-yellow-300)] [--radio-checked-border:var(--color-yellow-400)]/80 [--radio-checked-indicator:var(--color-yellow-950)]',
|
||||||
|
lime: '[--radio-checked-bg:var(--color-lime-300)] [--radio-checked-border:var(--color-lime-400)]/80 [--radio-checked-indicator:var(--color-lime-950)]',
|
||||||
|
green:
|
||||||
|
'[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-green-600)] [--radio-checked-border:var(--color-green-700)]/90',
|
||||||
|
emerald:
|
||||||
|
'[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-emerald-600)] [--radio-checked-border:var(--color-emerald-700)]/90',
|
||||||
|
teal: '[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-teal-600)] [--radio-checked-border:var(--color-teal-700)]/90',
|
||||||
|
cyan: '[--radio-checked-bg:var(--color-cyan-300)] [--radio-checked-border:var(--color-cyan-400)]/80 [--radio-checked-indicator:var(--color-cyan-950)]',
|
||||||
|
sky: '[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-sky-500)] [--radio-checked-border:var(--color-sky-600)]/80',
|
||||||
|
blue: '[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-blue-600)] [--radio-checked-border:var(--color-blue-700)]/90',
|
||||||
|
indigo:
|
||||||
|
'[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-indigo-500)] [--radio-checked-border:var(--color-indigo-600)]/90',
|
||||||
|
violet:
|
||||||
|
'[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-violet-500)] [--radio-checked-border:var(--color-violet-600)]/90',
|
||||||
|
purple:
|
||||||
|
'[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-purple-500)] [--radio-checked-border:var(--color-purple-600)]/90',
|
||||||
|
fuchsia:
|
||||||
|
'[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-fuchsia-500)] [--radio-checked-border:var(--color-fuchsia-600)]/90',
|
||||||
|
pink: '[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-pink-500)] [--radio-checked-border:var(--color-pink-600)]/90',
|
||||||
|
rose: '[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-rose-500)] [--radio-checked-border:var(--color-rose-600)]/90',
|
||||||
|
}
|
||||||
|
|
||||||
|
type Color = keyof typeof colors
|
||||||
|
|
||||||
|
export function Radio({
|
||||||
|
color = 'dark/zinc',
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: { color?: Color; className?: string } & Omit<Headless.RadioProps, 'as' | 'className' | 'children'>) {
|
||||||
|
return (
|
||||||
|
<Headless.Radio
|
||||||
|
data-slot="control"
|
||||||
|
{...props}
|
||||||
|
className={clsx(className, 'group inline-flex focus:outline-hidden')}
|
||||||
|
>
|
||||||
|
<span className={clsx([base, colors[color]])}>
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
'size-full rounded-full border-[4.5px] border-transparent bg-(--radio-indicator) bg-clip-padding',
|
||||||
|
// Forced colors mode
|
||||||
|
'forced-colors:border-[Canvas] forced-colors:group-data-checked:border-[Highlight]'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</Headless.Radio>
|
||||||
|
)
|
||||||
|
}
|
||||||
68
src/components/select.tsx
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import * as Headless from '@headlessui/react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import React, { forwardRef } from 'react'
|
||||||
|
|
||||||
|
export const Select = forwardRef(function Select(
|
||||||
|
{ className, multiple, ...props }: { className?: string } & Omit<Headless.SelectProps, 'as' | 'className'>,
|
||||||
|
ref: React.ForwardedRef<HTMLSelectElement>
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="control"
|
||||||
|
className={clsx([
|
||||||
|
className,
|
||||||
|
// Basic layout
|
||||||
|
'group relative block w-full',
|
||||||
|
// Background color + shadow applied to inset pseudo element, so shadow blends with border in light mode
|
||||||
|
'before:absolute before:inset-px before:rounded-[calc(var(--radius-lg)-1px)] before:bg-white before:shadow-sm',
|
||||||
|
// Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo
|
||||||
|
'dark:before:hidden',
|
||||||
|
// Focus ring
|
||||||
|
'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-transparent after:ring-inset has-data-focus:after:ring-2 has-data-focus:after:ring-blue-500',
|
||||||
|
// Disabled state
|
||||||
|
'has-data-disabled:opacity-50 has-data-disabled:before:bg-zinc-950/5 has-data-disabled:before:shadow-none',
|
||||||
|
])}
|
||||||
|
>
|
||||||
|
<Headless.Select
|
||||||
|
ref={ref}
|
||||||
|
multiple={multiple}
|
||||||
|
{...props}
|
||||||
|
className={clsx([
|
||||||
|
// Basic layout
|
||||||
|
'relative block w-full appearance-none rounded-lg py-[calc(--spacing(2.5)-1px)] sm:py-[calc(--spacing(1.5)-1px)]',
|
||||||
|
// Horizontal padding
|
||||||
|
multiple
|
||||||
|
? 'px-[calc(--spacing(3.5)-1px)] sm:px-[calc(--spacing(3)-1px)]'
|
||||||
|
: 'pr-[calc(--spacing(10)-1px)] pl-[calc(--spacing(3.5)-1px)] sm:pr-[calc(--spacing(9)-1px)] sm:pl-[calc(--spacing(3)-1px)]',
|
||||||
|
// Options (multi-select)
|
||||||
|
'[&_optgroup]:font-semibold',
|
||||||
|
// Typography
|
||||||
|
'text-base/6 text-zinc-950 placeholder:text-zinc-500 sm:text-sm/6 dark:text-white dark:*:text-white',
|
||||||
|
// Border
|
||||||
|
'border border-zinc-950/10 data-hover:border-zinc-950/20 dark:border-white/10 dark:data-hover:border-white/20',
|
||||||
|
// Background color
|
||||||
|
'bg-transparent dark:bg-white/5 dark:*:bg-zinc-800',
|
||||||
|
// Hide default focus styles
|
||||||
|
'focus:outline-hidden',
|
||||||
|
// Invalid state
|
||||||
|
'data-invalid:border-red-500 data-invalid:data-hover:border-red-500 dark:data-invalid:border-red-600 dark:data-invalid:data-hover:border-red-600',
|
||||||
|
// Disabled state
|
||||||
|
'data-disabled:border-zinc-950/20 data-disabled:opacity-100 dark:data-disabled:border-white/15 dark:data-disabled:bg-white/2.5 dark:data-hover:data-disabled:border-white/15',
|
||||||
|
])}
|
||||||
|
/>
|
||||||
|
{!multiple && (
|
||||||
|
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||||
|
<svg
|
||||||
|
className="size-5 stroke-zinc-500 group-has-data-disabled:stroke-zinc-600 sm:size-4 dark:stroke-zinc-400 forced-colors:stroke-[CanvasText]"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
aria-hidden="true"
|
||||||
|
fill="none"
|
||||||
|
>
|
||||||
|
<path d="M5.75 10.75L8 13L10.25 10.75" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
<path d="M10.25 5.25L8 3L5.75 5.25" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})
|
||||||
85
src/components/sidebar-layout.tsx
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as Headless from '@headlessui/react'
|
||||||
|
import { motion } from 'motion/react'
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
import { NavbarItem } from './navbar'
|
||||||
|
|
||||||
|
function OpenMenuIcon() {
|
||||||
|
return (
|
||||||
|
<svg data-slot="icon" viewBox="0 0 20 20" aria-hidden="true">
|
||||||
|
<path d="M2 6.75C2 6.33579 2.33579 6 2.75 6H17.25C17.6642 6 18 6.33579 18 6.75C18 7.16421 17.6642 7.5 17.25 7.5H2.75C2.33579 7.5 2 7.16421 2 6.75ZM2 13.25C2 12.8358 2.33579 12.5 2.75 12.5H17.25C17.6642 12.5 18 12.8358 18 13.25C18 13.6642 17.6642 14 17.25 14H2.75C2.33579 14 2 13.6642 2 13.25Z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CloseMenuIcon() {
|
||||||
|
return (
|
||||||
|
<svg data-slot="icon" viewBox="0 0 20 20" aria-hidden="true">
|
||||||
|
<path d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MobileSidebar({ open, close, children }: React.PropsWithChildren<{ open: boolean; close: () => void }>) {
|
||||||
|
return (
|
||||||
|
<Headless.Dialog open={open} onClose={close} className="lg:hidden">
|
||||||
|
<Headless.DialogBackdrop
|
||||||
|
transition
|
||||||
|
className="fixed inset-0 bg-black/30 transition data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in"
|
||||||
|
/>
|
||||||
|
<Headless.DialogPanel
|
||||||
|
transition
|
||||||
|
className="fixed inset-y-0 w-full max-w-80 p-2 transition duration-300 ease-in-out data-closed:-translate-x-full"
|
||||||
|
>
|
||||||
|
<div className="flex h-full flex-col rounded-lg bg-white shadow-xs ring-1 ring-zinc-950/5 dark:bg-zinc-900 dark:ring-white/10">
|
||||||
|
<div className="-mb-3 px-4 pt-3">
|
||||||
|
<Headless.CloseButton as={NavbarItem} aria-label="Close navigation">
|
||||||
|
<CloseMenuIcon />
|
||||||
|
</Headless.CloseButton>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</Headless.DialogPanel>
|
||||||
|
</Headless.Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SidebarLayout({
|
||||||
|
navbar,
|
||||||
|
sidebar,
|
||||||
|
children,
|
||||||
|
}: React.PropsWithChildren<{ navbar: React.ReactNode; sidebar: React.ReactNode }>) {
|
||||||
|
let [showSidebar, setShowSidebar] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative isolate flex min-h-svh w-full bg-white max-lg:flex-col lg:bg-zinc-100 dark:bg-zinc-900 dark:lg:bg-zinc-950">
|
||||||
|
{/* Sidebar on desktop */}
|
||||||
|
<motion.div layoutScroll className="fixed inset-y-0 left-0 w-64 max-lg:hidden">
|
||||||
|
{sidebar}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Sidebar on mobile */}
|
||||||
|
<MobileSidebar open={showSidebar} close={() => setShowSidebar(false)}>
|
||||||
|
{sidebar}
|
||||||
|
</MobileSidebar>
|
||||||
|
|
||||||
|
{/* Navbar on mobile */}
|
||||||
|
<header className="flex items-center px-4 lg:hidden">
|
||||||
|
<div className="py-2.5">
|
||||||
|
<NavbarItem onClick={() => setShowSidebar(true)} aria-label="Open navigation">
|
||||||
|
<OpenMenuIcon />
|
||||||
|
</NavbarItem>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">{navbar}</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<main className="flex flex-1 flex-col pb-2 lg:min-w-0 lg:pt-2 lg:pr-2 lg:pl-64">
|
||||||
|
<div className="grow p-6 lg:rounded-lg lg:bg-white lg:p-10 lg:shadow-xs lg:ring-1 lg:ring-zinc-950/5 dark:lg:bg-zinc-900 dark:lg:ring-white/10">
|
||||||
|
<div className="mx-auto max-w-6xl">{children}</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
142
src/components/sidebar.tsx
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as Headless from '@headlessui/react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import { LayoutGroup, motion } from 'motion/react'
|
||||||
|
import React, { forwardRef, useId } from 'react'
|
||||||
|
import { TouchTarget } from './button'
|
||||||
|
import { Link } from './link'
|
||||||
|
|
||||||
|
export function Sidebar({ className, ...props }: React.ComponentPropsWithoutRef<'nav'>) {
|
||||||
|
return <nav {...props} className={clsx(className, 'flex h-full min-h-0 flex-col')} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SidebarHeader({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
'flex flex-col border-b border-zinc-950/5 p-4 dark:border-white/5 [&>[data-slot=section]+[data-slot=section]]:mt-2.5'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SidebarBody({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
'flex flex-1 flex-col overflow-y-auto p-4 [&>[data-slot=section]+[data-slot=section]]:mt-8'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SidebarFooter({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
'flex flex-col border-t border-zinc-950/5 p-4 dark:border-white/5 [&>[data-slot=section]+[data-slot=section]]:mt-2.5'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SidebarSection({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
|
||||||
|
let id = useId()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LayoutGroup id={id}>
|
||||||
|
<div {...props} data-slot="section" className={clsx(className, 'flex flex-col gap-0.5')} />
|
||||||
|
</LayoutGroup>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SidebarDivider({ className, ...props }: React.ComponentPropsWithoutRef<'hr'>) {
|
||||||
|
return <hr {...props} className={clsx(className, 'my-4 border-t border-zinc-950/5 lg:-mx-4 dark:border-white/5')} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SidebarSpacer({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
|
||||||
|
return <div aria-hidden="true" {...props} className={clsx(className, 'mt-8 flex-1')} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SidebarHeading({ className, ...props }: React.ComponentPropsWithoutRef<'h3'>) {
|
||||||
|
return (
|
||||||
|
<h3 {...props} className={clsx(className, 'mb-1 px-2 text-xs/6 font-medium text-zinc-500 dark:text-zinc-400')} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SidebarItem = forwardRef(function SidebarItem(
|
||||||
|
{
|
||||||
|
current,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: { current?: boolean; className?: string; children: React.ReactNode } & (
|
||||||
|
| ({ href?: never } & Omit<Headless.ButtonProps, 'as' | 'className'>)
|
||||||
|
| ({ href: string } & Omit<Headless.ButtonProps<typeof Link>, 'as' | 'className'>)
|
||||||
|
),
|
||||||
|
ref: React.ForwardedRef<HTMLAnchorElement | HTMLButtonElement>
|
||||||
|
) {
|
||||||
|
let classes = clsx(
|
||||||
|
// Base
|
||||||
|
'flex w-full items-center gap-3 rounded-lg px-2 py-2.5 text-left text-base/6 font-medium text-zinc-950 sm:py-2 sm:text-sm/5',
|
||||||
|
// Leading icon/icon-only
|
||||||
|
'*:data-[slot=icon]:size-6 *:data-[slot=icon]:shrink-0 *:data-[slot=icon]:fill-zinc-500 sm:*:data-[slot=icon]:size-5',
|
||||||
|
// Trailing icon (down chevron or similar)
|
||||||
|
'*:last:data-[slot=icon]:ml-auto *:last:data-[slot=icon]:size-5 sm:*:last:data-[slot=icon]:size-4',
|
||||||
|
// Avatar
|
||||||
|
'*:data-[slot=avatar]:-m-0.5 *:data-[slot=avatar]:size-7 sm:*:data-[slot=avatar]:size-6',
|
||||||
|
// Hover
|
||||||
|
'data-hover:bg-zinc-950/5 data-hover:*:data-[slot=icon]:fill-zinc-950',
|
||||||
|
// Active
|
||||||
|
'data-active:bg-zinc-950/5 data-active:*:data-[slot=icon]:fill-zinc-950',
|
||||||
|
// Current
|
||||||
|
'data-current:*:data-[slot=icon]:fill-zinc-950',
|
||||||
|
// Dark mode
|
||||||
|
'dark:text-white dark:*:data-[slot=icon]:fill-zinc-400',
|
||||||
|
'dark:data-hover:bg-white/5 dark:data-hover:*:data-[slot=icon]:fill-white',
|
||||||
|
'dark:data-active:bg-white/5 dark:data-active:*:data-[slot=icon]:fill-white',
|
||||||
|
'dark:data-current:*:data-[slot=icon]:fill-white'
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={clsx(className, 'relative')}>
|
||||||
|
{current && (
|
||||||
|
<motion.span
|
||||||
|
layoutId="current-indicator"
|
||||||
|
className="absolute inset-y-2 -left-4 w-0.5 rounded-full bg-zinc-950 dark:bg-white"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{typeof props.href === 'string' ? (
|
||||||
|
<Headless.CloseButton
|
||||||
|
as={Link}
|
||||||
|
{...props}
|
||||||
|
className={classes}
|
||||||
|
data-current={current ? 'true' : undefined}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<TouchTarget>{children}</TouchTarget>
|
||||||
|
</Headless.CloseButton>
|
||||||
|
) : (
|
||||||
|
<Headless.Button
|
||||||
|
{...props}
|
||||||
|
className={clsx('cursor-default', classes)}
|
||||||
|
data-current={current ? 'true' : undefined}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<TouchTarget>{children}</TouchTarget>
|
||||||
|
</Headless.Button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export function SidebarLabel({ className, ...props }: React.ComponentPropsWithoutRef<'span'>) {
|
||||||
|
return <span {...props} className={clsx(className, 'truncate')} />
|
||||||
|
}
|
||||||
79
src/components/stacked-layout.tsx
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as Headless from '@headlessui/react'
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
import { NavbarItem } from './navbar'
|
||||||
|
|
||||||
|
function OpenMenuIcon() {
|
||||||
|
return (
|
||||||
|
<svg data-slot="icon" viewBox="0 0 20 20" aria-hidden="true">
|
||||||
|
<path d="M2 6.75C2 6.33579 2.33579 6 2.75 6H17.25C17.6642 6 18 6.33579 18 6.75C18 7.16421 17.6642 7.5 17.25 7.5H2.75C2.33579 7.5 2 7.16421 2 6.75ZM2 13.25C2 12.8358 2.33579 12.5 2.75 12.5H17.25C17.6642 12.5 18 12.8358 18 13.25C18 13.6642 17.6642 14 17.25 14H2.75C2.33579 14 2 13.6642 2 13.25Z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CloseMenuIcon() {
|
||||||
|
return (
|
||||||
|
<svg data-slot="icon" viewBox="0 0 20 20" aria-hidden="true">
|
||||||
|
<path d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MobileSidebar({ open, close, children }: React.PropsWithChildren<{ open: boolean; close: () => void }>) {
|
||||||
|
return (
|
||||||
|
<Headless.Dialog open={open} onClose={close} className="lg:hidden">
|
||||||
|
<Headless.DialogBackdrop
|
||||||
|
transition
|
||||||
|
className="fixed inset-0 bg-black/30 transition data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in"
|
||||||
|
/>
|
||||||
|
<Headless.DialogPanel
|
||||||
|
transition
|
||||||
|
className="fixed inset-y-0 w-full max-w-80 p-2 transition duration-300 ease-in-out data-closed:-translate-x-full"
|
||||||
|
>
|
||||||
|
<div className="flex h-full flex-col rounded-lg bg-white shadow-xs ring-1 ring-zinc-950/5 dark:bg-zinc-900 dark:ring-white/10">
|
||||||
|
<div className="-mb-3 px-4 pt-3">
|
||||||
|
<Headless.CloseButton as={NavbarItem} aria-label="Close navigation">
|
||||||
|
<CloseMenuIcon />
|
||||||
|
</Headless.CloseButton>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</Headless.DialogPanel>
|
||||||
|
</Headless.Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StackedLayout({
|
||||||
|
navbar,
|
||||||
|
sidebar,
|
||||||
|
children,
|
||||||
|
}: React.PropsWithChildren<{ navbar: React.ReactNode; sidebar: React.ReactNode }>) {
|
||||||
|
let [showSidebar, setShowSidebar] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative isolate flex min-h-svh w-full flex-col bg-white lg:bg-zinc-100 dark:bg-zinc-900 dark:lg:bg-zinc-950">
|
||||||
|
{/* Sidebar on mobile */}
|
||||||
|
<MobileSidebar open={showSidebar} close={() => setShowSidebar(false)}>
|
||||||
|
{sidebar}
|
||||||
|
</MobileSidebar>
|
||||||
|
|
||||||
|
{/* Navbar */}
|
||||||
|
<header className="flex items-center px-4">
|
||||||
|
<div className="py-2.5 lg:hidden">
|
||||||
|
<NavbarItem onClick={() => setShowSidebar(true)} aria-label="Open navigation">
|
||||||
|
<OpenMenuIcon />
|
||||||
|
</NavbarItem>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">{navbar}</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<main className="flex flex-1 flex-col pb-2 lg:px-2">
|
||||||
|
<div className="grow p-6 lg:rounded-lg lg:bg-white lg:p-10 lg:shadow-xs lg:ring-1 lg:ring-zinc-950/5 dark:lg:bg-zinc-900 dark:lg:ring-white/10">
|
||||||
|
<div className="mx-auto max-w-6xl">{children}</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
195
src/components/switch.tsx
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
import * as Headless from '@headlessui/react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import type React from 'react'
|
||||||
|
|
||||||
|
export function SwitchGroup({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="control"
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
// Basic groups
|
||||||
|
'space-y-3 **:data-[slot=label]:font-normal',
|
||||||
|
// With descriptions
|
||||||
|
'has-data-[slot=description]:space-y-6 has-data-[slot=description]:**:data-[slot=label]:font-medium'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SwitchField({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: { className?: string } & Omit<Headless.FieldProps, 'as' | 'className'>) {
|
||||||
|
return (
|
||||||
|
<Headless.Field
|
||||||
|
data-slot="field"
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
// Base layout
|
||||||
|
'grid grid-cols-[1fr_auto] gap-x-8 gap-y-1 sm:grid-cols-[1fr_auto]',
|
||||||
|
// Control layout
|
||||||
|
'*:data-[slot=control]:col-start-2 *:data-[slot=control]:self-start sm:*:data-[slot=control]:mt-0.5',
|
||||||
|
// Label layout
|
||||||
|
'*:data-[slot=label]:col-start-1 *:data-[slot=label]:row-start-1',
|
||||||
|
// Description layout
|
||||||
|
'*:data-[slot=description]:col-start-1 *:data-[slot=description]:row-start-2',
|
||||||
|
// With description
|
||||||
|
'has-data-[slot=description]:**:data-[slot=label]:font-medium'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const colors = {
|
||||||
|
'dark/zinc': [
|
||||||
|
'[--switch-bg-ring:var(--color-zinc-950)]/90 [--switch-bg:var(--color-zinc-900)] dark:[--switch-bg-ring:transparent] dark:[--switch-bg:var(--color-white)]/25',
|
||||||
|
'[--switch-ring:var(--color-zinc-950)]/90 [--switch-shadow:var(--color-black)]/10 [--switch:white] dark:[--switch-ring:var(--color-zinc-700)]/90',
|
||||||
|
],
|
||||||
|
'dark/white': [
|
||||||
|
'[--switch-bg-ring:var(--color-zinc-950)]/90 [--switch-bg:var(--color-zinc-900)] dark:[--switch-bg-ring:transparent] dark:[--switch-bg:var(--color-white)]',
|
||||||
|
'[--switch-ring:var(--color-zinc-950)]/90 [--switch-shadow:var(--color-black)]/10 [--switch:white] dark:[--switch-ring:transparent] dark:[--switch:var(--color-zinc-900)]',
|
||||||
|
],
|
||||||
|
dark: [
|
||||||
|
'[--switch-bg-ring:var(--color-zinc-950)]/90 [--switch-bg:var(--color-zinc-900)] dark:[--switch-bg-ring:var(--color-white)]/15',
|
||||||
|
'[--switch-ring:var(--color-zinc-950)]/90 [--switch-shadow:var(--color-black)]/10 [--switch:white]',
|
||||||
|
],
|
||||||
|
zinc: [
|
||||||
|
'[--switch-bg-ring:var(--color-zinc-700)]/90 [--switch-bg:var(--color-zinc-600)] dark:[--switch-bg-ring:transparent]',
|
||||||
|
'[--switch-shadow:var(--color-black)]/10 [--switch:white] [--switch-ring:var(--color-zinc-700)]/90',
|
||||||
|
],
|
||||||
|
white: [
|
||||||
|
'[--switch-bg-ring:var(--color-black)]/15 [--switch-bg:white] dark:[--switch-bg-ring:transparent]',
|
||||||
|
'[--switch-shadow:var(--color-black)]/10 [--switch-ring:transparent] [--switch:var(--color-zinc-950)]',
|
||||||
|
],
|
||||||
|
red: [
|
||||||
|
'[--switch-bg-ring:var(--color-red-700)]/90 [--switch-bg:var(--color-red-600)] dark:[--switch-bg-ring:transparent]',
|
||||||
|
'[--switch:white] [--switch-ring:var(--color-red-700)]/90 [--switch-shadow:var(--color-red-900)]/20',
|
||||||
|
],
|
||||||
|
orange: [
|
||||||
|
'[--switch-bg-ring:var(--color-orange-600)]/90 [--switch-bg:var(--color-orange-500)] dark:[--switch-bg-ring:transparent]',
|
||||||
|
'[--switch:white] [--switch-ring:var(--color-orange-600)]/90 [--switch-shadow:var(--color-orange-900)]/20',
|
||||||
|
],
|
||||||
|
amber: [
|
||||||
|
'[--switch-bg-ring:var(--color-amber-500)]/80 [--switch-bg:var(--color-amber-400)] dark:[--switch-bg-ring:transparent]',
|
||||||
|
'[--switch-ring:transparent] [--switch-shadow:transparent] [--switch:var(--color-amber-950)]',
|
||||||
|
],
|
||||||
|
yellow: [
|
||||||
|
'[--switch-bg-ring:var(--color-yellow-400)]/80 [--switch-bg:var(--color-yellow-300)] dark:[--switch-bg-ring:transparent]',
|
||||||
|
'[--switch-ring:transparent] [--switch-shadow:transparent] [--switch:var(--color-yellow-950)]',
|
||||||
|
],
|
||||||
|
lime: [
|
||||||
|
'[--switch-bg-ring:var(--color-lime-400)]/80 [--switch-bg:var(--color-lime-300)] dark:[--switch-bg-ring:transparent]',
|
||||||
|
'[--switch-ring:transparent] [--switch-shadow:transparent] [--switch:var(--color-lime-950)]',
|
||||||
|
],
|
||||||
|
green: [
|
||||||
|
'[--switch-bg-ring:var(--color-green-700)]/90 [--switch-bg:var(--color-green-600)] dark:[--switch-bg-ring:transparent]',
|
||||||
|
'[--switch:white] [--switch-ring:var(--color-green-700)]/90 [--switch-shadow:var(--color-green-900)]/20',
|
||||||
|
],
|
||||||
|
emerald: [
|
||||||
|
'[--switch-bg-ring:var(--color-emerald-600)]/90 [--switch-bg:var(--color-emerald-500)] dark:[--switch-bg-ring:transparent]',
|
||||||
|
'[--switch:white] [--switch-ring:var(--color-emerald-600)]/90 [--switch-shadow:var(--color-emerald-900)]/20',
|
||||||
|
],
|
||||||
|
teal: [
|
||||||
|
'[--switch-bg-ring:var(--color-teal-700)]/90 [--switch-bg:var(--color-teal-600)] dark:[--switch-bg-ring:transparent]',
|
||||||
|
'[--switch:white] [--switch-ring:var(--color-teal-700)]/90 [--switch-shadow:var(--color-teal-900)]/20',
|
||||||
|
],
|
||||||
|
cyan: [
|
||||||
|
'[--switch-bg-ring:var(--color-cyan-400)]/80 [--switch-bg:var(--color-cyan-300)] dark:[--switch-bg-ring:transparent]',
|
||||||
|
'[--switch-ring:transparent] [--switch-shadow:transparent] [--switch:var(--color-cyan-950)]',
|
||||||
|
],
|
||||||
|
sky: [
|
||||||
|
'[--switch-bg-ring:var(--color-sky-600)]/80 [--switch-bg:var(--color-sky-500)] dark:[--switch-bg-ring:transparent]',
|
||||||
|
'[--switch:white] [--switch-ring:var(--color-sky-600)]/80 [--switch-shadow:var(--color-sky-900)]/20',
|
||||||
|
],
|
||||||
|
blue: [
|
||||||
|
'[--switch-bg-ring:var(--color-blue-700)]/90 [--switch-bg:var(--color-blue-600)] dark:[--switch-bg-ring:transparent]',
|
||||||
|
'[--switch:white] [--switch-ring:var(--color-blue-700)]/90 [--switch-shadow:var(--color-blue-900)]/20',
|
||||||
|
],
|
||||||
|
indigo: [
|
||||||
|
'[--switch-bg-ring:var(--color-indigo-600)]/90 [--switch-bg:var(--color-indigo-500)] dark:[--switch-bg-ring:transparent]',
|
||||||
|
'[--switch:white] [--switch-ring:var(--color-indigo-600)]/90 [--switch-shadow:var(--color-indigo-900)]/20',
|
||||||
|
],
|
||||||
|
violet: [
|
||||||
|
'[--switch-bg-ring:var(--color-violet-600)]/90 [--switch-bg:var(--color-violet-500)] dark:[--switch-bg-ring:transparent]',
|
||||||
|
'[--switch:white] [--switch-ring:var(--color-violet-600)]/90 [--switch-shadow:var(--color-violet-900)]/20',
|
||||||
|
],
|
||||||
|
purple: [
|
||||||
|
'[--switch-bg-ring:var(--color-purple-600)]/90 [--switch-bg:var(--color-purple-500)] dark:[--switch-bg-ring:transparent]',
|
||||||
|
'[--switch:white] [--switch-ring:var(--color-purple-600)]/90 [--switch-shadow:var(--color-purple-900)]/20',
|
||||||
|
],
|
||||||
|
fuchsia: [
|
||||||
|
'[--switch-bg-ring:var(--color-fuchsia-600)]/90 [--switch-bg:var(--color-fuchsia-500)] dark:[--switch-bg-ring:transparent]',
|
||||||
|
'[--switch:white] [--switch-ring:var(--color-fuchsia-600)]/90 [--switch-shadow:var(--color-fuchsia-900)]/20',
|
||||||
|
],
|
||||||
|
pink: [
|
||||||
|
'[--switch-bg-ring:var(--color-pink-600)]/90 [--switch-bg:var(--color-pink-500)] dark:[--switch-bg-ring:transparent]',
|
||||||
|
'[--switch:white] [--switch-ring:var(--color-pink-600)]/90 [--switch-shadow:var(--color-pink-900)]/20',
|
||||||
|
],
|
||||||
|
rose: [
|
||||||
|
'[--switch-bg-ring:var(--color-rose-600)]/90 [--switch-bg:var(--color-rose-500)] dark:[--switch-bg-ring:transparent]',
|
||||||
|
'[--switch:white] [--switch-ring:var(--color-rose-600)]/90 [--switch-shadow:var(--color-rose-900)]/20',
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
type Color = keyof typeof colors
|
||||||
|
|
||||||
|
export function Switch({
|
||||||
|
color = 'dark/zinc',
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
color?: Color
|
||||||
|
className?: string
|
||||||
|
} & Omit<Headless.SwitchProps, 'as' | 'className' | 'children'>) {
|
||||||
|
return (
|
||||||
|
<Headless.Switch
|
||||||
|
data-slot="control"
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
// Base styles
|
||||||
|
'group relative isolate inline-flex h-6 w-10 cursor-default rounded-full p-[3px] sm:h-5 sm:w-8',
|
||||||
|
// Transitions
|
||||||
|
'transition duration-0 ease-in-out data-changing:duration-200',
|
||||||
|
// Outline and background color in forced-colors mode so switch is still visible
|
||||||
|
'forced-colors:outline forced-colors:[--switch-bg:Highlight] dark:forced-colors:[--switch-bg:Highlight]',
|
||||||
|
// Unchecked
|
||||||
|
'bg-zinc-200 ring-1 ring-black/5 ring-inset dark:bg-white/5 dark:ring-white/15',
|
||||||
|
// Checked
|
||||||
|
'data-checked:bg-(--switch-bg) data-checked:ring-(--switch-bg-ring) dark:data-checked:bg-(--switch-bg) dark:data-checked:ring-(--switch-bg-ring)',
|
||||||
|
// Focus
|
||||||
|
'focus:not-data-focus:outline-hidden data-focus:outline-2 data-focus:outline-offset-2 data-focus:outline-blue-500',
|
||||||
|
// Hover
|
||||||
|
'data-hover:ring-black/15 data-hover:data-checked:ring-(--switch-bg-ring)',
|
||||||
|
'dark:data-hover:ring-white/25 dark:data-hover:data-checked:ring-(--switch-bg-ring)',
|
||||||
|
// Disabled
|
||||||
|
'data-disabled:bg-zinc-200 data-disabled:opacity-50 data-disabled:data-checked:bg-zinc-200 data-disabled:data-checked:ring-black/5',
|
||||||
|
'dark:data-disabled:bg-white/15 dark:data-disabled:data-checked:bg-white/15 dark:data-disabled:data-checked:ring-white/15',
|
||||||
|
// Color specific styles
|
||||||
|
colors[color]
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={clsx(
|
||||||
|
// Basic layout
|
||||||
|
'pointer-events-none relative inline-block size-4.5 rounded-full sm:size-3.5',
|
||||||
|
// Transition
|
||||||
|
'translate-x-0 transition duration-200 ease-in-out',
|
||||||
|
// Invisible border so the switch is still visible in forced-colors mode
|
||||||
|
'border border-transparent',
|
||||||
|
// Unchecked
|
||||||
|
'bg-white shadow-sm ring-1 ring-black/5',
|
||||||
|
// Checked
|
||||||
|
'group-data-checked:bg-(--switch) group-data-checked:shadow-(--switch-shadow) group-data-checked:ring-(--switch-ring)',
|
||||||
|
'group-data-checked:translate-x-4 sm:group-data-checked:translate-x-3',
|
||||||
|
// Disabled
|
||||||
|
'group-data-checked:group-data-disabled:bg-white group-data-checked:group-data-disabled:shadow-sm group-data-checked:group-data-disabled:ring-black/5'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Headless.Switch>
|
||||||
|
)
|
||||||
|
}
|
||||||
124
src/components/table.tsx
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import type React from 'react'
|
||||||
|
import { createContext, useContext, useState } from 'react'
|
||||||
|
import { Link } from './link'
|
||||||
|
|
||||||
|
const TableContext = createContext<{ bleed: boolean; dense: boolean; grid: boolean; striped: boolean }>({
|
||||||
|
bleed: false,
|
||||||
|
dense: false,
|
||||||
|
grid: false,
|
||||||
|
striped: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
export function Table({
|
||||||
|
bleed = false,
|
||||||
|
dense = false,
|
||||||
|
grid = false,
|
||||||
|
striped = false,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: { bleed?: boolean; dense?: boolean; grid?: boolean; striped?: boolean } & React.ComponentPropsWithoutRef<'div'>) {
|
||||||
|
return (
|
||||||
|
<TableContext.Provider value={{ bleed, dense, grid, striped } as React.ContextType<typeof TableContext>}>
|
||||||
|
<div className="flow-root">
|
||||||
|
<div {...props} className={clsx(className, '-mx-(--gutter) overflow-x-auto whitespace-nowrap')}>
|
||||||
|
<div className={clsx('inline-block min-w-full align-middle', !bleed && 'sm:px-(--gutter)')}>
|
||||||
|
<table className="min-w-full text-left text-sm/6 text-zinc-950 dark:text-white">{children}</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TableHead({ className, ...props }: React.ComponentPropsWithoutRef<'thead'>) {
|
||||||
|
return <thead {...props} className={clsx(className, 'text-zinc-500 dark:text-zinc-400')} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TableBody(props: React.ComponentPropsWithoutRef<'tbody'>) {
|
||||||
|
return <tbody {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
const TableRowContext = createContext<{ href?: string; target?: string; title?: string }>({
|
||||||
|
href: undefined,
|
||||||
|
target: undefined,
|
||||||
|
title: undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
export function TableRow({
|
||||||
|
href,
|
||||||
|
target,
|
||||||
|
title,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: { href?: string; target?: string; title?: string } & React.ComponentPropsWithoutRef<'tr'>) {
|
||||||
|
let { striped } = useContext(TableContext)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRowContext.Provider value={{ href, target, title } as React.ContextType<typeof TableRowContext>}>
|
||||||
|
<tr
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
href &&
|
||||||
|
'has-[[data-row-link][data-focus]]:outline-2 has-[[data-row-link][data-focus]]:-outline-offset-2 has-[[data-row-link][data-focus]]:outline-blue-500 dark:focus-within:bg-white/2.5',
|
||||||
|
striped && 'even:bg-zinc-950/2.5 dark:even:bg-white/2.5',
|
||||||
|
href && striped && 'hover:bg-zinc-950/5 dark:hover:bg-white/5',
|
||||||
|
href && !striped && 'hover:bg-zinc-950/2.5 dark:hover:bg-white/2.5'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</TableRowContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TableHeader({ className, ...props }: React.ComponentPropsWithoutRef<'th'>) {
|
||||||
|
let { bleed, grid } = useContext(TableContext)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
'border-b border-b-zinc-950/10 px-4 py-2 font-medium first:pl-(--gutter,--spacing(2)) last:pr-(--gutter,--spacing(2)) dark:border-b-white/10',
|
||||||
|
grid && 'border-l border-l-zinc-950/5 first:border-l-0 dark:border-l-white/5',
|
||||||
|
!bleed && 'sm:first:pl-1 sm:last:pr-1'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TableCell({ className, children, ...props }: React.ComponentPropsWithoutRef<'td'>) {
|
||||||
|
let { bleed, dense, grid, striped } = useContext(TableContext)
|
||||||
|
let { href, target, title } = useContext(TableRowContext)
|
||||||
|
let [cellRef, setCellRef] = useState<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
ref={href ? setCellRef : undefined}
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
'relative px-4 first:pl-(--gutter,--spacing(2)) last:pr-(--gutter,--spacing(2))',
|
||||||
|
!striped && 'border-b border-zinc-950/5 dark:border-white/5',
|
||||||
|
grid && 'border-l border-l-zinc-950/5 first:border-l-0 dark:border-l-white/5',
|
||||||
|
dense ? 'py-2.5' : 'py-4',
|
||||||
|
!bleed && 'sm:first:pl-1 sm:last:pr-1'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{href && (
|
||||||
|
<Link
|
||||||
|
data-row-link
|
||||||
|
href={href}
|
||||||
|
target={target}
|
||||||
|
aria-label={title}
|
||||||
|
tabIndex={cellRef?.previousElementSibling === null ? 0 : -1}
|
||||||
|
className="absolute inset-0 focus:outline-hidden"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</td>
|
||||||
|
)
|
||||||
|
}
|
||||||
40
src/components/text.tsx
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import clsx from 'clsx'
|
||||||
|
import { Link } from './link'
|
||||||
|
|
||||||
|
export function Text({ className, ...props }: React.ComponentPropsWithoutRef<'p'>) {
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
data-slot="text"
|
||||||
|
{...props}
|
||||||
|
className={clsx(className, 'text-base/6 text-zinc-500 sm:text-sm/6 dark:text-zinc-400')}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TextLink({ className, ...props }: React.ComponentPropsWithoutRef<typeof Link>) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
'text-zinc-950 underline decoration-zinc-950/50 data-hover:decoration-zinc-950 dark:text-white dark:decoration-white/50 dark:data-hover:decoration-white'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Strong({ className, ...props }: React.ComponentPropsWithoutRef<'strong'>) {
|
||||||
|
return <strong {...props} className={clsx(className, 'font-medium text-zinc-950 dark:text-white')} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Code({ className, ...props }: React.ComponentPropsWithoutRef<'code'>) {
|
||||||
|
return (
|
||||||
|
<code
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
'rounded-sm border border-zinc-950/10 bg-zinc-950/2.5 px-0.5 text-sm font-medium text-zinc-950 sm:text-[0.8125rem] dark:border-white/20 dark:bg-white/5 dark:text-white'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
54
src/components/textarea.tsx
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import * as Headless from '@headlessui/react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import React, { forwardRef } from 'react'
|
||||||
|
|
||||||
|
export const Textarea = forwardRef(function Textarea(
|
||||||
|
{
|
||||||
|
className,
|
||||||
|
resizable = true,
|
||||||
|
...props
|
||||||
|
}: { className?: string; resizable?: boolean } & Omit<Headless.TextareaProps, 'as' | 'className'>,
|
||||||
|
ref: React.ForwardedRef<HTMLTextAreaElement>
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="control"
|
||||||
|
className={clsx([
|
||||||
|
className,
|
||||||
|
// Basic layout
|
||||||
|
'relative block w-full',
|
||||||
|
// Background color + shadow applied to inset pseudo element, so shadow blends with border in light mode
|
||||||
|
'before:absolute before:inset-px before:rounded-[calc(var(--radius-lg)-1px)] before:bg-white before:shadow-sm',
|
||||||
|
// Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo
|
||||||
|
'dark:before:hidden',
|
||||||
|
// Focus ring
|
||||||
|
'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-transparent after:ring-inset sm:focus-within:after:ring-2 sm:focus-within:after:ring-blue-500',
|
||||||
|
// Disabled state
|
||||||
|
'has-data-disabled:opacity-50 has-data-disabled:before:bg-zinc-950/5 has-data-disabled:before:shadow-none',
|
||||||
|
])}
|
||||||
|
>
|
||||||
|
<Headless.Textarea
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
className={clsx([
|
||||||
|
// Basic layout
|
||||||
|
'relative block h-full w-full appearance-none rounded-lg px-[calc(--spacing(3.5)-1px)] py-[calc(--spacing(2.5)-1px)] sm:px-[calc(--spacing(3)-1px)] sm:py-[calc(--spacing(1.5)-1px)]',
|
||||||
|
// Typography
|
||||||
|
'text-base/6 text-zinc-950 placeholder:text-zinc-500 sm:text-sm/6 dark:text-white',
|
||||||
|
// Border
|
||||||
|
'border border-zinc-950/10 data-hover:border-zinc-950/20 dark:border-white/10 dark:data-hover:border-white/20',
|
||||||
|
// Background color
|
||||||
|
'bg-transparent dark:bg-white/5',
|
||||||
|
// Hide default focus styles
|
||||||
|
'focus:outline-hidden',
|
||||||
|
// Invalid state
|
||||||
|
'data-invalid:border-red-500 data-invalid:data-hover:border-red-500 dark:data-invalid:border-red-600 dark:data-invalid:data-hover:border-red-600',
|
||||||
|
// Disabled state
|
||||||
|
'disabled:border-zinc-950/20 dark:disabled:border-white/15 dark:disabled:bg-white/2.5 dark:data-hover:disabled:border-white/15',
|
||||||
|
// Resizable
|
||||||
|
resizable ? 'resize-y' : 'resize-none',
|
||||||
|
])}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})
|
||||||
1062
src/data.ts
Normal file
6
src/styles/tailwind.css
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
@import 'tailwindcss';
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--font-sans: Inter, sans-serif;
|
||||||
|
--font-sans--font-feature-settings: 'cv11';
|
||||||
|
}
|
||||||
27
tsconfig.json
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
},
|
||||||
|
"target": "ES2017"
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||