setup-project #1

Merged
phaichayon merged 7 commits from setup-project into main 2026-04-17 07:19:57 +00:00
98 changed files with 12704 additions and 215 deletions
Showing only changes of commit 1aa871cdf9 - Show all commits

4
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,4 @@
{
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode"
}

View File

@@ -1,25 +0,0 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "radix-mira",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "hugeicons",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}

671
package-lock.json generated
View File

@@ -8,14 +8,21 @@
"name": "nextjs-elysia-allaos", "name": "nextjs-elysia-allaos",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@base-ui/react": "^1.4.0",
"@hugeicons/core-free-icons": "^4.1.1", "@hugeicons/core-free-icons": "^4.1.1",
"@hugeicons/react": "^1.1.6", "@hugeicons/react": "^1.1.6",
"@radix-ui/react-tooltip": "^1.2.8",
"@sentry/nextjs": "^10.49.0", "@sentry/nextjs": "^10.49.0",
"@tabler/icons-react": "^3.41.1", "@tabler/icons-react": "^3.41.1",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"embla-carousel-react": "^8.6.0",
"input-otp": "^1.4.2",
"kbar": "^0.1.0-beta.48",
"lucide-react": "^1.8.0",
"motion": "^12.38.0", "motion": "^12.38.0",
"next": "16.2.3", "next": "16.2.3",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
@@ -23,12 +30,18 @@
"nuqs": "^2.8.9", "nuqs": "^2.8.9",
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
"react": "19.2.4", "react": "19.2.4",
"react-day-picker": "^9.14.0",
"react-dom": "19.2.4", "react-dom": "19.2.4",
"react-resizable-panels": "^4.10.0",
"recharts": "^3.8.0",
"shadcn": "^4.3.0", "shadcn": "^4.3.0",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"zod": "^4.3.6" "uuid": "^13.0.0",
"vaul": "^1.1.2",
"zod": "^4.3.6",
"zustand": "^5.0.12"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
@@ -415,6 +428,15 @@
"@babel/core": "^7.0.0-0" "@babel/core": "^7.0.0-0"
} }
}, },
"node_modules/@babel/runtime": {
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": { "node_modules/@babel/template": {
"version": "7.28.6", "version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
@@ -460,6 +482,66 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@base-ui/react": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@base-ui/react/-/react-1.4.0.tgz",
"integrity": "sha512-QcqdVbr/+ba2/RAKJIV1PV6S02Q5+r6a4Eym8ndBw+ZbBILkkmQAyRxXCg/pArrHnkrGeU8goe26aw0h6eE8pg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.2",
"@base-ui/utils": "0.2.7",
"@floating-ui/react-dom": "^2.1.8",
"@floating-ui/utils": "^0.2.11",
"use-sync-external-store": "^1.6.0"
},
"engines": {
"node": ">=14.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
"@date-fns/tz": "^1.2.0",
"@types/react": "^17 || ^18 || ^19",
"date-fns": "^4.0.0",
"react": "^17 || ^18 || ^19",
"react-dom": "^17 || ^18 || ^19"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@base-ui/utils": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/@base-ui/utils/-/utils-0.2.7.tgz",
"integrity": "sha512-nXYKhiL/0JafyJE8PfcflipGftOftlIwKd72rU15iZ1M5yqgg5J9P8NHU71GReDuXco5MJA/eVQqUT5WRqX9sA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.2",
"@floating-ui/utils": "^0.2.11",
"reselect": "^5.1.1",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"@types/react": "^17 || ^18 || ^19",
"react": "^17 || ^18 || ^19",
"react-dom": "^17 || ^18 || ^19"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@date-fns/tz": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz",
"integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==",
"license": "MIT"
},
"node_modules/@dotenvx/dotenvx": { "node_modules/@dotenvx/dotenvx": {
"version": "1.61.0", "version": "1.61.0",
"resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.61.0.tgz", "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.61.0.tgz",
@@ -4021,6 +4103,48 @@
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@reach/observe-rect": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@reach/observe-rect/-/observe-rect-1.2.0.tgz",
"integrity": "sha512-Ba7HmkFgfQxZqqaeIWWkNK0rEhpxVQHIoVyW1YDSkGsGIXzcaW4deC8B0pZrNSSyLTdIk7y+5olKt5+g0GmFIQ==",
"license": "MIT"
},
"node_modules/@reduxjs/toolkit": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^11.0.0",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@reduxjs/toolkit/node_modules/immer": {
"version": "11.1.4",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz",
"integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/@rollup/plugin-commonjs": { "node_modules/@rollup/plugin-commonjs": {
"version": "28.0.1", "version": "28.0.1",
"resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-28.0.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-28.0.1.tgz",
@@ -4965,6 +5089,12 @@
"integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@swc/helpers": { "node_modules/@swc/helpers": {
"version": "0.5.15", "version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@@ -4974,6 +5104,15 @@
"tslib": "^2.8.0" "tslib": "^2.8.0"
} }
}, },
"node_modules/@tabby_ai/hijri-converter": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@tabby_ai/hijri-converter/-/hijri-converter-1.0.5.tgz",
"integrity": "sha512-r5bClKrcIusDoo049dSL8CawnHR6mRdDwhlQuIgZRNty68q0x8k3Lf1BtPAMxRf/GgnHBnIO4ujd3+GQdLWzxQ==",
"license": "MIT",
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/@tabler/icons": { "node_modules/@tabler/icons": {
"version": "3.41.1", "version": "3.41.1",
"resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.41.1.tgz", "resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.41.1.tgz",
@@ -5399,6 +5538,69 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/eslint": { "node_modules/@types/eslint": {
"version": "9.6.1", "version": "9.6.1",
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
@@ -5513,6 +5715,12 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@types/validate-npm-package-name": { "node_modules/@types/validate-npm-package-name": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz", "resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz",
@@ -7045,6 +7253,22 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/cmdk": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz",
"integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-id": "^1.1.0",
"@radix-ui/react-primitive": "^2.0.2"
},
"peerDependencies": {
"react": "^18 || ^19 || ^19.0.0-rc",
"react-dom": "^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/code-block-writer": { "node_modules/code-block-writer": {
"version": "13.0.3", "version": "13.0.3",
"resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz", "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz",
@@ -7213,6 +7437,127 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/damerau-levenshtein": { "node_modules/damerau-levenshtein": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
@@ -7293,6 +7638,12 @@
"url": "https://github.com/sponsors/kossnocorp" "url": "https://github.com/sponsors/kossnocorp"
} }
}, },
"node_modules/date-fns-jalali": {
"version": "4.1.0-0",
"resolved": "https://registry.npmjs.org/date-fns-jalali/-/date-fns-jalali-4.1.0-0.tgz",
"integrity": "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==",
"license": "MIT"
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -7310,6 +7661,12 @@
} }
} }
}, },
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/dedent": { "node_modules/dedent": {
"version": "1.7.2", "version": "1.7.2",
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz",
@@ -7518,6 +7875,34 @@
"integrity": "sha512-mgjZAz7Jyx1SRCwEpy9wefDS7GvNPazLthHg8eQMJ76wBdGQQDW33TCrUTvQ4wzpmOrv2zrFoD3oNufMdyMpog==", "integrity": "sha512-mgjZAz7Jyx1SRCwEpy9wefDS7GvNPazLthHg8eQMJ76wBdGQQDW33TCrUTvQ4wzpmOrv2zrFoD3oNufMdyMpog==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/embla-carousel": {
"version": "8.6.0",
"resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz",
"integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==",
"license": "MIT"
},
"node_modules/embla-carousel-react": {
"version": "8.6.0",
"resolved": "https://registry.npmjs.org/embla-carousel-react/-/embla-carousel-react-8.6.0.tgz",
"integrity": "sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==",
"license": "MIT",
"dependencies": {
"embla-carousel": "8.6.0",
"embla-carousel-reactive-utils": "8.6.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/embla-carousel-reactive-utils": {
"version": "8.6.0",
"resolved": "https://registry.npmjs.org/embla-carousel-reactive-utils/-/embla-carousel-reactive-utils-8.6.0.tgz",
"integrity": "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A==",
"license": "MIT",
"peerDependencies": {
"embla-carousel": "8.6.0"
}
},
"node_modules/emoji-regex": { "node_modules/emoji-regex": {
"version": "9.2.2", "version": "9.2.2",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
@@ -7746,6 +8131,16 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/es-toolkit": {
"version": "1.45.1",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz",
"integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks"
]
},
"node_modules/escalade": { "node_modules/escalade": {
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -8206,6 +8601,12 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/events": { "node_modules/events": {
"version": "3.3.0", "version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
@@ -8330,6 +8731,12 @@
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-equals": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-2.0.4.tgz",
"integrity": "sha512-caj/ZmjHljPrZtbzJ3kfH5ia/k4mTJe/qSiXAGzxZWRZgsgDV0cvNaQULqUX8t0/JVlzzEdYOwCN5DmzTxoD4w==",
"license": "MIT"
},
"node_modules/fast-glob": { "node_modules/fast-glob": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz",
@@ -8667,6 +9074,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/fuse.js": {
"version": "6.6.2",
"resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-6.6.2.tgz",
"integrity": "sha512-cJaJkxCCxC8qIIcPBF9yGxY0W/tVZS3uEISDxhYIdtk8OL93pe+6Zj7LjCqVV4dzbqcriOZ+kQ/NE4RXZHsIGA==",
"license": "Apache-2.0",
"engines": {
"node": ">=10"
}
},
"node_modules/fuzzysort": { "node_modules/fuzzysort": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/fuzzysort/-/fuzzysort-3.1.0.tgz", "resolved": "https://registry.npmjs.org/fuzzysort/-/fuzzysort-3.1.0.tgz",
@@ -9138,6 +9554,16 @@
"node": ">= 4" "node": ">= 4"
} }
}, },
"node_modules/immer": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/import-fresh": { "node_modules/import-fresh": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -9185,6 +9611,16 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/input-otp": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/input-otp/-/input-otp-1.4.2.tgz",
"integrity": "sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/internal-slot": { "node_modules/internal-slot": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
@@ -9200,6 +9636,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/ip-address": { "node_modules/ip-address": {
"version": "10.1.0", "version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
@@ -9982,6 +10427,38 @@
"node": ">=4.0" "node": ">=4.0"
} }
}, },
"node_modules/kbar": {
"version": "0.1.0-beta.48",
"resolved": "https://registry.npmjs.org/kbar/-/kbar-0.1.0-beta.48.tgz",
"integrity": "sha512-HD5A1dqfK6XGeoH4fRWTmRt4y76sDbtGxY4Dh2xNa5MYtvtKsqfz+nRZ0tKgcrjjGYN4rf5TLXMJuiE7Pb8rXg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-portal": "^1.0.1",
"fast-equals": "^2.0.3",
"fuse.js": "^6.6.2",
"react-virtual": "^2.8.2",
"tiny-invariant": "^1.2.0"
},
"peerDependencies": {
"react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/kbar/node_modules/react-virtual": {
"version": "2.10.4",
"resolved": "https://registry.npmjs.org/react-virtual/-/react-virtual-2.10.4.tgz",
"integrity": "sha512-Ir6+oPQZTVHfa6+JL9M7cvMILstFZH/H3jqeYeKI4MSUX+rIruVwFC6nGVXw9wqAw8L0Kg2KvfXxI85OvYQdpQ==",
"funding": [
"https://github.com/sponsors/tannerlinsley"
],
"license": "MIT",
"dependencies": {
"@reach/observe-rect": "^1.1.0"
},
"peerDependencies": {
"react": "^16.6.3 || ^17.0.0"
}
},
"node_modules/keyv": { "node_modules/keyv": {
"version": "4.5.4", "version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -10399,6 +10876,15 @@
"yallist": "^3.0.2" "yallist": "^3.0.2"
} }
}, },
"node_modules/lucide-react": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.8.0.tgz",
"integrity": "sha512-WuvlsjngSk7TnTBJ1hsCy3ql9V9VOdcPkd3PKcSmM34vJD8KG6molxz7m7zbYFgICwsanQWmJ13JlYs4Zp7Arw==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/magic-string": { "node_modules/magic-string": {
"version": "0.30.21", "version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -11769,6 +12255,28 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/react-day-picker": {
"version": "9.14.0",
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-9.14.0.tgz",
"integrity": "sha512-tBaoDWjPwe0M5pGrum4H0SR6Lyk+BO9oHnp9JbKpGKW2mlraNPgP9BMfsg5pWpwrssARmeqk7YBl2oXutZTaHA==",
"license": "MIT",
"dependencies": {
"@date-fns/tz": "^1.4.1",
"@tabby_ai/hijri-converter": "1.0.5",
"date-fns": "^4.1.0",
"date-fns-jalali": "4.1.0-0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "individual",
"url": "https://github.com/sponsors/gpbl"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/react-dom": { "node_modules/react-dom": {
"version": "19.2.4", "version": "19.2.4",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
@@ -11787,6 +12295,29 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/react-remove-scroll": { "node_modules/react-remove-scroll": {
"version": "2.7.2", "version": "2.7.2",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
@@ -11834,6 +12365,16 @@
} }
} }
}, },
"node_modules/react-resizable-panels": {
"version": "4.10.0",
"resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-4.10.0.tgz",
"integrity": "sha512-frjewRQt7TCv/vCH1pJfjZ7RxAhr5pKuqVQtVgzFq/vherxBFOWyC3xMbryx5Ti2wylViGUFc93Etg4rB3E0UA==",
"license": "MIT",
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
}
},
"node_modules/react-style-singleton": { "node_modules/react-style-singleton": {
"version": "2.2.3", "version": "2.2.3",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
@@ -11872,6 +12413,51 @@
"node": ">= 4" "node": ">= 4"
} }
}, },
"node_modules/recharts": {
"version": "3.8.0",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.0.tgz",
"integrity": "sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ==",
"license": "MIT",
"workspaces": [
"www"
],
"dependencies": {
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
"clsx": "^2.1.1",
"decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"immer": "^10.1.1",
"react-redux": "8.x.x || 9.x.x",
"reselect": "5.1.1",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.2.2",
"victory-vendor": "^37.0.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/reflect.getprototypeof": { "node_modules/reflect.getprototypeof": {
"version": "1.0.10", "version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -11947,6 +12533,12 @@
"node": ">=9.3.0 || >=8.10.0 <9.0.0" "node": ">=9.3.0 || >=8.10.0 <9.0.0"
} }
}, },
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT"
},
"node_modules/resolve": { "node_modules/resolve": {
"version": "2.0.0-next.6", "version": "2.0.0-next.6",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz",
@@ -13634,6 +14226,19 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/uuid": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist-node/bin/uuid"
}
},
"node_modules/validate-npm-package-name": { "node_modules/validate-npm-package-name": {
"version": "7.0.2", "version": "7.0.2",
"resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-7.0.2.tgz", "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-7.0.2.tgz",
@@ -13652,6 +14257,41 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/vaul": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz",
"integrity": "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-dialog": "^1.1.1"
},
"peerDependencies": {
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/victory-vendor": {
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/watchpack": { "node_modules/watchpack": {
"version": "2.5.1", "version": "2.5.1",
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz",
@@ -14137,6 +14777,35 @@
"peerDependencies": { "peerDependencies": {
"zod": "^3.25.0 || ^4.0.0" "zod": "^3.25.0 || ^4.0.0"
} }
},
"node_modules/zustand": {
"version": "5.0.12",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz",
"integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==",
"license": "MIT",
"engines": {
"node": ">=12.20.0"
},
"peerDependencies": {
"@types/react": ">=18.0.0",
"immer": ">=9.0.6",
"react": ">=18.0.0",
"use-sync-external-store": ">=1.2.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
},
"use-sync-external-store": {
"optional": true
}
}
} }
} }
} }

View File

@@ -9,14 +9,21 @@
"lint": "eslint" "lint": "eslint"
}, },
"dependencies": { "dependencies": {
"@base-ui/react": "^1.4.0",
"@hugeicons/core-free-icons": "^4.1.1", "@hugeicons/core-free-icons": "^4.1.1",
"@hugeicons/react": "^1.1.6", "@hugeicons/react": "^1.1.6",
"@radix-ui/react-tooltip": "^1.2.8",
"@sentry/nextjs": "^10.49.0", "@sentry/nextjs": "^10.49.0",
"@tabler/icons-react": "^3.41.1", "@tabler/icons-react": "^3.41.1",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"embla-carousel-react": "^8.6.0",
"input-otp": "^1.4.2",
"kbar": "^0.1.0-beta.48",
"lucide-react": "^1.8.0",
"motion": "^12.38.0", "motion": "^12.38.0",
"next": "16.2.3", "next": "16.2.3",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
@@ -24,12 +31,18 @@
"nuqs": "^2.8.9", "nuqs": "^2.8.9",
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
"react": "19.2.4", "react": "19.2.4",
"react-day-picker": "^9.14.0",
"react-dom": "19.2.4", "react-dom": "19.2.4",
"react-resizable-panels": "^4.10.0",
"recharts": "^3.8.0",
"shadcn": "^4.3.0", "shadcn": "^4.3.0",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"zod": "^4.3.6" "uuid": "^13.0.0",
"vaul": "^1.1.2",
"zod": "^4.3.6",
"zustand": "^5.0.12"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",

34
src/app/admin/layout.tsx Normal file
View File

@@ -0,0 +1,34 @@
import KBar from '@/components/kbar';
import AppSidebar from '@/components/layout/app-sidebar';
import Header from '@/components/layout/header';
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
import type { Metadata } from 'next';
import { cookies } from 'next/headers';
export const metadata: Metadata = {
title: 'Next Shadcn Dashboard Starter',
description: 'Basic dashboard with Next.js and Shadcn'
};
export default async function DashboardLayout({
children
}: {
children: React.ReactNode;
}) {
// Persisting the sidebar state in the cookie.
const cookieStore = await cookies();
const defaultOpen = cookieStore.get("sidebar_state")?.value === "true"
return (
<KBar>
<SidebarProvider defaultOpen={defaultOpen}>
<AppSidebar />
<SidebarInset>
<Header />
{/* page main content */}
{children}
{/* page main content ends */}
</SidebarInset>
</SidebarProvider>
</KBar>
);
}

View File

@@ -0,0 +1,4 @@
"use client";
export default function Page() {
return <main>xx</main>;
}

View File

@@ -1,43 +1,40 @@
import Providers from '@/components/layout/providers'; import Providers from "@/components/layout/providers";
import { Toaster } from '@/components/ui/sonner'; import { Toaster } from "@/components/ui/sonner";
import { fontVariables } from '@/lib/font'; import { fontVariables } from "@/lib/font";
import ThemeProvider from '@/components/layout/ThemeToggle/theme-provider'; import ThemeProvider from "@/components/layout/ThemeToggle/theme-provider";
import { cn } from '@/lib/utils'; import { cn } from "@/lib/utils";
import type { Metadata, Viewport } from 'next'; import type { Metadata, Viewport } from "next";
import { cookies } from 'next/headers'; import { cookies } from "next/headers";
import NextTopLoader from 'nextjs-toploader'; import NextTopLoader from "nextjs-toploader";
import { NuqsAdapter } from 'nuqs/adapters/next/app'; import { NuqsAdapter } from "nuqs/adapters/next/app";
import './globals.css'; import "./globals.css";
import './theme.css'; import "./theme.css";
import { Inter } from "next/font/google";
const inter = Inter({subsets:['latin'],variable:'--font-sans'});
const META_THEME_COLORS = { const META_THEME_COLORS = {
light: '#ffffff', light: "#ffffff",
dark: '#09090b' dark: "#09090b",
}; };
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Next Shadcn', title: "Next Shadcn",
description: 'Basic dashboard with Next.js and Shadcn' description: "Basic dashboard with Next.js and Shadcn",
}; };
export const viewport: Viewport = { export const viewport: Viewport = {
themeColor: META_THEME_COLORS.light themeColor: META_THEME_COLORS.light,
}; };
export default async function RootLayout({ export default async function RootLayout({
children children,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const cookieStore = await cookies(); const cookieStore = await cookies();
const activeThemeValue = cookieStore.get('active_theme')?.value; const activeThemeValue = cookieStore.get("active_theme")?.value;
const isScaled = activeThemeValue?.endsWith('-scaled'); const isScaled = activeThemeValue?.endsWith("-scaled");
return ( return (
<html lang='en' suppressHydrationWarning className={cn("font-sans", inter.variable)}> <html lang="en" suppressHydrationWarning>
<head> <head>
<script <script
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
@@ -47,23 +44,23 @@ export default async function RootLayout({
document.querySelector('meta[name="theme-color"]').setAttribute('content', '${META_THEME_COLORS.dark}') document.querySelector('meta[name="theme-color"]').setAttribute('content', '${META_THEME_COLORS.dark}')
} }
} catch (_) {} } catch (_) {}
` `,
}} }}
/> />
</head> </head>
<body <body
className={cn( className={cn(
'bg-background overflow-hidden overscroll-none font-sans antialiased', "bg-background overflow-hidden overscroll-none font-sans antialiased",
activeThemeValue ? `theme-${activeThemeValue}` : '', activeThemeValue ? `theme-${activeThemeValue}` : "",
isScaled ? 'theme-scaled' : '', isScaled ? "theme-scaled" : "",
fontVariables fontVariables,
)} )}
> >
<NextTopLoader color='var(--primary)' showSpinner={false} /> <NextTopLoader color="var(--primary)" showSpinner={false} />
<NuqsAdapter> <NuqsAdapter>
<ThemeProvider <ThemeProvider
attribute='class' attribute="class"
defaultTheme='system' defaultTheme="system"
enableSystem enableSystem
disableTransitionOnChange disableTransitionOnChange
enableColorScheme enableColorScheme

View File

@@ -1,12 +1,5 @@
import { auth } from '@clerk/nextjs/server'; import { redirect } from "next/navigation";
import { redirect } from 'next/navigation';
export default async function Page() { export default async function Page() {
const { userId } = await auth(); redirect("/admin/overview");
if (!userId) {
return redirect('/auth/sign-in');
} else {
redirect('/dashboard/overview');
}
} }

View File

@@ -0,0 +1,83 @@
'use client';
import { navItems } from '@/constants/data';
import {
KBarAnimator,
KBarPortal,
KBarPositioner,
KBarProvider,
KBarSearch
} from 'kbar';
import { useRouter } from 'next/navigation';
import { useMemo } from 'react';
import RenderResults from './render-result';
import useThemeSwitching from './use-theme-switching';
export default function KBar({ children }: { children: React.ReactNode }) {
const router = useRouter();
// These action are for the navigation
const actions = useMemo(() => {
// Define navigateTo inside the useMemo callback to avoid dependency array issues
const navigateTo = (url: string) => {
router.push(url);
};
return navItems.flatMap((navItem) => {
// Only include base action if the navItem has a real URL and is not just a container
const baseAction =
navItem.url !== '#'
? {
id: `${navItem.title.toLowerCase()}Action`,
name: navItem.title,
shortcut: navItem.shortcut,
keywords: navItem.title.toLowerCase(),
section: 'Navigation',
subtitle: `Go to ${navItem.title}`,
perform: () => navigateTo(navItem.url)
}
: null;
// Map child items into actions
const childActions =
navItem.items?.map((childItem) => ({
id: `${childItem.title.toLowerCase()}Action`,
name: childItem.title,
shortcut: childItem.shortcut,
keywords: childItem.title.toLowerCase(),
section: navItem.title,
subtitle: `Go to ${childItem.title}`,
perform: () => navigateTo(childItem.url)
})) ?? [];
// Return only valid actions (ignoring null base actions for containers)
return baseAction ? [baseAction, ...childActions] : childActions;
});
}, [router]);
return (
<KBarProvider actions={actions}>
<KBarComponent>{children}</KBarComponent>
</KBarProvider>
);
}
const KBarComponent = ({ children }: { children: React.ReactNode }) => {
useThemeSwitching();
return (
<>
<KBarPortal>
<KBarPositioner className='bg-background/80 fixed inset-0 z-99999 p-0! backdrop-blur-sm'>
<KBarAnimator className='bg-card text-card-foreground relative mt-64! w-full max-w-[600px] -translate-y-12! overflow-hidden rounded-lg border shadow-lg'>
<div className='bg-card border-border sticky top-0 z-10 border-b'>
<KBarSearch className='bg-card w-full border-none px-6 py-4 text-lg outline-hidden focus:ring-0 focus:ring-offset-0 focus:outline-hidden' />
</div>
<div className='max-h-[400px]'>
<RenderResults />
</div>
</KBarAnimator>
</KBarPositioner>
</KBarPortal>
{children}
</>
);
};

View File

@@ -0,0 +1,25 @@
import { KBarResults, useMatches } from 'kbar';
import ResultItem from './result-item';
export default function RenderResults() {
const { results, rootActionId } = useMatches();
return (
<KBarResults
items={results}
onRender={({ item, active }) =>
typeof item === 'string' ? (
<div className='text-primary-foreground px-4 py-2 text-sm uppercase opacity-50'>
{item}
</div>
) : (
<ResultItem
action={item}
active={active}
currentRootActionId={rootActionId ?? ''}
/>
)
}
/>
);
}

View File

@@ -0,0 +1,77 @@
import type { ActionId, ActionImpl } from 'kbar';
import * as React from 'react';
const ResultItem = React.forwardRef(
(
{
action,
active,
currentRootActionId
}: {
action: ActionImpl;
active: boolean;
currentRootActionId: ActionId;
},
ref: React.Ref<HTMLDivElement>
) => {
const ancestors = React.useMemo(() => {
if (!currentRootActionId) return action.ancestors;
const index = action.ancestors.findIndex(
(ancestor) => ancestor.id === currentRootActionId
);
return action.ancestors.slice(index + 1);
}, [action.ancestors, currentRootActionId]);
return (
<div
ref={ref}
className={`relative z-10 flex cursor-pointer items-center justify-between px-4 py-3`}
>
{active && (
<div
id='kbar-result-item'
className='border-primary bg-accent/50 absolute inset-0 z-[-1]! border-l-4'
></div>
)}
<div className='relative z-10 flex items-center gap-2'>
{action.icon && action.icon}
<div className='flex flex-col'>
<div>
{ancestors.length > 0 &&
ancestors.map((ancestor) => (
<React.Fragment key={ancestor.id}>
<span className='text-muted-foreground mr-2'>
{ancestor.name}
</span>
<span className='mr-2'>&rsaquo;</span>
</React.Fragment>
))}
<span>{action.name}</span>
</div>
{action.subtitle && (
<span className='text-muted-foreground text-sm'>
{action.subtitle}
</span>
)}
</div>
</div>
{action.shortcut?.length ? (
<div className='relative z-10 grid grid-flow-col gap-1'>
{action.shortcut.map((sc, i) => (
<kbd
key={sc + i}
className='bg-muted flex h-5 items-center gap-1 rounded-md border px-1.5 text-[10px] font-medium'
>
{sc}
</kbd>
))}
</div>
) : null}
</div>
);
}
);
ResultItem.displayName = 'KBarResultItem';
export default ResultItem;

View File

@@ -0,0 +1,36 @@
import { useRegisterActions } from 'kbar';
import { useTheme } from 'next-themes';
const useThemeSwitching = () => {
const { theme, setTheme } = useTheme();
const toggleTheme = () => {
setTheme(theme === 'light' ? 'dark' : 'light');
};
const themeAction = [
{
id: 'toggleTheme',
name: 'Toggle Theme',
shortcut: ['t', 't'],
section: 'Theme',
perform: toggleTheme
},
{
id: 'setLightTheme',
name: 'Set Light Theme',
section: 'Theme',
perform: () => setTheme('light')
},
{
id: 'setDarkTheme',
name: 'Set Dark Theme',
section: 'Theme',
perform: () => setTheme('dark')
}
];
useRegisterActions(themeAction, [theme]);
};
export default useThemeSwitching;

View File

@@ -1,9 +1,9 @@
'use client'; "use client";
import { import {
Collapsible, Collapsible,
CollapsibleContent, CollapsibleContent,
CollapsibleTrigger CollapsibleTrigger,
} from '@/components/ui/collapsible'; } from "@/components/ui/collapsible";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -11,8 +11,8 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'; } from "@/components/ui/dropdown-menu";
import { import {
Sidebar, Sidebar,
SidebarContent, SidebarContent,
@@ -26,12 +26,12 @@ import {
SidebarMenuSub, SidebarMenuSub,
SidebarMenuSubButton, SidebarMenuSubButton,
SidebarMenuSubItem, SidebarMenuSubItem,
SidebarRail SidebarRail,
} from '@/components/ui/sidebar'; } from "@/components/ui/sidebar";
import { UserAvatarProfile } from '@/components/user-avatar-profile'; //import { UserAvatarProfile } from "@/components/user-avatar-profile";
import { navItems } from '@/constants/data'; import { navItems } from "@/constants/data";
import { useMediaQuery } from '@/hooks/use-media-query'; import { useMediaQuery } from "@/hooks/use-media-query";
import { useUser } from '@clerk/nextjs';
import { import {
IconBell, IconBell,
IconChevronRight, IconChevronRight,
@@ -39,30 +39,29 @@ import {
IconCreditCard, IconCreditCard,
IconLogout, IconLogout,
IconPhotoUp, IconPhotoUp,
IconUserCircle IconUserCircle,
} from '@tabler/icons-react'; } from "@tabler/icons-react";
import { SignOutButton } from '@clerk/nextjs';
import Link from 'next/link'; import Link from "next/link";
import { usePathname, useRouter } from 'next/navigation'; import { usePathname, useRouter } from "next/navigation";
import * as React from 'react'; import * as React from "react";
import { Icons } from '../icons'; import { Icons } from "../icons";
import { OrgSwitcher } from '../org-switcher'; import { OrgSwitcher } from "../org-switcher";
export const company = { export const company = {
name: 'Acme Inc', name: "Acme Inc",
logo: IconPhotoUp, logo: IconPhotoUp,
plan: 'Enterprise' plan: "Enterprise",
}; };
const tenants = [ const tenants = [
{ id: '1', name: 'Acme Inc' }, { id: "1", name: "Acme Inc" },
{ id: '2', name: 'Beta Corp' }, { id: "2", name: "Beta Corp" },
{ id: '3', name: 'Gamma Ltd' } { id: "3", name: "Gamma Ltd" },
]; ];
export default function AppSidebar() { export default function AppSidebar() {
const pathname = usePathname(); const pathname = usePathname();
const { isOpen } = useMediaQuery(); const { isOpen } = useMediaQuery();
const { user } = useUser();
const router = useRouter(); const router = useRouter();
const handleSwitchTenant = (_tenantId: string) => { const handleSwitchTenant = (_tenantId: string) => {
// Tenant switching functionality would be implemented here // Tenant switching functionality would be implemented here
@@ -75,7 +74,7 @@ export default function AppSidebar() {
}, [isOpen]); }, [isOpen]);
return ( return (
<Sidebar collapsible='icon'> <Sidebar collapsible="icon">
<SidebarHeader> <SidebarHeader>
<OrgSwitcher <OrgSwitcher
tenants={tenants} tenants={tenants}
@@ -83,7 +82,7 @@ export default function AppSidebar() {
onTenantSwitch={handleSwitchTenant} onTenantSwitch={handleSwitchTenant}
/> />
</SidebarHeader> </SidebarHeader>
<SidebarContent className='overflow-x-hidden'> <SidebarContent className="overflow-x-hidden">
<SidebarGroup> <SidebarGroup>
<SidebarGroupLabel>Overview</SidebarGroupLabel> <SidebarGroupLabel>Overview</SidebarGroupLabel>
<SidebarMenu> <SidebarMenu>
@@ -94,7 +93,7 @@ export default function AppSidebar() {
key={item.title} key={item.title}
asChild asChild
defaultOpen={item.isActive} defaultOpen={item.isActive}
className='group/collapsible' className="group/collapsible"
> >
<SidebarMenuItem> <SidebarMenuItem>
<CollapsibleTrigger asChild> <CollapsibleTrigger asChild>
@@ -104,7 +103,7 @@ export default function AppSidebar() {
> >
{item.icon && <Icon />} {item.icon && <Icon />}
<span>{item.title}</span> <span>{item.title}</span>
<IconChevronRight className='ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90' /> <IconChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
</SidebarMenuButton> </SidebarMenuButton>
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent> <CollapsibleContent>
@@ -149,58 +148,58 @@ export default function AppSidebar() {
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<SidebarMenuButton <SidebarMenuButton
size='lg' size="lg"
className='data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground' className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
> >
{user && ( {/* {user && (
<UserAvatarProfile <UserAvatarProfile
className='h-8 w-8 rounded-lg' className='h-8 w-8 rounded-lg'
showInfo showInfo
user={user} user={user}
/> />
)} )} */}
<IconChevronsDown className='ml-auto size-4' /> <IconChevronsDown className="ml-auto size-4" />
</SidebarMenuButton> </SidebarMenuButton>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
className='w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg' className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
side='bottom' side="bottom"
align='end' align="end"
sideOffset={4} sideOffset={4}
> >
<DropdownMenuLabel className='p-0 font-normal'> <DropdownMenuLabel className="p-0 font-normal">
<div className='px-1 py-1.5'> <div className="px-1 py-1.5">
{user && ( {/* {user && (
<UserAvatarProfile <UserAvatarProfile
className='h-8 w-8 rounded-lg' className='h-8 w-8 rounded-lg'
showInfo showInfo
user={user} user={user}
/> />
)} )} */}
</div> </div>
</DropdownMenuLabel> </DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuGroup> <DropdownMenuGroup>
<DropdownMenuItem <DropdownMenuItem
onClick={() => router.push('/dashboard/profile')} onClick={() => router.push("/dashboard/profile")}
> >
<IconUserCircle className='mr-2 h-4 w-4' /> <IconUserCircle className="mr-2 h-4 w-4" />
Profile Profile
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem> <DropdownMenuItem>
<IconCreditCard className='mr-2 h-4 w-4' /> <IconCreditCard className="mr-2 h-4 w-4" />
Billing Billing
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem> <DropdownMenuItem>
<IconBell className='mr-2 h-4 w-4' /> <IconBell className="mr-2 h-4 w-4" />
Notifications Notifications
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuGroup> </DropdownMenuGroup>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem> <DropdownMenuItem>
<IconLogout className='mr-2 h-4 w-4' /> <IconLogout className="mr-2 h-4 w-4" />
<SignOutButton redirectUrl='/auth/sign-in' /> {/* <SignOutButton redirectUrl='/auth/sign-in' /> */}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>

View File

@@ -1,13 +1,11 @@
'use client'; "use client";
import { ClerkProvider } from '@clerk/nextjs'; import { useTheme } from "next-themes";
import { dark } from '@clerk/themes'; import React from "react";
import { useTheme } from 'next-themes'; import { ActiveThemeProvider } from "../active-theme";
import React from 'react';
import { ActiveThemeProvider } from '../active-theme';
export default function Providers({ export default function Providers({
activeThemeValue, activeThemeValue,
children children,
}: { }: {
activeThemeValue: string; activeThemeValue: string;
children: React.ReactNode; children: React.ReactNode;
@@ -18,13 +16,7 @@ export default function Providers({
return ( return (
<> <>
<ActiveThemeProvider initialTheme={activeThemeValue}> <ActiveThemeProvider initialTheme={activeThemeValue}>
<ClerkProvider
appearance={{
baseTheme: resolvedTheme === 'dark' ? dark : undefined
}}
>
{children} {children}
</ClerkProvider>
</ActiveThemeProvider> </ActiveThemeProvider>
</> </>
); );

View File

@@ -1,5 +1,5 @@
'use client'; "use client";
import { Button } from '@/components/ui/button'; import { Button } from "@/components/ui/button";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -7,53 +7,53 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'; } from "@/components/ui/dropdown-menu";
import { UserAvatarProfile } from '@/components/user-avatar-profile'; import { UserAvatarProfile } from "@/components/user-avatar-profile";
import { SignOutButton, useUser } from '@clerk/nextjs';
import { useRouter } from 'next/navigation'; import { useRouter } from "next/navigation";
export function UserNav() { export function UserNav() {
const { user } = useUser();
const router = useRouter(); const router = useRouter();
if (user) { // if (user) {
return ( // return (
<DropdownMenu> // <DropdownMenu>
<DropdownMenuTrigger asChild> // <DropdownMenuTrigger asChild>
<Button variant='ghost' className='relative h-8 w-8 rounded-full'> // <Button variant='ghost' className='relative h-8 w-8 rounded-full'>
<UserAvatarProfile user={user} /> // <UserAvatarProfile user={user} />
</Button> // </Button>
</DropdownMenuTrigger> // </DropdownMenuTrigger>
<DropdownMenuContent // <DropdownMenuContent
className='w-56' // className='w-56'
align='end' // align='end'
sideOffset={10} // sideOffset={10}
forceMount // forceMount
> // >
<DropdownMenuLabel className='font-normal'> // <DropdownMenuLabel className='font-normal'>
<div className='flex flex-col space-y-1'> // <div className='flex flex-col space-y-1'>
<p className='text-sm leading-none font-medium'> // <p className='text-sm leading-none font-medium'>
{user.fullName} // {user.fullName}
</p> // </p>
<p className='text-muted-foreground text-xs leading-none'> // <p className='text-muted-foreground text-xs leading-none'>
{user.emailAddresses[0].emailAddress} // {user.emailAddresses[0].emailAddress}
</p> // </p>
</div> // </div>
</DropdownMenuLabel> // </DropdownMenuLabel>
<DropdownMenuSeparator /> // <DropdownMenuSeparator />
<DropdownMenuGroup> // <DropdownMenuGroup>
<DropdownMenuItem onClick={() => router.push('/dashboard/profile')}> // <DropdownMenuItem onClick={() => router.push('/dashboard/profile')}>
Profile // Profile
</DropdownMenuItem> // </DropdownMenuItem>
<DropdownMenuItem>Billing</DropdownMenuItem> // <DropdownMenuItem>Billing</DropdownMenuItem>
<DropdownMenuItem>Settings</DropdownMenuItem> // <DropdownMenuItem>Settings</DropdownMenuItem>
<DropdownMenuItem>New Team</DropdownMenuItem> // <DropdownMenuItem>New Team</DropdownMenuItem>
</DropdownMenuGroup> // </DropdownMenuGroup>
<DropdownMenuSeparator /> // <DropdownMenuSeparator />
<DropdownMenuItem> // <DropdownMenuItem>
<SignOutButton redirectUrl='/auth/sign-in' /> // <SignOutButton redirectUrl='/auth/sign-in' />
</DropdownMenuItem> // </DropdownMenuItem>
</DropdownMenuContent> // </DropdownMenuContent>
</DropdownMenu> // </DropdownMenu>
); // );
} // }
return <div>user</div>;
} }

View File

@@ -0,0 +1,66 @@
'use client';
import * as React from 'react';
import * as AccordionPrimitive from '@radix-ui/react-accordion';
import { ChevronDownIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot='accordion' {...props} />;
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot='accordion-item'
className={cn('border-b last:border-b-0', className)}
{...props}
/>
);
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className='flex'>
<AccordionPrimitive.Trigger
data-slot='accordion-trigger'
className={cn(
'focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180',
className
)}
{...props}
>
{children}
<ChevronDownIcon className='text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200' />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
);
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot='accordion-content'
className='data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm'
{...props}
>
<div className={cn('pt-0 pb-4', className)}>{children}</div>
</AccordionPrimitive.Content>
);
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };

View File

@@ -0,0 +1,157 @@
'use client';
import * as React from 'react';
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
import { cn } from '@/lib/utils';
import { buttonVariants } from '@/components/ui/button';
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot='alert-dialog' {...props} />;
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot='alert-dialog-trigger' {...props} />
);
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot='alert-dialog-portal' {...props} />
);
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot='alert-dialog-overlay'
className={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
className
)}
{...props}
/>
);
}
function AlertDialogContent({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot='alert-dialog-content'
className={cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
className
)}
{...props}
/>
</AlertDialogPortal>
);
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot='alert-dialog-header'
className={cn('flex flex-col gap-2 text-center sm:text-left', className)}
{...props}
/>
);
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot='alert-dialog-footer'
className={cn(
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
className
)}
{...props}
/>
);
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot='alert-dialog-title'
className={cn('text-lg font-semibold', className)}
{...props}
/>
);
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot='alert-dialog-description'
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
);
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
return (
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)}
{...props}
/>
);
}
function AlertDialogCancel({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return (
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: 'outline' }), className)}
{...props}
/>
);
}
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel
};

View File

@@ -0,0 +1,66 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const alertVariants = cva(
'relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current',
{
variants: {
variant: {
default: 'bg-card text-card-foreground',
destructive:
'text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90'
}
},
defaultVariants: {
variant: 'default'
}
}
);
function Alert({
className,
variant,
...props
}: React.ComponentProps<'div'> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot='alert'
role='alert'
className={cn(alertVariants({ variant }), className)}
{...props}
/>
);
}
function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='alert-title'
className={cn(
'col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight',
className
)}
{...props}
/>
);
}
function AlertDescription({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot='alert-description'
className={cn(
'text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed',
className
)}
{...props}
/>
);
}
export { Alert, AlertTitle, AlertDescription };

View File

@@ -0,0 +1,11 @@
'use client';
import * as AspectRatioPrimitive from '@radix-ui/react-aspect-ratio';
function AspectRatio({
...props
}: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {
return <AspectRatioPrimitive.Root data-slot='aspect-ratio' {...props} />;
}
export { AspectRatio };

View File

@@ -0,0 +1,53 @@
'use client';
import * as React from 'react';
import * as AvatarPrimitive from '@radix-ui/react-avatar';
import { cn } from '@/lib/utils';
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot='avatar'
className={cn(
'relative flex size-8 shrink-0 overflow-hidden rounded-full',
className
)}
{...props}
/>
);
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot='avatar-image'
className={cn('aspect-square size-full', className)}
{...props}
/>
);
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot='avatar-fallback'
className={cn(
'bg-muted flex size-full items-center justify-center rounded-full',
className
)}
{...props}
/>
);
}
export { Avatar, AvatarImage, AvatarFallback };

View File

@@ -0,0 +1,46 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const badgeVariants = cva(
'inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden',
{
variants: {
variant: {
default:
'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
secondary:
'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
destructive:
'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline:
'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground'
}
},
defaultVariants: {
variant: 'default'
}
}
);
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<'span'> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : 'span';
return (
<Comp
data-slot='badge'
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
);
}
export { Badge, badgeVariants };

View File

@@ -0,0 +1,109 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { ChevronRight, MoreHorizontal } from 'lucide-react';
import { cn } from '@/lib/utils';
function Breadcrumb({ ...props }: React.ComponentProps<'nav'>) {
return <nav aria-label='breadcrumb' data-slot='breadcrumb' {...props} />;
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<'ol'>) {
return (
<ol
data-slot='breadcrumb-list'
className={cn(
'text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5',
className
)}
{...props}
/>
);
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<'li'>) {
return (
<li
data-slot='breadcrumb-item'
className={cn('inline-flex items-center gap-1.5', className)}
{...props}
/>
);
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<'a'> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : 'a';
return (
<Comp
data-slot='breadcrumb-link'
className={cn('hover:text-foreground transition-colors', className)}
{...props}
/>
);
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
data-slot='breadcrumb-page'
role='link'
aria-disabled='true'
aria-current='page'
className={cn('text-foreground font-normal', className)}
{...props}
/>
);
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<'li'>) {
return (
<li
data-slot='breadcrumb-separator'
role='presentation'
aria-hidden='true'
className={cn('[&>svg]:size-3.5', className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
);
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<'span'>) {
return (
<span
data-slot='breadcrumb-ellipsis'
role='presentation'
aria-hidden='true'
className={cn('flex size-9 items-center justify-center', className)}
{...props}
>
<MoreHorizontal className='size-4' />
<span className='sr-only'>More</span>
</span>
);
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis
};

View File

@@ -0,0 +1,78 @@
import { cva, type VariantProps } from 'class-variance-authority';
import { Slot } from 'radix-ui';
import { cn } from '@/lib/utils';
import { Separator } from '@/components/ui/separator';
const buttonGroupVariants = cva(
"flex w-fit items-stretch has-[>[data-slot=button-group]]:gap-2 [&>*]:focus-visible:relative [&>*]:focus-visible:z-10 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
{
variants: {
orientation: {
horizontal:
'[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none',
vertical:
'flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none'
}
},
defaultVariants: {
orientation: 'horizontal'
}
}
);
function ButtonGroup({
className,
orientation,
...props
}: React.ComponentProps<'div'> & VariantProps<typeof buttonGroupVariants>) {
return (
<div
role='group'
data-slot='button-group'
data-orientation={orientation}
className={cn(buttonGroupVariants({ orientation }), className)}
{...props}
/>
);
}
function ButtonGroupText({
className,
asChild = false,
...props
}: React.ComponentProps<'div'> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot.Root : 'div';
return (
<Comp
className={cn(
"bg-muted flex items-center gap-2 rounded-md border px-4 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
);
}
function ButtonGroupSeparator({
className,
orientation = 'vertical',
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot='button-group-separator'
orientation={orientation}
className={cn(
'bg-input relative m-0! self-stretch data-[orientation=vertical]:h-auto',
className
)}
{...props}
/>
);
}
export { ButtonGroup, ButtonGroupSeparator, ButtonGroupText, buttonGroupVariants };

View File

@@ -1,65 +1,59 @@
import * as React from "react" import * as React from 'react';
import { cva, type VariantProps } from "class-variance-authority" import { Slot } from '@radix-ui/react-slot';
import { Slot } from "radix-ui" import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
const buttonVariants = cva( const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-md border border-transparent bg-clip-padding text-xs/relaxed font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/30 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{ {
variants: { variants: {
variant: { variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/80", default:
outline: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
"border-border hover:bg-input/50 hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:bg-input/30",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive: destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40", 'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
link: "text-primary underline-offset-4 hover:underline", outline:
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
secondary:
'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
ghost:
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline'
}, },
size: { size: {
default: default: 'h-9 px-4 py-2 has-[>svg]:px-3',
"h-7 gap-1 px-2 text-xs/relaxed has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
xs: "h-5 gap-1 rounded-sm px-2 text-[0.625rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-2.5", lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
sm: "h-6 gap-1 px-2 text-xs/relaxed has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", icon: 'size-9'
lg: "h-8 gap-1 px-2.5 text-xs/relaxed has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*='size-'])]:size-4", }
icon: "size-7 [&_svg:not([class*='size-'])]:size-3.5",
"icon-xs": "size-5 rounded-sm [&_svg:not([class*='size-'])]:size-2.5",
"icon-sm": "size-6 [&_svg:not([class*='size-'])]:size-3",
"icon-lg": "size-8 [&_svg:not([class*='size-'])]:size-4",
},
}, },
defaultVariants: { defaultVariants: {
variant: "default", variant: 'default',
size: "default", size: 'default'
},
} }
) }
);
function Button({ function Button({
className, className,
variant = "default", variant,
size = "default", size,
asChild = false, asChild = false,
...props ...props
}: React.ComponentProps<"button"> & }: React.ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & { VariantProps<typeof buttonVariants> & {
asChild?: boolean asChild?: boolean;
}) { }) {
const Comp = asChild ? Slot.Root : "button" const Comp = asChild ? Slot : 'button';
return ( return (
<Comp <Comp
data-slot="button" data-slot='button'
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))} className={cn(buttonVariants({ variant, size, className }))}
{...props} {...props}
/> />
) );
} }
export { Button, buttonVariants } export { Button, buttonVariants };

View File

@@ -0,0 +1,76 @@
'use client';
import * as React from 'react';
import { DayPicker } from 'react-day-picker';
import type { ComponentProps } from 'react';
import { cn } from '@/lib/utils';
import { buttonVariants } from '@/components/ui/button';
import { ChevronLeftIcon, ChevronRightIcon } from '@radix-ui/react-icons';
// Custom icons that meet the DayPicker requirements
const LeftIcon = () => <ChevronLeftIcon className='size-4' />;
const RightIcon = () => <ChevronRightIcon className='size-4' />;
function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}: ComponentProps<typeof DayPicker>) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn('p-3', className)}
classNames={{
months: 'flex flex-col sm:flex-row gap-2',
month: 'flex flex-col gap-4',
caption: 'flex justify-center pt-1 relative items-center w-full',
caption_label: 'text-sm font-medium',
nav: 'flex items-center gap-1',
nav_button: cn(
buttonVariants({ variant: 'outline' }),
'size-7 bg-transparent p-0 opacity-50 hover:opacity-100'
),
nav_button_previous: 'absolute left-1',
nav_button_next: 'absolute right-1',
table: 'w-full border-collapse space-x-1',
head_row: 'flex',
head_cell:
'text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]',
row: 'flex w-full mt-2',
cell: cn(
'relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-range-end)]:rounded-r-md',
props.mode === 'range'
? '[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md'
: '[&:has([aria-selected])]:rounded-md'
),
day: cn(
buttonVariants({ variant: 'ghost' }),
'size-8 p-0 font-normal aria-selected:opacity-100'
),
day_range_start:
'day-range-start aria-selected:bg-primary aria-selected:text-primary-foreground',
day_range_end:
'day-range-end aria-selected:bg-primary aria-selected:text-primary-foreground',
day_selected:
'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground',
day_today: 'bg-accent text-accent-foreground',
day_outside:
'day-outside text-muted-foreground aria-selected:text-muted-foreground',
day_disabled: 'text-muted-foreground opacity-50',
day_range_middle:
'aria-selected:bg-accent aria-selected:text-accent-foreground',
day_hidden: 'invisible',
...classNames
}}
components={{
IconLeft: LeftIcon,
IconRight: RightIcon
}}
{...props}
/>
);
}
export { Calendar };

View File

@@ -0,0 +1,92 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
function Card({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='card'
className={cn(
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
className
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='card-header'
className={cn(
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
className
)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='card-title'
className={cn('leading-none font-semibold', className)}
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='card-description'
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
);
}
function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='card-action'
className={cn(
'col-start-2 row-span-2 row-start-1 self-start justify-self-end',
className
)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='card-content'
className={cn('px-6', className)}
{...props}
/>
);
}
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='card-footer'
className={cn('flex items-center px-6 [.border-t]:pt-6', className)}
{...props}
/>
);
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent
};

View File

@@ -0,0 +1,243 @@
"use client"
import * as React from "react"
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { HugeiconsIcon } from "@hugeicons/react"
import { ArrowLeft01Icon, ArrowRight01Icon } from "@hugeicons/core-free-icons"
type CarouselApi = UseEmblaCarouselType[1]
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
type CarouselOptions = UseCarouselParameters[0]
type CarouselPlugin = UseCarouselParameters[1]
type CarouselProps = {
opts?: CarouselOptions
plugins?: CarouselPlugin
orientation?: "horizontal" | "vertical"
setApi?: (api: CarouselApi) => void
}
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
api: ReturnType<typeof useEmblaCarousel>[1]
scrollPrev: () => void
scrollNext: () => void
canScrollPrev: boolean
canScrollNext: boolean
} & CarouselProps
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
function useCarousel() {
const context = React.useContext(CarouselContext)
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />")
}
return context
}
function Carousel({
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
}: React.ComponentProps<"div"> & CarouselProps) {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins
)
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
const [canScrollNext, setCanScrollNext] = React.useState(false)
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) return
setCanScrollPrev(api.canScrollPrev())
setCanScrollNext(api.canScrollNext())
}, [])
const scrollPrev = React.useCallback(() => {
api?.scrollPrev()
}, [api])
const scrollNext = React.useCallback(() => {
api?.scrollNext()
}, [api])
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault()
scrollPrev()
} else if (event.key === "ArrowRight") {
event.preventDefault()
scrollNext()
}
},
[scrollPrev, scrollNext]
)
React.useEffect(() => {
if (!api || !setApi) return
setApi(api)
}, [api, setApi])
React.useEffect(() => {
if (!api) return
onSelect(api)
api.on("reInit", onSelect)
api.on("select", onSelect)
return () => {
api?.off("select", onSelect)
}
}, [api, onSelect])
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
data-slot="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
)
}
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
const { carouselRef, orientation } = useCarousel()
return (
<div
ref={carouselRef}
className="overflow-hidden"
data-slot="carousel-content"
>
<div
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className
)}
{...props}
/>
</div>
)
}
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
const { orientation } = useCarousel()
return (
<div
role="group"
aria-roledescription="slide"
data-slot="carousel-item"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className
)}
{...props}
/>
)
}
function CarouselPrevious({
className,
variant = "outline",
size = "icon-sm",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
return (
<Button
data-slot="carousel-previous"
variant={variant}
size={size}
className={cn(
"absolute touch-manipulation rounded-full",
orientation === "horizontal"
? "top-1/2 -left-12 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<HugeiconsIcon icon={ArrowLeft01Icon} strokeWidth={2} />
<span className="sr-only">Previous slide</span>
</Button>
)
}
function CarouselNext({
className,
variant = "outline",
size = "icon-sm",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollNext, canScrollNext } = useCarousel()
return (
<Button
data-slot="carousel-next"
variant={variant}
size={size}
className={cn(
"absolute touch-manipulation rounded-full",
orientation === "horizontal"
? "top-1/2 -right-12 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<HugeiconsIcon icon={ArrowRight01Icon} strokeWidth={2} />
<span className="sr-only">Next slide</span>
</Button>
)
}
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
useCarousel,
}

354
src/components/ui/chart.tsx Normal file
View File

@@ -0,0 +1,354 @@
'use client';
import * as React from 'react';
import * as RechartsPrimitive from 'recharts';
import { cn } from '@/lib/utils';
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: '', dark: '.dark' } as const;
export type ChartConfig = {
[key in string]: {
label?: React.ReactNode;
icon?: React.ComponentType;
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
);
};
type ChartContextProps = {
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() {
const context = React.useContext(ChartContext);
if (!context) {
throw new Error('useChart must be used within a <ChartContainer />');
}
return context;
}
function ChartContainer({
id,
className,
children,
config,
...props
}: React.ComponentProps<'div'> & {
config: ChartConfig;
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>['children'];
}) {
const uniqueId = React.useId();
const chartId = `chart-${id || uniqueId.replace(/:/g, '')}`;
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot='chart'
data-chart={chartId}
className={cn(
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
{/* adding debounce will fix chart laggy behavior while animating */}
<RechartsPrimitive.ResponsiveContainer debounce={2000}>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
);
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color
);
if (!colorConfig.length) {
return null;
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([configKey, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color;
return color ? ` --color-${configKey}: ${color};` : null;
})
.join('\n')}
}
`
)
.join('\n')
}}
/>
);
};
const ChartTooltip = RechartsPrimitive.Tooltip;
function ChartTooltipContent({
active,
payload,
className,
indicator = 'dot',
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<'div'> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: 'line' | 'dot' | 'dashed';
nameKey?: string;
labelKey?: string;
}) {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null;
}
const [item] = payload;
const key = `${labelKey || item?.dataKey || item?.name || 'value'}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value =
!labelKey && typeof label === 'string'
? config[label as keyof typeof config]?.label || label
: itemConfig?.label;
if (labelFormatter) {
return (
<div className={cn('font-medium', labelClassName)}>
{labelFormatter(value, payload)}
</div>
);
}
if (!value) {
return null;
}
return <div className={cn('font-medium', labelClassName)}>{value}</div>;
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey
]);
if (!active || !payload?.length) {
return null;
}
const nestLabel = payload.length === 1 && indicator !== 'dot';
return (
<div
className={cn(
'border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl',
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className='grid gap-1.5'>
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || 'value'}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload.fill || item.color;
return (
<div
key={item.dataKey}
className={cn(
'[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5',
indicator === 'dot' && 'items-center'
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
'shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)',
{
'h-2.5 w-2.5': indicator === 'dot',
'w-1': indicator === 'line',
'w-0 border-[1.5px] border-dashed bg-transparent':
indicator === 'dashed',
'my-0.5': nestLabel && indicator === 'dashed'
}
)}
style={
{
'--color-bg': indicatorColor,
'--color-border': indicatorColor
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
'flex flex-1 justify-between leading-none',
nestLabel ? 'items-end' : 'items-center'
)}
>
<div className='grid gap-1.5'>
{nestLabel ? tooltipLabel : null}
<span className='text-muted-foreground'>
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className='text-foreground font-mono font-medium tabular-nums'>
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
</div>
);
}
const ChartLegend = RechartsPrimitive.Legend;
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = 'bottom',
nameKey
}: React.ComponentProps<'div'> &
Pick<RechartsPrimitive.LegendProps, 'payload' | 'verticalAlign'> & {
hideIcon?: boolean;
nameKey?: string;
}) {
const { config } = useChart();
if (!payload?.length) {
return null;
}
return (
<div
className={cn(
'flex items-center justify-center gap-4',
verticalAlign === 'top' ? 'pb-3' : 'pt-3',
className
)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || 'value'}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (
<div
key={item.value}
className={cn(
'[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3'
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className='h-2 w-2 shrink-0 rounded-[2px]'
style={{
backgroundColor: item.color
}}
/>
)}
{itemConfig?.label}
</div>
);
})}
</div>
);
}
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== 'object' || payload === null) {
return undefined;
}
const payloadPayload =
'payload' in payload &&
typeof payload.payload === 'object' &&
payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key;
if (
key in payload &&
typeof payload[key as keyof typeof payload] === 'string'
) {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === 'string'
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string;
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config];
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle
};

View File

@@ -0,0 +1,32 @@
'use client';
import * as React from 'react';
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
import { CheckIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot='checkbox'
className={cn(
'peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot='checkbox-indicator'
className='flex items-center justify-center text-current transition-none'
>
<CheckIcon className='size-3.5' />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
}
export { Checkbox };

View File

@@ -0,0 +1,33 @@
'use client';
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible';
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot='collapsible' {...props} />;
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot='collapsible-trigger'
{...props}
/>
);
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot='collapsible-content'
{...props}
/>
);
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent };

View File

@@ -0,0 +1,300 @@
"use client"
import * as React from "react"
import { Combobox as ComboboxPrimitive } from "@base-ui/react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@/components/ui/input-group"
import { HugeiconsIcon } from "@hugeicons/react"
import { ArrowDown01Icon, Cancel01Icon, Tick02Icon } from "@hugeicons/core-free-icons"
const Combobox = ComboboxPrimitive.Root
function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) {
return <ComboboxPrimitive.Value data-slot="combobox-value" {...props} />
}
function ComboboxTrigger({
className,
children,
...props
}: ComboboxPrimitive.Trigger.Props) {
return (
<ComboboxPrimitive.Trigger
data-slot="combobox-trigger"
className={cn("[&_svg:not([class*='size-'])]:size-3.5", className)}
{...props}
>
{children}
<HugeiconsIcon icon={ArrowDown01Icon} strokeWidth={2} className="pointer-events-none size-3.5 text-muted-foreground" />
</ComboboxPrimitive.Trigger>
)
}
function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
return (
<ComboboxPrimitive.Clear
data-slot="combobox-clear"
render={<InputGroupButton variant="ghost" size="icon-xs" />}
className={cn(className)}
{...props}
>
<HugeiconsIcon icon={Cancel01Icon} strokeWidth={2} className="pointer-events-none" />
</ComboboxPrimitive.Clear>
)
}
function ComboboxInput({
className,
children,
disabled = false,
showTrigger = true,
showClear = false,
...props
}: ComboboxPrimitive.Input.Props & {
showTrigger?: boolean
showClear?: boolean
}) {
return (
<InputGroup className={cn("w-auto", className)}>
<ComboboxPrimitive.Input
render={<InputGroupInput disabled={disabled} />}
{...props}
/>
<InputGroupAddon align="inline-end">
{showTrigger && (
<InputGroupButton
size="icon-xs"
variant="ghost"
asChild
data-slot="input-group-button"
className="group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent"
disabled={disabled}
>
<ComboboxTrigger />
</InputGroupButton>
)}
{showClear && <ComboboxClear disabled={disabled} />}
</InputGroupAddon>
{children}
</InputGroup>
)
}
function ComboboxContent({
className,
side = "bottom",
sideOffset = 6,
align = "start",
alignOffset = 0,
anchor,
...props
}: ComboboxPrimitive.Popup.Props &
Pick<
ComboboxPrimitive.Positioner.Props,
"side" | "align" | "sideOffset" | "alignOffset" | "anchor"
>) {
return (
<ComboboxPrimitive.Portal>
<ComboboxPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
anchor={anchor}
className="isolate z-50"
>
<ComboboxPrimitive.Popup
data-slot="combobox-content"
data-chips={!!anchor}
className={cn("group/combobox-content relative max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) overflow-hidden rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[chips=true]:min-w-(--anchor-width) data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-7 *:data-[slot=input-group]:border-none *:data-[slot=input-group]:bg-input/20 *:data-[slot=input-group]:shadow-none dark:bg-popover data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</ComboboxPrimitive.Positioner>
</ComboboxPrimitive.Portal>
)
}
function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
return (
<ComboboxPrimitive.List
data-slot="combobox-list"
className={cn(
"no-scrollbar max-h-[min(calc(--spacing(72)---spacing(9)),calc(var(--available-height)---spacing(9)))] scroll-py-1 overflow-y-auto overscroll-contain p-1 data-empty:p-0",
className
)}
{...props}
/>
)
}
function ComboboxItem({
className,
children,
...props
}: ComboboxPrimitive.Item.Props) {
return (
<ComboboxPrimitive.Item
data-slot="combobox-item"
className={cn(
"relative flex min-h-7 w-full cursor-default items-center gap-2 rounded-md px-2 py-1 text-xs/relaxed outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground not-data-[variant=destructive]:data-highlighted:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
className
)}
{...props}
>
{children}
<ComboboxPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex items-center justify-center" />
}
>
<HugeiconsIcon icon={Tick02Icon} strokeWidth={2} className="pointer-events-none" />
</ComboboxPrimitive.ItemIndicator>
</ComboboxPrimitive.Item>
)
}
function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
return (
<ComboboxPrimitive.Group
data-slot="combobox-group"
className={cn(className)}
{...props}
/>
)
}
function ComboboxLabel({
className,
...props
}: ComboboxPrimitive.GroupLabel.Props) {
return (
<ComboboxPrimitive.GroupLabel
data-slot="combobox-label"
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) {
return (
<ComboboxPrimitive.Collection data-slot="combobox-collection" {...props} />
)
}
function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
return (
<ComboboxPrimitive.Empty
data-slot="combobox-empty"
className={cn(
"hidden w-full justify-center py-2 text-center text-xs/relaxed text-muted-foreground group-data-empty/combobox-content:flex",
className
)}
{...props}
/>
)
}
function ComboboxSeparator({
className,
...props
}: ComboboxPrimitive.Separator.Props) {
return (
<ComboboxPrimitive.Separator
data-slot="combobox-separator"
className={cn("-mx-1 my-1 h-px bg-border/50", className)}
{...props}
/>
)
}
function ComboboxChips({
className,
...props
}: React.ComponentPropsWithRef<typeof ComboboxPrimitive.Chips> &
ComboboxPrimitive.Chips.Props) {
return (
<ComboboxPrimitive.Chips
data-slot="combobox-chips"
className={cn(
"flex min-h-7 flex-wrap items-center gap-1 rounded-md border border-input bg-input/20 bg-clip-padding px-2 py-0.5 text-xs/relaxed transition-colors focus-within:border-ring focus-within:ring-2 focus-within:ring-ring/30 has-aria-invalid:border-destructive has-aria-invalid:ring-2 has-aria-invalid:ring-destructive/20 has-data-[slot=combobox-chip]:px-1 dark:bg-input/30 dark:has-aria-invalid:border-destructive/50 dark:has-aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
function ComboboxChip({
className,
children,
showRemove = true,
...props
}: ComboboxPrimitive.Chip.Props & {
showRemove?: boolean
}) {
return (
<ComboboxPrimitive.Chip
data-slot="combobox-chip"
className={cn(
"flex h-[calc(--spacing(4.75))] w-fit items-center justify-center gap-1 rounded-[calc(var(--radius-sm)-2px)] bg-muted-foreground/10 px-1.5 text-xs/relaxed font-medium whitespace-nowrap text-foreground has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50 has-data-[slot=combobox-chip-remove]:pr-0",
className
)}
{...props}
>
{children}
{showRemove && (
<ComboboxPrimitive.ChipRemove
render={<Button variant="ghost" size="icon-xs" />}
className="-ml-1 opacity-50 hover:opacity-100"
data-slot="combobox-chip-remove"
>
<HugeiconsIcon icon={Cancel01Icon} strokeWidth={2} className="pointer-events-none" />
</ComboboxPrimitive.ChipRemove>
)}
</ComboboxPrimitive.Chip>
)
}
function ComboboxChipsInput({
className,
...props
}: ComboboxPrimitive.Input.Props) {
return (
<ComboboxPrimitive.Input
data-slot="combobox-chip-input"
className={cn("min-w-16 flex-1 outline-none", className)}
{...props}
/>
)
}
function useComboboxAnchor() {
return React.useRef<HTMLDivElement | null>(null)
}
export {
Combobox,
ComboboxInput,
ComboboxContent,
ComboboxList,
ComboboxItem,
ComboboxGroup,
ComboboxLabel,
ComboboxCollection,
ComboboxEmpty,
ComboboxSeparator,
ComboboxChips,
ComboboxChip,
ComboboxChipsInput,
ComboboxTrigger,
ComboboxValue,
useComboboxAnchor,
}

View File

@@ -0,0 +1,177 @@
'use client';
import * as React from 'react';
import { Command as CommandPrimitive } from 'cmdk';
import { SearchIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog';
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot='command'
className={cn(
'bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md',
className
)}
{...props}
/>
);
}
function CommandDialog({
title = 'Command Palette',
description = 'Search for a command to run...',
children,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string;
description?: string;
}) {
return (
<Dialog {...props}>
<DialogHeader className='sr-only'>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent className='overflow-hidden p-0'>
<Command className='[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5'>
{children}
</Command>
</DialogContent>
</Dialog>
);
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot='command-input-wrapper'
className='flex h-9 items-center gap-2 border-b px-3'
>
<SearchIcon className='size-4 shrink-0 opacity-50' />
<CommandPrimitive.Input
data-slot='command-input'
className={cn(
'placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
/>
</div>
);
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot='command-list'
className={cn(
'max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto',
className
)}
{...props}
/>
);
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot='command-empty'
className='py-6 text-center text-sm'
{...props}
/>
);
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot='command-group'
className={cn(
'text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium',
className
)}
{...props}
/>
);
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot='command-separator'
className={cn('bg-border -mx-1 h-px', className)}
{...props}
/>
);
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot='command-item'
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
);
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<'span'>) {
return (
<span
data-slot='command-shortcut'
className={cn(
'text-muted-foreground ml-auto text-xs tracking-widest',
className
)}
{...props}
/>
);
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator
};

View File

@@ -0,0 +1,252 @@
'use client';
import * as React from 'react';
import * as ContextMenuPrimitive from '@radix-ui/react-context-menu';
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
function ContextMenu({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot='context-menu' {...props} />;
}
function ContextMenuTrigger({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return (
<ContextMenuPrimitive.Trigger data-slot='context-menu-trigger' {...props} />
);
}
function ContextMenuGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return (
<ContextMenuPrimitive.Group data-slot='context-menu-group' {...props} />
);
}
function ContextMenuPortal({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return (
<ContextMenuPrimitive.Portal data-slot='context-menu-portal' {...props} />
);
}
function ContextMenuSub({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot='context-menu-sub' {...props} />;
}
function ContextMenuRadioGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
return (
<ContextMenuPrimitive.RadioGroup
data-slot='context-menu-radio-group'
{...props}
/>
);
}
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.SubTrigger
data-slot='context-menu-sub-trigger'
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className='ml-auto' />
</ContextMenuPrimitive.SubTrigger>
);
}
function ContextMenuSubContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
return (
<ContextMenuPrimitive.SubContent
data-slot='context-menu-sub-content'
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',
className
)}
{...props}
/>
);
}
function ContextMenuContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
data-slot='context-menu-content'
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
className
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
);
}
function ContextMenuItem({
className,
inset,
variant = 'default',
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean;
variant?: 'default' | 'destructive';
}) {
return (
<ContextMenuPrimitive.Item
data-slot='context-menu-item'
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
);
}
function ContextMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
return (
<ContextMenuPrimitive.CheckboxItem
data-slot='context-menu-checkbox-item'
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className='pointer-events-none absolute left-2 flex size-3.5 items-center justify-center'>
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon className='size-4' />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
);
}
function ContextMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
return (
<ContextMenuPrimitive.RadioItem
data-slot='context-menu-radio-item'
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className='pointer-events-none absolute left-2 flex size-3.5 items-center justify-center'>
<ContextMenuPrimitive.ItemIndicator>
<CircleIcon className='size-2 fill-current' />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
);
}
function ContextMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.Label
data-slot='context-menu-label'
data-inset={inset}
className={cn(
'text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8',
className
)}
{...props}
/>
);
}
function ContextMenuSeparator({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
return (
<ContextMenuPrimitive.Separator
data-slot='context-menu-separator'
className={cn('bg-border -mx-1 my-1 h-px', className)}
{...props}
/>
);
}
function ContextMenuShortcut({
className,
...props
}: React.ComponentProps<'span'>) {
return (
<span
data-slot='context-menu-shortcut'
className={cn(
'text-muted-foreground ml-auto text-xs tracking-widest',
className
)}
{...props}
/>
);
}
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup
};

View File

@@ -0,0 +1,135 @@
'use client';
import * as React from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { XIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot='dialog' {...props} />;
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot='dialog-trigger' {...props} />;
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot='dialog-portal' {...props} />;
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot='dialog-close' {...props} />;
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot='dialog-overlay'
className={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
className
)}
{...props}
/>
);
}
function DialogContent({
className,
children,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
return (
<DialogPortal data-slot='dialog-portal'>
<DialogOverlay />
<DialogPrimitive.Content
data-slot='dialog-content'
className={cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<XIcon />
<span className='sr-only'>Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='dialog-header'
className={cn('flex flex-col gap-2 text-center sm:text-left', className)}
{...props}
/>
);
}
function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='dialog-footer'
className={cn(
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
className
)}
{...props}
/>
);
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot='dialog-title'
className={cn('text-lg leading-none font-semibold', className)}
{...props}
/>
);
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot='dialog-description'
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger
};

View File

@@ -0,0 +1,22 @@
"use client"
import * as React from "react"
import { Direction } from "radix-ui"
function DirectionProvider({
dir,
direction,
children,
}: React.ComponentProps<typeof Direction.DirectionProvider> & {
direction?: React.ComponentProps<typeof Direction.DirectionProvider>["dir"]
}) {
return (
<Direction.DirectionProvider dir={direction ?? dir}>
{children}
</Direction.DirectionProvider>
)
}
const useDirection = Direction.useDirection
export { DirectionProvider, useDirection }

View File

@@ -0,0 +1,132 @@
'use client';
import * as React from 'react';
import { Drawer as DrawerPrimitive } from 'vaul';
import { cn } from '@/lib/utils';
function Drawer({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot='drawer' {...props} />;
}
function DrawerTrigger({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot='drawer-trigger' {...props} />;
}
function DrawerPortal({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot='drawer-portal' {...props} />;
}
function DrawerClose({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot='drawer-close' {...props} />;
}
function DrawerOverlay({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
return (
<DrawerPrimitive.Overlay
data-slot='drawer-overlay'
className={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
className
)}
{...props}
/>
);
}
function DrawerContent({
className,
children,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
return (
<DrawerPortal data-slot='drawer-portal'>
<DrawerOverlay />
<DrawerPrimitive.Content
data-slot='drawer-content'
className={cn(
'group/drawer-content bg-background fixed z-50 flex h-auto flex-col',
'data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b',
'data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t',
'data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm',
'data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm',
className
)}
{...props}
>
<div className='bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block' />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
);
}
function DrawerHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='drawer-header'
className={cn('flex flex-col gap-1.5 p-4', className)}
{...props}
/>
);
}
function DrawerFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='drawer-footer'
className={cn('mt-auto flex flex-col gap-2 p-4', className)}
{...props}
/>
);
}
function DrawerTitle({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
data-slot='drawer-title'
className={cn('text-foreground font-semibold', className)}
{...props}
/>
);
}
function DrawerDescription({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
data-slot='drawer-description'
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
);
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription
};

View File

@@ -0,0 +1,257 @@
'use client';
import * as React from 'react';
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot='dropdown-menu' {...props} />;
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot='dropdown-menu-portal' {...props} />
);
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot='dropdown-menu-trigger'
{...props}
/>
);
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot='dropdown-menu-content'
sideOffset={sideOffset}
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot='dropdown-menu-group' {...props} />
);
}
function DropdownMenuItem({
className,
inset,
variant = 'default',
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
variant?: 'default' | 'destructive';
}) {
return (
<DropdownMenuPrimitive.Item
data-slot='dropdown-menu-item'
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
);
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot='dropdown-menu-checkbox-item'
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className='pointer-events-none absolute left-2 flex size-3.5 items-center justify-center'>
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className='size-4' />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
);
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot='dropdown-menu-radio-group'
{...props}
/>
);
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot='dropdown-menu-radio-item'
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className='pointer-events-none absolute left-2 flex size-3.5 items-center justify-center'>
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className='size-2 fill-current' />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
);
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.Label
data-slot='dropdown-menu-label'
data-inset={inset}
className={cn(
'px-2 py-1.5 text-sm font-medium data-[inset]:pl-8',
className
)}
{...props}
/>
);
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot='dropdown-menu-separator'
className={cn('bg-border -mx-1 my-1 h-px', className)}
{...props}
/>
);
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<'span'>) {
return (
<span
data-slot='dropdown-menu-shortcut'
className={cn(
'text-muted-foreground ml-auto text-xs tracking-widest',
className
)}
{...props}
/>
);
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot='dropdown-menu-sub' {...props} />;
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot='dropdown-menu-sub-trigger'
data-inset={inset}
className={cn(
'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8',
className
)}
{...props}
>
{children}
<ChevronRightIcon className='ml-auto size-4' />
</DropdownMenuPrimitive.SubTrigger>
);
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot='dropdown-menu-sub-content'
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',
className
)}
{...props}
/>
);
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent
};

104
src/components/ui/empty.tsx Normal file
View File

@@ -0,0 +1,104 @@
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
function Empty({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty"
className={cn(
"flex w-full min-w-0 flex-1 flex-col items-center justify-center gap-4 rounded-xl border-dashed p-6 text-center text-balance",
className
)}
{...props}
/>
)
}
function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-header"
className={cn("flex max-w-sm flex-col items-center gap-1", className)}
{...props}
/>
)
}
const emptyMediaVariants = cva(
"mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-transparent",
icon: "flex size-8 shrink-0 items-center justify-center rounded-md bg-muted text-foreground [&_svg:not([class*='size-'])]:size-4",
},
},
defaultVariants: {
variant: "default",
},
}
)
function EmptyMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
return (
<div
data-slot="empty-icon"
data-variant={variant}
className={cn(emptyMediaVariants({ variant, className }))}
{...props}
/>
)
}
function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-title"
className={cn(
"font-heading text-sm font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<div
data-slot="empty-description"
className={cn(
"text-xs/relaxed text-muted-foreground [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className
)}
{...props}
/>
)
}
function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-content"
className={cn(
"flex w-full max-w-sm min-w-0 flex-col items-center gap-2 text-xs/relaxed text-balance",
className
)}
{...props}
/>
)
}
export {
Empty,
EmptyHeader,
EmptyTitle,
EmptyDescription,
EmptyContent,
EmptyMedia,
}

245
src/components/ui/field.tsx Normal file
View File

@@ -0,0 +1,245 @@
'use client';
import { useMemo } from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
import { Label } from '@/components/ui/label';
import { Separator } from '@/components/ui/separator';
function FieldSet({ className, ...props }: React.ComponentProps<'fieldset'>) {
return (
<fieldset
data-slot='field-set'
className={cn(
'flex flex-col gap-6',
'has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3',
className
)}
{...props}
/>
);
}
function FieldLegend({
className,
variant = 'legend',
...props
}: React.ComponentProps<'legend'> & { variant?: 'legend' | 'label' }) {
return (
<legend
data-slot='field-legend'
data-variant={variant}
className={cn(
'mb-3 font-medium',
'data-[variant=legend]:text-base',
'data-[variant=label]:text-sm',
className
)}
{...props}
/>
);
}
function FieldGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='field-group'
className={cn(
'group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4',
className
)}
{...props}
/>
);
}
const fieldVariants = cva('group/field flex w-full gap-3 data-[invalid=true]:text-destructive', {
variants: {
orientation: {
vertical: ['flex-col [&>*]:w-full [&>.sr-only]:w-auto'],
horizontal: [
'flex-row items-center',
'[&>[data-slot=field-label]]:flex-auto',
'has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px'
],
responsive: [
'flex-col @md/field-group:flex-row @md/field-group:items-center [&>*]:w-full @md/field-group:[&>*]:w-auto [&>.sr-only]:w-auto',
'@md/field-group:[&>[data-slot=field-label]]:flex-auto',
'@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px'
]
}
},
defaultVariants: {
orientation: 'vertical'
}
});
function Field({
className,
orientation = 'vertical',
...props
}: React.ComponentProps<'div'> & VariantProps<typeof fieldVariants>) {
return (
<div
role='group'
data-slot='field'
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
);
}
function FieldContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='field-content'
className={cn('group/field-content flex flex-1 flex-col gap-1.5 leading-snug', className)}
{...props}
/>
);
}
function FieldLabel({ className, ...props }: React.ComponentProps<typeof Label>) {
return (
<Label
data-slot='field-label'
className={cn(
'group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50',
'has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4',
'has-data-[state=checked]:border-primary has-data-[state=checked]:bg-primary/5 dark:has-data-[state=checked]:bg-primary/10',
className
)}
{...props}
/>
);
}
function FieldTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='field-label'
className={cn(
'flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50',
className
)}
{...props}
/>
);
}
function FieldDescription({ className, ...props }: React.ComponentProps<'p'>) {
return (
<p
data-slot='field-description'
className={cn(
'text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance',
'last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5',
'[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4',
className
)}
{...props}
/>
);
}
function FieldSeparator({
children,
className,
...props
}: React.ComponentProps<'div'> & {
children?: React.ReactNode;
}) {
return (
<div
data-slot='field-separator'
data-content={!!children}
className={cn(
'relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2',
className
)}
{...props}
>
<Separator className='absolute inset-0 top-1/2' />
{children && (
<span
className='bg-background text-muted-foreground relative mx-auto block w-fit px-2'
data-slot='field-separator-content'
>
{children}
</span>
)}
</div>
);
}
function FieldError({
className,
children,
errors,
...props
}: React.ComponentProps<'div'> & {
errors?: Array<string | { message?: string } | undefined>;
}) {
const content = useMemo(() => {
if (children) {
return children;
}
if (!errors?.length) {
return null;
}
// Normalize errors to strings, handling both string and {message} formats
const messages = errors
.map((error) => {
if (typeof error === 'string') return error;
return error?.message;
})
.filter(Boolean) as string[];
const uniqueMessages = Array.from(new Set(messages));
if (uniqueMessages.length === 1) {
return uniqueMessages[0];
}
return (
<ul className='ml-4 flex list-disc flex-col gap-1'>
{uniqueMessages.map((msg, index) => (
<li key={index}>{msg}</li>
))}
</ul>
);
}, [children, errors]);
if (!content) {
return null;
}
return (
<div
role='alert'
data-slot='field-error'
className={cn('text-destructive text-sm font-normal', className)}
{...props}
>
{content}
</div>
);
}
export {
Field,
FieldLabel,
FieldDescription,
FieldError,
FieldGroup,
FieldLegend,
FieldSeparator,
FieldSet,
FieldContent,
FieldTitle,
fieldVariants
};

View File

@@ -0,0 +1,198 @@
'use client';
import Image from 'next/image';
import type { FC } from 'react';
import { Icons } from '@/components/icons';
import { cn } from '@/lib/utils';
export interface UploadedFile {
id: string;
url?: string;
name: string;
type: string;
description?: string;
isUploading?: boolean;
}
export interface FilePreviewProps {
files: UploadedFile[];
onRemove?: (id: string) => void;
className?: string;
variant?: 'default' | 'inverted';
}
const getFileExtension = (fileName: string): string => {
const parts = fileName.split('.');
return parts.length > 1 ? parts[parts.length - 1] : '';
};
const getFileIcon = (fileType: string, fileName: string) => {
const extension = getFileExtension(fileName).toLowerCase();
const iconProps = { size: 24 };
if (fileType.startsWith('image/'))
return <Icons.media {...iconProps} className='text-emerald-500 dark:text-emerald-400' />;
if (fileType === 'application/pdf' || extension === 'pdf')
return <Icons.fileTypePdf {...iconProps} className='text-red-500 dark:text-red-400' />;
if (
['doc', 'docx', 'odt', 'rtf'].includes(extension) ||
fileType.includes('wordprocessing') ||
fileType.includes('msword')
)
return <Icons.fileTypeDoc {...iconProps} className='text-blue-500 dark:text-blue-400' />;
if (
['xls', 'xlsx', 'csv', 'ods'].includes(extension) ||
fileType.includes('spreadsheet') ||
fileType.includes('excel')
)
return <Icons.fileTypeXls {...iconProps} className='text-green-500 dark:text-green-400' />;
if (['txt', 'md'].includes(extension) || fileType === 'text/plain')
return <Icons.post {...iconProps} className='text-zinc-500 dark:text-zinc-400' />;
if (
['js', 'ts', 'jsx', 'tsx', 'py', 'java', 'c', 'cpp', 'html', 'css'].includes(extension) ||
fileType.includes('javascript') ||
fileType.includes('typescript')
)
return <Icons.code {...iconProps} className='text-yellow-500 dark:text-yellow-400' />;
if (['json', 'xml', 'yaml', 'yml'].includes(extension))
return <Icons.code {...iconProps} className='text-zinc-500 dark:text-zinc-400' />;
if (fileType.startsWith('video/') || ['mp4', 'avi', 'mov', 'mkv'].includes(extension))
return <Icons.video {...iconProps} className='text-purple-500 dark:text-purple-400' />;
if (fileType.startsWith('audio/') || ['mp3', 'wav', 'ogg'].includes(extension))
return <Icons.music {...iconProps} className='text-pink-500 dark:text-pink-400' />;
if (
['zip', 'rar', 'tar', 'gz', '7z'].includes(extension) ||
fileType.includes('archive') ||
fileType.includes('compressed')
)
return <Icons.fileZip {...iconProps} className='text-amber-500 dark:text-amber-400' />;
return <Icons.page {...iconProps} className='text-zinc-500 dark:text-zinc-400' />;
};
const getFormattedFileType = (fileType: string, fileName: string): string => {
const ext = getFileExtension(fileName).toUpperCase();
if (fileType.includes('msword') || fileType.includes('wordprocessing')) return 'DOC';
if (fileType.includes('spreadsheet') || fileType.includes('excel')) return 'SPREADSHEET';
const typePart = fileType.split('/')[1];
if (!typePart || typePart === 'octet-stream') {
return ext || 'FILE';
}
const cleanType = typePart
.replace('vnd.openxmlformats-officedocument.', '')
.replace('vnd.ms-', '')
.replace('x-', '')
.replace('document.', '')
.replace('presentation.', '')
.replace('application.', '')
.split('.')[0];
return cleanType.toUpperCase().substring(0, 8);
};
export const FilePreview: FC<FilePreviewProps> = ({
files,
onRemove,
className,
variant = 'default'
}) => {
const isInverted = variant === 'inverted';
if (files.length === 0) return null;
return (
<div className={cn('flex w-full flex-col gap-2 rounded-xl p-2', className)}>
<div className='flex w-full flex-wrap gap-2'>
{files.map((file) => (
<div
key={file.id}
className={cn(
'group/file relative flex items-center rounded-xl transition-all',
isInverted
? 'bg-primary-foreground/15 hover:bg-primary-foreground/20'
: 'bg-muted hover:bg-muted/80',
file.type.startsWith('image/') && file.url
? 'h-14 w-14 justify-center'
: 'max-w-[220px] min-w-[180px] p-2 pr-8'
)}
>
{file.isUploading && (
<div className='absolute inset-0 flex items-center justify-center rounded-xl bg-black/30'>
<Icons.spinner size={20} className='animate-spin text-white' />
</div>
)}
{onRemove && (
<button
type='button'
onClick={() => onRemove(file.id)}
className={cn(
'absolute -top-1 -right-1 z-10 flex h-5 w-5 items-center justify-center rounded-full',
'scale-75 opacity-0 transition-all duration-150 group-hover/file:scale-100 group-hover/file:opacity-100',
'bg-muted-foreground/60 hover:bg-muted-foreground/80 cursor-pointer'
)}
aria-label={`Remove ${file.name}`}
>
<Icons.close size={10} className='text-white' />
</button>
)}
{file.type.startsWith('image/') && file.url ? (
<div className='h-12 w-12 overflow-hidden rounded-md'>
<Image
width={48}
height={48}
src={file.url}
alt={file.name}
className='h-full w-full object-cover'
/>
</div>
) : (
<>
<div
className={cn(
'mr-3 flex h-10 w-10 items-center justify-center rounded-lg',
isInverted ? 'bg-primary-foreground/10' : 'bg-muted-foreground/10'
)}
>
{getFileIcon(file.type, file.name)}
</div>
<div className='flex min-w-0 flex-1 flex-col'>
<p
className={cn(
'truncate text-sm font-medium',
isInverted ? 'text-primary-foreground' : 'text-foreground'
)}
>
{file.name.length > 18 ? `${file.name.substring(0, 15)}...` : file.name}
</p>
<span
className={cn(
'text-xs',
isInverted ? 'text-primary-foreground/70' : 'text-muted-foreground'
)}
>
{getFormattedFileType(file.type, file.name)}
</span>
</div>
</>
)}
</div>
))}
</div>
</div>
);
};

View File

@@ -0,0 +1,384 @@
/**
* form-context.tsx — Shared primitives for the TanStack Form + shadcn/ui integration.
*
* This file provides:
* - React contexts created by TanStack Form (fieldContext, formContext)
* - Enhanced useFieldContext with accessibility IDs and error state
* - Structural layout components (FormFieldSet, FormField, FormFieldError)
* - createFormField() — wraps a base field into a flat, self-wiring component
* - FieldConfig types — validators, listeners, asyncDebounceMs
* - Type-safe name utilities (WithTypedName, typedField)
*
* Consumed by:
* - fields/*.tsx (import structural components + createFormField)
* - tanstack-form.tsx (import contexts + re-export everything)
*
* This file must NOT import from tanstack-form.tsx or fields/*.tsx to avoid
* circular dependencies.
*/
import { createFormHookContexts, revalidateLogic, useStore } from '@tanstack/react-form';
import type { AnyFieldApi, DeepKeys } from '@tanstack/form-core';
import type { VariantProps } from 'class-variance-authority';
import * as React from 'react';
import {
Field as DefaultField,
FieldError as DefaultFieldError,
FieldSet as DefaultFieldSet,
fieldVariants
} from '@/components/ui/field';
import { cn } from '@/lib/utils';
// ---------------------------------------------------------------------------
// 1. Contexts
// ---------------------------------------------------------------------------
const {
fieldContext,
formContext,
useFieldContext: _useFieldContext,
useFormContext
} = createFormHookContexts();
type FormItemContextValue = {
id: string;
};
const FormItemContext = React.createContext<FormItemContextValue>({} as FormItemContextValue);
// ---------------------------------------------------------------------------
// 2. Enhanced useFieldContext
// ---------------------------------------------------------------------------
const useFieldContext = () => {
const { id } = React.useContext(FormItemContext);
const fieldCtx = _useFieldContext();
if (!fieldCtx) {
throw new Error('useFieldContext should be used within <AppField>');
}
const { name, store, ...rest } = fieldCtx;
const errors = useStore(store, (state) => state.meta.errors);
return {
id,
name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
errors,
store,
...rest
};
};
// ---------------------------------------------------------------------------
// 3. Structural field components
// ---------------------------------------------------------------------------
function FieldSet({ className, children, ...props }: React.ComponentProps<'fieldset'>) {
const id = React.useId();
return (
<FormItemContext.Provider value={{ id }}>
<DefaultFieldSet className={cn('grid gap-1', className)} {...props}>
{children}
</DefaultFieldSet>
</FormItemContext.Provider>
);
}
function Field({
children,
...props
}: React.ComponentProps<'div'> & VariantProps<typeof fieldVariants>) {
const { errors, formItemId, formDescriptionId, formMessageId, store } = useFieldContext();
const form = useFormContext();
const isTouched = useStore(store, (state) => state.meta.isTouched);
// Show errors after user interaction OR after first submit attempt
const hasSubmitted = useStore(form.store, (s) => s.submissionAttempts > 0);
const hasVisibleErrors = !!errors.length && (isTouched || hasSubmitted);
return (
<DefaultField
data-invalid={hasVisibleErrors}
id={formItemId}
aria-describedby={
!hasVisibleErrors ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}`
}
aria-invalid={hasVisibleErrors}
{...props}
>
{children}
</DefaultField>
);
}
function FieldError({ className, ...props }: React.ComponentProps<'p'>) {
const { errors, formMessageId, store } = useFieldContext();
const form = useFormContext();
const isTouched = useStore(store, (state) => state.meta.isTouched);
const hasSubmitted = useStore(form.store, (s) => s.submissionAttempts > 0);
if (!errors.length || (!isTouched && !hasSubmitted)) return null;
return (
<DefaultFieldError
data-slot='form-message'
id={formMessageId}
className={cn('text-destructive text-sm', className)}
{...props}
errors={errors}
/>
);
}
/**
* Renders form-level validation errors (cross-field errors).
* Place inside form.AppForm to show errors from form-level validators.
*
* @example
* ```tsx
* <form.AppForm>
* <form.Form>
* <FormErrors />
* ...fields...
* </form.Form>
* </form.AppForm>
* ```
*/
function FormErrors({ className, ...props }: React.ComponentProps<'div'>) {
const form = useFormContext();
return (
<form.Subscribe selector={(state) => state.errors}>
{(errors) => {
if (!errors.length) return null;
return (
<div
role='alert'
className={cn(
'bg-destructive/10 text-destructive rounded-md border p-3 text-sm',
className
)}
{...props}
>
<ul className='list-disc space-y-1 pl-4'>
{errors.map((error, i) => (
<li key={i}>{String(error)}</li>
))}
</ul>
</div>
);
}}
</form.Subscribe>
);
}
/**
* Scrolls to the first field with a validation error.
* Call after a failed form.handleSubmit() to guide the user to the problem.
*
* @example
* ```tsx
* onSubmit: ({ value }) => { ... },
* onSubmitInvalid: () => scrollToFirstError(),
* ```
*/
function scrollToFirstError() {
// Fields with errors have data-invalid="true" set by the FormField component
requestAnimationFrame(() => {
const firstError = document.querySelector('[data-invalid="true"]');
if (firstError) {
firstError.scrollIntoView({ behavior: 'smooth', block: 'center' });
// Focus the first focusable element within the error field
const focusable = firstError.querySelector<HTMLElement>(
'input, textarea, select, button, [tabindex]'
);
focusable?.focus({ preventScroll: true });
}
});
}
// ---------------------------------------------------------------------------
// 4. Field-level configuration types
//
// These mirror TanStack Form's field options that get forwarded to
// form.Field when using the flat FormXxxField pattern via createFormField.
// Validator values accept Zod schemas (StandardSchemaV1) or plain functions.
// ---------------------------------------------------------------------------
/** Field-level validators forwarded to form.Field */
interface FieldValidatorConfig {
/** Sync validator — runs on every value change. Accepts a function or Zod schema. */
onChange?: unknown;
/** Async validator — runs on value change (debounced). */
onChangeAsync?: unknown;
/** Debounce (ms) for onChangeAsync. */
onChangeAsyncDebounceMs?: number;
/** Re-run onChange/onChangeAsync when these other fields change (linked validation). */
onChangeListenTo?: string[];
/** Sync validator — runs when the field loses focus. Accepts a function or Zod schema. */
onBlur?: unknown;
/** Async validator — runs on blur. */
onBlurAsync?: unknown;
/** Debounce (ms) for onBlurAsync. */
onBlurAsyncDebounceMs?: number;
/** Re-run onBlur/onBlurAsync when these other fields blur. */
onBlurListenTo?: string[];
/** Sync validator — runs on form submission. */
onSubmit?: unknown;
/** Async validator — runs on form submission. */
onSubmitAsync?: unknown;
/** Sync validator — runs on field mount. */
onMount?: unknown;
}
/** Field-level side-effect listeners forwarded to form.Field */
interface FieldListenerConfig {
/** Fires after the field value changes. Use for side effects (e.g., resetting dependent fields). */
onChange?: (props: { value: unknown; fieldApi: AnyFieldApi }) => void;
/** Debounce (ms) for the onChange listener. */
onChangeDebounceMs?: number;
/** Fires when the field loses focus. */
onBlur?: (props: { value: unknown; fieldApi: AnyFieldApi }) => void;
/** Debounce (ms) for the onBlur listener. */
onBlurDebounceMs?: number;
/** Fires when the field mounts. */
onMount?: (props: { value: unknown; fieldApi: AnyFieldApi }) => void;
/** Fires on form submission. */
onSubmit?: (props: { value: unknown; fieldApi: AnyFieldApi }) => void;
}
/**
* Configuration forwarded to TanStack Form's form.Field component.
* Use with createFormField composed components (FormTextField, etc.)
* to enable field-level validation, async validation, and side-effect listeners.
*
* For type-safe field names, use form.AppField render-prop pattern instead.
*/
interface FieldConfig {
/** Field-level validators (onBlur, onChange, onSubmit + async variants). */
validators?: FieldValidatorConfig;
/** Default debounce (ms) for all async validators on this field. */
asyncDebounceMs?: number;
/** Side-effect listeners (onChange, onBlur, onMount, onSubmit). */
listeners?: FieldListenerConfig;
/** Set to 'array' for array fields (enables pushValue, removeValue, etc.). */
mode?: 'value' | 'array';
/** Default value for this field (useful for dynamically added fields). */
defaultValue?: unknown;
}
// ---------------------------------------------------------------------------
// 5. createFormField — lifts a field component into a flat form-level component
//
// Forwards TanStack Form's field-level config (validators, listeners)
// to form.Field while keeping the ergonomic flat API.
//
// For type-safe field names, use form.AppField render-prop pattern.
// The flat FormXxxField pattern trades name type-safety for ergonomics.
// ---------------------------------------------------------------------------
type FormFieldSlot = React.ComponentType<{
name: string;
validators?: FieldValidatorConfig;
asyncDebounceMs?: number;
listeners?: FieldListenerConfig;
mode?: 'value' | 'array';
defaultValue?: unknown;
children: (fieldApi: AnyFieldApi) => React.ReactNode;
}>;
function createFormField<P extends object>(FieldComponent: React.ComponentType<P>) {
function ComposedFormField({
name,
validators,
asyncDebounceMs,
listeners,
mode,
defaultValue,
...props
}: { name: string } & FieldConfig &
Omit<P, 'name' | 'validators' | 'asyncDebounceMs' | 'listeners' | 'mode' | 'defaultValue'>) {
const form = useFormContext();
const FieldSlot = form.Field as unknown as FormFieldSlot;
return (
<FieldSlot
name={name}
validators={validators}
asyncDebounceMs={asyncDebounceMs}
listeners={listeners}
mode={mode}
defaultValue={defaultValue}
>
{(fieldApi) => (
<fieldContext.Provider value={fieldApi}>
<FieldComponent {...(props as unknown as P)} />
</fieldContext.Provider>
)}
</FieldSlot>
);
}
ComposedFormField.displayName = `FormField(${FieldComponent.displayName || FieldComponent.name})`;
return ComposedFormField;
}
// ---------------------------------------------------------------------------
// 6. Type-safe field name utilities
//
// Standalone FormXxxField components have `name: string` by default because
// they don't know which form they'll be used in at definition time.
//
// These utilities narrow `name` to the form's actual field paths using
// TanStack Form's DeepKeys<TValues>, giving full autocomplete + type errors.
// ---------------------------------------------------------------------------
/**
* Narrows a composed field component's `name` prop to `DeepKeys<TValues>`.
* Used internally by useFormFields and typedField.
*/
type WithTypedName<C, TValues> =
C extends React.ComponentType<infer P>
? P extends { name: string }
? React.ComponentType<Omit<P, 'name'> & { name: DeepKeys<TValues> & string }>
: C
: C;
/**
* Narrows any single composed field component's `name` prop to type-safe field paths.
* Use for custom fields not included in useFormFields.
*
* @example
* ```tsx
* const narrow = typedField<MyFormValues>();
* const TypedDatePicker = narrow(FormDatePickerField);
* <TypedDatePicker name="birthDate" /> // ✅ type-safe
* ```
*/
function typedField<TValues extends Record<string, unknown>>() {
return function <C extends React.ComponentType<{ name: string }>>(
Component: C
): WithTypedName<C, TValues> {
return Component as WithTypedName<C, TValues>;
};
}
// ---------------------------------------------------------------------------
// 7. Exports
// ---------------------------------------------------------------------------
export type { FieldConfig, FieldValidatorConfig, FieldListenerConfig, WithTypedName };
export {
fieldContext,
formContext,
useFieldContext,
useFormContext,
createFormField,
typedField,
revalidateLogic,
scrollToFirstError,
FieldSet as FormFieldSet,
Field as FormField,
FieldError as FormFieldError,
FormErrors
};

187
src/components/ui/form.tsx Normal file
View File

@@ -0,0 +1,187 @@
'use client';
import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
import { Slot } from '@radix-ui/react-slot';
import {
Controller,
FormProvider,
useFormContext,
UseFormReturn,
useFormState,
type ControllerProps,
type FieldPath,
type FieldValues
} from 'react-hook-form';
import { cn } from '@/lib/utils';
import { Label } from '@/components/ui/label';
const Form = ({
children,
onSubmit,
form,
className
}: {
children: React.ReactNode;
onSubmit: (data: any) => void;
form: UseFormReturn<any, any, undefined>;
className?: string;
}) => {
return (
<FormProvider {...form}>
<form onSubmit={onSubmit} className={className}>
{children}
</form>
</FormProvider>
);
};
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
> = {
name: TName;
};
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
);
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
);
};
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext);
const { getFieldState } = useFormContext();
const formState = useFormState({ name: fieldContext.name });
const fieldState = getFieldState(fieldContext.name, formState);
if (!fieldContext) {
throw new Error('useFormField should be used within <FormField>');
}
const { id } = itemContext;
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState
};
};
type FormItemContextValue = {
id: string;
};
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
);
function FormItem({ className, ...props }: React.ComponentProps<'div'>) {
const id = React.useId();
return (
<FormItemContext.Provider value={{ id }}>
<div
data-slot='form-item'
className={cn('grid gap-2', className)}
{...props}
/>
</FormItemContext.Provider>
);
}
function FormLabel({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField();
return (
<Label
data-slot='form-label'
data-error={!!error}
className={cn('data-[error=true]:text-destructive', className)}
htmlFor={formItemId}
{...props}
/>
);
}
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } =
useFormField();
return (
<Slot
data-slot='form-control'
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
);
}
function FormDescription({ className, ...props }: React.ComponentProps<'p'>) {
const { formDescriptionId } = useFormField();
return (
<p
data-slot='form-description'
id={formDescriptionId}
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
);
}
function FormMessage({ className, ...props }: React.ComponentProps<'p'>) {
const { error, formMessageId } = useFormField();
const body = error ? String(error?.message ?? '') : props.children;
if (!body) {
return null;
}
return (
<p
data-slot='form-message'
id={formMessageId}
className={cn('text-destructive text-sm', className)}
{...props}
>
{body}
</p>
);
}
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField
};

View File

@@ -0,0 +1,78 @@
import type * as React from 'react';
import { cn } from '@/lib/utils';
function Frame({
className,
stackedPanels = false,
...props
}: React.ComponentProps<'div'> & { stackedPanels?: boolean }) {
return (
<div
className={cn(
'bg-muted/50 relative flex flex-col rounded-2xl p-1',
stackedPanels
? '*:has-[+[data-slot=frame-panel]]:rounded-b-none *:has-[+[data-slot=frame-panel]]:before:hidden dark:*:has-[+[data-slot=frame-panel]]:before:block *:[[data-slot=frame-panel]+[data-slot=frame-panel]]:rounded-t-none *:[[data-slot=frame-panel]+[data-slot=frame-panel]]:border-t-0 dark:*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:before:hidden'
: '*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:mt-1',
className
)}
data-slot='frame'
{...props}
/>
);
}
function FramePanel({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
className={cn(
'bg-background relative rounded-xl border bg-clip-padding p-5 shadow-xs before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-xl)-1px)] before:shadow-[0_1px_--theme(--color-black/4%)] dark:bg-clip-border dark:before:shadow-[0_-1px_--theme(--color-white/8%)]',
className
)}
data-slot='frame-panel'
{...props}
/>
);
}
function FrameHeader({ className, ...props }: React.ComponentProps<'header'>) {
return (
<header
className={cn('flex flex-col px-5 py-4', className)}
data-slot='frame-panel-header'
{...props}
/>
);
}
function FrameTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
className={cn('text-sm font-semibold', className)}
data-slot='frame-panel-title'
{...props}
/>
);
}
function FrameDescription({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
className={cn('text-muted-foreground text-sm', className)}
data-slot='frame-panel-description'
{...props}
/>
);
}
function FrameFooter({ className, ...props }: React.ComponentProps<'footer'>) {
return (
<footer
className={cn('flex flex-col gap-1 px-5 py-4', className)}
data-slot='frame-panel-footer'
{...props}
/>
);
}
export { Frame, FramePanel, FrameHeader, FrameTitle, FrameDescription, FrameFooter };

View File

@@ -0,0 +1,13 @@
interface HeadingProps {
title: string;
description: string;
}
export const Heading: React.FC<HeadingProps> = ({ title, description }) => {
return (
<div>
<h2 className='text-3xl font-bold tracking-tight'>{title}</h2>
<p className='text-muted-foreground text-sm'>{description}</p>
</div>
);
};

View File

@@ -0,0 +1,44 @@
'use client';
import * as React from 'react';
import * as HoverCardPrimitive from '@radix-ui/react-hover-card';
import { cn } from '@/lib/utils';
function HoverCard({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
return <HoverCardPrimitive.Root data-slot='hover-card' {...props} />;
}
function HoverCardTrigger({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
return (
<HoverCardPrimitive.Trigger data-slot='hover-card-trigger' {...props} />
);
}
function HoverCardContent({
className,
align = 'center',
sideOffset = 4,
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
return (
<HoverCardPrimitive.Portal data-slot='hover-card-portal'>
<HoverCardPrimitive.Content
data-slot='hover-card-content'
align={align}
sideOffset={sideOffset}
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden',
className
)}
{...props}
/>
</HoverCardPrimitive.Portal>
);
}
export { HoverCard, HoverCardTrigger, HoverCardContent };

View File

@@ -0,0 +1,53 @@
'use client';
import * as React from 'react';
import { Icons } from '@/components/icons';
import { Button } from '@/components/ui/button';
import { useInfobar, type InfobarContent } from '@/components/ui/infobar';
import { cn } from '@/lib/utils';
interface InfoButtonProps extends Omit<React.ComponentProps<typeof Button>, 'content'> {
content: InfobarContent;
variant?: 'default' | 'ghost' | 'outline' | 'secondary' | 'destructive' | 'link';
size?: 'default' | 'sm' | 'lg' | 'icon';
}
export function InfoButton({
content,
className,
variant = 'ghost',
size = 'icon',
...props
}: InfoButtonProps) {
const { setContent, setOpen, open } = useInfobar();
// Set content on mount so the infobar has it ready, but don't force it open
const contentRef = React.useRef(content);
contentRef.current = content;
React.useEffect(() => {
setContent(contentRef.current);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
setContent(content);
if (!open) {
setOpen(true);
}
props.onClick?.(e);
};
return (
<Button
variant={variant}
size={size}
className={cn('shrink-0', className)}
onClick={handleClick}
aria-label='Show information'
{...props}
>
<Icons.info className='h-4 w-4' />
<span className='sr-only'>Show information</span>
</Button>
);
}

View File

@@ -0,0 +1,766 @@
'use client';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Separator } from '@/components/ui/separator';
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle
} from '@/components/ui/sheet';
import { Skeleton } from '@/components/ui/skeleton';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { useIsMobile } from '@/hooks/use-mobile';
import { cn } from '@/lib/utils';
import { Slot } from '@radix-ui/react-slot';
import { VariantProps, cva } from 'class-variance-authority';
import { Icons } from '@/components/icons';
import { usePathname } from 'next/navigation';
import * as React from 'react';
const INFOBAR_WIDTH = '22rem';
const INFOBAR_WIDTH_MOBILE = '22rem';
const INFOBAR_WIDTH_ICON = '3rem';
const INFOBAR_KEYBOARD_SHORTCUT = 'i';
export type HelpfulLink = {
title: string;
url: string;
};
export type DescriptiveSection = {
title: string;
description: string;
links?: HelpfulLink[];
};
export type InfobarContent = {
title: string;
sections: DescriptiveSection[];
};
type InfobarContextProps = {
state: 'expanded' | 'collapsed';
open: boolean;
setOpen: (open: boolean) => void;
openMobile: boolean;
setOpenMobile: (open: boolean) => void;
isMobile: boolean;
toggleInfobar: () => void;
content: InfobarContent | null;
setContent: (content: InfobarContent | null) => void;
isPathnameChanging: boolean;
};
const InfobarContext = React.createContext<InfobarContextProps | null>(null);
function useInfobar() {
const context = React.useContext(InfobarContext);
if (!context) {
throw new Error('useInfobar must be used within a InfobarProvider.');
}
return context;
}
function InfobarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<'div'> & {
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}) {
const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false);
const [content, setContent] = React.useState<InfobarContent | null>(null);
const [contentPathname, setContentPathname] = React.useState<string | null>(null);
const [isPathnameChanging, setIsPathnameChanging] = React.useState(false);
const pathname = usePathname();
// This is the internal state of the infobar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen);
const open = openProp ?? _open;
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === 'function' ? value(open) : value;
// On mobile, also update the mobile state for the Sheet component
if (isMobile) {
setOpenMobile(openState);
}
// Handle desktop state
if (setOpenProp) {
setOpenProp(openState);
} else {
_setOpen(openState);
}
},
[setOpenProp, open, isMobile]
);
// Helper to toggle the infobar.
const toggleInfobar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
}, [isMobile, setOpen, setOpenMobile]);
// Adds a keyboard shortcut to toggle the infobar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === INFOBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
toggleInfobar();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [toggleInfobar]);
// Clear content and close infobar when pathname changes
React.useEffect(() => {
if (contentPathname !== null && contentPathname !== pathname) {
setIsPathnameChanging(true);
setContent(null);
setContentPathname(null);
setOpen(false);
const timer = setTimeout(() => {
setIsPathnameChanging(false);
}, 200);
return () => clearTimeout(timer);
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- setOpen is a stable React state setter
}, [pathname, contentPathname]);
// Update setContent to also track pathname
const handleSetContent = React.useCallback(
(newContent: InfobarContent | null) => {
setContent(newContent);
setContentPathname(newContent ? pathname : null);
},
[pathname]
);
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the infobar with Tailwind classes.
const state = open ? 'expanded' : 'collapsed';
// Update context to use handleSetContent instead of setContent
const contextValue = React.useMemo<InfobarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleInfobar,
content,
setContent: handleSetContent,
isPathnameChanging
}),
[
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleInfobar,
content,
handleSetContent,
isPathnameChanging
]
);
return (
<InfobarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
data-slot='infobar-wrapper'
style={
{
'--infobar-width': INFOBAR_WIDTH,
'--infobar-width-icon': INFOBAR_WIDTH_ICON,
...style
} as React.CSSProperties
}
className={cn(
'group/infobar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full',
className
)}
{...props}
>
{children}
</div>
</TooltipProvider>
</InfobarContext.Provider>
);
}
function Infobar({
side = 'left',
variant = 'sidebar',
collapsible = 'offcanvas',
className,
children,
...props
}: React.ComponentProps<'div'> & {
side?: 'left' | 'right';
variant?: 'sidebar' | 'floating' | 'inset';
collapsible?: 'offcanvas' | 'icon' | 'none';
}) {
const { isMobile, state, openMobile, setOpenMobile, isPathnameChanging } = useInfobar();
if (collapsible === 'none') {
return (
<div
data-slot='infobar'
className={cn(
'bg-sidebar text-sidebar-foreground flex h-full w-(--infobar-width) flex-col',
className
)}
{...props}
>
{children}
</div>
);
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-infobar='infobar'
data-slot='infobar'
data-mobile='true'
className='bg-sidebar text-sidebar-foreground w-(--infobar-width) p-0 [&>button]:hidden'
style={
{
'--infobar-width': INFOBAR_WIDTH_MOBILE
} as React.CSSProperties
}
side={side}
>
<SheetHeader className='sr-only'>
<SheetTitle>Infobar</SheetTitle>
<SheetDescription>Displays the mobile infobar.</SheetDescription>
</SheetHeader>
<div className='flex h-full w-full flex-col'>{children}</div>
</SheetContent>
</Sheet>
);
}
return (
<div
className='group peer text-sidebar-foreground hidden md:block'
data-state={state}
data-collapsible={state === 'collapsed' ? collapsible : ''}
data-variant={variant}
data-side={side}
data-slot='infobar'
style={
{
'--infobar-transition-duration': isPathnameChanging ? '0ms' : '200ms'
} as React.CSSProperties
}
>
{/* This is what handles the infobar gap on desktop */}
<div
data-slot='infobar-gap'
className={cn(
'relative w-(--infobar-width) bg-transparent transition-[width] duration-(--infobar-transition-duration,200ms) ease-linear',
'group-data-[collapsible=offcanvas]:w-0',
'group-data-[side=right]:rotate-180',
variant === 'floating' || variant === 'inset'
? 'group-data-[collapsible=icon]:w-[calc(var(--infobar-width-icon)+(--spacing(4)))]'
: 'group-data-[collapsible=icon]:w-(--infobar-width-icon)'
)}
/>
<div
data-slot='infobar-container'
className={cn(
'fixed inset-y-0 z-30 hidden h-dvh w-(--infobar-width) transition-[left,right,width] duration-(--infobar-transition-duration,200ms) ease-linear md:flex',
side === 'left'
? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--infobar-width)*-1)]'
: 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--infobar-width)*-1)]',
// Adjust the padding for floating and inset variants.
variant === 'floating' || variant === 'inset'
? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--infobar-width-icon)+(--spacing(4))+2px)]'
: 'group-data-[collapsible=icon]:w-(--infobar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l',
className
)}
{...props}
>
<div
data-infobar='infobar'
data-slot='infobar-inner'
className='bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm'
>
{children}
</div>
</div>
</div>
);
}
function InfobarTrigger({ className, onClick, ...props }: React.ComponentProps<typeof Button>) {
const { toggleInfobar } = useInfobar();
return (
<Button
data-infobar='trigger'
data-slot='infobar-trigger'
variant='ghost'
size='icon'
className={cn('size-7', className)}
aria-label='Toggle info infobar'
onClick={(event) => {
onClick?.(event);
toggleInfobar();
}}
{...props}
>
<Icons.circleX className='size-7' />
<span className='sr-only'>Toggle Infobar</span>
</Button>
);
}
function InfobarRail({ className, ...props }: React.ComponentProps<'button'>) {
const { toggleInfobar } = useInfobar();
return (
<button
data-infobar='rail'
data-slot='infobar-rail'
aria-label='Toggle Infobar'
tabIndex={-1}
onClick={toggleInfobar}
title='Toggle Infobar'
className={cn(
'hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex',
'in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize',
'[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize',
'hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full',
'[[data-side=left][data-collapsible=offcanvas]_&]:-right-2',
'[[data-side=right][data-collapsible=offcanvas]_&]:-left-2',
className
)}
{...props}
/>
);
}
function InfobarInset({ className, ...props }: React.ComponentProps<'main'>) {
return (
<main
data-slot='infobar-inset'
className={cn(
'bg-background relative flex w-full flex-1 flex-col',
'md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2',
className
)}
{...props}
/>
);
}
function InfobarInput({ className, ...props }: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot='infobar-input'
data-infobar='input'
className={cn('bg-background h-8 w-full shadow-none', className)}
{...props}
/>
);
}
function InfobarHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='infobar-header'
data-infobar='header'
className={cn('flex flex-col gap-2 p-2', className)}
{...props}
/>
);
}
function InfobarFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='infobar-footer'
data-infobar='footer'
className={cn('flex flex-col gap-2 p-2', className)}
{...props}
/>
);
}
function InfobarSeparator({ className, ...props }: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot='infobar-separator'
data-infobar='separator'
className={cn('bg-sidebar-border mx-2 w-auto', className)}
{...props}
/>
);
}
function InfobarContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='infobar-content'
data-infobar='content'
className={cn(
'flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden',
className
)}
{...props}
/>
);
}
function InfobarGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='infobar-group'
data-infobar='group'
className={cn('relative flex w-full min-w-0 flex-col p-2', className)}
{...props}
/>
);
}
function InfobarGroupLabel({
className,
asChild = false,
...props
}: React.ComponentProps<'div'> & { asChild?: boolean }) {
const Comp = asChild ? Slot : 'div';
return (
<Comp
data-slot='infobar-group-label'
data-infobar='group-label'
className={cn(
'text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
'group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0',
className
)}
{...props}
/>
);
}
function InfobarGroupAction({
className,
asChild = false,
...props
}: React.ComponentProps<'button'> & { asChild?: boolean }) {
const Comp = asChild ? Slot : 'button';
return (
<Comp
data-slot='infobar-group-action'
data-infobar='group-action'
className={cn(
'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
// Increases the hit area of the button on mobile.
'after:absolute after:-inset-2 md:after:hidden',
'group-data-[collapsible=icon]:hidden',
className
)}
{...props}
/>
);
}
function InfobarGroupContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='infobar-group-content'
data-infobar='group-content'
className={cn('w-full text-sm', className)}
{...props}
/>
);
}
function InfobarMenu({ className, ...props }: React.ComponentProps<'ul'>) {
return (
<ul
data-slot='infobar-menu'
data-infobar='menu'
className={cn('flex w-full min-w-0 flex-col gap-1', className)}
{...props}
/>
);
}
function InfobarMenuItem({ className, ...props }: React.ComponentProps<'li'>) {
return (
<li
data-slot='infobar-menu-item'
data-infobar='menu-item'
className={cn('group/menu-item relative', className)}
{...props}
/>
);
}
const infobarMenuButtonVariants = cva(
'peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[infobar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
{
variants: {
variant: {
default: 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
outline:
'bg-background shadow-[0_0_0_1px_var(--sidebar-border)] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_var(--sidebar-accent)]'
},
size: {
default: 'h-8 text-sm',
sm: 'h-7 text-xs',
lg: 'h-12 text-sm group-data-[collapsible=icon]:p-0!'
}
},
defaultVariants: {
variant: 'default',
size: 'default'
}
}
);
function InfobarMenuButton({
asChild = false,
isActive = false,
variant = 'default',
size = 'default',
tooltip,
className,
...props
}: React.ComponentProps<'button'> & {
asChild?: boolean;
isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof infobarMenuButtonVariants>) {
const Comp = asChild ? Slot : 'button';
const { isMobile, state } = useInfobar();
const button = (
<Comp
data-slot='infobar-menu-button'
data-infobar='menu-button'
data-size={size}
data-active={isActive}
className={cn(infobarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
);
if (!tooltip) {
return button;
}
if (typeof tooltip === 'string') {
tooltip = {
children: tooltip
};
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side='right'
align='center'
hidden={state !== 'collapsed' || isMobile}
{...tooltip}
/>
</Tooltip>
);
}
function InfobarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: React.ComponentProps<'button'> & {
asChild?: boolean;
showOnHover?: boolean;
}) {
const Comp = asChild ? Slot : 'button';
return (
<Comp
data-slot='infobar-menu-action'
data-infobar='menu-action'
className={cn(
'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
// Increases the hit area of the button on mobile.
'after:absolute after:-inset-2 md:after:hidden',
'peer-data-[size=sm]/menu-button:top-1',
'peer-data-[size=default]/menu-button:top-1.5',
'peer-data-[size=lg]/menu-button:top-2.5',
'group-data-[collapsible=icon]:hidden',
showOnHover &&
'peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0',
className
)}
{...props}
/>
);
}
function InfobarMenuBadge({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='infobar-menu-badge'
data-infobar='menu-badge'
className={cn(
'text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none',
'peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground',
'peer-data-[size=sm]/menu-button:top-1',
'peer-data-[size=default]/menu-button:top-1.5',
'peer-data-[size=lg]/menu-button:top-2.5',
'group-data-[collapsible=icon]:hidden',
className
)}
{...props}
/>
);
}
function InfobarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<'div'> & {
showIcon?: boolean;
}) {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`;
}, []);
return (
<div
data-slot='infobar-menu-skeleton'
data-infobar='menu-skeleton'
className={cn('flex h-8 items-center gap-2 rounded-md px-2', className)}
{...props}
>
{showIcon && <Skeleton className='size-4 rounded-md' data-infobar='menu-skeleton-icon' />}
<Skeleton
className='h-4 max-w-(--skeleton-width) flex-1'
data-infobar='menu-skeleton-text'
style={
{
'--skeleton-width': width
} as React.CSSProperties
}
/>
</div>
);
}
function InfobarMenuSub({ className, ...props }: React.ComponentProps<'ul'>) {
return (
<ul
data-slot='infobar-menu-sub'
data-infobar='menu-sub'
className={cn(
'border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5',
'group-data-[collapsible=icon]:hidden',
className
)}
{...props}
/>
);
}
function InfobarMenuSubItem({ className, ...props }: React.ComponentProps<'li'>) {
return (
<li
data-slot='infobar-menu-sub-item'
data-infobar='menu-sub-item'
className={cn('group/menu-sub-item relative', className)}
{...props}
/>
);
}
function InfobarMenuSubButton({
asChild = false,
size = 'md',
isActive = false,
className,
...props
}: React.ComponentProps<'a'> & {
asChild?: boolean;
size?: 'sm' | 'md';
isActive?: boolean;
}) {
const Comp = asChild ? Slot : 'a';
return (
<Comp
data-slot='infobar-menu-sub-button'
data-infobar='menu-sub-button'
data-size={size}
data-active={isActive}
className={cn(
'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
'data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground',
size === 'sm' && 'text-xs',
size === 'md' && 'text-sm',
'group-data-[collapsible=icon]:hidden',
className
)}
{...props}
/>
);
}
export {
Infobar,
InfobarContent,
InfobarFooter,
InfobarGroup,
InfobarGroupAction,
InfobarGroupContent,
InfobarGroupLabel,
InfobarHeader,
InfobarInput,
InfobarInset,
InfobarMenu,
InfobarMenuAction,
InfobarMenuBadge,
InfobarMenuButton,
InfobarMenuItem,
InfobarMenuSkeleton,
InfobarMenuSub,
InfobarMenuSubButton,
InfobarMenuSubItem,
InfobarProvider,
InfobarRail,
InfobarSeparator,
InfobarTrigger,
useInfobar
};

View File

@@ -0,0 +1,163 @@
'use client';
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
function InputGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='input-group'
role='group'
className={cn(
'group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-md border shadow-xs transition-[color,box-shadow] outline-none',
'h-9 min-w-0 has-[>textarea]:h-auto',
// Variants based on alignment.
'has-[>[data-align=inline-start]]:[&>input]:pl-2',
'has-[>[data-align=inline-end]]:[&>input]:pr-2',
'has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3',
'has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3',
// Focus state.
'has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]',
// Error state.
'has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-destructive/20 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40',
className
)}
{...props}
/>
);
}
const inputGroupAddonVariants = cva(
"flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
{
variants: {
align: {
'inline-start': 'order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]',
'inline-end': 'order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]',
'block-start':
'order-first w-full justify-start px-3 pt-3 group-has-[>input]/input-group:pt-2.5 [.border-b]:pb-3',
'block-end':
'order-last w-full justify-start px-3 pb-3 group-has-[>input]/input-group:pb-2.5 [.border-t]:pt-3'
}
},
defaultVariants: {
align: 'inline-start'
}
}
);
function InputGroupAddon({
className,
align = 'inline-start',
...props
}: React.ComponentProps<'div'> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role='group'
data-slot='input-group-addon'
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest('button')) {
return;
}
e.currentTarget.parentElement?.querySelector('input')?.focus();
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.currentTarget.parentElement?.querySelector('input')?.focus();
}
}}
{...props}
/>
);
}
const inputGroupButtonVariants = cva('flex items-center gap-2 text-sm shadow-none', {
variants: {
size: {
xs: "h-6 gap-1 rounded-[calc(var(--radius)-5px)] px-2 has-[>svg]:px-2 [&>svg:not([class*='size-'])]:size-3.5",
sm: 'h-8 gap-1.5 rounded-md px-2.5 has-[>svg]:px-2.5',
'icon-xs': 'size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0',
'icon-sm': 'size-8 p-0 has-[>svg]:p-0'
}
},
defaultVariants: {
size: 'xs'
}
});
function InputGroupButton({
className,
type = 'button',
variant = 'ghost',
size = 'xs',
...props
}: Omit<React.ComponentProps<typeof Button>, 'size'> &
VariantProps<typeof inputGroupButtonVariants>) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
);
}
function InputGroupText({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
className={cn(
"text-muted-foreground flex items-center gap-2 text-sm [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
);
}
function InputGroupInput({ className, ...props }: React.ComponentProps<'input'>) {
return (
<Input
data-slot='input-group-control'
className={cn(
'flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent',
className
)}
{...props}
/>
);
}
function InputGroupTextarea({ className, ...props }: React.ComponentProps<'textarea'>) {
return (
<Textarea
data-slot='input-group-control'
className={cn(
'flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent',
className
)}
{...props}
/>
);
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea
};

View File

@@ -0,0 +1,77 @@
'use client';
import * as React from 'react';
import { OTPInput, OTPInputContext } from 'input-otp';
import { MinusIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
function InputOTP({
className,
containerClassName,
...props
}: React.ComponentProps<typeof OTPInput> & {
containerClassName?: string;
}) {
return (
<OTPInput
data-slot='input-otp'
containerClassName={cn(
'flex items-center gap-2 has-disabled:opacity-50',
containerClassName
)}
className={cn('disabled:cursor-not-allowed', className)}
{...props}
/>
);
}
function InputOTPGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='input-otp-group'
className={cn('flex items-center', className)}
{...props}
/>
);
}
function InputOTPSlot({
index,
className,
...props
}: React.ComponentProps<'div'> & {
index: number;
}) {
const inputOTPContext = React.useContext(OTPInputContext);
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {};
return (
<div
data-slot='input-otp-slot'
data-active={isActive}
className={cn(
'data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]',
className
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className='pointer-events-none absolute inset-0 flex items-center justify-center'>
<div className='animate-caret-blink bg-foreground h-4 w-px duration-1000' />
</div>
)}
</div>
);
}
function InputOTPSeparator({ ...props }: React.ComponentProps<'div'>) {
return (
<div data-slot='input-otp-separator' role='separator' {...props}>
<MinusIcon />
</div>
);
}
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };

View File

@@ -0,0 +1,21 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
return (
<input
type={type}
data-slot='input'
className={cn(
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
className
)}
{...props}
/>
);
}
export { Input };

196
src/components/ui/item.tsx Normal file
View File

@@ -0,0 +1,196 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
import { Separator } from "@/components/ui/separator"
function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
role="list"
data-slot="item-group"
className={cn(
"group/item-group flex w-full flex-col gap-4 has-data-[size=sm]:gap-2.5 has-data-[size=xs]:gap-2",
className
)}
{...props}
/>
)
}
function ItemSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="item-separator"
orientation="horizontal"
className={cn("my-2", className)}
{...props}
/>
)
}
const itemVariants = cva(
"group/item flex w-full flex-wrap items-center rounded-md border text-xs/relaxed transition-colors duration-100 outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 [a]:transition-colors [a]:hover:bg-muted",
{
variants: {
variant: {
default: "border-transparent",
outline: "border-border",
muted: "border-transparent bg-muted/50",
},
size: {
default: "gap-2.5 px-3 py-2.5",
sm: "gap-2.5 px-3 py-2.5",
xs: "gap-2.5 px-2.5 py-2 in-data-[slot=dropdown-menu-content]:p-0",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Item({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"div"> &
VariantProps<typeof itemVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "div"
return (
<Comp
data-slot="item"
data-variant={variant}
data-size={size}
className={cn(itemVariants({ variant, size, className }))}
{...props}
/>
)
}
const itemMediaVariants = cva(
"flex shrink-0 items-center justify-center gap-2 group-has-data-[slot=item-description]/item:translate-y-0.5 group-has-data-[slot=item-description]/item:self-start [&_svg]:pointer-events-none",
{
variants: {
variant: {
default: "bg-transparent",
icon: "[&_svg:not([class*='size-'])]:size-4",
image:
"size-8 overflow-hidden rounded-sm group-data-[size=sm]/item:size-8 group-data-[size=xs]/item:size-6 [&_img]:size-full [&_img]:object-cover",
},
},
defaultVariants: {
variant: "default",
},
}
)
function ItemMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof itemMediaVariants>) {
return (
<div
data-slot="item-media"
data-variant={variant}
className={cn(itemMediaVariants({ variant, className }))}
{...props}
/>
)
}
function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-content"
className={cn(
"flex flex-1 flex-col gap-1 group-data-[size=xs]/item:gap-0.5 [&+[data-slot=item-content]]:flex-none",
className
)}
{...props}
/>
)
}
function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-title"
className={cn(
"line-clamp-1 flex w-fit items-center gap-2 text-xs/relaxed leading-snug font-medium underline-offset-4",
className
)}
{...props}
/>
)
}
function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="item-description"
className={cn(
"line-clamp-2 text-left text-xs/relaxed font-normal text-muted-foreground [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className
)}
{...props}
/>
)
}
function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-actions"
className={cn("flex items-center gap-2", className)}
{...props}
/>
)
}
function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-header"
className={cn(
"flex basis-full items-center justify-between gap-2",
className
)}
{...props}
/>
)
}
function ItemFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-footer"
className={cn(
"flex basis-full items-center justify-between gap-2",
className
)}
{...props}
/>
)
}
export {
Item,
ItemMedia,
ItemContent,
ItemActions,
ItemGroup,
ItemSeparator,
ItemTitle,
ItemDescription,
ItemHeader,
ItemFooter,
}

1023
src/components/ui/kanban.tsx Normal file

File diff suppressed because it is too large Load Diff

26
src/components/ui/kbd.tsx Normal file
View File

@@ -0,0 +1,26 @@
import { cn } from '@/lib/utils';
function Kbd({ className, ...props }: React.ComponentProps<'kbd'>) {
return (
<kbd
data-slot='kbd'
className={cn(
"bg-muted text-muted-foreground in-data-[slot=tooltip-content]:bg-background/20 in-data-[slot=tooltip-content]:text-background dark:in-data-[slot=tooltip-content]:bg-background/10 pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm px-1 font-sans text-xs font-medium select-none [&_svg:not([class*='size-'])]:size-3",
className
)}
{...props}
/>
);
}
function KbdGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<kbd
data-slot='kbd-group'
className={cn('inline-flex items-center gap-1', className)}
{...props}
/>
);
}
export { Kbd, KbdGroup };

View File

@@ -0,0 +1,24 @@
'use client';
import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
import { cn } from '@/lib/utils';
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot='label'
className={cn(
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
className
)}
{...props}
/>
);
}
export { Label };

View File

@@ -0,0 +1,276 @@
'use client';
import * as React from 'react';
import * as MenubarPrimitive from '@radix-ui/react-menubar';
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
function Menubar({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Root>) {
return (
<MenubarPrimitive.Root
data-slot='menubar'
className={cn(
'bg-background flex h-9 items-center gap-1 rounded-md border p-1 shadow-xs',
className
)}
{...props}
/>
);
}
function MenubarMenu({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Menu>) {
return <MenubarPrimitive.Menu data-slot='menubar-menu' {...props} />;
}
function MenubarGroup({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Group>) {
return <MenubarPrimitive.Group data-slot='menubar-group' {...props} />;
}
function MenubarPortal({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Portal>) {
return <MenubarPrimitive.Portal data-slot='menubar-portal' {...props} />;
}
function MenubarRadioGroup({
...props
}: React.ComponentProps<typeof MenubarPrimitive.RadioGroup>) {
return (
<MenubarPrimitive.RadioGroup data-slot='menubar-radio-group' {...props} />
);
}
function MenubarTrigger({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Trigger>) {
return (
<MenubarPrimitive.Trigger
data-slot='menubar-trigger'
className={cn(
'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex items-center rounded-sm px-2 py-1 text-sm font-medium outline-hidden select-none',
className
)}
{...props}
/>
);
}
function MenubarContent({
className,
align = 'start',
alignOffset = -4,
sideOffset = 8,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Content>) {
return (
<MenubarPortal>
<MenubarPrimitive.Content
data-slot='menubar-content'
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[12rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-md',
className
)}
{...props}
/>
</MenubarPortal>
);
}
function MenubarItem({
className,
inset,
variant = 'default',
...props
}: React.ComponentProps<typeof MenubarPrimitive.Item> & {
inset?: boolean;
variant?: 'default' | 'destructive';
}) {
return (
<MenubarPrimitive.Item
data-slot='menubar-item'
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
);
}
function MenubarCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof MenubarPrimitive.CheckboxItem>) {
return (
<MenubarPrimitive.CheckboxItem
data-slot='menubar-checkbox-item'
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className='pointer-events-none absolute left-2 flex size-3.5 items-center justify-center'>
<MenubarPrimitive.ItemIndicator>
<CheckIcon className='size-4' />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.CheckboxItem>
);
}
function MenubarRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof MenubarPrimitive.RadioItem>) {
return (
<MenubarPrimitive.RadioItem
data-slot='menubar-radio-item'
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className='pointer-events-none absolute left-2 flex size-3.5 items-center justify-center'>
<MenubarPrimitive.ItemIndicator>
<CircleIcon className='size-2 fill-current' />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.RadioItem>
);
}
function MenubarLabel({
className,
inset,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Label> & {
inset?: boolean;
}) {
return (
<MenubarPrimitive.Label
data-slot='menubar-label'
data-inset={inset}
className={cn(
'px-2 py-1.5 text-sm font-medium data-[inset]:pl-8',
className
)}
{...props}
/>
);
}
function MenubarSeparator({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Separator>) {
return (
<MenubarPrimitive.Separator
data-slot='menubar-separator'
className={cn('bg-border -mx-1 my-1 h-px', className)}
{...props}
/>
);
}
function MenubarShortcut({
className,
...props
}: React.ComponentProps<'span'>) {
return (
<span
data-slot='menubar-shortcut'
className={cn(
'text-muted-foreground ml-auto text-xs tracking-widest',
className
)}
{...props}
/>
);
}
function MenubarSub({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Sub>) {
return <MenubarPrimitive.Sub data-slot='menubar-sub' {...props} />;
}
function MenubarSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof MenubarPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<MenubarPrimitive.SubTrigger
data-slot='menubar-sub-trigger'
data-inset={inset}
className={cn(
'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[inset]:pl-8',
className
)}
{...props}
>
{children}
<ChevronRightIcon className='ml-auto h-4 w-4' />
</MenubarPrimitive.SubTrigger>
);
}
function MenubarSubContent({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.SubContent>) {
return (
<MenubarPrimitive.SubContent
data-slot='menubar-sub-content'
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',
className
)}
{...props}
/>
);
}
export {
Menubar,
MenubarPortal,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarGroup,
MenubarSeparator,
MenubarLabel,
MenubarItem,
MenubarShortcut,
MenubarCheckboxItem,
MenubarRadioGroup,
MenubarRadioItem,
MenubarSub,
MenubarSubTrigger,
MenubarSubContent
};

View File

@@ -0,0 +1,42 @@
'use client';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog';
interface ModalProps {
title: string;
description: string;
isOpen: boolean;
onClose: () => void;
children?: React.ReactNode;
}
export const Modal: React.FC<ModalProps> = ({
title,
description,
isOpen,
onClose,
children
}) => {
const onChange = (open: boolean) => {
if (!open) {
onClose();
}
};
return (
<Dialog open={isOpen} onOpenChange={onChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div>{children}</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,62 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { HugeiconsIcon } from "@hugeicons/react"
import { UnfoldMoreIcon } from "@hugeicons/core-free-icons"
type NativeSelectProps = Omit<React.ComponentProps<"select">, "size"> & {
size?: "sm" | "default"
}
function NativeSelect({
className,
size = "default",
...props
}: NativeSelectProps) {
return (
<div
className={cn(
"group/native-select relative w-fit has-[select:disabled]:opacity-50",
className
)}
data-slot="native-select-wrapper"
data-size={size}
>
<select
data-slot="native-select"
data-size={size}
className="h-7 w-full min-w-0 appearance-none rounded-md border border-input bg-input/20 py-0.5 pr-6 pl-2 text-xs/relaxed transition-colors outline-none select-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/30 disabled:pointer-events-none disabled:cursor-not-allowed aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/20 data-[size=sm]:h-6 data-[size=sm]:text-[0.625rem] dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40"
{...props}
/>
<HugeiconsIcon icon={UnfoldMoreIcon} strokeWidth={2} className="pointer-events-none absolute top-1/2 right-1.5 size-3.5 -translate-y-1/2 text-muted-foreground select-none group-data-[size=sm]/native-select:size-3 group-data-[size=sm]/native-select:-translate-y-[calc(--spacing(1.25))]" aria-hidden="true" data-slot="native-select-icon" />
</div>
)
}
function NativeSelectOption({
className,
...props
}: React.ComponentProps<"option">) {
return (
<option
data-slot="native-select-option"
className={cn("bg-[Canvas] text-[CanvasText]", className)}
{...props}
/>
)
}
function NativeSelectOptGroup({
className,
...props
}: React.ComponentProps<"optgroup">) {
return (
<optgroup
data-slot="native-select-optgroup"
className={cn("bg-[Canvas] text-[CanvasText]", className)}
{...props}
/>
)
}
export { NativeSelect, NativeSelectOptGroup, NativeSelectOption }

View File

@@ -0,0 +1,168 @@
import * as React from 'react';
import * as NavigationMenuPrimitive from '@radix-ui/react-navigation-menu';
import { cva } from 'class-variance-authority';
import { ChevronDownIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
function NavigationMenu({
className,
children,
viewport = true,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
viewport?: boolean;
}) {
return (
<NavigationMenuPrimitive.Root
data-slot='navigation-menu'
data-viewport={viewport}
className={cn(
'group/navigation-menu relative flex max-w-max flex-1 items-center justify-center',
className
)}
{...props}
>
{children}
{viewport && <NavigationMenuViewport />}
</NavigationMenuPrimitive.Root>
);
}
function NavigationMenuList({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {
return (
<NavigationMenuPrimitive.List
data-slot='navigation-menu-list'
className={cn(
'group flex flex-1 list-none items-center justify-center gap-1',
className
)}
{...props}
/>
);
}
function NavigationMenuItem({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {
return (
<NavigationMenuPrimitive.Item
data-slot='navigation-menu-item'
className={cn('relative', className)}
{...props}
/>
);
}
const navigationMenuTriggerStyle = cva(
'group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1'
);
function NavigationMenuTrigger({
className,
children,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {
return (
<NavigationMenuPrimitive.Trigger
data-slot='navigation-menu-trigger'
className={cn(navigationMenuTriggerStyle(), 'group', className)}
{...props}
>
{children}{' '}
<ChevronDownIcon
className='relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180'
aria-hidden='true'
/>
</NavigationMenuPrimitive.Trigger>
);
}
function NavigationMenuContent({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
return (
<NavigationMenuPrimitive.Content
data-slot='navigation-menu-content'
className={cn(
'data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto',
'group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none',
className
)}
{...props}
/>
);
}
function NavigationMenuViewport({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
return (
<div
className={cn(
'absolute top-full left-0 isolate z-50 flex justify-center'
)}
>
<NavigationMenuPrimitive.Viewport
data-slot='navigation-menu-viewport'
className={cn(
'origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--radix-navigation-menu-viewport-width)]',
className
)}
{...props}
/>
</div>
);
}
function NavigationMenuLink({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {
return (
<NavigationMenuPrimitive.Link
data-slot='navigation-menu-link'
className={cn(
"data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-all outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
);
}
function NavigationMenuIndicator({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {
return (
<NavigationMenuPrimitive.Indicator
data-slot='navigation-menu-indicator'
className={cn(
'data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden',
className
)}
{...props}
>
<div className='bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md' />
</NavigationMenuPrimitive.Indicator>
);
}
export {
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
navigationMenuTriggerStyle
};

View File

@@ -0,0 +1,187 @@
'use client';
import type { FC } from 'react';
import { Icons } from '@/components/icons';
import { cn } from '@/lib/utils';
export type NotificationStatus = 'unread' | 'read' | 'archived';
export type ActionType = 'redirect' | 'api_call' | 'workflow' | 'modal';
export type ActionStyle = 'primary' | 'danger' | 'default';
export interface NotificationAction {
id: string;
label: string;
type: ActionType;
style?: ActionStyle;
executed?: boolean;
}
export interface NotificationCardProps {
id: string;
title: string;
body: string;
status?: NotificationStatus;
createdAt?: string | Date;
actions?: NotificationAction[];
onMarkAsRead?: (id: string) => void;
onAction?: (notificationId: string, actionId: string, actionType: ActionType) => void;
loadingActionId?: string;
className?: string;
}
const formatDate = (date: string | Date): string => {
const d = new Date(date);
const now = new Date();
const diffMs = now.getTime() - d.getTime();
const diffMins = Math.floor(diffMs / (1000 * 60));
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return d.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric'
});
};
const getActionIcon = (actionType: ActionType) => {
const iconProps = { size: 12, strokeWidth: 2.5 };
switch (actionType) {
case 'redirect':
return <Icons.externalLink {...iconProps} />;
case 'api_call':
return <Icons.check {...iconProps} />;
case 'workflow':
return <Icons.clock {...iconProps} />;
case 'modal':
return <Icons.alertCircle {...iconProps} />;
default:
return null;
}
};
export const NotificationCard: FC<NotificationCardProps> = ({
id,
title,
body,
status = 'unread',
createdAt,
actions = [],
onMarkAsRead,
onAction,
loadingActionId,
className
}) => {
const isUnread = status === 'unread';
return (
<div
className={cn(
'group relative w-full rounded-2xl transition-all',
isUnread ? 'bg-muted' : 'bg-muted/40',
className
)}
>
<div className='px-4 py-3.5'>
<div className='flex items-start justify-between gap-3'>
{/* Main content */}
<div className='min-w-0 flex-1 space-y-1'>
{/* Title with unread indicator */}
<div className='flex items-center gap-2'>
<h3
className={cn(
'text-[15px] leading-tight font-semibold',
isUnread ? 'text-foreground' : 'text-muted-foreground'
)}
>
{title}
</h3>
{isUnread && <div className='h-1.5 w-1.5 flex-shrink-0 rounded-full bg-sky-500' />}
</div>
{/* Description */}
<p
className={cn(
'mb-0 text-[13px]',
isUnread ? 'text-muted-foreground' : 'text-muted-foreground/60'
)}
>
{body}
</p>
</div>
{/* Mark as read button */}
{isUnread && onMarkAsRead && (
<button
type='button'
onClick={() => onMarkAsRead(id)}
className={cn(
'rounded-lg p-1.5 transition-colors',
'text-muted-foreground hover:bg-accent hover:text-foreground'
)}
aria-label='Mark as read'
>
<Icons.check size={16} />
</button>
)}
</div>
<div className='mt-3 flex items-end justify-between'>
{/* Actions */}
{actions.length > 0 && (
<div className={cn('flex flex-wrap items-center gap-2', !isUnread && 'opacity-60')}>
{actions.map((action) => {
const isLoading = loadingActionId === action.id;
const isExecuted = action.executed || false;
const showLoading = isLoading && action.type !== 'modal';
return (
<button
key={action.id}
type='button'
disabled={isLoading || isExecuted}
onClick={() => onAction?.(id, action.id, action.type)}
className={cn(
'flex items-center gap-1.5 rounded-lg px-4 py-1.5 text-xs font-normal transition',
action.style === 'primary'
? 'bg-primary/10 text-primary hover:bg-primary/20'
: action.style === 'danger'
? 'bg-destructive/10 text-destructive hover:bg-destructive/20'
: 'bg-accent text-muted-foreground hover:bg-accent hover:text-foreground',
showLoading && 'opacity-50',
isExecuted && 'cursor-not-allowed opacity-60'
)}
>
{showLoading ? (
<Icons.spinner size={12} className='animate-spin' />
) : (
<>
<span>{action.label}</span>
{isExecuted ? (
<Icons.check size={12} strokeWidth={2.5} />
) : (
getActionIcon(action.type)
)}
</>
)}
</button>
);
})}
</div>
)}
{/* Timestamp */}
{createdAt && (
<span className='text-muted-foreground/60 inline-block text-[11px]'>
{formatDate(createdAt)}
</span>
)}
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,127 @@
import * as React from 'react';
import {
ChevronLeftIcon,
ChevronRightIcon,
MoreHorizontalIcon
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button, buttonVariants } from '@/components/ui/button';
function Pagination({ className, ...props }: React.ComponentProps<'nav'>) {
return (
<nav
role='navigation'
aria-label='pagination'
data-slot='pagination'
className={cn('mx-auto flex w-full justify-center', className)}
{...props}
/>
);
}
function PaginationContent({
className,
...props
}: React.ComponentProps<'ul'>) {
return (
<ul
data-slot='pagination-content'
className={cn('flex flex-row items-center gap-1', className)}
{...props}
/>
);
}
function PaginationItem({ ...props }: React.ComponentProps<'li'>) {
return <li data-slot='pagination-item' {...props} />;
}
type PaginationLinkProps = {
isActive?: boolean;
} & Pick<React.ComponentProps<typeof Button>, 'size'> &
React.ComponentProps<'a'>;
function PaginationLink({
className,
isActive,
size = 'icon',
...props
}: PaginationLinkProps) {
return (
<a
aria-current={isActive ? 'page' : undefined}
data-slot='pagination-link'
data-active={isActive}
className={cn(
buttonVariants({
variant: isActive ? 'outline' : 'ghost',
size
}),
className
)}
{...props}
/>
);
}
function PaginationPrevious({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label='Go to previous page'
size='default'
className={cn('gap-1 px-2.5 sm:pl-2.5', className)}
{...props}
>
<ChevronLeftIcon />
<span className='hidden sm:block'>Previous</span>
</PaginationLink>
);
}
function PaginationNext({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label='Go to next page'
size='default'
className={cn('gap-1 px-2.5 sm:pr-2.5', className)}
{...props}
>
<span className='hidden sm:block'>Next</span>
<ChevronRightIcon />
</PaginationLink>
);
}
function PaginationEllipsis({
className,
...props
}: React.ComponentProps<'span'>) {
return (
<span
aria-hidden
data-slot='pagination-ellipsis'
className={cn('flex size-9 items-center justify-center', className)}
{...props}
>
<MoreHorizontalIcon className='size-4' />
<span className='sr-only'>More pages</span>
</span>
);
}
export {
Pagination,
PaginationContent,
PaginationLink,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationEllipsis
};

View File

@@ -0,0 +1,48 @@
'use client';
import * as React from 'react';
import * as PopoverPrimitive from '@radix-ui/react-popover';
import { cn } from '@/lib/utils';
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot='popover' {...props} />;
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot='popover-trigger' {...props} />;
}
function PopoverContent({
className,
align = 'center',
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot='popover-content'
align={align}
sideOffset={sideOffset}
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden',
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
);
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot='popover-anchor' {...props} />;
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };

View File

@@ -0,0 +1,31 @@
'use client';
import * as React from 'react';
import * as ProgressPrimitive from '@radix-ui/react-progress';
import { cn } from '@/lib/utils';
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot='progress'
className={cn(
'bg-primary/20 relative h-2 w-full overflow-hidden rounded-full',
className
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot='progress-indicator'
className='bg-primary h-full w-full flex-1 transition-all'
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
);
}
export { Progress };

View File

@@ -0,0 +1,45 @@
'use client';
import * as React from 'react';
import * as RadioGroupPrimitive from '@radix-ui/react-radio-group';
import { CircleIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot='radio-group'
className={cn('grid gap-3', className)}
{...props}
/>
);
}
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot='radio-group-item'
className={cn(
'border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot='radio-group-indicator'
className='relative flex items-center justify-center'
>
<CircleIcon className='fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2' />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
);
}
export { RadioGroup, RadioGroupItem };

View File

@@ -0,0 +1,56 @@
'use client';
import * as React from 'react';
import { GripVerticalIcon } from 'lucide-react';
import * as ResizablePrimitive from 'react-resizable-panels';
import { cn } from '@/lib/utils';
function ResizablePanelGroup({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) {
return (
<ResizablePrimitive.PanelGroup
data-slot='resizable-panel-group'
className={cn(
'flex h-full w-full data-[panel-group-direction=vertical]:flex-col',
className
)}
{...props}
/>
);
}
function ResizablePanel({
...props
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
return <ResizablePrimitive.Panel data-slot='resizable-panel' {...props} />;
}
function ResizableHandle({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean;
}) {
return (
<ResizablePrimitive.PanelResizeHandle
data-slot='resizable-handle'
className={cn(
'bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90',
className
)}
{...props}
>
{withHandle && (
<div className='bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border'>
<GripVerticalIcon className='size-2.5' />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
);
}
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };

View File

@@ -0,0 +1,58 @@
'use client';
import * as React from 'react';
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
import { cn } from '@/lib/utils';
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot='scroll-area'
className={cn('relative', className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot='scroll-area-viewport'
className='focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1'
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
);
}
function ScrollBar({
className,
orientation = 'vertical',
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot='scroll-area-scrollbar'
orientation={orientation}
className={cn(
'flex touch-none p-px transition-colors select-none',
orientation === 'vertical' &&
'h-full w-2.5 border-l border-l-transparent',
orientation === 'horizontal' &&
'h-2.5 flex-col border-t border-t-transparent',
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot='scroll-area-thumb'
className='bg-border relative flex-1 rounded-full'
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
);
}
export { ScrollArea, ScrollBar };

View File

@@ -0,0 +1,185 @@
'use client';
import * as React from 'react';
import * as SelectPrimitive from '@radix-ui/react-select';
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot='select' {...props} />;
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot='select-group' {...props} />;
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot='select-value' {...props} />;
}
function SelectTrigger({
className,
size = 'default',
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: 'sm' | 'default';
}) {
return (
<SelectPrimitive.Trigger
data-slot='select-trigger'
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className='size-4 opacity-50' />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
function SelectContent({
className,
children,
position = 'popper',
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot='select-content'
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1'
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot='select-label'
className={cn('text-muted-foreground px-2 py-1.5 text-xs', className)}
{...props}
/>
);
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot='select-item'
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className='absolute right-2 flex size-3.5 items-center justify-center'>
<SelectPrimitive.ItemIndicator>
<CheckIcon className='size-4' />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot='select-separator'
className={cn('bg-border pointer-events-none -mx-1 my-1 h-px', className)}
{...props}
/>
);
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot='select-scroll-up-button'
className={cn(
'flex cursor-default items-center justify-center py-1',
className
)}
{...props}
>
<ChevronUpIcon className='size-4' />
</SelectPrimitive.ScrollUpButton>
);
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot='select-scroll-down-button'
className={cn(
'flex cursor-default items-center justify-center py-1',
className
)}
{...props}
>
<ChevronDownIcon className='size-4' />
</SelectPrimitive.ScrollDownButton>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue
};

View File

@@ -0,0 +1,28 @@
'use client';
import * as React from 'react';
import * as SeparatorPrimitive from '@radix-ui/react-separator';
import { cn } from '@/lib/utils';
function Separator({
className,
orientation = 'horizontal',
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot='separator-root'
decorative={decorative}
orientation={orientation}
className={cn(
'bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px',
className
)}
{...props}
/>
);
}
export { Separator };

139
src/components/ui/sheet.tsx Normal file
View File

@@ -0,0 +1,139 @@
'use client';
import * as React from 'react';
import * as SheetPrimitive from '@radix-ui/react-dialog';
import { XIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot='sheet' {...props} />;
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot='sheet-trigger' {...props} />;
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot='sheet-close' {...props} />;
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot='sheet-portal' {...props} />;
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot='sheet-overlay'
className={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
className
)}
{...props}
/>
);
}
function SheetContent({
className,
children,
side = 'right',
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: 'top' | 'right' | 'bottom' | 'left';
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot='sheet-content'
className={cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
side === 'right' &&
'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm',
side === 'left' &&
'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm',
side === 'top' &&
'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b',
side === 'bottom' &&
'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t',
className
)}
{...props}
>
{children}
<SheetPrimitive.Close className='ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none'>
<XIcon className='size-4' />
<span className='sr-only'>Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
);
}
function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='sheet-header'
className={cn('flex flex-col gap-1.5 p-4', className)}
{...props}
/>
);
}
function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='sheet-footer'
className={cn('mt-auto flex flex-col gap-2 p-4', className)}
{...props}
/>
);
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot='sheet-title'
className={cn('text-foreground font-semibold', className)}
{...props}
/>
);
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot='sheet-description'
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
);
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription
};

View File

@@ -0,0 +1,725 @@
'use client';
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { VariantProps, cva } from 'class-variance-authority';
import { PanelLeftIcon } from 'lucide-react';
import { useIsMobile } from '@/hooks/use-mobile';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Separator } from '@/components/ui/separator';
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle
} from '@/components/ui/sheet';
import { Skeleton } from '@/components/ui/skeleton';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from '@/components/ui/tooltip';
const SIDEBAR_COOKIE_NAME = 'sidebar_state';
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = '16rem';
const SIDEBAR_WIDTH_MOBILE = '18rem';
const SIDEBAR_WIDTH_ICON = '3rem';
const SIDEBAR_KEYBOARD_SHORTCUT = 'b';
type SidebarContextProps = {
state: 'expanded' | 'collapsed';
open: boolean;
setOpen: (open: boolean) => void;
openMobile: boolean;
setOpenMobile: (open: boolean) => void;
isMobile: boolean;
toggleSidebar: () => void;
};
const SidebarContext = React.createContext<SidebarContextProps | null>(null);
function useSidebar() {
const context = React.useContext(SidebarContext);
if (!context) {
throw new Error('useSidebar must be used within a SidebarProvider.');
}
return context;
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<'div'> & {
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}) {
const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false);
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen);
const open = openProp ?? _open;
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === 'function' ? value(open) : value;
if (setOpenProp) {
setOpenProp(openState);
} else {
_setOpen(openState);
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
},
[setOpenProp, open]
);
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
}, [isMobile, setOpen, setOpenMobile]);
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault();
toggleSidebar();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [toggleSidebar]);
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? 'expanded' : 'collapsed';
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
);
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
data-slot='sidebar-wrapper'
style={
{
'--sidebar-width': SIDEBAR_WIDTH,
'--sidebar-width-icon': SIDEBAR_WIDTH_ICON,
...style
} as React.CSSProperties
}
className={cn(
'group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full',
className
)}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
);
}
function Sidebar({
side = 'left',
variant = 'sidebar',
collapsible = 'offcanvas',
className,
children,
...props
}: React.ComponentProps<'div'> & {
side?: 'left' | 'right';
variant?: 'sidebar' | 'floating' | 'inset';
collapsible?: 'offcanvas' | 'icon' | 'none';
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
if (collapsible === 'none') {
return (
<div
data-slot='sidebar'
className={cn(
'bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col',
className
)}
{...props}
>
{children}
</div>
);
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar='sidebar'
data-slot='sidebar'
data-mobile='true'
className='bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden'
style={
{
'--sidebar-width': SIDEBAR_WIDTH_MOBILE
} as React.CSSProperties
}
side={side}
>
<SheetHeader className='sr-only'>
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className='flex h-full w-full flex-col'>{children}</div>
</SheetContent>
</Sheet>
);
}
return (
<div
className='group peer text-sidebar-foreground hidden md:block'
data-state={state}
data-collapsible={state === 'collapsed' ? collapsible : ''}
data-variant={variant}
data-side={side}
data-slot='sidebar'
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot='sidebar-gap'
className={cn(
'relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear',
'group-data-[collapsible=offcanvas]:w-0',
'group-data-[side=right]:rotate-180',
variant === 'floating' || variant === 'inset'
? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]'
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)'
)}
/>
<div
data-slot='sidebar-container'
className={cn(
'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex',
side === 'left'
? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'
: 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]',
// Adjust the padding for floating and inset variants.
variant === 'floating' || variant === 'inset'
? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]'
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l',
className
)}
{...props}
>
<div
data-sidebar='sidebar'
data-slot='sidebar-inner'
className='bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm'
>
{children}
</div>
</div>
</div>
);
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar();
return (
<Button
data-sidebar='trigger'
data-slot='sidebar-trigger'
variant='ghost'
size='icon'
className={cn('size-7', className)}
onClick={(event) => {
onClick?.(event);
toggleSidebar();
}}
{...props}
>
<PanelLeftIcon />
<span className='sr-only'>Toggle Sidebar</span>
</Button>
);
}
function SidebarRail({ className, ...props }: React.ComponentProps<'button'>) {
const { toggleSidebar } = useSidebar();
return (
<button
data-sidebar='rail'
data-slot='sidebar-rail'
aria-label='Toggle Sidebar'
tabIndex={-1}
onClick={toggleSidebar}
title='Toggle Sidebar'
className={cn(
'hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex',
'in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize',
'[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize',
'hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full',
'[[data-side=left][data-collapsible=offcanvas]_&]:-right-2',
'[[data-side=right][data-collapsible=offcanvas]_&]:-left-2',
className
)}
{...props}
/>
);
}
function SidebarInset({ className, ...props }: React.ComponentProps<'main'>) {
return (
<main
data-slot='sidebar-inset'
className={cn(
'bg-background relative flex w-full flex-1 flex-col',
'md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2',
className
)}
{...props}
/>
);
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot='sidebar-input'
data-sidebar='input'
className={cn('bg-background h-8 w-full shadow-none', className)}
{...props}
/>
);
}
function SidebarHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='sidebar-header'
data-sidebar='header'
className={cn('flex flex-col gap-2 p-2', className)}
{...props}
/>
);
}
function SidebarFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='sidebar-footer'
data-sidebar='footer'
className={cn('flex flex-col gap-2 p-2', className)}
{...props}
/>
);
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot='sidebar-separator'
data-sidebar='separator'
className={cn('bg-sidebar-border mx-2 w-auto', className)}
{...props}
/>
);
}
function SidebarContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='sidebar-content'
data-sidebar='content'
className={cn(
'flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden',
className
)}
{...props}
/>
);
}
function SidebarGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='sidebar-group'
data-sidebar='group'
className={cn('relative flex w-full min-w-0 flex-col p-2', className)}
{...props}
/>
);
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}: React.ComponentProps<'div'> & { asChild?: boolean }) {
const Comp = asChild ? Slot : 'div';
return (
<Comp
data-slot='sidebar-group-label'
data-sidebar='group-label'
className={cn(
'text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
'group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0',
className
)}
{...props}
/>
);
}
function SidebarGroupAction({
className,
asChild = false,
...props
}: React.ComponentProps<'button'> & { asChild?: boolean }) {
const Comp = asChild ? Slot : 'button';
return (
<Comp
data-slot='sidebar-group-action'
data-sidebar='group-action'
className={cn(
'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
// Increases the hit area of the button on mobile.
'after:absolute after:-inset-2 md:after:hidden',
'group-data-[collapsible=icon]:hidden',
className
)}
{...props}
/>
);
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot='sidebar-group-content'
data-sidebar='group-content'
className={cn('w-full text-sm', className)}
{...props}
/>
);
}
function SidebarMenu({ className, ...props }: React.ComponentProps<'ul'>) {
return (
<ul
data-slot='sidebar-menu'
data-sidebar='menu'
className={cn('flex w-full min-w-0 flex-col gap-1', className)}
{...props}
/>
);
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<'li'>) {
return (
<li
data-slot='sidebar-menu-item'
data-sidebar='menu-item'
className={cn('group/menu-item relative', className)}
{...props}
/>
);
}
const sidebarMenuButtonVariants = cva(
'peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
{
variants: {
variant: {
default: 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
outline:
'bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]'
},
size: {
default: 'h-8 text-sm',
sm: 'h-7 text-xs',
lg: 'h-12 text-sm group-data-[collapsible=icon]:p-0!'
}
},
defaultVariants: {
variant: 'default',
size: 'default'
}
}
);
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = 'default',
size = 'default',
tooltip,
className,
...props
}: React.ComponentProps<'button'> & {
asChild?: boolean;
isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot : 'button';
const { isMobile, state } = useSidebar();
const button = (
<Comp
data-slot='sidebar-menu-button'
data-sidebar='menu-button'
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
);
if (!tooltip) {
return button;
}
if (typeof tooltip === 'string') {
tooltip = {
children: tooltip
};
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side='right'
align='center'
hidden={state !== 'collapsed' || isMobile}
{...tooltip}
/>
</Tooltip>
);
}
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: React.ComponentProps<'button'> & {
asChild?: boolean;
showOnHover?: boolean;
}) {
const Comp = asChild ? Slot : 'button';
return (
<Comp
data-slot='sidebar-menu-action'
data-sidebar='menu-action'
className={cn(
'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
// Increases the hit area of the button on mobile.
'after:absolute after:-inset-2 md:after:hidden',
'peer-data-[size=sm]/menu-button:top-1',
'peer-data-[size=default]/menu-button:top-1.5',
'peer-data-[size=lg]/menu-button:top-2.5',
'group-data-[collapsible=icon]:hidden',
showOnHover &&
'peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0',
className
)}
{...props}
/>
);
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot='sidebar-menu-badge'
data-sidebar='menu-badge'
className={cn(
'text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none',
'peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground',
'peer-data-[size=sm]/menu-button:top-1',
'peer-data-[size=default]/menu-button:top-1.5',
'peer-data-[size=lg]/menu-button:top-2.5',
'group-data-[collapsible=icon]:hidden',
className
)}
{...props}
/>
);
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<'div'> & {
showIcon?: boolean;
}) {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`;
}, []);
return (
<div
data-slot='sidebar-menu-skeleton'
data-sidebar='menu-skeleton'
className={cn('flex h-8 items-center gap-2 rounded-md px-2', className)}
{...props}
>
{showIcon && (
<Skeleton
className='size-4 rounded-md'
data-sidebar='menu-skeleton-icon'
/>
)}
<Skeleton
className='h-4 max-w-(--skeleton-width) flex-1'
data-sidebar='menu-skeleton-text'
style={
{
'--skeleton-width': width
} as React.CSSProperties
}
/>
</div>
);
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<'ul'>) {
return (
<ul
data-slot='sidebar-menu-sub'
data-sidebar='menu-sub'
className={cn(
'border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5',
'group-data-[collapsible=icon]:hidden',
className
)}
{...props}
/>
);
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<'li'>) {
return (
<li
data-slot='sidebar-menu-sub-item'
data-sidebar='menu-sub-item'
className={cn('group/menu-sub-item relative', className)}
{...props}
/>
);
}
function SidebarMenuSubButton({
asChild = false,
size = 'md',
isActive = false,
className,
...props
}: React.ComponentProps<'a'> & {
asChild?: boolean;
size?: 'sm' | 'md';
isActive?: boolean;
}) {
const Comp = asChild ? Slot : 'a';
return (
<Comp
data-slot='sidebar-menu-sub-button'
data-sidebar='menu-sub-button'
data-size={size}
data-active={isActive}
className={cn(
'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
'data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground',
size === 'sm' && 'text-xs',
size === 'md' && 'text-sm',
'group-data-[collapsible=icon]:hidden',
className
)}
{...props}
/>
);
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar
};

View File

@@ -0,0 +1,13 @@
import { cn } from '@/lib/utils';
function Skeleton({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='skeleton'
className={cn('bg-accent animate-pulse rounded-md', className)}
{...props}
/>
);
}
export { Skeleton };

View File

@@ -0,0 +1,63 @@
'use client';
import * as React from 'react';
import * as SliderPrimitive from '@radix-ui/react-slider';
import { cn } from '@/lib/utils';
function Slider({
className,
defaultValue,
value,
min = 0,
max = 100,
...props
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
const _values = React.useMemo(
() =>
Array.isArray(value)
? value
: Array.isArray(defaultValue)
? defaultValue
: [min, max],
[value, defaultValue, min, max]
);
return (
<SliderPrimitive.Root
data-slot='slider'
defaultValue={defaultValue}
value={value}
min={min}
max={max}
className={cn(
'relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col',
className
)}
{...props}
>
<SliderPrimitive.Track
data-slot='slider-track'
className={cn(
'bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5'
)}
>
<SliderPrimitive.Range
data-slot='slider-range'
className={cn(
'bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full'
)}
/>
</SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => (
<SliderPrimitive.Thumb
data-slot='slider-thumb'
key={index}
className='border-primary bg-background ring-ring/50 block size-4 shrink-0 rounded-full border shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50'
/>
))}
</SliderPrimitive.Root>
);
}
export { Slider };

View File

@@ -0,0 +1,25 @@
'use client';
import { useTheme } from 'next-themes';
import { Toaster as Sonner, ToasterProps } from 'sonner';
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = 'system' } = useTheme();
return (
<Sonner
theme={theme as ToasterProps['theme']}
className='toaster group'
style={
{
'--normal-bg': 'var(--popover)',
'--normal-text': 'var(--popover-foreground)',
'--normal-border': 'var(--border)'
} as React.CSSProperties
}
{...props}
/>
);
};
export { Toaster };

View File

@@ -0,0 +1,16 @@
import { Icons } from '@/components/icons';
import { cn } from '@/lib/utils';
function Spinner({ className, ...props }: React.ComponentProps<'svg'>) {
return (
<Icons.spinner
role='status'
aria-label='Loading'
className={cn('size-4 animate-spin', className)}
{...props}
/>
);
}
export { Spinner };

View File

@@ -0,0 +1,31 @@
'use client';
import * as React from 'react';
import * as SwitchPrimitive from '@radix-ui/react-switch';
import { cn } from '@/lib/utils';
function Switch({
className,
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot='switch'
className={cn(
'peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot='switch-thumb'
className={cn(
'bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0'
)}
/>
</SwitchPrimitive.Root>
);
}
export { Switch };

116
src/components/ui/table.tsx Normal file
View File

@@ -0,0 +1,116 @@
'use client';
import * as React from 'react';
import { cn } from '@/lib/utils';
function Table({ className, ...props }: React.ComponentProps<'table'>) {
return (
<div
data-slot='table-container'
className='relative w-full overflow-x-auto'
>
<table
data-slot='table'
className={cn('w-full caption-bottom text-sm', className)}
{...props}
/>
</div>
);
}
function TableHeader({ className, ...props }: React.ComponentProps<'thead'>) {
return (
<thead
data-slot='table-header'
className={cn('[&_tr]:border-b', className)}
{...props}
/>
);
}
function TableBody({ className, ...props }: React.ComponentProps<'tbody'>) {
return (
<tbody
data-slot='table-body'
className={cn('[&_tr:last-child]:border-0', className)}
{...props}
/>
);
}
function TableFooter({ className, ...props }: React.ComponentProps<'tfoot'>) {
return (
<tfoot
data-slot='table-footer'
className={cn(
'bg-muted/50 border-t font-medium [&>tr]:last:border-b-0',
className
)}
{...props}
/>
);
}
function TableRow({ className, ...props }: React.ComponentProps<'tr'>) {
return (
<tr
data-slot='table-row'
className={cn(
'hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors',
className
)}
{...props}
/>
);
}
function TableHead({ className, ...props }: React.ComponentProps<'th'>) {
return (
<th
data-slot='table-head'
className={cn(
'text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
className
)}
{...props}
/>
);
}
function TableCell({ className, ...props }: React.ComponentProps<'td'>) {
return (
<td
data-slot='table-cell'
className={cn(
'p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
className
)}
{...props}
/>
);
}
function TableCaption({
className,
...props
}: React.ComponentProps<'caption'>) {
return (
<caption
data-slot='table-caption'
className={cn('text-muted-foreground mt-4 text-sm', className)}
{...props}
/>
);
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption
};

View File

@@ -0,0 +1,99 @@
'use client';
import type { Column } from '@tanstack/react-table';
import { EyeOff } from 'lucide-react';
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu';
import { cn } from '@/lib/utils';
import {
ChevronDownIcon,
ChevronUpIcon,
CaretSortIcon,
Cross2Icon
} from '@radix-ui/react-icons';
interface DataTableColumnHeaderProps<TData, TValue>
extends React.ComponentProps<typeof DropdownMenuTrigger> {
column: Column<TData, TValue>;
title: string;
}
export function DataTableColumnHeader<TData, TValue>({
column,
title,
className,
...props
}: DataTableColumnHeaderProps<TData, TValue>) {
if (!column.getCanSort() && !column.getCanHide()) {
return <div className={cn(className)}>{title}</div>;
}
return (
<DropdownMenu>
<DropdownMenuTrigger
className={cn(
'hover:bg-accent focus:ring-ring data-[state=open]:bg-accent [&_svg]:text-muted-foreground -ml-1.5 flex h-8 items-center gap-1.5 rounded-md px-2 py-1.5 focus:ring-1 focus:outline-none [&_svg]:size-4 [&_svg]:shrink-0',
className
)}
{...props}
>
{title}
{column.getCanSort() &&
(column.getIsSorted() === 'desc' ? (
<ChevronDownIcon />
) : column.getIsSorted() === 'asc' ? (
<ChevronUpIcon />
) : (
<CaretSortIcon />
))}
</DropdownMenuTrigger>
<DropdownMenuContent align='start' className='w-28'>
{column.getCanSort() && (
<>
<DropdownMenuCheckboxItem
className='[&_svg]:text-muted-foreground relative pr-8 pl-2 [&>span:first-child]:right-2 [&>span:first-child]:left-auto'
checked={column.getIsSorted() === 'asc'}
onClick={() => column.toggleSorting(false)}
>
<ChevronUpIcon />
Asc
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
className='[&_svg]:text-muted-foreground relative pr-8 pl-2 [&>span:first-child]:right-2 [&>span:first-child]:left-auto'
checked={column.getIsSorted() === 'desc'}
onClick={() => column.toggleSorting(true)}
>
<ChevronDownIcon />
Desc
</DropdownMenuCheckboxItem>
{column.getIsSorted() && (
<DropdownMenuItem
className='[&_svg]:text-muted-foreground pl-2'
onClick={() => column.clearSorting()}
>
<Cross2Icon />
Reset
</DropdownMenuItem>
)}
</>
)}
{column.getCanHide() && (
<DropdownMenuCheckboxItem
className='[&_svg]:text-muted-foreground relative pr-8 pl-2 [&>span:first-child]:right-2 [&>span:first-child]:left-auto'
checked={!column.getIsVisible()}
onClick={() => column.toggleVisibility(false)}
>
<EyeOff />
Hide
</DropdownMenuCheckboxItem>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,220 @@
'use client';
import type { Column } from '@tanstack/react-table';
import { CalendarIcon, XCircle } from 'lucide-react';
import * as React from 'react';
import type { DateRange } from 'react-day-picker';
import { Button } from '@/components/ui/button';
import { Calendar } from '@/components/ui/calendar';
import {
Popover,
PopoverContent,
PopoverTrigger
} from '@/components/ui/popover';
import { Separator } from '@/components/ui/separator';
import { formatDate } from '@/lib/format';
type DateSelection = Date[] | DateRange;
function getIsDateRange(value: DateSelection): value is DateRange {
return value && typeof value === 'object' && !Array.isArray(value);
}
function parseAsDate(timestamp: number | string | undefined): Date | undefined {
if (!timestamp) return undefined;
const numericTimestamp =
typeof timestamp === 'string' ? Number(timestamp) : timestamp;
const date = new Date(numericTimestamp);
return !Number.isNaN(date.getTime()) ? date : undefined;
}
function parseColumnFilterValue(value: unknown) {
if (value === null || value === undefined) {
return [];
}
if (Array.isArray(value)) {
return value.map((item) => {
if (typeof item === 'number' || typeof item === 'string') {
return item;
}
return undefined;
});
}
if (typeof value === 'string' || typeof value === 'number') {
return [value];
}
return [];
}
interface DataTableDateFilterProps<TData> {
column: Column<TData, unknown>;
title?: string;
multiple?: boolean;
}
export function DataTableDateFilter<TData>({
column,
title,
multiple
}: DataTableDateFilterProps<TData>) {
const columnFilterValue = column.getFilterValue();
const selectedDates = React.useMemo<DateSelection>(() => {
if (!columnFilterValue) {
return multiple ? { from: undefined, to: undefined } : [];
}
if (multiple) {
const timestamps = parseColumnFilterValue(columnFilterValue);
return {
from: parseAsDate(timestamps[0]),
to: parseAsDate(timestamps[1])
};
}
const timestamps = parseColumnFilterValue(columnFilterValue);
const date = parseAsDate(timestamps[0]);
return date ? [date] : [];
}, [columnFilterValue, multiple]);
const onSelect = React.useCallback(
(date: Date | DateRange | undefined) => {
if (!date) {
column.setFilterValue(undefined);
return;
}
if (multiple && !('getTime' in date)) {
const from = date.from?.getTime();
const to = date.to?.getTime();
column.setFilterValue(from || to ? [from, to] : undefined);
} else if (!multiple && 'getTime' in date) {
column.setFilterValue(date.getTime());
}
},
[column, multiple]
);
const onReset = React.useCallback(
(event: React.MouseEvent) => {
event.stopPropagation();
column.setFilterValue(undefined);
},
[column]
);
const hasValue = React.useMemo(() => {
if (multiple) {
if (!getIsDateRange(selectedDates)) return false;
return selectedDates.from || selectedDates.to;
}
if (!Array.isArray(selectedDates)) return false;
return selectedDates.length > 0;
}, [multiple, selectedDates]);
const formatDateRange = React.useCallback((range: DateRange) => {
if (!range.from && !range.to) return '';
if (range.from && range.to) {
return `${formatDate(range.from)} - ${formatDate(range.to)}`;
}
return formatDate(range.from ?? range.to);
}, []);
const label = React.useMemo(() => {
if (multiple) {
if (!getIsDateRange(selectedDates)) return null;
const hasSelectedDates = selectedDates.from || selectedDates.to;
const dateText = hasSelectedDates
? formatDateRange(selectedDates)
: 'Select date range';
return (
<span className='flex items-center gap-2'>
<span>{title}</span>
{hasSelectedDates && (
<>
<Separator
orientation='vertical'
className='mx-0.5 data-[orientation=vertical]:h-4'
/>
<span>{dateText}</span>
</>
)}
</span>
);
}
if (getIsDateRange(selectedDates)) return null;
const hasSelectedDate = selectedDates.length > 0;
const dateText = hasSelectedDate
? formatDate(selectedDates[0])
: 'Select date';
return (
<span className='flex items-center gap-2'>
<span>{title}</span>
{hasSelectedDate && (
<>
<Separator
orientation='vertical'
className='mx-0.5 data-[orientation=vertical]:h-4'
/>
<span>{dateText}</span>
</>
)}
</span>
);
}, [selectedDates, multiple, formatDateRange, title]);
return (
<Popover>
<PopoverTrigger asChild>
<Button variant='outline' size='sm' className='border-dashed'>
{hasValue ? (
<div
role='button'
aria-label={`Clear ${title} filter`}
tabIndex={0}
onClick={onReset}
className='focus-visible:ring-ring rounded-sm opacity-70 transition-opacity hover:opacity-100 focus-visible:ring-1 focus-visible:outline-none'
>
<XCircle />
</div>
) : (
<CalendarIcon />
)}
{label}
</Button>
</PopoverTrigger>
<PopoverContent className='w-auto p-0' align='start'>
{multiple ? (
<Calendar
initialFocus
mode='range'
selected={
getIsDateRange(selectedDates)
? selectedDates
: { from: undefined, to: undefined }
}
onSelect={onSelect}
/>
) : (
<Calendar
initialFocus
mode='single'
selected={
!getIsDateRange(selectedDates) ? selectedDates[0] : undefined
}
onSelect={onSelect}
/>
)}
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,187 @@
'use client';
import type { Option } from '@/types/data-table';
import type { Column } from '@tanstack/react-table';
import { PlusCircle, XCircle } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator
} from '@/components/ui/command';
import {
Popover,
PopoverContent,
PopoverTrigger
} from '@/components/ui/popover';
import { Separator } from '@/components/ui/separator';
import { cn } from '@/lib/utils';
import * as React from 'react';
import { CheckIcon } from '@radix-ui/react-icons';
interface DataTableFacetedFilterProps<TData, TValue> {
column?: Column<TData, TValue>;
title?: string;
options: Option[];
multiple?: boolean;
}
export function DataTableFacetedFilter<TData, TValue>({
column,
title,
options,
multiple
}: DataTableFacetedFilterProps<TData, TValue>) {
const [open, setOpen] = React.useState(false);
const columnFilterValue = column?.getFilterValue();
const selectedValues = React.useMemo(
() => new Set(Array.isArray(columnFilterValue) ? columnFilterValue : []),
[columnFilterValue]
);
const onItemSelect = React.useCallback(
(option: Option, isSelected: boolean) => {
if (!column) return;
if (multiple) {
const newSelectedValues = new Set(selectedValues);
if (isSelected) {
newSelectedValues.delete(option.value);
} else {
newSelectedValues.add(option.value);
}
const filterValues = Array.from(newSelectedValues);
column.setFilterValue(filterValues.length ? filterValues : undefined);
} else {
column.setFilterValue(isSelected ? undefined : [option.value]);
setOpen(false);
}
},
[column, multiple, selectedValues]
);
const onReset = React.useCallback(
(event?: React.MouseEvent) => {
event?.stopPropagation();
column?.setFilterValue(undefined);
},
[column]
);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant='outline' size='sm' className='border-dashed'>
{selectedValues?.size > 0 ? (
<div
role='button'
aria-label={`Clear ${title} filter`}
tabIndex={0}
onClick={onReset}
className='focus-visible:ring-ring rounded-sm opacity-70 transition-opacity hover:opacity-100 focus-visible:ring-1 focus-visible:outline-none'
>
<XCircle />
</div>
) : (
<PlusCircle />
)}
{title}
{selectedValues?.size > 0 && (
<>
<Separator
orientation='vertical'
className='mx-0.5 data-[orientation=vertical]:h-4'
/>
<Badge
variant='secondary'
className='rounded-sm px-1 font-normal lg:hidden'
>
{selectedValues.size}
</Badge>
<div className='hidden items-center gap-1 lg:flex'>
{selectedValues.size > 2 ? (
<Badge
variant='secondary'
className='rounded-sm px-1 font-normal'
>
{selectedValues.size} selected
</Badge>
) : (
options
.filter((option) => selectedValues.has(option.value))
.map((option) => (
<Badge
variant='secondary'
key={option.value}
className='rounded-sm px-1 font-normal'
>
{option.label}
</Badge>
))
)}
</div>
</>
)}
</Button>
</PopoverTrigger>
<PopoverContent className='w-[12.5rem] p-0' align='start'>
<Command>
<CommandInput placeholder={title} />
<CommandList className='max-h-full'>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup className='max-h-[18.75rem] overflow-x-hidden overflow-y-auto'>
{options.map((option) => {
const isSelected = selectedValues.has(option.value);
return (
<CommandItem
key={option.value}
onSelect={() => onItemSelect(option, isSelected)}
>
<div
className={cn(
'border-primary flex size-4 items-center justify-center rounded-sm border',
isSelected
? 'bg-primary'
: 'opacity-50 [&_svg]:invisible'
)}
>
<CheckIcon />
</div>
{option.icon && <option.icon />}
<span className='truncate'>{option.label}</span>
{option.count && (
<span className='ml-auto font-mono text-xs'>
{option.count}
</span>
)}
</CommandItem>
);
})}
</CommandGroup>
{selectedValues.size > 0 && (
<>
<CommandSeparator />
<CommandGroup>
<CommandItem
onSelect={() => onReset()}
className='justify-center text-center'
>
Clear filters
</CommandItem>
</CommandGroup>
</>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,114 @@
import type { Table } from '@tanstack/react-table';
import { ChevronsLeft, ChevronsRight } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
import { ChevronLeftIcon, ChevronRightIcon } from '@radix-ui/react-icons';
interface DataTablePaginationProps<TData> extends React.ComponentProps<'div'> {
table: Table<TData>;
pageSizeOptions?: number[];
}
export function DataTablePagination<TData>({
table,
pageSizeOptions = [10, 20, 30, 40, 50],
className,
...props
}: DataTablePaginationProps<TData>) {
return (
<div
className={cn(
'flex w-full flex-col-reverse items-center justify-between gap-4 overflow-auto p-1 sm:flex-row sm:gap-8',
className
)}
{...props}
>
<div className='text-muted-foreground flex-1 text-sm whitespace-nowrap'>
{table.getFilteredSelectedRowModel().rows.length > 0 ? (
<>
{table.getFilteredSelectedRowModel().rows.length} of{' '}
{table.getFilteredRowModel().rows.length} row(s) selected.
</>
) : (
<>{table.getFilteredRowModel().rows.length} row(s) total.</>
)}
</div>
<div className='flex flex-col-reverse items-center gap-4 sm:flex-row sm:gap-6 lg:gap-8'>
<div className='flex items-center space-x-2'>
<p className='text-sm font-medium whitespace-nowrap'>Rows per page</p>
<Select
value={`${table.getState().pagination.pageSize}`}
onValueChange={(value) => {
table.setPageSize(Number(value));
}}
>
<SelectTrigger className='h-8 w-[4.5rem] [&[data-size]]:h-8'>
<SelectValue placeholder={table.getState().pagination.pageSize} />
</SelectTrigger>
<SelectContent side='top'>
{pageSizeOptions.map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className='flex items-center justify-center text-sm font-medium'>
Page {table.getState().pagination.pageIndex + 1} of{' '}
{table.getPageCount()}
</div>
<div className='flex items-center space-x-2'>
<Button
aria-label='Go to first page'
variant='outline'
size='icon'
className='hidden size-8 lg:flex'
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<ChevronsLeft />
</Button>
<Button
aria-label='Go to previous page'
variant='outline'
size='icon'
className='size-8'
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<ChevronLeftIcon />
</Button>
<Button
aria-label='Go to next page'
variant='outline'
size='icon'
className='size-8'
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<ChevronRightIcon />
</Button>
<Button
aria-label='Go to last page'
variant='outline'
size='icon'
className='hidden size-8 lg:flex'
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
<ChevronsRight />
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,113 @@
import { Skeleton } from '@/components/ui/skeleton';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '@/components/ui/table';
import { cn } from '@/lib/utils';
interface DataTableSkeletonProps extends React.ComponentProps<'div'> {
columnCount: number;
rowCount?: number;
filterCount?: number;
cellWidths?: string[];
withViewOptions?: boolean;
withPagination?: boolean;
shrinkZero?: boolean;
}
export function DataTableSkeleton({
columnCount,
rowCount = 10,
filterCount = 0,
cellWidths = ['auto'],
withViewOptions = true,
withPagination = true,
shrinkZero = false,
className,
...props
}: DataTableSkeletonProps) {
const cozyCellWidths = Array.from(
{ length: columnCount },
(_, index) => cellWidths[index % cellWidths.length] ?? 'auto'
);
return (
<div className={cn('flex flex-1 flex-col space-y-4', className)} {...props}>
<div className='flex w-full items-center justify-between gap-2 overflow-auto p-1'>
<div className='flex flex-1 items-center gap-2'>
{filterCount > 0
? Array.from({ length: filterCount }).map((_, i) => (
<Skeleton key={i} className='h-7 w-[4.5rem] border-dashed' />
))
: null}
</div>
{withViewOptions ? (
<Skeleton className='ml-auto hidden h-7 w-[4.5rem] lg:flex' />
) : null}
</div>
<div className='flex-1 rounded-md border'>
<Table>
<TableHeader>
{Array.from({ length: 1 }).map((_, i) => (
<TableRow key={i} className='hover:bg-transparent'>
{Array.from({ length: columnCount }).map((_, j) => (
<TableHead
key={j}
style={{
width: cozyCellWidths[j],
minWidth: shrinkZero ? cozyCellWidths[j] : 'auto'
}}
>
<Skeleton className='h-6 w-full' />
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{Array.from({ length: rowCount }).map((_, i) => (
<TableRow key={i} className='hover:bg-transparent'>
{Array.from({ length: columnCount }).map((_, j) => (
<TableCell
key={j}
style={{
width: cozyCellWidths[j],
minWidth: shrinkZero ? cozyCellWidths[j] : 'auto'
}}
>
<Skeleton className='h-6 w-full' />
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
{withPagination ? (
<div className='flex w-full items-center justify-between gap-4 overflow-auto p-1 sm:gap-8'>
<Skeleton className='h-7 w-40 shrink-0' />
<div className='flex items-center gap-4 sm:gap-6 lg:gap-8'>
<div className='flex items-center gap-2'>
<Skeleton className='h-7 w-24' />
<Skeleton className='h-7 w-[4.5rem]' />
</div>
<div className='flex items-center justify-center text-sm font-medium'>
<Skeleton className='h-7 w-20' />
</div>
<div className='flex items-center gap-2'>
<Skeleton className='hidden size-7 lg:block' />
<Skeleton className='size-7' />
<Skeleton className='size-7' />
<Skeleton className='hidden size-7 lg:block' />
</div>
</div>
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,240 @@
'use client';
import type { Column } from '@tanstack/react-table';
import * as React from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Popover,
PopoverContent,
PopoverTrigger
} from '@/components/ui/popover';
import { Separator } from '@/components/ui/separator';
import { Slider } from '@/components/ui/slider';
import { cn } from '@/lib/utils';
import { PlusCircle, XCircle } from 'lucide-react';
interface Range {
min: number;
max: number;
}
type RangeValue = [number, number];
function getIsValidRange(value: unknown): value is RangeValue {
return (
Array.isArray(value) &&
value.length === 2 &&
typeof value[0] === 'number' &&
typeof value[1] === 'number'
);
}
interface DataTableSliderFilterProps<TData> {
column: Column<TData, unknown>;
title?: string;
}
export function DataTableSliderFilter<TData>({
column,
title
}: DataTableSliderFilterProps<TData>) {
const id = React.useId();
const columnFilterValue = getIsValidRange(column.getFilterValue())
? (column.getFilterValue() as RangeValue)
: undefined;
const defaultRange = column.columnDef.meta?.range;
const unit = column.columnDef.meta?.unit;
const { min, max, step } = React.useMemo<Range & { step: number }>(() => {
let minValue = 0;
let maxValue = 100;
if (defaultRange && getIsValidRange(defaultRange)) {
[minValue, maxValue] = defaultRange;
} else {
const values = column.getFacetedMinMaxValues();
if (values && Array.isArray(values) && values.length === 2) {
const [facetMinValue, facetMaxValue] = values;
if (
typeof facetMinValue === 'number' &&
typeof facetMaxValue === 'number'
) {
minValue = facetMinValue;
maxValue = facetMaxValue;
}
}
}
const rangeSize = maxValue - minValue;
const step =
rangeSize <= 20
? 1
: rangeSize <= 100
? Math.ceil(rangeSize / 20)
: Math.ceil(rangeSize / 50);
return { min: minValue, max: maxValue, step };
}, [column, defaultRange]);
const range = React.useMemo((): RangeValue => {
return columnFilterValue ?? [min, max];
}, [columnFilterValue, min, max]);
const formatValue = React.useCallback((value: number) => {
return value.toLocaleString(undefined, { maximumFractionDigits: 0 });
}, []);
const onFromInputChange = React.useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const numValue = Number(event.target.value);
if (!Number.isNaN(numValue) && numValue >= min && numValue <= range[1]) {
column.setFilterValue([numValue, range[1]]);
}
},
[column, min, range]
);
const onToInputChange = React.useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const numValue = Number(event.target.value);
if (!Number.isNaN(numValue) && numValue <= max && numValue >= range[0]) {
column.setFilterValue([range[0], numValue]);
}
},
[column, max, range]
);
const onSliderValueChange = React.useCallback(
(value: RangeValue) => {
if (Array.isArray(value) && value.length === 2) {
column.setFilterValue(value);
}
},
[column]
);
const onReset = React.useCallback(
(event: React.MouseEvent) => {
if (event.target instanceof HTMLDivElement) {
event.stopPropagation();
}
column.setFilterValue(undefined);
},
[column]
);
return (
<Popover>
<PopoverTrigger asChild>
<Button variant='outline' size='sm' className='border-dashed'>
{columnFilterValue ? (
<div
role='button'
aria-label={`Clear ${title} filter`}
tabIndex={0}
className='focus-visible:ring-ring rounded-sm opacity-70 transition-opacity hover:opacity-100 focus-visible:ring-1 focus-visible:outline-none'
onClick={onReset}
>
<XCircle />
</div>
) : (
<PlusCircle />
)}
<span>{title}</span>
{columnFilterValue ? (
<>
<Separator
orientation='vertical'
className='mx-0.5 data-[orientation=vertical]:h-4'
/>
{formatValue(columnFilterValue[0])} -{' '}
{formatValue(columnFilterValue[1])}
{unit ? ` ${unit}` : ''}
</>
) : null}
</Button>
</PopoverTrigger>
<PopoverContent align='start' className='flex w-auto flex-col gap-4'>
<div className='flex flex-col gap-3'>
<p className='leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70'>
{title}
</p>
<div className='flex items-center gap-4'>
<Label htmlFor={`${id}-from`} className='sr-only'>
From
</Label>
<div className='relative'>
<Input
id={`${id}-from`}
type='number'
aria-valuemin={min}
aria-valuemax={max}
inputMode='numeric'
pattern='[0-9]*'
placeholder={min.toString()}
min={min}
max={max}
value={range[0]?.toString()}
onChange={onFromInputChange}
className={cn('h-8 w-24', unit && 'pr-8')}
/>
{unit && (
<span className='bg-accent text-muted-foreground absolute top-0 right-0 bottom-0 flex items-center rounded-r-md px-2 text-sm'>
{unit}
</span>
)}
</div>
<Label htmlFor={`${id}-to`} className='sr-only'>
to
</Label>
<div className='relative'>
<Input
id={`${id}-to`}
type='number'
aria-valuemin={min}
aria-valuemax={max}
inputMode='numeric'
pattern='[0-9]*'
placeholder={max.toString()}
min={min}
max={max}
value={range[1]?.toString()}
onChange={onToInputChange}
className={cn('h-8 w-24', unit && 'pr-8')}
/>
{unit && (
<span className='bg-accent text-muted-foreground absolute top-0 right-0 bottom-0 flex items-center rounded-r-md px-2 text-sm'>
{unit}
</span>
)}
</div>
</div>
<Label htmlFor={`${id}-slider`} className='sr-only'>
{title} slider
</Label>
<Slider
id={`${id}-slider`}
min={min}
max={max}
step={step}
value={range}
onValueChange={onSliderValueChange}
/>
</div>
<Button
aria-label={`Clear ${title} filter`}
variant='outline'
size='sm'
onClick={onReset}
>
Clear
</Button>
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,149 @@
'use client';
import type { Column, Table } from '@tanstack/react-table';
import * as React from 'react';
import { DataTableDateFilter } from '@/components/ui/table/data-table-date-filter';
import { DataTableFacetedFilter } from '@/components/ui/table/data-table-faceted-filter';
import { DataTableSliderFilter } from '@/components/ui/table/data-table-slider-filter';
import { DataTableViewOptions } from '@/components/ui/table/data-table-view-options';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { cn } from '@/lib/utils';
import { Cross2Icon } from '@radix-ui/react-icons';
interface DataTableToolbarProps<TData> extends React.ComponentProps<'div'> {
table: Table<TData>;
}
export function DataTableToolbar<TData>({
table,
children,
className,
...props
}: DataTableToolbarProps<TData>) {
const isFiltered = table.getState().columnFilters.length > 0;
const columns = React.useMemo(
() => table.getAllColumns().filter((column) => column.getCanFilter()),
[table]
);
const onReset = React.useCallback(() => {
table.resetColumnFilters();
}, [table]);
return (
<div
role='toolbar'
aria-orientation='horizontal'
className={cn(
'flex w-full items-start justify-between gap-2 p-1',
className
)}
{...props}
>
<div className='flex flex-1 flex-wrap items-center gap-2'>
{columns.map((column) => (
<DataTableToolbarFilter key={column.id} column={column} />
))}
{isFiltered && (
<Button
aria-label='Reset filters'
variant='outline'
size='sm'
className='border-dashed'
onClick={onReset}
>
<Cross2Icon />
Reset
</Button>
)}
</div>
<div className='flex items-center gap-2'>
{children}
<DataTableViewOptions table={table} />
</div>
</div>
);
}
interface DataTableToolbarFilterProps<TData> {
column: Column<TData>;
}
function DataTableToolbarFilter<TData>({
column
}: DataTableToolbarFilterProps<TData>) {
{
const columnMeta = column.columnDef.meta;
const onFilterRender = React.useCallback(() => {
if (!columnMeta?.variant) return null;
switch (columnMeta.variant) {
case 'text':
return (
<Input
placeholder={columnMeta.placeholder ?? columnMeta.label}
value={(column.getFilterValue() as string) ?? ''}
onChange={(event) => column.setFilterValue(event.target.value)}
className='h-8 w-40 lg:w-56'
/>
);
case 'number':
return (
<div className='relative'>
<Input
type='number'
inputMode='numeric'
placeholder={columnMeta.placeholder ?? columnMeta.label}
value={(column.getFilterValue() as string) ?? ''}
onChange={(event) => column.setFilterValue(event.target.value)}
className={cn('h-8 w-[120px]', columnMeta.unit && 'pr-8')}
/>
{columnMeta.unit && (
<span className='bg-accent text-muted-foreground absolute top-0 right-0 bottom-0 flex items-center rounded-r-md px-2 text-sm'>
{columnMeta.unit}
</span>
)}
</div>
);
case 'range':
return (
<DataTableSliderFilter
column={column}
title={columnMeta.label ?? column.id}
/>
);
case 'date':
case 'dateRange':
return (
<DataTableDateFilter
column={column}
title={columnMeta.label ?? column.id}
multiple={columnMeta.variant === 'dateRange'}
/>
);
case 'select':
case 'multiSelect':
return (
<DataTableFacetedFilter
column={column}
title={columnMeta.label ?? column.id}
options={columnMeta.options ?? []}
multiple={columnMeta.variant === 'multiSelect'}
/>
);
default:
return null;
}
}, [column, columnMeta]);
return onFilterRender();
}
}

View File

@@ -0,0 +1,87 @@
'use client';
import type { Table } from '@tanstack/react-table';
import { Settings2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from '@/components/ui/command';
import {
Popover,
PopoverContent,
PopoverTrigger
} from '@/components/ui/popover';
import { cn } from '@/lib/utils';
import * as React from 'react';
import { CheckIcon, CaretSortIcon } from '@radix-ui/react-icons';
interface DataTableViewOptionsProps<TData> {
table: Table<TData>;
}
export function DataTableViewOptions<TData>({
table
}: DataTableViewOptionsProps<TData>) {
const columns = React.useMemo(
() =>
table
.getAllColumns()
.filter(
(column) =>
typeof column.accessorFn !== 'undefined' && column.getCanHide()
),
[table]
);
return (
<Popover>
<PopoverTrigger asChild>
<Button
aria-label='Toggle columns'
role='combobox'
variant='outline'
size='sm'
className='ml-auto hidden h-8 lg:flex'
>
<Settings2 />
View
<CaretSortIcon className='ml-auto opacity-50' />
</Button>
</PopoverTrigger>
<PopoverContent align='end' className='w-44 p-0'>
<Command>
<CommandInput placeholder='Search columns...' />
<CommandList>
<CommandEmpty>No columns found.</CommandEmpty>
<CommandGroup>
{columns.map((column) => (
<CommandItem
key={column.id}
onSelect={() =>
column.toggleVisibility(!column.getIsVisible())
}
>
<span className='truncate'>
{column.columnDef.meta?.label ?? column.id}
</span>
<CheckIcon
className={cn(
'ml-auto size-4 shrink-0',
column.getIsVisible() ? 'opacity-100' : 'opacity-0'
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,101 @@
import { type Table as TanstackTable, flexRender } from '@tanstack/react-table';
import type * as React from 'react';
import { DataTablePagination } from '@/components/ui/table/data-table-pagination';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '@/components/ui/table';
import { getCommonPinningStyles } from '@/lib/data-table';
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area';
interface DataTableProps<TData> extends React.ComponentProps<'div'> {
table: TanstackTable<TData>;
actionBar?: React.ReactNode;
}
export function DataTable<TData>({
table,
actionBar,
children
}: DataTableProps<TData>) {
return (
<div className='flex flex-1 flex-col space-y-4'>
{children}
<div className='relative flex flex-1'>
<div className='absolute inset-0 flex overflow-hidden rounded-lg border'>
<ScrollArea className='h-full w-full'>
<Table>
<TableHeader className='bg-muted sticky top-0 z-10'>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
colSpan={header.colSpan}
style={{
...getCommonPinningStyles({ column: header.column })
}}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
style={{
...getCommonPinningStyles({ column: cell.column })
}}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={table.getAllColumns().length}
className='h-24 text-center'
>
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<ScrollBar orientation='horizontal' />
</ScrollArea>
</div>
</div>
<div className='flex flex-col gap-2.5'>
<DataTablePagination table={table} />
{actionBar &&
table.getFilteredSelectedRowModel().rows.length > 0 &&
actionBar}
</div>
</div>
);
}

View File

@@ -0,0 +1,66 @@
'use client';
import * as React from 'react';
import * as TabsPrimitive from '@radix-ui/react-tabs';
import { cn } from '@/lib/utils';
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot='tabs'
className={cn('flex flex-col gap-2', className)}
{...props}
/>
);
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot='tabs-list'
className={cn(
'bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]',
className
)}
{...props}
/>
);
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot='tabs-trigger'
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
);
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot='tabs-content'
className={cn('flex-1 outline-none', className)}
{...props}
/>
);
}
export { Tabs, TabsList, TabsTrigger, TabsContent };

View File

@@ -0,0 +1,234 @@
/**
* tanstack-form.tsx — Main entry point for the form system.
*
* Provides useAppForm, useFormFields, Form, SubmitButton, StepButton,
* withForm, and withFieldGroup. See docs/forms.md for full usage guide.
*/
import { createFormHook } from '@tanstack/react-form';
import type { VariantProps } from 'class-variance-authority';
import * as React from 'react';
import { Button, type buttonVariants } from '@/components/ui/button';
import {
FieldContent,
FieldDescription,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSeparator,
FieldTitle
} from '@/components/ui/field';
import { InputGroup, InputGroupAddon, InputGroupInput } from '@/components/ui/input-group';
import {
TextField,
TextareaField,
SelectField,
CheckboxField,
SwitchField,
RadioGroupField,
SliderField,
FileUploadField,
FormTextField,
FormTextareaField,
FormSelectField,
FormCheckboxField,
FormSwitchField,
FormRadioGroupField,
FormSliderField,
FormFileUploadField
} from '@/components/forms/fields';
import { cn } from '@/lib/utils';
import {
fieldContext,
formContext,
useFormContext,
FormFieldSet,
FormField,
FormFieldError
} from './form-context';
// ---------------------------------------------------------------------------
// Form-level components (used as form.ComponentName)
// ---------------------------------------------------------------------------
function Form({
children,
...props
}: Omit<React.ComponentPropsWithoutRef<'form'>, 'onSubmit' | 'noValidate'> & {
children?: React.ReactNode;
}) {
const form = useFormContext();
const handleSubmit = React.useCallback(
(e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit();
},
[form]
);
return (
<form
onSubmit={handleSubmit}
className={cn('mx-auto flex w-full flex-col gap-2 p-2 md:p-5', props.className)}
noValidate
{...props}
>
{children}
</form>
);
}
function SubmitButton({
children,
className,
size,
...props
}: React.ComponentProps<'button'> & VariantProps<typeof buttonVariants>) {
const form = useFormContext();
return (
<form.Subscribe selector={(state) => [state.canSubmit, state.isSubmitting] as const}>
{([canSubmit, isSubmitting]) => (
<Button
className={className}
size={size}
type='submit'
disabled={!canSubmit}
isLoading={isSubmitting}
{...props}
>
{children}
</Button>
)}
</form.Subscribe>
);
}
function StepButton({
label,
handleMovement,
...props
}: React.ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & {
label: React.ReactNode | string;
handleMovement: () => void;
}) {
return (
<Button size='sm' variant='ghost' type='button' onClick={handleMovement} {...props}>
{label}
</Button>
);
}
// ---------------------------------------------------------------------------
// Hook creation
// ---------------------------------------------------------------------------
const { useAppForm, withForm, withFieldGroup } = createFormHook({
fieldContext,
formContext,
fieldComponents: {
// Structural (for custom fields via AppField escape hatch)
Field: FormField,
FieldError: FormFieldError,
FieldSet: FormFieldSet,
FieldContent,
FieldDescription,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSeparator,
FieldTitle,
InputGroup,
InputGroupAddon,
InputGroupInput,
// Base field components (for AppField render-prop pattern)
TextField,
TextareaField,
SelectField,
CheckboxField,
SwitchField,
RadioGroupField,
SliderField,
FileUploadField
},
formComponents: {
// Layout & actions
Form,
SubmitButton,
StepButton,
FieldLegend,
FieldDescription,
FieldSeparator,
// Composed field components (flat API — convenience pattern)
// These allow form.TextField, form.SelectField, etc.
// For type-safe field names, use form.AppField render-prop instead.
TextField: FormTextField,
TextareaField: FormTextareaField,
SelectField: FormSelectField,
CheckboxField: FormCheckboxField,
SwitchField: FormSwitchField,
RadioGroupField: FormRadioGroupField,
SliderField: FormSliderField,
FileUploadField: FormFileUploadField
}
});
// ---------------------------------------------------------------------------
// Type-safe field names — useFormFields
// ---------------------------------------------------------------------------
import type { WithTypedName } from './form-context';
/**
* Returns all composed field components with type-safe `name` props.
* Pass your form's value type (or `z.infer<typeof schema>`) to narrow names.
*
* @example
* ```tsx
* type FormValues = z.infer<typeof mySchema>;
* const form = useAppForm({ defaultValues: {...} as FormValues, ... });
* const { FormTextField, FormSelectField } = useFormFields<FormValues>();
*
* <FormTextField name="email" /> // ✅ autocomplete + type check
* <FormTextField name="typo" /> // ❌ TypeScript error!
* ```
*/
function useFormFields<TValues extends Record<string, unknown>>() {
type Typed<C> = WithTypedName<C, TValues>;
return {
FormTextField: FormTextField as unknown as Typed<typeof FormTextField>,
FormTextareaField: FormTextareaField as unknown as Typed<typeof FormTextareaField>,
FormSelectField: FormSelectField as unknown as Typed<typeof FormSelectField>,
FormCheckboxField: FormCheckboxField as unknown as Typed<typeof FormCheckboxField>,
FormSwitchField: FormSwitchField as unknown as Typed<typeof FormSwitchField>,
FormRadioGroupField: FormRadioGroupField as unknown as Typed<typeof FormRadioGroupField>,
FormSliderField: FormSliderField as unknown as Typed<typeof FormSliderField>,
FormFileUploadField: FormFileUploadField as unknown as Typed<typeof FormFileUploadField>
};
}
// ---------------------------------------------------------------------------
// Exports
// ---------------------------------------------------------------------------
export { useAppForm, withForm, withFieldGroup, useFormFields };
export type {
FieldConfig,
FieldValidatorConfig,
FieldListenerConfig,
WithTypedName
} from './form-context';
export {
createFormField,
typedField,
revalidateLogic,
scrollToFirstError,
useFieldContext,
useFormContext,
FormFieldSet,
FormField,
FormFieldError,
FormErrors
} from './form-context';

View File

@@ -0,0 +1,18 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {
return (
<textarea
data-slot='textarea'
className={cn(
'border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
className
)}
{...props}
/>
);
}
export { Textarea };

View File

@@ -0,0 +1,73 @@
'use client';
import * as React from 'react';
import * as ToggleGroupPrimitive from '@radix-ui/react-toggle-group';
import { type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
import { toggleVariants } from '@/components/ui/toggle';
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants>
>({
size: 'default',
variant: 'default'
});
function ToggleGroup({
className,
variant,
size,
children,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<ToggleGroupPrimitive.Root
data-slot='toggle-group'
data-variant={variant}
data-size={size}
className={cn(
'group/toggle-group flex w-fit items-center rounded-md data-[variant=outline]:shadow-xs',
className
)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
);
}
function ToggleGroupItem({
className,
children,
variant,
size,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext);
return (
<ToggleGroupPrimitive.Item
data-slot='toggle-group-item'
data-variant={context.variant || variant}
data-size={context.size || size}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size
}),
'min-w-0 flex-1 shrink-0 rounded-none shadow-none first:rounded-l-md last:rounded-r-md focus:z-10 focus-visible:z-10 data-[variant=outline]:border-l-0 data-[variant=outline]:first:border-l',
className
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
);
}
export { ToggleGroup, ToggleGroupItem };

View File

@@ -0,0 +1,47 @@
'use client';
import * as React from 'react';
import * as TogglePrimitive from '@radix-ui/react-toggle';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
{
variants: {
variant: {
default: 'bg-transparent',
outline:
'border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground'
},
size: {
default: 'h-9 px-2 min-w-9',
sm: 'h-8 px-1.5 min-w-8',
lg: 'h-10 px-2.5 min-w-10'
}
},
defaultVariants: {
variant: 'default',
size: 'default'
}
}
);
function Toggle({
className,
variant,
size,
...props
}: React.ComponentProps<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive.Root
data-slot='toggle'
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Toggle, toggleVariants };

View File

@@ -0,0 +1,61 @@
'use client';
import * as React from 'react';
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
import { cn } from '@/lib/utils';
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot='tooltip-provider'
delayDuration={delayDuration}
{...props}
/>
);
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot='tooltip' {...props} />
</TooltipProvider>
);
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot='tooltip-trigger' {...props} />;
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot='tooltip-content'
sideOffset={sideOffset}
className={cn(
'bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance',
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className='bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]' />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

82
src/config/data-table.ts Normal file
View File

@@ -0,0 +1,82 @@
export type DataTableConfig = typeof dataTableConfig;
export const dataTableConfig = {
textOperators: [
{ label: 'Contains', value: 'iLike' as const },
{ label: 'Does not contain', value: 'notILike' as const },
{ label: 'Is', value: 'eq' as const },
{ label: 'Is not', value: 'ne' as const },
{ label: 'Is empty', value: 'isEmpty' as const },
{ label: 'Is not empty', value: 'isNotEmpty' as const }
],
numericOperators: [
{ label: 'Is', value: 'eq' as const },
{ label: 'Is not', value: 'ne' as const },
{ label: 'Is less than', value: 'lt' as const },
{ label: 'Is less than or equal to', value: 'lte' as const },
{ label: 'Is greater than', value: 'gt' as const },
{ label: 'Is greater than or equal to', value: 'gte' as const },
{ label: 'Is between', value: 'isBetween' as const },
{ label: 'Is empty', value: 'isEmpty' as const },
{ label: 'Is not empty', value: 'isNotEmpty' as const }
],
dateOperators: [
{ label: 'Is', value: 'eq' as const },
{ label: 'Is not', value: 'ne' as const },
{ label: 'Is before', value: 'lt' as const },
{ label: 'Is after', value: 'gt' as const },
{ label: 'Is on or before', value: 'lte' as const },
{ label: 'Is on or after', value: 'gte' as const },
{ label: 'Is between', value: 'isBetween' as const },
{ label: 'Is relative to today', value: 'isRelativeToToday' as const },
{ label: 'Is empty', value: 'isEmpty' as const },
{ label: 'Is not empty', value: 'isNotEmpty' as const }
],
selectOperators: [
{ label: 'Is', value: 'eq' as const },
{ label: 'Is not', value: 'ne' as const },
{ label: 'Is empty', value: 'isEmpty' as const },
{ label: 'Is not empty', value: 'isNotEmpty' as const }
],
multiSelectOperators: [
{ label: 'Has any of', value: 'inArray' as const },
{ label: 'Has none of', value: 'notInArray' as const },
{ label: 'Is empty', value: 'isEmpty' as const },
{ label: 'Is not empty', value: 'isNotEmpty' as const }
],
booleanOperators: [
{ label: 'Is', value: 'eq' as const },
{ label: 'Is not', value: 'ne' as const }
],
sortOrders: [
{ label: 'Asc', value: 'asc' as const },
{ label: 'Desc', value: 'desc' as const }
],
filterVariants: [
'text',
'number',
'range',
'date',
'dateRange',
'boolean',
'select',
'multiSelect'
] as const,
operators: [
'iLike',
'notILike',
'eq',
'ne',
'inArray',
'notInArray',
'isEmpty',
'isNotEmpty',
'lt',
'lte',
'gt',
'gte',
'isBetween',
'isRelativeToToday'
] as const,
joinOperators: ['and', 'or'] as const
};

243
src/config/infoconfig.ts Normal file
View File

@@ -0,0 +1,243 @@
import type { InfobarContent } from '@/components/ui/infobar';
export const workspacesInfoContent: InfobarContent = {
title: 'Workspaces Management',
sections: [
{
title: 'Overview',
description:
'The Workspaces page allows you to manage your workspaces and switch between them. This feature is powered by Clerk Organizations, which enables multi-tenant workspace management. You can view all available workspaces, create new ones, and switch your active workspace.',
links: [
{
title: 'Clerk Organizations Documentation',
url: 'https://clerk.com/docs/organizations/overview'
}
]
},
{
title: 'Creating Workspaces',
description:
'To create a new workspace, click the "Create Organization" button. You will be prompted to enter a workspace name and configure initial settings. Once created, you can switch to the new workspace and start managing it.',
links: [
{
title: 'Multi-tenant Authentication Guide',
url: 'https://clerk.com/blog/how-to-build-multitenant-authentication-with-clerk'
}
]
},
{
title: 'Switching Workspaces',
description:
'You can switch between workspaces by clicking on a workspace in the list. The selected workspace becomes your active organization context, and all organization-specific features will use this workspace.',
links: []
},
{
title: 'Workspace Features',
description:
'Each workspace operates independently with its own team members, roles, permissions, and billing. This allows you to manage multiple projects or teams within a single account while keeping their data and settings separate.',
links: []
},
{
title: 'Server-Side Permission Checks',
description:
"This application follows Clerk's recommended patterns for multi-tenant authentication. Server-side permission checks ensure that users can only access resources for their active organization.",
links: [
{
title: 'Clerk Organizations Documentation',
url: 'https://clerk.com/docs/organizations/overview'
}
]
}
]
};
export const teamInfoContent: InfobarContent = {
title: 'Team Management',
sections: [
{
title: 'Overview',
description:
"The Team Management page allows you to manage your workspace team, including members, roles, security settings, and more. This page provides comprehensive organization management through Clerk's OrganizationProfile component.",
links: [
{
title: 'Clerk Organizations Documentation',
url: 'https://clerk.com/docs/organizations/overview'
}
]
},
{
title: 'Managing Team Members',
description:
'You can add, remove, and manage team members from this page. Invite new members by email, assign roles, and control their access levels. Each member can have different permissions based on their role.',
links: []
},
{
title: 'Roles and Permissions',
description:
'Configure default roles and permissions in the Clerk Dashboard under Organizations settings. Roles define what actions team members can perform within the workspace. Common roles include admin, member, and custom roles you define.',
links: [
{
title: 'Clerk Organizations Documentation',
url: 'https://clerk.com/docs/organizations/overview'
}
]
},
{
title: 'Security Settings',
description:
"Manage security settings for your workspace, including authentication requirements, session management, and access controls. These settings help protect your organization's data and resources.",
links: []
},
{
title: 'Organization Settings',
description:
'Configure general organization settings such as name, logo, and other workspace preferences. These settings apply to the entire workspace and affect all team members.',
links: []
},
{
title: 'Navigation RBAC System',
description:
'The application includes a fully client-side navigation filtering system using the `useNav` hook. It supports `requireOrg`, `permission`, and `role` checks for instant access control. Navigation items are configured in `src/config/nav-config.ts` with `access` properties.',
links: []
}
]
};
export const billingInfoContent: InfobarContent = {
title: 'Billing & Plans',
sections: [
{
title: 'Overview',
description:
"The Billing page allows you to manage your organization's subscription and usage limits. Plans and subscriptions are managed through Clerk Billing for B2B, which provides organization-level subscription management with integrated Stripe payment processing.",
links: [
{
title: 'Clerk Billing Documentation',
url: 'https://clerk.com/docs/billing/overview'
}
]
},
{
title: 'Available Plans',
description:
'View and subscribe to available plans through the pricing table. Plans are created and managed in the Clerk Dashboard. Toggle "Publicly available" on plans to show them in the pricing table. Common plans include free, pro, and team tiers.',
links: [
{
title: 'Clerk Dashboard - Plans',
url: 'https://dashboard.clerk.com/~/billing/plans'
}
]
},
{
title: 'Plan Features',
description:
'Each plan can include specific features that unlock functionality in the application. Features are added to plans in the Clerk Dashboard and can be checked in code using the `has()` function with `feature` checks.',
links: []
},
{
title: 'Access Control',
description:
'Plans and features are used for access control throughout the application. Server-side checks use the `has()` function to verify plan or feature access. Client-side protection uses the `<Protect>` component to conditionally render content based on subscription status.',
links: []
},
{
title: 'Billing Cost Structure',
description:
"Clerk Billing costs 0.7% per transaction, plus transaction fees paid directly to Stripe. Clerk Billing is not the same as Stripe Billing - plans and pricing are managed through the Clerk Dashboard and won't sync with existing Stripe products. Clerk uses Stripe only for payment processing.",
links: []
},
{
title: 'Setup Requirements',
description:
"To enable billing, navigate to Billing Settings in the Clerk Dashboard and enable billing for your application. Choose between Clerk's development gateway (for testing) or your own Stripe account (for production). Note: A Stripe account created for development cannot be used for production.",
links: [
{
title: 'Billing Settings',
url: 'https://dashboard.clerk.com/~/billing/settings'
}
]
},
{
title: 'Beta Status',
description:
'Billing is currently in Beta and its APIs are experimental and may undergo breaking changes. To mitigate potential disruptions, we recommend pinning your SDK and `clerk-js` package versions.',
links: []
}
]
};
export const productInfoContent: InfobarContent = {
title: 'Product Management',
sections: [
{
title: 'Overview',
description:
'The Products page allows you to manage your product catalog. You can view all products in a table format with server-side functionality including sorting, filtering, pagination, and search capabilities. Use the "Add New" button to create new products.',
links: [
{
title: 'Product Management Guide',
url: '#'
}
]
},
{
title: 'Adding Products',
description:
'To add a new product, click the "Add New" button in the page header. You will be taken to a form where you can enter product details including name, description, price, category, and upload product images.',
links: [
{
title: 'Adding Products Documentation',
url: '#'
}
]
},
{
title: 'Editing Products',
description:
'You can edit existing products by clicking on a product row in the table. This will open the product edit form where you can modify any product information. Changes are saved automatically when you submit the form.',
links: [
{
title: 'Editing Products Guide',
url: '#'
}
]
},
{
title: 'Deleting Products',
description:
'Products can be deleted from the product listing table. Click the delete action for the product you want to remove. You will be asked to confirm the deletion before the product is permanently removed from your catalog.',
links: [
{
title: 'Product Deletion Policy',
url: '#'
}
]
},
{
title: 'Table Features',
description:
'The product table includes several powerful features to help you manage large product catalogs efficiently. You can sort columns by clicking on column headers, filter products using the filter controls, navigate through pages using pagination, and quickly find products using the search functionality.',
links: [
{
title: 'Table Features Documentation',
url: '#'
},
{
title: 'Sorting and Filtering Guide',
url: '#'
}
]
},
{
title: 'Product Fields',
description:
'Each product can have the following fields: Name (required), Description (optional text), Price (numeric value), Category (for organizing products), and Image Upload (for product photos). All fields can be edited when creating or updating a product.',
links: [
{
title: 'Product Fields Specification',
url: '#'
}
]
}
]
};

197
src/config/nav-config.ts Normal file
View File

@@ -0,0 +1,197 @@
import { NavGroup } from '@/types';
/**
* Navigation configuration with RBAC support
*
* This configuration is used for both the sidebar navigation and Cmd+K bar.
* Items are organized into groups, each rendered with a SidebarGroupLabel.
*
* RBAC Access Control:
* Each navigation item can have an `access` property that controls visibility
* based on permissions, plans, features, roles, and organization context.
*
* Examples:
*
* 1. Require organization:
* access: { requireOrg: true }
*
* 2. Require specific permission:
* access: { requireOrg: true, permission: 'org:teams:manage' }
*
* 3. Require specific plan:
* access: { plan: 'pro' }
*
* 4. Require specific feature:
* access: { feature: 'premium_access' }
*
* 5. Require specific role:
* access: { role: 'admin' }
*
* 6. Multiple conditions (all must be true):
* access: { requireOrg: true, permission: 'org:teams:manage', plan: 'pro' }
*
* Note: The `visible` function is deprecated but still supported for backward compatibility.
* Use the `access` property for new items.
*/
export const navGroups: NavGroup[] = [
{
label: 'Overview',
items: [
{
title: 'Dashboard',
url: '/dashboard/overview',
icon: 'dashboard',
isActive: false,
shortcut: ['d', 'd'],
items: []
},
{
title: 'Workspaces',
url: '/dashboard/workspaces',
icon: 'workspace',
isActive: false,
items: []
},
{
title: 'Teams',
url: '/dashboard/workspaces/team',
icon: 'teams',
isActive: false,
items: [],
access: { requireOrg: true }
},
{
title: 'Product',
url: '/dashboard/product',
icon: 'product',
shortcut: ['p', 'p'],
isActive: false,
items: []
},
{
title: 'Users',
url: '/dashboard/users',
icon: 'teams',
shortcut: ['u', 'u'],
isActive: false,
items: []
},
{
title: 'Kanban',
url: '/dashboard/kanban',
icon: 'kanban',
shortcut: ['k', 'k'],
isActive: false,
items: []
},
{
title: 'Chat',
url: '/dashboard/chat',
icon: 'chat',
shortcut: ['c', 'c'],
isActive: false,
items: []
}
]
},
{
label: 'Elements',
items: [
{
title: 'Forms',
url: '#',
icon: 'forms',
isActive: true,
items: [
{
title: 'Basic Form',
url: '/dashboard/forms/basic',
icon: 'forms',
shortcut: ['f', 'f']
},
{
title: 'Multi-Step Form',
url: '/dashboard/forms/multi-step',
icon: 'forms'
},
{
title: 'Sheet & Dialog',
url: '/dashboard/forms/sheet-form',
icon: 'forms'
},
{
title: 'Advanced Patterns',
url: '/dashboard/forms/advanced',
icon: 'forms'
}
]
},
{
title: 'React Query',
url: '/dashboard/react-query',
icon: 'code',
isActive: false,
items: []
},
{
title: 'Icons',
url: '/dashboard/elements/icons',
icon: 'palette',
isActive: false,
items: []
}
]
},
{
label: '',
items: [
{
title: 'Pro',
url: '#',
icon: 'pro',
isActive: true,
items: [
{
title: 'Exclusive',
url: '/dashboard/exclusive',
icon: 'exclusive',
shortcut: ['e', 'e']
}
]
},
{
title: 'Account',
url: '#',
icon: 'account',
isActive: true,
items: [
{
title: 'Profile',
url: '/dashboard/profile',
icon: 'profile',
shortcut: ['m', 'm']
},
{
title: 'Notifications',
url: '/dashboard/notifications',
icon: 'notification',
shortcut: ['n', 'n']
},
{
title: 'Billing',
url: '/dashboard/billing',
icon: 'billing',
shortcut: ['b', 'b'],
access: { requireOrg: true }
},
{
title: 'Login',
shortcut: ['l', 'l'],
url: '/',
icon: 'login'
}
]
}
]
}
];

113
src/constants/data.ts Normal file
View File

@@ -0,0 +1,113 @@
import { NavItem } from '@/types';
export type Product = {
photo_url: string;
name: string;
description: string;
created_at: string;
price: number;
id: number;
category: string;
updated_at: string;
};
//Info: The following data is used for the sidebar navigation and Cmd K bar.
export const navItems: NavItem[] = [
{
title: 'Dashboard',
url: '/dashboard/overview',
icon: 'dashboard',
isActive: false,
shortcut: ['d', 'd'],
items: [] // Empty array as there are no child items for Dashboard
},
{
title: 'Product',
url: '/dashboard/product',
icon: 'product',
shortcut: ['p', 'p'],
isActive: false,
items: [] // No child items
},
{
title: 'Account',
url: '#', // Placeholder as there is no direct link for the parent
icon: 'billing',
isActive: true,
items: [
{
title: 'Profile',
url: '/dashboard/profile',
icon: 'userPen',
shortcut: ['m', 'm']
},
{
title: 'Login',
shortcut: ['l', 'l'],
url: '/',
icon: 'login'
}
]
},
{
title: 'Kanban',
url: '/dashboard/kanban',
icon: 'kanban',
shortcut: ['k', 'k'],
isActive: false,
items: [] // No child items
}
];
export interface SaleUser {
id: number;
name: string;
email: string;
amount: string;
image: string;
initials: string;
}
export const recentSalesData: SaleUser[] = [
{
id: 1,
name: 'Olivia Martin',
email: 'olivia.martin@email.com',
amount: '+$1,999.00',
image: 'https://api.slingacademy.com/public/sample-users/1.png',
initials: 'OM'
},
{
id: 2,
name: 'Jackson Lee',
email: 'jackson.lee@email.com',
amount: '+$39.00',
image: 'https://api.slingacademy.com/public/sample-users/2.png',
initials: 'JL'
},
{
id: 3,
name: 'Isabella Nguyen',
email: 'isabella.nguyen@email.com',
amount: '+$299.00',
image: 'https://api.slingacademy.com/public/sample-users/3.png',
initials: 'IN'
},
{
id: 4,
name: 'William Kim',
email: 'will@email.com',
amount: '+$99.00',
image: 'https://api.slingacademy.com/public/sample-users/4.png',
initials: 'WK'
},
{
id: 5,
name: 'Sofia Davis',
email: 'sofia.davis@email.com',
amount: '+$39.00',
image: 'https://api.slingacademy.com/public/sample-users/5.png',
initials: 'SD'
}
];

View File

@@ -0,0 +1,191 @@
////////////////////////////////////////////////////////////////////////////////
// 🛑 Nothing in here has anything to do with Nextjs, it's just a fake database
////////////////////////////////////////////////////////////////////////////////
import { faker } from '@faker-js/faker';
import { matchSorter } from 'match-sorter';
export const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
export type User = {
id: number;
first_name: string;
last_name: string;
email: string;
phone: string;
status: string;
role: string;
created_at: string;
updated_at: string;
};
// Mock user data store
export const fakeUsers = {
records: [] as User[],
initialize() {
const sampleUsers: User[] = [];
function generateRandomUserData(id: number): User {
const roles = ['Developer', 'Designer', 'Manager', 'QA', 'DevOps', 'Product Owner'];
const statuses = ['Active', 'Inactive', 'Invited'];
return {
id,
first_name: faker.person.firstName(),
last_name: faker.person.lastName(),
email: faker.internet.email(),
phone: faker.phone.number({ style: 'national' }),
status: faker.helpers.arrayElement(statuses),
role: faker.helpers.arrayElement(roles),
created_at: faker.date.between({ from: '2022-01-01', to: '2023-12-31' }).toISOString(),
updated_at: faker.date.recent().toISOString()
};
}
for (let i = 1; i <= 50; i++) {
sampleUsers.push(generateRandomUserData(i));
}
this.records = sampleUsers;
},
async getAll({ roles = [], search }: { roles?: string[]; search?: string }) {
let users = [...this.records];
if (roles.length > 0) {
users = users.filter((user) => roles.includes(user.role));
}
if (search) {
users = matchSorter(users, search, {
keys: ['first_name', 'last_name', 'email']
});
}
return users;
},
async createUser(data: Omit<User, 'id' | 'created_at' | 'updated_at'>) {
await delay(800);
const newUser: User = {
...data,
id: this.records.length + 1,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
};
this.records.push(newUser);
return {
success: true,
message: 'User created successfully',
user: newUser
};
},
async updateUser(id: number, data: Omit<User, 'id' | 'created_at' | 'updated_at'>) {
await delay(800);
const index = this.records.findIndex((user) => user.id === id);
if (index === -1) {
return { success: false, message: `User with ID ${id} not found` };
}
this.records[index] = {
...this.records[index],
...data,
updated_at: new Date().toISOString()
};
return {
success: true,
message: 'User updated successfully',
user: this.records[index]
};
},
async deleteUser(id: number) {
await delay(800);
const index = this.records.findIndex((user) => user.id === id);
if (index === -1) {
return { success: false, message: `User with ID ${id} not found` };
}
this.records.splice(index, 1);
return {
success: true,
message: 'User deleted successfully'
};
},
async getUsers({
page = 1,
limit = 10,
roles,
search,
sort
}: {
page?: number;
limit?: number;
roles?: string | string[];
search?: string;
sort?: string;
}) {
await delay(800);
const rolesArray = roles ? (Array.isArray(roles) ? roles : String(roles).split(/[.,]/)) : [];
const allUsers = await this.getAll({
roles: rolesArray,
search
});
// Sorting
if (sort) {
try {
const sortItems = JSON.parse(sort) as {
id: string;
desc: boolean;
}[];
if (sortItems.length > 0) {
const { id, desc } = sortItems[0];
allUsers.sort((a, b) => {
// Handle computed 'name' column
const aVal =
id === 'name' ? `${a.first_name} ${a.last_name}` : (a as Record<string, unknown>)[id];
const bVal =
id === 'name' ? `${b.first_name} ${b.last_name}` : (b as Record<string, unknown>)[id];
if (typeof aVal === 'number' && typeof bVal === 'number') {
return desc ? bVal - aVal : aVal - bVal;
}
const aStr = String(aVal ?? '').toLowerCase();
const bStr = String(bVal ?? '').toLowerCase();
return desc ? bStr.localeCompare(aStr) : aStr.localeCompare(bStr);
});
}
} catch {
// Invalid sort param — ignore
}
}
const totalUsers = allUsers.length;
const offset = (page - 1) * limit;
const paginatedUsers = allUsers.slice(offset, offset + limit);
return {
success: true,
time: new Date().toISOString(),
message: 'Sample data for testing and learning purposes',
total_users: totalUsers,
offset,
limit,
users: paginatedUsers
};
}
};
fakeUsers.initialize();

157
src/constants/mock-api.ts Normal file
View File

@@ -0,0 +1,157 @@
////////////////////////////////////////////////////////////////////////////////
// 🛑 Nothing in here has anything to do with Nextjs, it's just a fake database
////////////////////////////////////////////////////////////////////////////////
import { faker } from '@faker-js/faker';
import { matchSorter } from 'match-sorter'; // For filtering
export const delay = (ms: number) =>
new Promise((resolve) => setTimeout(resolve, ms));
// Define the shape of Product data
export type Product = {
photo_url: string;
name: string;
description: string;
created_at: string;
price: number;
id: number;
category: string;
updated_at: string;
};
// Mock product data store
export const fakeProducts = {
records: [] as Product[], // Holds the list of product objects
// Initialize with sample data
initialize() {
const sampleProducts: Product[] = [];
function generateRandomProductData(id: number): Product {
const categories = [
'Electronics',
'Furniture',
'Clothing',
'Toys',
'Groceries',
'Books',
'Jewelry',
'Beauty Products'
];
return {
id,
name: faker.commerce.productName(),
description: faker.commerce.productDescription(),
created_at: faker.date
.between({ from: '2022-01-01', to: '2023-12-31' })
.toISOString(),
price: parseFloat(faker.commerce.price({ min: 5, max: 500, dec: 2 })),
photo_url: `https://api.slingacademy.com/public/sample-products/${id}.png`,
category: faker.helpers.arrayElement(categories),
updated_at: faker.date.recent().toISOString()
};
}
// Generate remaining records
for (let i = 1; i <= 20; i++) {
sampleProducts.push(generateRandomProductData(i));
}
this.records = sampleProducts;
},
// Get all products with optional category filtering and search
async getAll({
categories = [],
search
}: {
categories?: string[];
search?: string;
}) {
let products = [...this.records];
// Filter products based on selected categories
if (categories.length > 0) {
products = products.filter((product) =>
categories.includes(product.category)
);
}
// Search functionality across multiple fields
if (search) {
products = matchSorter(products, search, {
keys: ['name', 'description', 'category']
});
}
return products;
},
// Get paginated results with optional category filtering and search
async getProducts({
page = 1,
limit = 10,
categories,
search
}: {
page?: number;
limit?: number;
categories?: string;
search?: string;
}) {
await delay(1000);
const categoriesArray = categories ? categories.split('.') : [];
const allProducts = await this.getAll({
categories: categoriesArray,
search
});
const totalProducts = allProducts.length;
// Pagination logic
const offset = (page - 1) * limit;
const paginatedProducts = allProducts.slice(offset, offset + limit);
// Mock current time
const currentTime = new Date().toISOString();
// Return paginated response
return {
success: true,
time: currentTime,
message: 'Sample data for testing and learning purposes',
total_products: totalProducts,
offset,
limit,
products: paginatedProducts
};
},
// Get a specific product by its ID
async getProductById(id: number) {
await delay(1000); // Simulate a delay
// Find the product by its ID
const product = this.records.find((product) => product.id === id);
if (!product) {
return {
success: false,
message: `Product with ID ${id} not found`
};
}
// Mock current time
const currentTime = new Date().toISOString();
return {
success: true,
time: currentTime,
message: `Product with ID ${id} found`,
product
};
}
};
// Initialize sample products
fakeProducts.initialize();

19
src/hooks/use-mobile.ts Normal file
View File

@@ -0,0 +1,19 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
}

49
src/lib/font.ts Normal file
View File

@@ -0,0 +1,49 @@
import {
Geist,
Geist_Mono,
Instrument_Sans,
Inter,
Mulish,
Noto_Sans_Mono
} from 'next/font/google';
import { cn } from '@/lib/utils';
const fontSans = Geist({
subsets: ['latin'],
variable: '--font-sans'
});
const fontMono = Geist_Mono({
subsets: ['latin'],
variable: '--font-mono'
});
const fontInstrument = Instrument_Sans({
subsets: ['latin'],
variable: '--font-instrument'
});
const fontNotoMono = Noto_Sans_Mono({
subsets: ['latin'],
variable: '--font-noto-mono'
});
const fontMullish = Mulish({
subsets: ['latin'],
variable: '--font-mullish'
});
const fontInter = Inter({
subsets: ['latin'],
variable: '--font-inter'
});
export const fontVariables = cn(
fontSans.variable,
fontMono.variable,
fontInstrument.variable,
fontNotoMono.variable,
fontMullish.variable,
fontInter.variable
);