Merge remote-tracking branch 'origin/main'
# Conflicts: # db/custom.db # src/components/FuseButton.tsx # src/components/SlideCommit.tsx
This commit is contained in:
commit
58521ca03a
23 files changed
+2620
-24
No files matched your search
+1
-1
@@ -1 +1 @@
|
|||||||
984
|
1114
|
||||||
@@ -9,6 +9,8 @@
|
|||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@hookform/resolvers": "^5.1.1",
|
"@hookform/resolvers": "^5.1.1",
|
||||||
|
"@hugeicons/core-free-icons": "^4.3.3",
|
||||||
|
"@hugeicons/react": "^1.1.10",
|
||||||
"@mdxeditor/editor": "^3.39.1",
|
"@mdxeditor/editor": "^3.39.1",
|
||||||
"@prisma/client": "^6.11.1",
|
"@prisma/client": "^6.11.1",
|
||||||
"@radix-ui/react-accordion": "^1.2.11",
|
"@radix-ui/react-accordion": "^1.2.11",
|
||||||
@@ -46,10 +48,11 @@
|
|||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"framer-motion": "^12.23.2",
|
"framer-motion": "^14.7.1",
|
||||||
"imapflow": "^2.9.1",
|
"imapflow": "^2.9.1",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"lucide-react": "^0.525.0",
|
"lucide-react": "^1.62.0",
|
||||||
|
"motion": "^12.23.12",
|
||||||
"next": "^16.1.1",
|
"next": "^16.1.1",
|
||||||
"next-auth": "^4.24.11",
|
"next-auth": "^4.24.11",
|
||||||
"next-intl": "^4.3.4",
|
"next-intl": "^4.3.4",
|
||||||
@@ -65,7 +68,7 @@
|
|||||||
"recharts": "^2.15.4",
|
"recharts": "^2.15.4",
|
||||||
"sharp": "^0.34.3",
|
"sharp": "^0.34.3",
|
||||||
"sonner": "^2.0.6",
|
"sonner": "^2.0.6",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.7.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"uuid": "^11.1.0",
|
"uuid": "^11.1.0",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
@@ -247,6 +250,10 @@
|
|||||||
|
|
||||||
"@hookform/resolvers": ["@hookform/resolvers@5.2.2", "https://registry.npmjs.com/@hookform/resolvers/-/resolvers-5.2.2.tgz", { "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { "react-hook-form": "^7.55.0" } }, "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA=="],
|
"@hookform/resolvers": ["@hookform/resolvers@5.2.2", "https://registry.npmjs.com/@hookform/resolvers/-/resolvers-5.2.2.tgz", { "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { "react-hook-form": "^7.55.0" } }, "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA=="],
|
||||||
|
|
||||||
|
"@hugeicons/core-free-icons": ["@hugeicons/core-free-icons@4.3.5", "", {}, "sha512-Sv+NjHRPnQk+yZsGCMcGznCHdTfJR9PMMOEKcJYAQU4gy90dmlc9PwdtvYOImBZIjiheMsTLL5eMn2DmHxUiUg=="],
|
||||||
|
|
||||||
|
"@hugeicons/react": ["@hugeicons/react@1.1.10", "", { "peerDependencies": { "react": ">=16.0.0" } }, "sha512-9tAsd92yMVVGP+E+d8cM+wc0VnXuYZHNbWQi7TOe8k2neawmhiaFmVFbzyruejhOMa197/LbgvSED32ayUo3ZA=="],
|
||||||
|
|
||||||
"@humanfs/core": ["@humanfs/core@0.19.1", "https://registry.npmjs.com/@humanfs/core/-/core-0.19.1.tgz", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
|
"@humanfs/core": ["@humanfs/core@0.19.1", "https://registry.npmjs.com/@humanfs/core/-/core-0.19.1.tgz", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
|
||||||
|
|
||||||
"@humanfs/node": ["@humanfs/node@0.16.7", "https://registry.npmjs.com/@humanfs/node/-/node-0.16.7.tgz", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="],
|
"@humanfs/node": ["@humanfs/node@0.16.7", "https://registry.npmjs.com/@humanfs/node/-/node-0.16.7.tgz", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="],
|
||||||
@@ -1113,7 +1120,7 @@
|
|||||||
|
|
||||||
"format": ["format@0.2.2", "https://registry.npmjs.com/format/-/format-0.2.2.tgz", {}, "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww=="],
|
"format": ["format@0.2.2", "https://registry.npmjs.com/format/-/format-0.2.2.tgz", {}, "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww=="],
|
||||||
|
|
||||||
"framer-motion": ["framer-motion@12.26.2", "https://registry.npmjs.com/framer-motion/-/framer-motion-12.26.2.tgz", { "dependencies": { "motion-dom": "^12.26.2", "motion-utils": "^12.24.10", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-lflOQEdjquUi9sCg5Y1LrsZDlsjrHw7m0T9Yedvnk7Bnhqfkc89/Uha10J3CFhkL+TCZVCRw9eUGyM/lyYhXQA=="],
|
"framer-motion": ["framer-motion@14.7.1", "", { "dependencies": { "motion-dom": "14.7.1", "motion-utils": "14.7.0", "tslib": "^2.4.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-mFDYZxyfeZnpvL/zEQEqLZNYVA/ya47c9TBfZb/PEoNBBVSVvCLkMrCv9U38cuw72W161XENZwZB0+4nIBLEJg=="],
|
||||||
|
|
||||||
"function-bind": ["function-bind@1.1.2", "https://registry.npmjs.com/function-bind/-/function-bind-1.1.2.tgz", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
"function-bind": ["function-bind@1.1.2", "https://registry.npmjs.com/function-bind/-/function-bind-1.1.2.tgz", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||||
|
|
||||||
@@ -1359,7 +1366,7 @@
|
|||||||
|
|
||||||
"lru-cache": ["lru-cache@6.0.0", "https://registry.npmjs.com/lru-cache/-/lru-cache-6.0.0.tgz", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
|
"lru-cache": ["lru-cache@6.0.0", "https://registry.npmjs.com/lru-cache/-/lru-cache-6.0.0.tgz", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
|
||||||
|
|
||||||
"lucide-react": ["lucide-react@0.525.0", "https://registry.npmjs.com/lucide-react/-/lucide-react-0.525.0.tgz", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Tm1txJ2OkymCGkvwoHt33Y2JpN5xucVq1slHcgE6Lk0WjDfjgKWor5CdVER8U6DvcfMwh4M8XxmpTiyzfmfDYQ=="],
|
"lucide-react": ["lucide-react@1.62.0", "", { "peerDependencies": { "@types/react": "*", "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-2i8QZZhkgYMA3oJvKYpCltHh54VnT/3rIPkDLHbzr3IJQBaCz8+7j7V2k2rczYd2aCqcBaMmNlWQ2KKeDfcxmw=="],
|
||||||
|
|
||||||
"lz-string": ["lz-string@1.5.0", "https://registry.npmjs.com/lz-string/-/lz-string-1.5.0.tgz", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="],
|
"lz-string": ["lz-string@1.5.0", "https://registry.npmjs.com/lz-string/-/lz-string-1.5.0.tgz", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="],
|
||||||
|
|
||||||
@@ -1477,9 +1484,11 @@
|
|||||||
|
|
||||||
"minimist": ["minimist@1.2.8", "https://registry.npmjs.com/minimist/-/minimist-1.2.8.tgz", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
"minimist": ["minimist@1.2.8", "https://registry.npmjs.com/minimist/-/minimist-1.2.8.tgz", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
||||||
|
|
||||||
"motion-dom": ["motion-dom@12.26.2", "https://registry.npmjs.com/motion-dom/-/motion-dom-12.26.2.tgz", { "dependencies": { "motion-utils": "^12.24.10" } }, "sha512-KLMT1BroY8oKNeliA3JMNJ+nbCIsTKg6hJpDb4jtRAJ7nCKnnpg/LTq/NGqG90Limitz3kdAnAVXecdFVGlWTw=="],
|
"motion": ["motion@12.43.0", "", { "dependencies": { "framer-motion": "^12.43.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-BQgQbSa9Hn3/mtbib0MK53y6JSANa+YKUKlaYnWzAVDH424RYQ5LVpV3pNiWH00BA2z4ojsSdMzqT7g2FQwjuQ=="],
|
||||||
|
|
||||||
"motion-utils": ["motion-utils@12.24.10", "https://registry.npmjs.com/motion-utils/-/motion-utils-12.24.10.tgz", {}, "sha512-x5TFgkCIP4pPsRLpKoI86jv/q8t8FQOiM/0E8QKBzfMozWHfkKap2gA1hOki+B5g3IsBNpxbUnfOum1+dgvYww=="],
|
"motion-dom": ["motion-dom@14.7.1", "", { "dependencies": { "motion-utils": "14.7.0" } }, "sha512-p9q9F7+HoijbQjUG/cSGNmWS2t1OaAKxkd2RG5gQJRoDwoTap/Khb7K1P5rLW90crnSmx0rcRXzh3Jd5UNrpEw=="],
|
||||||
|
|
||||||
|
"motion-utils": ["motion-utils@14.7.0", "", {}, "sha512-woO0/Nb7ys75f94fjvQi/6leiHGvZUYkeHUPEZ3vxsV+9OpEVhkxp01x8MlRwX0zt31EqgXsKDAoBoJLC148Tw=="],
|
||||||
|
|
||||||
"mri": ["mri@1.2.0", "https://registry.npmjs.com/mri/-/mri-1.2.0.tgz", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
|
"mri": ["mri@1.2.0", "https://registry.npmjs.com/mri/-/mri-1.2.0.tgz", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
|
||||||
|
|
||||||
@@ -1761,7 +1770,7 @@
|
|||||||
|
|
||||||
"tabbable": ["tabbable@6.4.0", "https://registry.npmjs.com/tabbable/-/tabbable-6.4.0.tgz", {}, "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg=="],
|
"tabbable": ["tabbable@6.4.0", "https://registry.npmjs.com/tabbable/-/tabbable-6.4.0.tgz", {}, "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg=="],
|
||||||
|
|
||||||
"tailwind-merge": ["tailwind-merge@3.4.0", "https://registry.npmjs.com/tailwind-merge/-/tailwind-merge-3.4.0.tgz", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="],
|
"tailwind-merge": ["tailwind-merge@3.7.0", "", {}, "sha512-XPPUyAc+cvspz3lHTcR/QgPfW2A0lv/xQNIjX3HGhLR+Nq2lHaLq5MtTesHn8GUr3W3DguT2KT5x3NVgRtYwmA=="],
|
||||||
|
|
||||||
"tailwindcss": ["tailwindcss@4.1.18", "https://registry.npmjs.com/tailwindcss/-/tailwindcss-4.1.18.tgz", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
|
"tailwindcss": ["tailwindcss@4.1.18", "https://registry.npmjs.com/tailwindcss/-/tailwindcss-4.1.18.tgz", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
|
||||||
|
|
||||||
@@ -1979,6 +1988,8 @@
|
|||||||
|
|
||||||
"micromatch/picomatch": ["picomatch@2.3.1", "https://registry.npmjs.com/picomatch/-/picomatch-2.3.1.tgz", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
"micromatch/picomatch": ["picomatch@2.3.1", "https://registry.npmjs.com/picomatch/-/picomatch-2.3.1.tgz", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||||
|
|
||||||
|
"motion/framer-motion": ["framer-motion@12.43.0", "", { "dependencies": { "motion-dom": "^12.43.0", "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-1eaL3RvR/kAlbG7UYcpMptEyzPoENO0c6w7ZnB3/hh2vSAz/6uGAFn6fdoqTBguNstf3MsFhJHsD/0DHiclG+g=="],
|
||||||
|
|
||||||
"next/postcss": ["postcss@8.4.31", "https://registry.npmjs.com/postcss/-/postcss-8.4.31.tgz", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
|
"next/postcss": ["postcss@8.4.31", "https://registry.npmjs.com/postcss/-/postcss-8.4.31.tgz", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
|
||||||
|
|
||||||
"next-auth/uuid": ["uuid@8.3.2", "https://registry.npmjs.com/uuid/-/uuid-8.3.2.tgz", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
|
"next-auth/uuid": ["uuid@8.3.2", "https://registry.npmjs.com/uuid/-/uuid-8.3.2.tgz", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
|
||||||
@@ -1999,6 +2010,10 @@
|
|||||||
|
|
||||||
"hastscript/@types/hast/@types/unist": ["@types/unist@2.0.11", "https://registry.npmjs.com/@types/unist/-/unist-2.0.11.tgz", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
|
"hastscript/@types/hast/@types/unist": ["@types/unist@2.0.11", "https://registry.npmjs.com/@types/unist/-/unist-2.0.11.tgz", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
|
||||||
|
|
||||||
|
"motion/framer-motion/motion-dom": ["motion-dom@12.43.0", "", { "dependencies": { "motion-utils": "^12.39.0" } }, "sha512-azKON4d9S65PEoFUiQTMTgPheEmzf2QngdRc50AKfJp9Q9mmcBVw22c8eMq9k8kxOFHdL7+WZY7N/5F/lwiDag=="],
|
||||||
|
|
||||||
|
"motion/framer-motion/motion-utils": ["motion-utils@12.39.0", "", {}, "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ=="],
|
||||||
|
|
||||||
"refractor/parse-entities/character-entities": ["character-entities@1.2.4", "https://registry.npmjs.com/character-entities/-/character-entities-1.2.4.tgz", {}, "sha512-iBMyeEHxfVnIakwOuDXpVkc54HijNgCyQB2w0VfGQThle6NXn50zU6V/u+LDhxHcDUPojn6Kpga3PTAD8W1bQw=="],
|
"refractor/parse-entities/character-entities": ["character-entities@1.2.4", "https://registry.npmjs.com/character-entities/-/character-entities-1.2.4.tgz", {}, "sha512-iBMyeEHxfVnIakwOuDXpVkc54HijNgCyQB2w0VfGQThle6NXn50zU6V/u+LDhxHcDUPojn6Kpga3PTAD8W1bQw=="],
|
||||||
|
|
||||||
"refractor/parse-entities/character-entities-legacy": ["character-entities-legacy@1.1.4", "https://registry.npmjs.com/character-entities-legacy/-/character-entities-legacy-1.1.4.tgz", {}, "sha512-3Xnr+7ZFS1uxeiUDvV02wQ+QDbc55o97tIV5zHScSPJpcLm/r0DFPcoY3tYRp+VZukxuMeKgXYmsXQHO05zQeA=="],
|
"refractor/parse-entities/character-entities-legacy": ["character-entities-legacy@1.1.4", "https://registry.npmjs.com/character-entities-legacy/-/character-entities-legacy-1.1.4.tgz", {}, "sha512-3Xnr+7ZFS1uxeiUDvV02wQ+QDbc55o97tIV5zHScSPJpcLm/r0DFPcoY3tYRp+VZukxuMeKgXYmsXQHO05zQeA=="],
|
||||||
|
|||||||
+4
-1
@@ -17,5 +17,8 @@
|
|||||||
"lib": "@/lib",
|
"lib": "@/lib",
|
||||||
"hooks": "@/hooks"
|
"hooks": "@/hooks"
|
||||||
},
|
},
|
||||||
"iconLibrary": "lucide"
|
"iconLibrary": "lucide",
|
||||||
|
"registries": {
|
||||||
|
"@react-bits": "https://reactbits.dev/r/{name}.json"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
%PDF-1.4
|
||||||
|
fake content
|
||||||
@@ -43,6 +43,7 @@ const eslintConfig = [...nextCoreWebVitals, ...nextTypescript, {
|
|||||||
|
|
||||||
// React rules
|
// React rules
|
||||||
"react-hooks/exhaustive-deps": "off",
|
"react-hooks/exhaustive-deps": "off",
|
||||||
|
"react-hooks/refs": "off",
|
||||||
"react-hooks/purity": "off",
|
"react-hooks/purity": "off",
|
||||||
"react/no-unescaped-entities": "off",
|
"react/no-unescaped-entities": "off",
|
||||||
"react/display-name": "off",
|
"react/display-name": "off",
|
||||||
|
|||||||
+6
-3
@@ -19,6 +19,8 @@
|
|||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@hookform/resolvers": "^5.1.1",
|
"@hookform/resolvers": "^5.1.1",
|
||||||
|
"@hugeicons/core-free-icons": "^4.3.3",
|
||||||
|
"@hugeicons/react": "^1.1.10",
|
||||||
"@mdxeditor/editor": "^3.39.1",
|
"@mdxeditor/editor": "^3.39.1",
|
||||||
"@prisma/client": "^6.11.1",
|
"@prisma/client": "^6.11.1",
|
||||||
"@radix-ui/react-accordion": "^1.2.11",
|
"@radix-ui/react-accordion": "^1.2.11",
|
||||||
@@ -56,10 +58,11 @@
|
|||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"framer-motion": "^12.23.2",
|
"framer-motion": "^14.7.1",
|
||||||
"imapflow": "^2.9.1",
|
"imapflow": "^2.9.1",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"lucide-react": "^0.525.0",
|
"lucide-react": "^1.62.0",
|
||||||
|
"motion": "^12.23.12",
|
||||||
"next": "^16.1.1",
|
"next": "^16.1.1",
|
||||||
"next-auth": "^4.24.11",
|
"next-auth": "^4.24.11",
|
||||||
"next-intl": "^4.3.4",
|
"next-intl": "^4.3.4",
|
||||||
@@ -75,7 +78,7 @@
|
|||||||
"recharts": "^2.15.4",
|
"recharts": "^2.15.4",
|
||||||
"sharp": "^0.34.3",
|
"sharp": "^0.34.3",
|
||||||
"sonner": "^2.0.6",
|
"sonner": "^2.0.6",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.7.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"uuid": "^11.1.0",
|
"uuid": "^11.1.0",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { db } from '@/lib/db'
|
||||||
|
import { storeFile, detectContentType } from '@/lib/storage'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { checkRateLimit } from '@/lib/rate-limit'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
export const maxDuration = 30
|
||||||
|
|
||||||
|
// POST /api/aos/belege/upload
|
||||||
|
// Upload a receipt file with content-addressed storage (PRD §5.5 + §5.7 Belege).
|
||||||
|
// Accepts multipart/form-data with file + metadata.
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const rateLimited = checkRateLimit(req, 'public')
|
||||||
|
if (rateLimited) return rateLimited
|
||||||
|
|
||||||
|
const formData = await req.formData()
|
||||||
|
const file = formData.get('file') as File | null
|
||||||
|
const type = formData.get('type') as string | null
|
||||||
|
const vendor = formData.get('vendor') as string | null
|
||||||
|
const invoiceNumber = formData.get('invoiceNumber') as string | null
|
||||||
|
const amountCentsStr = formData.get('amountCents') as string | null
|
||||||
|
const bookingDateStr = formData.get('bookingDate') as string | null
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return NextResponse.json({ error: 'file required (multipart/form-data, field name: file)' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read file content
|
||||||
|
const arrayBuffer = await file.arrayBuffer()
|
||||||
|
const buffer = Buffer.from(arrayBuffer)
|
||||||
|
|
||||||
|
// Detect content type from magic bytes
|
||||||
|
const detectedType = detectContentType(buffer)
|
||||||
|
const fileName = file.name
|
||||||
|
const fileType = file.type || detectedType
|
||||||
|
const fileSizeBytes = buffer.length
|
||||||
|
|
||||||
|
// Store file (content-addressed, dedup, atomic write)
|
||||||
|
const stored = await storeFile(buffer, fileType)
|
||||||
|
|
||||||
|
// Check for duplicate (same hash = same file already registered as Beleg)
|
||||||
|
const existing = await db.beleg.findUnique({ where: { fileHash: stored.hash } })
|
||||||
|
if (existing) {
|
||||||
|
return NextResponse.json({
|
||||||
|
beleg: existing,
|
||||||
|
duplicate: true,
|
||||||
|
message: 'File already registered (same SHA-256 hash)',
|
||||||
|
}, { status: 409 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create Beleg record
|
||||||
|
const count = await db.beleg.count()
|
||||||
|
const number = `BEL-2026-${String(count + 1).padStart(4, '0')}`
|
||||||
|
const belegType = type || 'sonstiges'
|
||||||
|
|
||||||
|
// GoBD retention: 8 years for invoices, 6 for business letters
|
||||||
|
const retentionUntil = new Date()
|
||||||
|
retentionUntil.setFullYear(retentionUntil.getFullYear() + (belegType.includes('rechnung') ? 8 : 6))
|
||||||
|
|
||||||
|
const beleg = await db.beleg.create({
|
||||||
|
data: {
|
||||||
|
number,
|
||||||
|
type: belegType,
|
||||||
|
fileHash: stored.hash,
|
||||||
|
fileName,
|
||||||
|
fileType,
|
||||||
|
fileSizeBytes,
|
||||||
|
vendor: vendor ?? null,
|
||||||
|
invoiceNumber: invoiceNumber ?? null,
|
||||||
|
amountCents: amountCentsStr ? parseInt(amountCentsStr) : null,
|
||||||
|
bookingDate: bookingDateStr ? new Date(bookingDateStr) : null,
|
||||||
|
retentionUntil,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await db.auditLog.create({
|
||||||
|
data: {
|
||||||
|
actor: 'nora@nordlicht.digital',
|
||||||
|
action: 'beleg.upload',
|
||||||
|
entity: 'Beleg',
|
||||||
|
entityId: beleg.id,
|
||||||
|
after: JSON.stringify({ number, type: belegType, fileName, hash: stored.hash.slice(0, 16) + '…', sizeBytes: fileSizeBytes }),
|
||||||
|
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
beleg,
|
||||||
|
storage: {
|
||||||
|
hash: stored.hash,
|
||||||
|
sizeBytes: stored.size,
|
||||||
|
contentType: stored.contentType,
|
||||||
|
detectedType,
|
||||||
|
deduped: stored.size !== fileSizeBytes, // if size differs, existing file was returned
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||||
|
return NextResponse.json({ error: msg }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { db } from '@/lib/db'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
export const maxDuration = 15
|
||||||
|
|
||||||
|
// GET /api/aos/invoices/[id]/pdf
|
||||||
|
// Generates a real PDF invoice (German layout) using HTML → print-ready format.
|
||||||
|
// Returns the invoice as an HTML page that browsers can "Print to PDF".
|
||||||
|
// In production, this would use Typst (PRD §5.2) for server-side PDF generation.
|
||||||
|
export async function GET(
|
||||||
|
_req: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id } = await params
|
||||||
|
|
||||||
|
const invoice = await db.invoice.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { company: true, lines: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!invoice) {
|
||||||
|
return NextResponse.json({ error: 'invoice not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get agency settings for the invoice header
|
||||||
|
const settings = await db.setting.findMany()
|
||||||
|
const getSetting = (key: string) => settings.find((s) => s.key === key)?.value ?? ''
|
||||||
|
|
||||||
|
const agencyName = getSetting('company.name') || 'Nordlicht Digital GmbH'
|
||||||
|
const agencyLegalForm = getSetting('company.legalForm') || 'GmbH'
|
||||||
|
const agencyVatId = getSetting('company.vatId') || ''
|
||||||
|
const agencyRegisterCourt = getSetting('company.registerCourt') || ''
|
||||||
|
const agencyRegisterNumber = getSetting('company.registerNumber') || ''
|
||||||
|
const agencyCity = getSetting('company.city') || ''
|
||||||
|
const agencyBank = getSetting('company.bank') || ''
|
||||||
|
const agencyBic = getSetting('company.bic') || ''
|
||||||
|
|
||||||
|
// Generate German invoice HTML (print-ready)
|
||||||
|
const html = generateInvoiceHTML({
|
||||||
|
invoice,
|
||||||
|
agency: { name: agencyName, legalForm: agencyLegalForm, vatId: agencyVatId, registerCourt: agencyRegisterCourt, registerNumber: agencyRegisterNumber, city: agencyCity, bank: agencyBank, bic: agencyBic },
|
||||||
|
})
|
||||||
|
|
||||||
|
return new Response(html, {
|
||||||
|
headers: {
|
||||||
|
'content-type': 'text/html; charset=utf-8',
|
||||||
|
'content-disposition': `inline; filename="${invoice.number}.html"`,
|
||||||
|
'cache-control': 'no-store',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||||
|
return NextResponse.json({ error: msg }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateInvoiceHTML({ invoice, agency }: {
|
||||||
|
invoice: {
|
||||||
|
number: string; status: string; type: string; issueDate: Date; dueDate: Date;
|
||||||
|
netCents: number; taxBp: number; grossCents: number;
|
||||||
|
zugferd: boolean; xrechnung: boolean;
|
||||||
|
company: { name: string; legalForm: string | null; city: string | null; vatId: string | null };
|
||||||
|
lines: { description: string; quantity: number; unitCents: number; totalCents: number }[];
|
||||||
|
};
|
||||||
|
agency: { name: string; legalForm: string; vatId: string; registerCourt: string; registerNumber: string; city: string; bank: string; bic: string };
|
||||||
|
}): string {
|
||||||
|
const fmtDate = (d: Date) => d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||||
|
const fmtEuro = (cents: number) => (cents / 100).toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' €'
|
||||||
|
const taxPct = (invoice.taxBp / 100).toFixed(0)
|
||||||
|
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Rechnung ${invoice.number}</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { font-family: -apple-system, 'Segoe UI', Roboto, Arial, sans-serif; color: #111; line-height: 1.6; padding: 40px; max-width: 800px; margin: 0 auto; }
|
||||||
|
.header { display: flex; justify-content: space-between; margin-bottom: 40px; }
|
||||||
|
.agency h1 { font-size: 18px; font-weight: 700; }
|
||||||
|
.agency p { font-size: 12px; color: #666; }
|
||||||
|
.invoice-meta { text-align: right; }
|
||||||
|
.invoice-meta h2 { font-size: 24px; font-weight: 700; margin-bottom: 8px; }
|
||||||
|
.invoice-meta p { font-size: 12px; color: #666; }
|
||||||
|
.customer { margin-bottom: 30px; }
|
||||||
|
.customer h3 { font-size: 14px; font-weight: 600; margin-bottom: 4px; }
|
||||||
|
.customer p { font-size: 13px; color: #333; }
|
||||||
|
table { width: 100%; border-collapse: collapse; margin-bottom: 30px; }
|
||||||
|
th { text-align: left; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; color: #999; border-bottom: 2px solid #111; padding: 8px; }
|
||||||
|
td { font-size: 13px; padding: 10px 8px; border-bottom: 1px solid #eee; }
|
||||||
|
.right { text-align: right; }
|
||||||
|
.totals { width: 300px; margin-left: auto; }
|
||||||
|
.totals td { border: none; padding: 6px 8px; font-size: 13px; }
|
||||||
|
.totals .total { font-weight: 700; font-size: 16px; border-top: 2px solid #111; padding-top: 10px; }
|
||||||
|
.footer { margin-top: 40px; padding-top: 20px; border-top: 1px solid #eee; font-size: 11px; color: #999; }
|
||||||
|
.footer p { margin-bottom: 4px; }
|
||||||
|
@media print { body { padding: 0; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<div class="agency">
|
||||||
|
<h1>${agency.name}</h1>
|
||||||
|
<p>${agency.city}</p>
|
||||||
|
<p>USt-IdNr.: ${agency.vatId}</p>
|
||||||
|
<p>${agency.registerCourt} ${agency.registerNumber}</p>
|
||||||
|
</div>
|
||||||
|
<div class="invoice-meta">
|
||||||
|
<h2>RECHNUNG</h2>
|
||||||
|
<p>Nr.: ${invoice.number}</p>
|
||||||
|
<p>Datum: ${fmtDate(invoice.issueDate)}</p>
|
||||||
|
<p>Fällig: ${fmtDate(invoice.dueDate)}</p>
|
||||||
|
${invoice.zugferd ? '<p style="font-weight:600">✓ ZUGFeRD</p>' : ''}
|
||||||
|
${invoice.xrechnung ? '<p style="font-weight:600">✓ XRechnung</p>' : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="customer">
|
||||||
|
<h3>Rechnungsempfänger</h3>
|
||||||
|
<p><strong>${invoice.company.name}</strong></p>
|
||||||
|
${invoice.company.legalForm ? `<p>${invoice.company.legalForm}</p>` : ''}
|
||||||
|
${invoice.company.city ? `<p>${invoice.company.city}</p>` : ''}
|
||||||
|
${invoice.company.vatId ? `<p>USt-IdNr.: ${invoice.company.vatId}</p>` : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Beschreibung</th>
|
||||||
|
<th class="right">Menge</th>
|
||||||
|
<th class="right">Einzelpreis</th>
|
||||||
|
<th class="right">Gesamt</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${invoice.lines.map((l) => `
|
||||||
|
<tr>
|
||||||
|
<td>${l.description}</td>
|
||||||
|
<td class="right">${l.quantity}</td>
|
||||||
|
<td class="right">${fmtEuro(l.unitCents)}</td>
|
||||||
|
<td class="right">${fmtEuro(l.totalCents)}</td>
|
||||||
|
</tr>`).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<table class="totals">
|
||||||
|
<tr><td>Netto</td><td class="right">${fmtEuro(invoice.netCents)}</td></tr>
|
||||||
|
<tr><td>USt (${taxPct}%)</td><td class="right">${fmtEuro(invoice.grossCents - invoice.netCents)}</td></tr>
|
||||||
|
<tr class="total"><td>Gesamt</td><td class="right">${fmtEuro(invoice.grossCents)}</td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<p><strong>${agency.name}</strong> · ${agency.city}</p>
|
||||||
|
<p>Bank: ${agency.bank}</p>
|
||||||
|
<p>BIC: ${agency.bic}</p>
|
||||||
|
<p>USt-IdNr.: ${agency.vatId}</p>
|
||||||
|
<p>Register: ${agency.registerCourt} ${agency.registerNumber}</p>
|
||||||
|
<p style="margin-top:10px">Rechnung Nr. ${invoice.number} vom ${fmtDate(invoice.issueDate)}. Fällig bis ${fmtDate(invoice.dueDate)}.</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { db } from '@/lib/db'
|
||||||
|
import { createHmac } from 'crypto'
|
||||||
|
import { checkRateLimit } from '@/lib/rate-limit'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
// POST /api/aos/webhooks/github
|
||||||
|
// GitHub/Gitea webhook receiver (PRD §6.3: "GitHub/Gitea webhooks (commits and PRs linked to tasks)")
|
||||||
|
//
|
||||||
|
// Receives push events and PR events from GitHub, extracts commit messages,
|
||||||
|
// and links them to tasks by matching "#TASK-XXXX" references in commit messages.
|
||||||
|
//
|
||||||
|
// Security: verifies X-Hub-Signature-256 HMAC using GITHUB_WEBHOOK_SECRET env var.
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const rateLimited = checkRateLimit(req, 'webhook')
|
||||||
|
if (rateLimited) return rateLimited
|
||||||
|
|
||||||
|
const eventType = req.headers.get('x-github-event') ?? ''
|
||||||
|
const signature = req.headers.get('x-hub-signature-256') ?? ''
|
||||||
|
const body = await req.text()
|
||||||
|
|
||||||
|
// Verify HMAC signature if secret is configured
|
||||||
|
const secret = process.env.GITHUB_WEBHOOK_SECRET
|
||||||
|
if (secret) {
|
||||||
|
const expected = 'sha256=' + createHmac('sha256', secret).update(body).digest('hex')
|
||||||
|
if (signature !== expected) {
|
||||||
|
return NextResponse.json({ error: 'invalid signature' }, { status: 401 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = JSON.parse(body)
|
||||||
|
const repo = payload.repository?.full_name ?? 'unknown'
|
||||||
|
|
||||||
|
if (eventType === 'push') {
|
||||||
|
// Push event: extract commits and link to tasks
|
||||||
|
const commits = payload.commits ?? []
|
||||||
|
const linkedTasks: { commit: string; message: string; taskId: string; taskTitle: string }[] = []
|
||||||
|
|
||||||
|
for (const commit of commits) {
|
||||||
|
const message = commit.message ?? ''
|
||||||
|
const sha = (commit.id ?? '').slice(0, 7)
|
||||||
|
|
||||||
|
// Match task references in commit message: #TKT-2026-XXXX or task:<id>
|
||||||
|
const taskMatches = message.match(/(?:#TKT-2026-|task:)([a-z0-9]+)/gi) ?? []
|
||||||
|
|
||||||
|
for (const match of taskMatches) {
|
||||||
|
const taskId = match.replace(/#TKT-2026-|task:/i, '').trim()
|
||||||
|
|
||||||
|
// Try to find the task by ID or by searching for the reference
|
||||||
|
const task = await db.task.findFirst({
|
||||||
|
where: { id: { contains: taskId } },
|
||||||
|
include: { project: { select: { name: true } } },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (task) {
|
||||||
|
// Add a comment to the task with the commit reference
|
||||||
|
await db.taskComment.create({
|
||||||
|
data: {
|
||||||
|
taskId: task.id,
|
||||||
|
author: `GitHub (${payload.pusher?.name ?? 'unknown'})`,
|
||||||
|
body: `Commit ${sha} in ${repo}:\n${message.split('\n')[0]}\n\nLink: ${payload.repository?.html_url}/commit/${commit.id}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
linkedTasks.push({
|
||||||
|
commit: sha,
|
||||||
|
message: message.split('\n')[0],
|
||||||
|
taskId: task.id,
|
||||||
|
taskTitle: task.title,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.auditLog.create({
|
||||||
|
data: {
|
||||||
|
actor: 'system:github-webhook',
|
||||||
|
action: 'webhook.github:push',
|
||||||
|
entity: 'Task',
|
||||||
|
entityId: 'batch',
|
||||||
|
after: JSON.stringify({ repo, commits: commits.length, linked: linkedTasks.length }),
|
||||||
|
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
event: 'push',
|
||||||
|
repo,
|
||||||
|
commits: commits.length,
|
||||||
|
linkedTasks: linkedTasks.length,
|
||||||
|
links: linkedTasks,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventType === 'pull_request') {
|
||||||
|
// PR event: link PR to project
|
||||||
|
const pr = payload.pull_request ?? {}
|
||||||
|
const action = payload.action ?? 'opened'
|
||||||
|
const prTitle = pr.title ?? 'Unknown PR'
|
||||||
|
const prNumber = pr.number ?? 0
|
||||||
|
const prUrl = pr.html_url ?? ''
|
||||||
|
const branch = pr.head?.ref ?? ''
|
||||||
|
|
||||||
|
// Try to match project by branch name (e.g. "feature/intranet-portal" → Intranet-Portal)
|
||||||
|
const project = await db.project.findFirst({
|
||||||
|
where: { name: { contains: branch.replace(/feature\/|fix\//g, '').replace(/-/g, ' ') } },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (project) {
|
||||||
|
await db.auditLog.create({
|
||||||
|
data: {
|
||||||
|
actor: 'system:github-webhook',
|
||||||
|
action: 'webhook.github:pr',
|
||||||
|
entity: 'Project',
|
||||||
|
entityId: project.id,
|
||||||
|
after: JSON.stringify({ prNumber, prTitle, prUrl, action, branch, repo }),
|
||||||
|
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
event: 'pull_request',
|
||||||
|
action,
|
||||||
|
repo,
|
||||||
|
prNumber,
|
||||||
|
prTitle,
|
||||||
|
branch,
|
||||||
|
linkedProject: project.name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
event: 'pull_request',
|
||||||
|
action,
|
||||||
|
repo,
|
||||||
|
prNumber,
|
||||||
|
prTitle,
|
||||||
|
branch,
|
||||||
|
linkedProject: null,
|
||||||
|
note: 'No matching project found for this branch',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown event type
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
event: eventType,
|
||||||
|
note: `Event type '${eventType}' received but not processed`,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||||
|
return NextResponse.json({ error: msg }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,6 +48,15 @@ export const metadata: Metadata = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// PRD §5.6: suppressHydrationWarning on html + body because we use:
|
||||||
|
// 1. next-themes (class on <html> changes client-side)
|
||||||
|
// 2. Zustand persist (view/locale loaded from localStorage client-side)
|
||||||
|
// 3. Radix UI generates IDs with useId() which differ between server and client
|
||||||
|
// (this is expected and the warning is cosmetic — Radix components still work correctly)
|
||||||
|
// The alternative would be to delay rendering until after hydration (mount gate),
|
||||||
|
// but that causes a flash of unstyled content. suppressHydrationWarning is the
|
||||||
|
// official next-themes + Radix pattern.
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
@@ -57,6 +66,7 @@ export default function RootLayout({
|
|||||||
<html lang="de" suppressHydrationWarning>
|
<html lang="de" suppressHydrationWarning>
|
||||||
<body
|
<body
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-background text-foreground`}
|
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-background text-foreground`}
|
||||||
|
suppressHydrationWarning
|
||||||
>
|
>
|
||||||
<ThemeProvider attribute="class" defaultTheme="light" enableSystem={false}>
|
<ThemeProvider attribute="class" defaultTheme="light" enableSystem={false}>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -0,0 +1,530 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useCallback, useEffect, useId, useMemo, useRef, useState, type CSSProperties } from 'react';
|
||||||
|
import {
|
||||||
|
animate,
|
||||||
|
motion,
|
||||||
|
motionValue,
|
||||||
|
useMotionValue,
|
||||||
|
useReducedMotion,
|
||||||
|
useTransform,
|
||||||
|
type MotionValue
|
||||||
|
} from 'motion/react';
|
||||||
|
import { HugeiconsIcon } from '@hugeicons/react';
|
||||||
|
import { Tick02Icon } from '@hugeicons/core-free-icons';
|
||||||
|
|
||||||
|
const EASE_OUT: [number, number, number, number] = [0.23, 1, 0.32, 1];
|
||||||
|
const WASH_IN = 0.3;
|
||||||
|
const WASH_OUT = 0.2;
|
||||||
|
const SINK_DELAY = 0.06;
|
||||||
|
const SINK_STEP = 0.03;
|
||||||
|
const CHECK_DELAY = 0.28;
|
||||||
|
const CHECK_RISE = 8;
|
||||||
|
const SINK_FADE = 0.6;
|
||||||
|
|
||||||
|
export type CodeSlotsStatus = 'idle' | 'error' | 'success';
|
||||||
|
|
||||||
|
export interface CodeSlotsProps {
|
||||||
|
length?: number;
|
||||||
|
value?: string;
|
||||||
|
defaultValue?: string;
|
||||||
|
onChange?: (code: string) => void;
|
||||||
|
onComplete?: (code: string) => void;
|
||||||
|
status?: CodeSlotsStatus;
|
||||||
|
mask?: boolean;
|
||||||
|
caret?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
autoFocus?: boolean;
|
||||||
|
accentColor?: string;
|
||||||
|
inkColor?: string;
|
||||||
|
slotColor?: string;
|
||||||
|
digitColor?: string;
|
||||||
|
dangerColor?: string;
|
||||||
|
slotSize?: number;
|
||||||
|
gap?: number;
|
||||||
|
radius?: number;
|
||||||
|
bounce?: number;
|
||||||
|
settle?: number;
|
||||||
|
rise?: number;
|
||||||
|
cascade?: number;
|
||||||
|
ariaLabel?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Live {
|
||||||
|
settle: number;
|
||||||
|
bounce: number;
|
||||||
|
cascade: number;
|
||||||
|
reduce: boolean | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SlotProps {
|
||||||
|
mv: MotionValue<number>;
|
||||||
|
drop: MotionValue<number>;
|
||||||
|
char: string;
|
||||||
|
active: boolean;
|
||||||
|
rise: number;
|
||||||
|
sink: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clamp01 = (v: number) => Math.min(1, Math.max(0, v));
|
||||||
|
const digitsOf = (raw: string | undefined) => String(raw ?? '').replace(/\D/g, '');
|
||||||
|
const toSlots = (raw: string | undefined, n: number) => {
|
||||||
|
const d = digitsOf(raw).slice(0, n);
|
||||||
|
return Array.from({ length: n }, (_, i) => d[i] ?? '');
|
||||||
|
};
|
||||||
|
const firstEmptyOf = (slots: string[]) => {
|
||||||
|
const i = slots.indexOf('');
|
||||||
|
return i === -1 ? slots.length - 1 : i;
|
||||||
|
};
|
||||||
|
const isFull = (slots: string[]) => slots.every(Boolean);
|
||||||
|
const STYLE = '@keyframes code-slots-blink{0%,49.9%{opacity:1}50%,100%{opacity:0}}';
|
||||||
|
|
||||||
|
const CodeSlots: React.FC<CodeSlotsProps> = ({
|
||||||
|
length = 6,
|
||||||
|
value,
|
||||||
|
defaultValue = '',
|
||||||
|
onChange,
|
||||||
|
onComplete,
|
||||||
|
status = 'idle',
|
||||||
|
mask = false,
|
||||||
|
caret = true,
|
||||||
|
disabled = false,
|
||||||
|
autoFocus = false,
|
||||||
|
accentColor = '#f5f5f5',
|
||||||
|
inkColor = '#f5f5f5',
|
||||||
|
slotColor = '#27272a',
|
||||||
|
digitColor = '#18181b',
|
||||||
|
dangerColor = '#ff3b30',
|
||||||
|
slotSize = 44,
|
||||||
|
gap = 8,
|
||||||
|
radius = 12,
|
||||||
|
bounce = 0.2,
|
||||||
|
settle = 0.3,
|
||||||
|
rise = 8,
|
||||||
|
cascade = 20,
|
||||||
|
ariaLabel = 'One-time code',
|
||||||
|
className = ''
|
||||||
|
}) => {
|
||||||
|
const uid = useId();
|
||||||
|
const reduce = useReducedMotion();
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const rowRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [slots, setSlots] = useState(() => toSlots(value ?? defaultValue, length));
|
||||||
|
const [active, setActive] = useState(() => firstEmptyOf(slots));
|
||||||
|
const [focused, setFocused] = useState(false);
|
||||||
|
const [veiled, setVeiled] = useState(status === 'success');
|
||||||
|
const activeMv = useMotionValue(active);
|
||||||
|
const openMv = useMotionValue(status === 'success' ? 1 : 0);
|
||||||
|
const checkMv = useMotionValue(status === 'success' ? 1 : 0);
|
||||||
|
const glide = useRef<Set<number>>(new Set());
|
||||||
|
const target = useRef<number[]>([]);
|
||||||
|
const draining = useRef(false);
|
||||||
|
const drainTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||||
|
const statusRef = useRef(status);
|
||||||
|
const emitted = useRef(digitsOf(value ?? defaultValue).slice(0, length));
|
||||||
|
const slotsRef = useRef(slots);
|
||||||
|
slotsRef.current = slots;
|
||||||
|
const live = useRef<Live>({} as Live);
|
||||||
|
live.current = { settle, bounce, cascade, reduce };
|
||||||
|
|
||||||
|
const springs = useMemo(
|
||||||
|
() => ({
|
||||||
|
mvs: Array.from({ length }, (_, i) => motionValue(slotsRef.current[i] ? 1 : 0)),
|
||||||
|
drops: Array.from({ length }, () => motionValue(statusRef.current === 'success' ? 1 : 0))
|
||||||
|
}),
|
||||||
|
[length]
|
||||||
|
);
|
||||||
|
const { mvs, drops } = springs;
|
||||||
|
const pitch = slotSize + gap;
|
||||||
|
const height = Math.round(slotSize * 1.18);
|
||||||
|
const washRadius = Math.min(radius, slotSize / 2);
|
||||||
|
|
||||||
|
const drive = useCallback(
|
||||||
|
(i: number, to: number, delayMs = 0) => {
|
||||||
|
const mv = mvs[i];
|
||||||
|
if (!mv) return;
|
||||||
|
target.current[i] = to;
|
||||||
|
const L = live.current;
|
||||||
|
if (L.reduce) {
|
||||||
|
mv.jump(to);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
animate(mv, to, { type: 'spring', duration: L.settle, bounce: L.bounce, delay: delayMs / 1000 });
|
||||||
|
},
|
||||||
|
[mvs]
|
||||||
|
);
|
||||||
|
const land = useCallback(
|
||||||
|
(i: number, delayMs = 0) => {
|
||||||
|
if (mvs[i].get() > 0) mvs[i].jump(0);
|
||||||
|
drive(i, 1, delayMs);
|
||||||
|
},
|
||||||
|
[mvs, drive]
|
||||||
|
);
|
||||||
|
const moveActive = useCallback(
|
||||||
|
(next: number, crossed: number[]) => {
|
||||||
|
crossed.forEach(j => glide.current.add(j));
|
||||||
|
activeMv.jump(next);
|
||||||
|
setActive(next);
|
||||||
|
},
|
||||||
|
[activeMv]
|
||||||
|
);
|
||||||
|
const jumpActive = useCallback(
|
||||||
|
(next: number) => {
|
||||||
|
glide.current.clear();
|
||||||
|
activeMv.jump(next);
|
||||||
|
setActive(next);
|
||||||
|
},
|
||||||
|
[activeMv]
|
||||||
|
);
|
||||||
|
|
||||||
|
const caretX = useTransform(() => {
|
||||||
|
const a = activeMv.get();
|
||||||
|
let x = a * pitch;
|
||||||
|
for (let j = 0; j < mvs.length; j++) {
|
||||||
|
const h = clamp01(mvs[j].get());
|
||||||
|
if (!glide.current.has(j)) continue;
|
||||||
|
const to = target.current[j];
|
||||||
|
if (to === undefined || h === clamp01(to)) {
|
||||||
|
glide.current.delete(j);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
x += j < a ? -(1 - h) * pitch : h * pitch;
|
||||||
|
}
|
||||||
|
return Math.min(Math.max(x, 0), (mvs.length - 1) * pitch);
|
||||||
|
});
|
||||||
|
const caretTransform = useTransform(caretX, (x: number) => `translateX(${x}px)`);
|
||||||
|
const washClip = useTransform(openMv, (o: number) => `inset(0 ${(1 - clamp01(o)) * 50}% round ${washRadius}px)`);
|
||||||
|
const checkTransform = useTransform(
|
||||||
|
checkMv,
|
||||||
|
(c: number) => `translateY(${(1 - c) * CHECK_RISE}px) scale(${0.85 + 0.15 * Math.max(c, 0)})`
|
||||||
|
);
|
||||||
|
const checkOpacity = useTransform(checkMv, clamp01);
|
||||||
|
|
||||||
|
const commit = useCallback(
|
||||||
|
(next: string[]) => {
|
||||||
|
const prev = slotsRef.current;
|
||||||
|
slotsRef.current = next;
|
||||||
|
setSlots(next);
|
||||||
|
const code = next.join('');
|
||||||
|
emitted.current = code;
|
||||||
|
onChange?.(code);
|
||||||
|
if (!isFull(prev) && isFull(next)) onComplete?.(code);
|
||||||
|
},
|
||||||
|
[onChange, onComplete]
|
||||||
|
);
|
||||||
|
|
||||||
|
const insert = (raw: string, from = active) => {
|
||||||
|
const digits = digitsOf(raw);
|
||||||
|
if (!digits) return;
|
||||||
|
const next = [...slotsRef.current];
|
||||||
|
const crossed: number[] = [];
|
||||||
|
const step = reduce ? 0 : cascade;
|
||||||
|
let i = from;
|
||||||
|
for (const ch of digits) {
|
||||||
|
if (i >= length) break;
|
||||||
|
next[i] = ch;
|
||||||
|
land(i, (i - from) * step);
|
||||||
|
crossed.push(i);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
if (!crossed.length) return;
|
||||||
|
commit(next);
|
||||||
|
moveActive(Math.min(i, length - 1), crossed);
|
||||||
|
};
|
||||||
|
const clearSlot = (i: number, stepBack = false) => {
|
||||||
|
if (!slotsRef.current[i]) {
|
||||||
|
if (stepBack) jumpActive(i);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const next = [...slotsRef.current];
|
||||||
|
next[i] = '';
|
||||||
|
drive(i, 0);
|
||||||
|
commit(next);
|
||||||
|
if (stepBack) moveActive(i, [i]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const busy = disabled || draining.current || status === 'success';
|
||||||
|
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (busy || e.metaKey || e.ctrlKey || e.altKey) return;
|
||||||
|
const k = e.key;
|
||||||
|
if (/^[0-9]$/.test(k)) {
|
||||||
|
e.preventDefault();
|
||||||
|
insert(k);
|
||||||
|
} else if (k === 'Backspace') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (slots[active]) clearSlot(active);
|
||||||
|
else if (active > 0) clearSlot(active - 1, true);
|
||||||
|
} else if (k === 'Delete') {
|
||||||
|
e.preventDefault();
|
||||||
|
clearSlot(active);
|
||||||
|
} else if (k === 'ArrowLeft') {
|
||||||
|
e.preventDefault();
|
||||||
|
jumpActive(Math.max(active - 1, 0));
|
||||||
|
} else if (k === 'ArrowRight') {
|
||||||
|
e.preventDefault();
|
||||||
|
jumpActive(Math.min(active + 1, length - 1));
|
||||||
|
} else if (k === 'Home') {
|
||||||
|
e.preventDefault();
|
||||||
|
jumpActive(0);
|
||||||
|
} else if (k === 'End') {
|
||||||
|
e.preventDefault();
|
||||||
|
jumpActive(length - 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onPaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
|
||||||
|
if (busy) return;
|
||||||
|
e.preventDefault();
|
||||||
|
insert(e.clipboardData.getData('text'));
|
||||||
|
};
|
||||||
|
const onInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (busy) return;
|
||||||
|
const d = digitsOf(e.target.value);
|
||||||
|
if (!d) return;
|
||||||
|
insert(d, d.length === 1 ? active : 0);
|
||||||
|
};
|
||||||
|
const onRowMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (disabled) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const row = rowRef.current;
|
||||||
|
if (row && !draining.current && status !== 'success') {
|
||||||
|
const rect = row.getBoundingClientRect();
|
||||||
|
const zoom = rect.width / (row.offsetWidth || rect.width) || 1;
|
||||||
|
const i = Math.floor((e.clientX - rect.left) / zoom / pitch);
|
||||||
|
jumpActive(Math.max(0, Math.min(i, firstEmptyOf(slotsRef.current))));
|
||||||
|
}
|
||||||
|
inputRef.current?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
glide.current.clear();
|
||||||
|
target.current = [];
|
||||||
|
const next = Array.from({ length }, (_, i) => slotsRef.current[i] ?? '');
|
||||||
|
slotsRef.current = next;
|
||||||
|
setSlots(next);
|
||||||
|
jumpActive(firstEmptyOf(next));
|
||||||
|
const code = next.join('');
|
||||||
|
if (code !== emitted.current) {
|
||||||
|
emitted.current = code;
|
||||||
|
onChange?.(code);
|
||||||
|
}
|
||||||
|
}, [length]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (value === undefined) return;
|
||||||
|
const clean = digitsOf(value).slice(0, length);
|
||||||
|
if (clean === emitted.current) return;
|
||||||
|
emitted.current = clean;
|
||||||
|
const prev = slotsRef.current;
|
||||||
|
const next = toSlots(clean, length);
|
||||||
|
const hidden = statusRef.current === 'success';
|
||||||
|
const landing: number[] = [];
|
||||||
|
const leaving: number[] = [];
|
||||||
|
next.forEach((ch, i) => {
|
||||||
|
if (ch === prev[i]) return;
|
||||||
|
(ch ? landing : leaving).push(i);
|
||||||
|
});
|
||||||
|
const step = live.current.reduce || hidden ? 0 : live.current.cascade;
|
||||||
|
landing.forEach((i, k) => land(i, k * step));
|
||||||
|
leaving.reverse().forEach((i, k) => {
|
||||||
|
if (hidden) {
|
||||||
|
target.current[i] = 0;
|
||||||
|
mvs[i].jump(0);
|
||||||
|
drops[i].jump(0);
|
||||||
|
} else drive(i, 0, k * step);
|
||||||
|
});
|
||||||
|
slotsRef.current = next;
|
||||||
|
setSlots(next);
|
||||||
|
moveActive(firstEmptyOf(next), [...landing, ...leaving]);
|
||||||
|
if (!isFull(prev) && isFull(next)) onComplete?.(clean);
|
||||||
|
}, [value, length]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const was = statusRef.current;
|
||||||
|
const L = live.current;
|
||||||
|
if (status === 'success') {
|
||||||
|
setVeiled(true);
|
||||||
|
if (L.reduce) {
|
||||||
|
openMv.jump(1);
|
||||||
|
drops.forEach(d => d.jump(1));
|
||||||
|
checkMv.jump(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
animate(openMv, 1, { duration: WASH_IN, ease: EASE_OUT });
|
||||||
|
drops.forEach((d, k) =>
|
||||||
|
animate(d, 1, { type: 'spring', duration: 0.3, bounce: 0, delay: SINK_DELAY + k * SINK_STEP })
|
||||||
|
);
|
||||||
|
animate(checkMv, 1, { type: 'spring', duration: 0.35, bounce: L.bounce, delay: CHECK_DELAY });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (was !== 'success') return;
|
||||||
|
if (L.reduce) {
|
||||||
|
openMv.jump(0);
|
||||||
|
checkMv.jump(0);
|
||||||
|
drops.forEach(d => d.jump(0));
|
||||||
|
setVeiled(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
animate(checkMv, 0, { duration: 0.15, ease: EASE_OUT });
|
||||||
|
animate(openMv, 0, { duration: WASH_OUT, ease: EASE_OUT, delay: 0.06 }).then(() => {
|
||||||
|
if (openMv.get() === 0) setVeiled(false);
|
||||||
|
});
|
||||||
|
drops.forEach(d => animate(d, 0, { type: 'spring', duration: 0.3, bounce: 0, delay: 0.1 }));
|
||||||
|
}, [status]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (status !== 'error') return;
|
||||||
|
const filled = slotsRef.current.map((c, i) => (c ? i : -1)).filter(i => i >= 0);
|
||||||
|
if (!filled.length) return;
|
||||||
|
filled.reverse();
|
||||||
|
draining.current = true;
|
||||||
|
const L = live.current;
|
||||||
|
const step = L.reduce ? 0 : L.cascade;
|
||||||
|
filled.forEach((i, k) => drive(i, 0, k * step));
|
||||||
|
moveActive(
|
||||||
|
0,
|
||||||
|
slotsRef.current.map((_, j) => j)
|
||||||
|
);
|
||||||
|
clearTimeout(drainTimer.current);
|
||||||
|
drainTimer.current = setTimeout(
|
||||||
|
() => {
|
||||||
|
draining.current = false;
|
||||||
|
commit(Array.from({ length }, () => ''));
|
||||||
|
},
|
||||||
|
L.reduce ? 300 : (filled.length - 1) * step + L.settle * 1000
|
||||||
|
);
|
||||||
|
}, [status]);
|
||||||
|
useEffect(() => {
|
||||||
|
statusRef.current = status;
|
||||||
|
}, [status]);
|
||||||
|
useEffect(() => () => clearTimeout(drainTimer.current), []);
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoFocus) inputRef.current?.focus();
|
||||||
|
}, [autoFocus]);
|
||||||
|
|
||||||
|
const view = slots.length === length ? slots : Array.from({ length }, (_, i) => slots[i] ?? '');
|
||||||
|
const showCaret =
|
||||||
|
caret && focused && !disabled && !veiled && status !== 'success' && (status === 'error' || !view[active]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`relative inline-block${className ? ` ${className}` : ''}`}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
'--cs-accent': accentColor,
|
||||||
|
'--cs-ink': inkColor,
|
||||||
|
'--cs-slot': slotColor,
|
||||||
|
'--cs-digit': digitColor,
|
||||||
|
'--cs-danger': dangerColor,
|
||||||
|
'--cs-size': `${slotSize}px`,
|
||||||
|
'--cs-height': `${height}px`,
|
||||||
|
'--cs-gap': `${gap}px`,
|
||||||
|
'--cs-radius': `${Math.min(radius, slotSize / 2)}px`,
|
||||||
|
'--cs-font': `${Math.round(slotSize * 0.5)}px`
|
||||||
|
} as CSSProperties
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<style>{STYLE}</style>
|
||||||
|
<div
|
||||||
|
ref={rowRef}
|
||||||
|
className="group/row relative inline-flex cursor-text touch-manipulation gap-[var(--cs-gap)] [-webkit-tap-highlight-color:transparent] [transition:opacity_200ms_ease] data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50"
|
||||||
|
data-status={status}
|
||||||
|
data-focused={focused ? '' : undefined}
|
||||||
|
data-disabled={disabled ? '' : undefined}
|
||||||
|
onMouseDown={onRowMouseDown}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
className="absolute inset-0 z-[4] m-0 cursor-[inherit] appearance-none border-0 bg-transparent p-0 text-[16px] text-transparent opacity-0 outline-0 [caret-color:transparent]"
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
value=""
|
||||||
|
maxLength={length}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
aria-invalid={status === 'error'}
|
||||||
|
aria-describedby={`${uid}-count`}
|
||||||
|
disabled={disabled}
|
||||||
|
readOnly={status === 'success'}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
onPaste={onPaste}
|
||||||
|
onChange={onInput}
|
||||||
|
onFocus={() => setFocused(true)}
|
||||||
|
onBlur={() => setFocused(false)}
|
||||||
|
/>
|
||||||
|
{view.map((ch, i) => (
|
||||||
|
<Slot
|
||||||
|
key={i}
|
||||||
|
mv={mvs[i]}
|
||||||
|
drop={drops[i]}
|
||||||
|
char={mask && ch ? '•' : ch}
|
||||||
|
active={focused && i === active}
|
||||||
|
rise={rise}
|
||||||
|
sink={Math.round(height * 0.5)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<motion.span
|
||||||
|
className="pointer-events-none absolute inset-0 z-[1] grid place-items-center rounded-[var(--cs-radius)] bg-[var(--cs-accent)] [color:var(--cs-digit)] motion-reduce:opacity-0 motion-reduce:[transition:opacity_200ms_ease] motion-reduce:in-data-[status=success]:opacity-100"
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{ clipPath: washClip }}
|
||||||
|
>
|
||||||
|
<motion.span
|
||||||
|
className="grid place-items-center motion-reduce:transform-none! motion-reduce:[transition:opacity_150ms_ease]"
|
||||||
|
style={{ transform: checkTransform, opacity: checkOpacity }}
|
||||||
|
>
|
||||||
|
<HugeiconsIcon icon={Tick02Icon} size={Math.round(slotSize * 0.6)} strokeWidth={2.2} />
|
||||||
|
</motion.span>
|
||||||
|
</motion.span>
|
||||||
|
<motion.span
|
||||||
|
className="pointer-events-none absolute top-1/4 left-[calc(var(--cs-size)/2_-_0.75px)] z-[3] h-1/2 w-[1.5px] opacity-0 data-[show]:opacity-100"
|
||||||
|
aria-hidden="true"
|
||||||
|
data-show={showCaret ? '' : undefined}
|
||||||
|
style={{ transform: caretTransform }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
key={active}
|
||||||
|
className="block h-full w-full bg-[var(--cs-ink)] animate-[code-slots-blink_1s_linear_infinite] motion-reduce:animate-none"
|
||||||
|
/>
|
||||||
|
</motion.span>
|
||||||
|
</div>
|
||||||
|
<span id={`${uid}-count`} className="sr-only" aria-live="polite">
|
||||||
|
{status === 'success' ? 'Code accepted' : `${view.filter(Boolean).length} of ${length} digits entered`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function Slot({ mv, drop, char, active, rise, sink }: SlotProps) {
|
||||||
|
const [shown, setShown] = useState(char);
|
||||||
|
if (char && char !== shown) setShown(char);
|
||||||
|
const fill = useTransform(mv, (t: number) => `scale(${Math.max(t, 0)})`);
|
||||||
|
const lift = useTransform(
|
||||||
|
[mv, drop],
|
||||||
|
([t, d]: number[]) => `translateY(${(1 - t) * rise + Math.max(d, 0) * sink}px)`
|
||||||
|
);
|
||||||
|
const ink = useTransform([mv, drop], ([t, d]: number[]) => clamp01(t) * (1 - clamp01(d / SINK_FADE)));
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="relative h-[var(--cs-height)] w-[var(--cs-size)] overflow-hidden rounded-[var(--cs-radius)] bg-[var(--cs-slot)] select-none [transition:background-color_200ms_ease] data-[active]:[background-color:color-mix(in_srgb,var(--cs-ink)_8%,var(--cs-slot))] in-data-[status=error]:[background-color:color-mix(in_srgb,var(--cs-danger)_20%,var(--cs-slot))] in-data-[status=error]:data-[active]:[background-color:color-mix(in_srgb,var(--cs-danger)_20%,var(--cs-slot))] [@media(hover:hover)_and_(pointer:fine)]:group-hover/row:in-data-[status=idle]:not-in-data-[disabled]:not-data-[active]:[background-color:color-mix(in_srgb,var(--cs-ink)_4%,var(--cs-slot))]"
|
||||||
|
data-active={active ? '' : undefined}
|
||||||
|
data-filled={char ? '' : undefined}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<motion.span
|
||||||
|
className="absolute inset-0 origin-center rounded-[inherit] bg-[var(--cs-accent)] [transition:background-color_200ms_ease] in-data-[status=error]:bg-[var(--cs-danger)]"
|
||||||
|
style={{ transform: fill }}
|
||||||
|
/>
|
||||||
|
{shown ? (
|
||||||
|
<motion.span
|
||||||
|
className="absolute inset-0 z-[2] grid place-items-center text-[length:var(--cs-font)] leading-none font-semibold tabular-nums [font-family:inherit] [color:var(--cs-digit)] motion-reduce:transform-none! motion-reduce:[transition:opacity_150ms_ease]"
|
||||||
|
style={{ transform: lift, opacity: ink }}
|
||||||
|
>
|
||||||
|
{shown}
|
||||||
|
</motion.span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CodeSlots;
|
||||||
@@ -0,0 +1,446 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from 'react';
|
||||||
|
import { animate, useMotionValue, useReducedMotion } from 'motion/react';
|
||||||
|
|
||||||
|
const R = 80;
|
||||||
|
const DEAD = 44;
|
||||||
|
const K = 12;
|
||||||
|
const V_FULL = 4;
|
||||||
|
const V_FLICK = 2;
|
||||||
|
const TAU_FOLLOW = 0.06;
|
||||||
|
const TAU_V = 0.05;
|
||||||
|
const DECEL = 0.99;
|
||||||
|
const DRAG_PX = 10;
|
||||||
|
const STALE_MS = 80;
|
||||||
|
|
||||||
|
export interface CometDialChangeDetail {
|
||||||
|
velocity: number;
|
||||||
|
bounce: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CometDialProps {
|
||||||
|
value?: number;
|
||||||
|
defaultValue?: number;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
step?: number;
|
||||||
|
unit?: string;
|
||||||
|
label?: string;
|
||||||
|
accent?: string;
|
||||||
|
ink?: string;
|
||||||
|
size?: number;
|
||||||
|
sweep?: number;
|
||||||
|
thickness?: number;
|
||||||
|
speed?: number;
|
||||||
|
tapBounce?: number;
|
||||||
|
flickBounce?: number;
|
||||||
|
momentum?: number;
|
||||||
|
cometReach?: number;
|
||||||
|
cometWidth?: number;
|
||||||
|
disabled?: boolean;
|
||||||
|
onChange?: (value: number) => void;
|
||||||
|
onChangeEnd?: (value: number, detail: CometDialChangeDetail) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Sample = [number, number];
|
||||||
|
|
||||||
|
interface Grip {
|
||||||
|
id: number;
|
||||||
|
at: number | null;
|
||||||
|
hist: Sample[];
|
||||||
|
side: 'hi' | 'lo' | null;
|
||||||
|
moved: boolean;
|
||||||
|
x0: number;
|
||||||
|
y0: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Loop {
|
||||||
|
raf: number;
|
||||||
|
last: number;
|
||||||
|
v: number;
|
||||||
|
rPrev: number | null;
|
||||||
|
text: string;
|
||||||
|
valueText: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Live {
|
||||||
|
move: (e: PointerEvent) => void;
|
||||||
|
up: (e: PointerEvent) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
|
||||||
|
const velocityOf = (hist: Sample[]) => {
|
||||||
|
if (hist.length < 2) return 0;
|
||||||
|
const a = hist[0];
|
||||||
|
const b = hist[hist.length - 1];
|
||||||
|
return ((b[1] - a[1]) / Math.max(1, b[0] - a[0])) * 1000;
|
||||||
|
};
|
||||||
|
const decimalsOf = (step: number) => {
|
||||||
|
const s = String(step);
|
||||||
|
const i = s.indexOf('.');
|
||||||
|
return i === -1 ? 0 : s.length - i - 1;
|
||||||
|
};
|
||||||
|
const pointAt = (deg: number): [number, number] => {
|
||||||
|
const a = (deg * Math.PI) / 180;
|
||||||
|
return [100 + Math.cos(a) * R, 100 + Math.sin(a) * R];
|
||||||
|
};
|
||||||
|
const arcPath = (a0: number, a1: number) => {
|
||||||
|
const [x0, y0] = pointAt(a0);
|
||||||
|
const [x1, y1] = pointAt(a1);
|
||||||
|
return `M ${x0.toFixed(3)} ${y0.toFixed(3)} A ${R} ${R} 0 ${a1 - a0 > 180 ? 1 : 0} 1 ${x1.toFixed(3)} ${y1.toFixed(3)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CometDial: React.FC<CometDialProps> = ({
|
||||||
|
value,
|
||||||
|
defaultValue = 62,
|
||||||
|
min = 0,
|
||||||
|
max = 100,
|
||||||
|
step = 1,
|
||||||
|
unit = '%',
|
||||||
|
label = 'Level',
|
||||||
|
accent = '#f5f5f5',
|
||||||
|
ink = '#fdfdfd',
|
||||||
|
size = 250,
|
||||||
|
sweep = 320,
|
||||||
|
thickness = 5,
|
||||||
|
speed = 25,
|
||||||
|
tapBounce = 0.2,
|
||||||
|
flickBounce = 0.1,
|
||||||
|
momentum = 1,
|
||||||
|
cometReach = 180,
|
||||||
|
cometWidth = 12,
|
||||||
|
disabled = false,
|
||||||
|
onChange,
|
||||||
|
onChangeEnd,
|
||||||
|
className = ''
|
||||||
|
}) => {
|
||||||
|
const reduce = useReducedMotion();
|
||||||
|
const [dragging, setDragging] = useState(false);
|
||||||
|
const svgRef = useRef<SVGSVGElement>(null);
|
||||||
|
const litRef = useRef<SVGPathElement>(null);
|
||||||
|
const headRef = useRef<SVGCircleElement>(null);
|
||||||
|
const comet = useRef<(SVGPathElement | null)[]>([]);
|
||||||
|
const figure = useRef<HTMLSpanElement>(null);
|
||||||
|
const target = useRef(clamp(value ?? defaultValue, min, max));
|
||||||
|
const grip = useRef<Grip | null>(null);
|
||||||
|
const unbind = useRef<(() => void) | null>(null);
|
||||||
|
const loop = useRef<Loop>({ raf: 0, last: 0, v: 0, rPrev: null, text: '', valueText: '' });
|
||||||
|
const live = useRef<Live>({} as Live);
|
||||||
|
const reading = useMotionValue(target.current);
|
||||||
|
|
||||||
|
const range = Math.max(1e-9, max - min);
|
||||||
|
const gap = 360 - sweep;
|
||||||
|
const start = 90 + gap / 2;
|
||||||
|
const end = start + sweep;
|
||||||
|
const decimals = decimalsOf(step);
|
||||||
|
const k = 200 + (clamp(speed, 0, 100) / 100) * 700;
|
||||||
|
const crit = 2 * Math.sqrt(k);
|
||||||
|
const snap = (v: number) =>
|
||||||
|
step > 0 ? clamp(Math.round((v - min) / step) * step + min, min, max) : clamp(v, min, max);
|
||||||
|
|
||||||
|
const commit = (v: number, finished: boolean, detail?: CometDialChangeDetail) => {
|
||||||
|
const s = snap(v);
|
||||||
|
if (s !== target.current) {
|
||||||
|
target.current = s;
|
||||||
|
onChange?.(s);
|
||||||
|
}
|
||||||
|
if (finished) onChangeEnd?.(s, detail ?? { velocity: 0, bounce: tapBounce });
|
||||||
|
};
|
||||||
|
|
||||||
|
const paintRef = useRef<((now: number) => void) | null>(null);
|
||||||
|
const tick = useCallback((now: number) => paintRef.current?.(now), []);
|
||||||
|
const wake = () => {
|
||||||
|
const L = loop.current;
|
||||||
|
if (L.raf) return;
|
||||||
|
L.last = performance.now();
|
||||||
|
L.rPrev = null;
|
||||||
|
L.raf = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
const launch = (to: number, bounce: number, velocity = reading.getVelocity()) => {
|
||||||
|
reading.stop();
|
||||||
|
if (reduce) {
|
||||||
|
reading.jump(to);
|
||||||
|
wake();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
animate(reading, to, {
|
||||||
|
type: 'spring',
|
||||||
|
stiffness: k,
|
||||||
|
damping: crit * (1 - clamp(bounce, 0, 0.9)),
|
||||||
|
mass: 1,
|
||||||
|
velocity
|
||||||
|
});
|
||||||
|
wake();
|
||||||
|
};
|
||||||
|
|
||||||
|
const paint = (now: number) => {
|
||||||
|
const L = loop.current;
|
||||||
|
const svg = svgRef.current;
|
||||||
|
if (!svg) {
|
||||||
|
L.raf = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dt = clamp((now - L.last) / 1000, 0, 0.05);
|
||||||
|
L.last = now;
|
||||||
|
const g = grip.current;
|
||||||
|
if (g && g.at != null) {
|
||||||
|
if (reduce) reading.jump(g.at);
|
||||||
|
else reading.set(reading.get() + (g.at - reading.get()) * (1 - Math.exp(-dt / TAU_FOLLOW)));
|
||||||
|
}
|
||||||
|
const r = reading.get();
|
||||||
|
const vRaw = L.rPrev == null || dt === 0 || reduce ? 0 : (r - L.rPrev) / dt / range;
|
||||||
|
L.rPrev = r;
|
||||||
|
L.v += (vRaw - L.v) * (1 - Math.exp(-dt / TAU_V));
|
||||||
|
const v = L.v;
|
||||||
|
const s = clamp(Math.abs(v) / V_FULL, 0, 1);
|
||||||
|
const dir = Math.sign(v) || 1;
|
||||||
|
const f = clamp((r - min) / range, 0, 1);
|
||||||
|
const ang = start + f * sweep;
|
||||||
|
if (litRef.current) litRef.current.setAttribute('d', f > 0.0005 ? arcPath(start, ang) : '');
|
||||||
|
if (headRef.current) {
|
||||||
|
const [cx, cy] = pointAt(ang);
|
||||||
|
headRef.current.setAttribute('cx', cx.toFixed(3));
|
||||||
|
headRef.current.setAttribute('cy', cy.toFixed(3));
|
||||||
|
}
|
||||||
|
const seg = (cometReach * s) / K;
|
||||||
|
for (let j = 0; j < K; j++) {
|
||||||
|
const el = comet.current[j];
|
||||||
|
if (!el) continue;
|
||||||
|
let a0 = dir > 0 ? ang - (j + 1) * seg : ang + j * seg;
|
||||||
|
let a1 = dir > 0 ? ang - j * seg : ang + (j + 1) * seg;
|
||||||
|
a0 = clamp(a0, start, end);
|
||||||
|
a1 = clamp(a1, start, end);
|
||||||
|
if (seg < 0.01 || a1 - a0 < 0.01) {
|
||||||
|
if (el.style.opacity !== '0') el.style.opacity = '0';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const w = 1 - j / K;
|
||||||
|
el.setAttribute('d', arcPath(a0, a1));
|
||||||
|
el.setAttribute('stroke-width', (thickness + cometWidth * w * s).toFixed(2));
|
||||||
|
el.style.opacity = (w * s).toFixed(3);
|
||||||
|
}
|
||||||
|
const shown = clamp(r, min, max).toFixed(decimals);
|
||||||
|
if (shown !== L.text) {
|
||||||
|
L.text = shown;
|
||||||
|
if (figure.current) figure.current.textContent = shown;
|
||||||
|
svg.setAttribute('aria-valuenow', shown);
|
||||||
|
}
|
||||||
|
const valueText = `${target.current}${unit}`;
|
||||||
|
if (valueText !== L.valueText) {
|
||||||
|
L.valueText = valueText;
|
||||||
|
svg.setAttribute('aria-valuetext', valueText);
|
||||||
|
}
|
||||||
|
const moving = !!g || reading.isAnimating() || Math.abs(v) > 0.002;
|
||||||
|
if (!moving) {
|
||||||
|
for (const el of comet.current) if (el && el.style.opacity !== '0') el.style.opacity = '0';
|
||||||
|
}
|
||||||
|
L.raf = moving ? requestAnimationFrame(tick) : 0;
|
||||||
|
};
|
||||||
|
paintRef.current = paint;
|
||||||
|
|
||||||
|
const localPoint = (cx: number, cy: number): [number, number] => {
|
||||||
|
const b = (svgRef.current as SVGSVGElement).getBoundingClientRect();
|
||||||
|
return [((cx - b.left) / b.width) * 200 - 100, ((cy - b.top) / b.height) * 200 - 100];
|
||||||
|
};
|
||||||
|
const angleAt = (cx: number, cy: number) => {
|
||||||
|
const [x, y] = localPoint(cx, cy);
|
||||||
|
let rel = ((Math.atan2(y, x) * 180) / Math.PI - start + 720) % 360;
|
||||||
|
const g = grip.current;
|
||||||
|
if (rel > sweep) rel = g && g.side ? (g.side === 'hi' ? sweep : 0) : rel < sweep + gap / 2 ? sweep : 0;
|
||||||
|
else if (g) g.side = rel > sweep / 2 ? 'hi' : 'lo';
|
||||||
|
return min + (rel / sweep) * range;
|
||||||
|
};
|
||||||
|
|
||||||
|
const move = (e: PointerEvent) => {
|
||||||
|
const g = grip.current;
|
||||||
|
if (!g || g.id !== e.pointerId) return;
|
||||||
|
if (!g.moved) {
|
||||||
|
if (Math.hypot(e.clientX - g.x0, e.clientY - g.y0) < DRAG_PX) return;
|
||||||
|
g.moved = true;
|
||||||
|
reading.stop();
|
||||||
|
}
|
||||||
|
g.at = angleAt(e.clientX, e.clientY);
|
||||||
|
g.hist.push([performance.now(), g.at]);
|
||||||
|
if (g.hist.length > 4) g.hist.shift();
|
||||||
|
commit(g.at, false);
|
||||||
|
wake();
|
||||||
|
};
|
||||||
|
const up = (e: PointerEvent) => {
|
||||||
|
const g = grip.current;
|
||||||
|
if (!g || g.id !== e.pointerId) return;
|
||||||
|
grip.current = null;
|
||||||
|
unbind.current?.();
|
||||||
|
unbind.current = null;
|
||||||
|
setDragging(false);
|
||||||
|
try {
|
||||||
|
svgRef.current?.releasePointerCapture(e.pointerId);
|
||||||
|
} catch {}
|
||||||
|
if (!g.moved) {
|
||||||
|
commit(target.current, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const stale = performance.now() - g.hist[g.hist.length - 1][0] > STALE_MS;
|
||||||
|
const v = e.type === 'pointercancel' || stale ? 0 : velocityOf(g.hist);
|
||||||
|
const bounce = tapBounce + (flickBounce - tapBounce) * clamp(Math.abs(v) / range / V_FLICK, 0, 1);
|
||||||
|
const to = snap((g.at ?? target.current) + (v / 1000) * (DECEL / (1 - DECEL)) * momentum);
|
||||||
|
commit(to, true, { velocity: v, bounce });
|
||||||
|
launch(to, bounce, v);
|
||||||
|
};
|
||||||
|
live.current = { move, up };
|
||||||
|
|
||||||
|
const down = (e: React.PointerEvent<SVGSVGElement>) => {
|
||||||
|
if (disabled || grip.current || e.button > 0) return;
|
||||||
|
const [x, y] = localPoint(e.clientX, e.clientY);
|
||||||
|
if (Math.hypot(x, y) < DEAD) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const svg = svgRef.current as SVGSVGElement;
|
||||||
|
try {
|
||||||
|
svg.setPointerCapture(e.pointerId);
|
||||||
|
} catch {}
|
||||||
|
svg.focus({ preventScroll: true });
|
||||||
|
grip.current = { id: e.pointerId, at: null, hist: [], side: null, moved: false, x0: e.clientX, y0: e.clientY };
|
||||||
|
const at = angleAt(e.clientX, e.clientY);
|
||||||
|
grip.current.hist.push([performance.now(), at]);
|
||||||
|
setDragging(true);
|
||||||
|
commit(at, false);
|
||||||
|
launch(snap(at), tapBounce);
|
||||||
|
const onMove = (ev: PointerEvent) => {
|
||||||
|
if (ev.isTrusted) live.current.move(ev);
|
||||||
|
};
|
||||||
|
const onUp = (ev: PointerEvent) => {
|
||||||
|
if (ev.isTrusted) live.current.up(ev);
|
||||||
|
};
|
||||||
|
window.addEventListener('pointermove', onMove);
|
||||||
|
window.addEventListener('pointerup', onUp);
|
||||||
|
window.addEventListener('pointercancel', onUp);
|
||||||
|
unbind.current = () => {
|
||||||
|
window.removeEventListener('pointermove', onMove);
|
||||||
|
window.removeEventListener('pointerup', onUp);
|
||||||
|
window.removeEventListener('pointercancel', onUp);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const key = (e: React.KeyboardEvent<SVGSVGElement>) => {
|
||||||
|
if (disabled) return;
|
||||||
|
const t = target.current;
|
||||||
|
const big = e.shiftKey ? 10 : 1;
|
||||||
|
let to: number;
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowRight':
|
||||||
|
case 'ArrowUp':
|
||||||
|
to = t + step * big;
|
||||||
|
break;
|
||||||
|
case 'ArrowLeft':
|
||||||
|
case 'ArrowDown':
|
||||||
|
to = t - step * big;
|
||||||
|
break;
|
||||||
|
case 'PageUp':
|
||||||
|
to = t + step * 10;
|
||||||
|
break;
|
||||||
|
case 'PageDown':
|
||||||
|
to = t - step * 10;
|
||||||
|
break;
|
||||||
|
case 'Home':
|
||||||
|
to = min;
|
||||||
|
break;
|
||||||
|
case 'End':
|
||||||
|
to = max;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
e.preventDefault();
|
||||||
|
to = snap(to);
|
||||||
|
reading.stop();
|
||||||
|
reading.jump(to);
|
||||||
|
commit(to, true);
|
||||||
|
wake();
|
||||||
|
};
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const L = loop.current;
|
||||||
|
L.text = '';
|
||||||
|
L.valueText = '';
|
||||||
|
paintRef.current?.(performance.now());
|
||||||
|
}, [min, max, step, sweep, thickness, cometReach, cometWidth, unit]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (value === undefined || grip.current) return;
|
||||||
|
const s = snap(value);
|
||||||
|
if (s === target.current) return;
|
||||||
|
target.current = s;
|
||||||
|
launch(s, tapBounce);
|
||||||
|
}, [value]);
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
cancelAnimationFrame(loop.current.raf);
|
||||||
|
unbind.current?.();
|
||||||
|
reading.stop();
|
||||||
|
},
|
||||||
|
[reading]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`group relative select-none [-webkit-touch-callout:none] [-webkit-tap-highlight-color:transparent] [width:var(--cd-size)] [height:var(--cd-size)] [color:var(--cd-ink)] data-[disabled]:opacity-55${className ? ` ${className}` : ''}`}
|
||||||
|
data-dragging={dragging ? '' : undefined}
|
||||||
|
data-disabled={disabled ? '' : undefined}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
'--cd-accent': accent,
|
||||||
|
'--cd-ink': ink,
|
||||||
|
'--cd-size': `${size}px`,
|
||||||
|
'--cd-figure': `${Math.round(size * 0.16)}px`
|
||||||
|
} as CSSProperties
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
ref={svgRef}
|
||||||
|
className="absolute inset-0 block h-full w-full cursor-grab touch-none rounded-full outline-none group-data-[dragging]:cursor-grabbing group-data-[disabled]:pointer-events-none group-data-[disabled]:cursor-default"
|
||||||
|
viewBox="0 0 200 200"
|
||||||
|
role="slider"
|
||||||
|
tabIndex={disabled ? -1 : 0}
|
||||||
|
aria-label={label}
|
||||||
|
aria-valuemin={min}
|
||||||
|
aria-valuemax={max}
|
||||||
|
aria-valuenow={target.current}
|
||||||
|
aria-valuetext={`${target.current}${unit}`}
|
||||||
|
aria-disabled={disabled || undefined}
|
||||||
|
onPointerDown={down}
|
||||||
|
onKeyDown={key}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
className="fill-none opacity-[0.24] [stroke:var(--cd-ink)] [stroke-linecap:round]"
|
||||||
|
d={arcPath(start, end)}
|
||||||
|
strokeWidth={thickness}
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
ref={litRef}
|
||||||
|
className="fill-none opacity-70 [stroke:var(--cd-accent)] [stroke-linecap:round]"
|
||||||
|
strokeWidth={thickness}
|
||||||
|
/>
|
||||||
|
<g className="[&_path]:fill-none [&_path]:[stroke:var(--cd-accent)] [&_path]:[stroke-linecap:round]">
|
||||||
|
{Array.from({ length: K }, (_, j) => (
|
||||||
|
<path
|
||||||
|
key={j}
|
||||||
|
ref={el => {
|
||||||
|
comet.current[j] = el;
|
||||||
|
}}
|
||||||
|
style={{ opacity: 0 }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
<circle ref={headRef} className="[fill:var(--cd-accent)]" r={thickness * 1.8} />
|
||||||
|
</svg>
|
||||||
|
<div className="pointer-events-none absolute inset-0 grid place-items-center" aria-hidden="true">
|
||||||
|
<span className="inline-flex items-baseline gap-[0.06em] leading-none font-medium tracking-[-0.04em] tabular-nums [font-size:var(--cd-figure)]">
|
||||||
|
<span ref={figure} className="comet-dial__figure" />
|
||||||
|
{unit ? <span className="text-[0.5em] opacity-50">{unit}</span> : null}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CometDial;
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useEffect, useId, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';
|
||||||
|
|
||||||
|
export type HoldButtonSize = 'sm' | 'md' | 'lg';
|
||||||
|
export type HoldButtonDirection = 'right' | 'up';
|
||||||
|
|
||||||
|
export interface HoldButtonProps {
|
||||||
|
children?: ReactNode;
|
||||||
|
doneLabel?: ReactNode;
|
||||||
|
icon?: ReactNode;
|
||||||
|
doneIcon?: ReactNode;
|
||||||
|
backgroundColor?: string;
|
||||||
|
fillColor?: string;
|
||||||
|
textColor?: string;
|
||||||
|
fillTextColor?: string;
|
||||||
|
size?: HoldButtonSize;
|
||||||
|
radius?: number;
|
||||||
|
fillDirection?: HoldButtonDirection;
|
||||||
|
holdTime?: number;
|
||||||
|
releaseTime?: number;
|
||||||
|
pressScale?: number;
|
||||||
|
wave?: boolean;
|
||||||
|
waveAmplitude?: number;
|
||||||
|
glow?: boolean;
|
||||||
|
resetAfter?: number;
|
||||||
|
disabled?: boolean;
|
||||||
|
onHold?: () => void;
|
||||||
|
onTap?: () => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Phase = 'idle' | 'holding' | 'done';
|
||||||
|
type Input = 'pointer' | 'key' | null;
|
||||||
|
|
||||||
|
interface Motion {
|
||||||
|
raf: number;
|
||||||
|
p: number;
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
start: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Gesture {
|
||||||
|
pointerId: number | null;
|
||||||
|
start: number;
|
||||||
|
rect: DOMRect | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReleaseOptions {
|
||||||
|
drifted?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TAP_MS = 250;
|
||||||
|
const HIT_PAD = 10;
|
||||||
|
const LINEAR = (t: number) => t;
|
||||||
|
const EASE_OUT = (t: number) => 1 - Math.pow(1 - t, 3);
|
||||||
|
|
||||||
|
const SIZES: Record<HoldButtonSize, string> = {
|
||||||
|
sm: 'h-9 px-4 text-[13px]',
|
||||||
|
md: 'h-11 px-[22px] text-[15px]',
|
||||||
|
lg: 'h-[52px] px-7 text-[17px]'
|
||||||
|
};
|
||||||
|
|
||||||
|
const GLOW = 'inset_0_1px_0_rgba(255,255,255,0.06),0_10px_32px_-6px_color-mix(in_srgb,var(--hb-fill)_70%,transparent)';
|
||||||
|
|
||||||
|
const LABEL_SPAN =
|
||||||
|
'[grid-area:1/1] inline-flex items-center gap-2 whitespace-nowrap [transition:opacity_200ms_ease,filter_200ms_ease]';
|
||||||
|
|
||||||
|
const STYLE = `
|
||||||
|
.hb-root{--hb-w:0px;--hb-h:0px;--hb-cycles:2;--hb-p:0}
|
||||||
|
.hb-fill{clip-path:inset(0 calc((1 - var(--hb-p)) * (100% + 0.75 * var(--hb-wave)) - var(--hb-p) * 0.25 * var(--hb-wave)) 0 0)}
|
||||||
|
.hb-root[data-direction=up] .hb-fill{clip-path:inset(calc((1 - var(--hb-p)) * (100% + 0.75 * var(--hb-wave)) - var(--hb-p) * 0.25 * var(--hb-wave)) 0 0 0)}
|
||||||
|
.hb-crest{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='200' viewBox='0 0 20 200' preserveAspectRatio='none'%3E%3Cpath d='M0 0H10C18 8 18 25.3 10 33.3S2 58.7 10 66.7S18 92 10 100S2 125.3 10 133.3S18 158.7 10 166.7S2 192 10 200H0Z'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='200' viewBox='0 0 20 200' preserveAspectRatio='none'%3E%3Cpath d='M0 0H10C18 8 18 25.3 10 33.3S2 58.7 10 66.7S18 92 10 100S2 125.3 10 133.3S18 158.7 10 166.7S2 192 10 200H0Z'/%3E%3C/svg%3E");-webkit-mask-repeat:repeat-y;mask-repeat:repeat-y;-webkit-mask-size:var(--hb-wave) calc(var(--hb-h) * 2);mask-size:var(--hb-wave) calc(var(--hb-h) * 2);-webkit-mask-position-x:calc(-1 * var(--hb-wave) + var(--hb-p) * (var(--hb-w) + var(--hb-wave)));mask-position-x:calc(-1 * var(--hb-wave) + var(--hb-p) * (var(--hb-w) + var(--hb-wave)));-webkit-mask-position-y:calc(-1 * var(--hb-p) * var(--hb-cycles) * var(--hb-h));mask-position-y:calc(-1 * var(--hb-p) * var(--hb-cycles) * var(--hb-h))}
|
||||||
|
.hb-root[data-direction=up] .hb-crest{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='20' viewBox='0 0 200 20' preserveAspectRatio='none'%3E%3Cpath d='M0 20V10C12 2 38 2 50 10S88 18 100 10S138 2 150 10S188 18 200 10V20Z'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='20' viewBox='0 0 200 20' preserveAspectRatio='none'%3E%3Cpath d='M0 20V10C12 2 38 2 50 10S88 18 100 10S138 2 150 10S188 18 200 10V20Z'/%3E%3C/svg%3E");-webkit-mask-repeat:repeat-x;mask-repeat:repeat-x;-webkit-mask-size:calc(var(--hb-w) * 2) var(--hb-wave);mask-size:calc(var(--hb-w) * 2) var(--hb-wave);-webkit-mask-position-x:calc(-1 * var(--hb-p) * var(--hb-cycles) * var(--hb-w));mask-position-x:calc(-1 * var(--hb-p) * var(--hb-cycles) * var(--hb-w));-webkit-mask-position-y:calc(var(--hb-h) - var(--hb-p) * (var(--hb-h) + var(--hb-wave)));mask-position-y:calc(var(--hb-h) - var(--hb-p) * (var(--hb-h) + var(--hb-wave)))}
|
||||||
|
@keyframes hb-pulse{from{opacity:1;box-shadow:0 0 0 0 color-mix(in srgb,var(--hb-fill) 55%,transparent)}to{opacity:0;box-shadow:0 0 0 14px color-mix(in srgb,var(--hb-fill) 0%,transparent)}}
|
||||||
|
@media (prefers-reduced-motion:reduce){
|
||||||
|
.hb-root{transform:none!important;transition:background-color 160ms ease,box-shadow var(--hb-release) ease!important}
|
||||||
|
.hb-fill{clip-path:inset(0)!important;opacity:0;transition:opacity var(--hb-release) ease!important}
|
||||||
|
.hb-crest{display:none}
|
||||||
|
.hb-root[data-phase=holding] .hb-fill,.hb-root[data-phase=done] .hb-fill{opacity:1;transition:opacity var(--hb-hold) linear!important}
|
||||||
|
.hb-pulse{animation:none!important}
|
||||||
|
.hb-label>span{filter:none!important;transition:opacity 200ms ease!important}
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const HoldButton: React.FC<HoldButtonProps> = ({
|
||||||
|
children = 'Hold to delete',
|
||||||
|
doneLabel = 'Deleted',
|
||||||
|
icon = null,
|
||||||
|
doneIcon = null,
|
||||||
|
backgroundColor = '#27272a',
|
||||||
|
fillColor = '#5227FF',
|
||||||
|
textColor = '#f5f5f5',
|
||||||
|
fillTextColor = '#ffffff',
|
||||||
|
size = 'md',
|
||||||
|
radius = 14,
|
||||||
|
fillDirection = 'right',
|
||||||
|
holdTime = 2000,
|
||||||
|
releaseTime = 200,
|
||||||
|
pressScale = 0.97,
|
||||||
|
wave = true,
|
||||||
|
waveAmplitude = 6,
|
||||||
|
glow = true,
|
||||||
|
resetAfter = 1200,
|
||||||
|
disabled = false,
|
||||||
|
onHold,
|
||||||
|
onTap,
|
||||||
|
className = ''
|
||||||
|
}) => {
|
||||||
|
const [phase, setPhase] = useState<Phase>('idle');
|
||||||
|
const [input, setInput] = useState<Input>(null);
|
||||||
|
const phaseRef = useRef<Phase>('idle');
|
||||||
|
const inputRef = useRef<Input>(null);
|
||||||
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const gesture = useRef<Gesture>({ pointerId: null, start: 0, rect: null });
|
||||||
|
const timers = useRef({ complete: 0, reset: 0 });
|
||||||
|
const hintId = useId();
|
||||||
|
|
||||||
|
const go = (next: Phase, kind: Input = null) => {
|
||||||
|
phaseRef.current = next;
|
||||||
|
inputRef.current = kind;
|
||||||
|
setPhase(next);
|
||||||
|
setInput(kind);
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearTimers = () => {
|
||||||
|
clearTimeout(timers.current.complete);
|
||||||
|
clearTimeout(timers.current.reset);
|
||||||
|
};
|
||||||
|
|
||||||
|
const motion = useRef<Motion>({ raf: 0, p: 0, from: 0, to: 0, start: 0 });
|
||||||
|
const drive = (to: number, duration: number, ease: (t: number) => number) => {
|
||||||
|
const m = motion.current;
|
||||||
|
cancelAnimationFrame(m.raf);
|
||||||
|
m.from = m.p;
|
||||||
|
m.to = to;
|
||||||
|
m.start = performance.now();
|
||||||
|
const step = (now: number) => {
|
||||||
|
const t = duration > 0 ? Math.min(1, (now - m.start) / duration) : 1;
|
||||||
|
m.p = m.from + (m.to - m.from) * ease(t);
|
||||||
|
buttonRef.current?.style.setProperty('--hb-p', m.p.toFixed(4));
|
||||||
|
if (t < 1) {
|
||||||
|
m.raf = requestAnimationFrame(step);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
m.raf = 0;
|
||||||
|
if (m.to === 1) complete();
|
||||||
|
};
|
||||||
|
m.raf = requestAnimationFrame(step);
|
||||||
|
};
|
||||||
|
|
||||||
|
const complete = () => {
|
||||||
|
if (phaseRef.current !== 'holding') return;
|
||||||
|
if (performance.now() - gesture.current.start < holdTime - 50) return;
|
||||||
|
clearTimers();
|
||||||
|
go('done', inputRef.current);
|
||||||
|
onHold?.();
|
||||||
|
if (resetAfter > 0) {
|
||||||
|
timers.current.reset = window.setTimeout(() => {
|
||||||
|
go('idle');
|
||||||
|
drive(0, releaseTime, EASE_OUT);
|
||||||
|
}, resetAfter);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const begin = (kind: Input) => {
|
||||||
|
if (disabled || phaseRef.current !== 'idle') return false;
|
||||||
|
const button = buttonRef.current;
|
||||||
|
if (!button) return false;
|
||||||
|
gesture.current.start = performance.now();
|
||||||
|
gesture.current.rect = button.getBoundingClientRect();
|
||||||
|
go('holding', kind);
|
||||||
|
drive(1, holdTime, LINEAR);
|
||||||
|
timers.current.complete = window.setTimeout(complete, holdTime + 100);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const release = ({ drifted = false }: ReleaseOptions = {}) => {
|
||||||
|
if (phaseRef.current !== 'holding') return;
|
||||||
|
clearTimers();
|
||||||
|
const held = performance.now() - gesture.current.start;
|
||||||
|
go('idle');
|
||||||
|
drive(0, releaseTime, EASE_OUT);
|
||||||
|
if (!drifted && held < TAP_MS) onTap?.();
|
||||||
|
};
|
||||||
|
const releaseRef = useRef(release);
|
||||||
|
releaseRef.current = release;
|
||||||
|
|
||||||
|
const handlePointerDown = (e: React.PointerEvent<HTMLButtonElement>) => {
|
||||||
|
if (e.button !== 0 || !e.isPrimary || gesture.current.pointerId !== null) return;
|
||||||
|
if (!begin('pointer')) return;
|
||||||
|
gesture.current.pointerId = e.pointerId;
|
||||||
|
try {
|
||||||
|
e.currentTarget.setPointerCapture(e.pointerId);
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const endPointer = (e: React.PointerEvent<HTMLButtonElement>, options?: ReleaseOptions) => {
|
||||||
|
if (e.pointerId !== gesture.current.pointerId) return;
|
||||||
|
gesture.current.pointerId = null;
|
||||||
|
try {
|
||||||
|
if (e.currentTarget.hasPointerCapture(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId);
|
||||||
|
} catch {}
|
||||||
|
release(options);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerMove = (e: React.PointerEvent<HTMLButtonElement>) => {
|
||||||
|
if (e.pointerId !== gesture.current.pointerId) return;
|
||||||
|
const r = gesture.current.rect;
|
||||||
|
if (!r) return;
|
||||||
|
const out =
|
||||||
|
e.clientX < r.left - HIT_PAD ||
|
||||||
|
e.clientX > r.right + HIT_PAD ||
|
||||||
|
e.clientY < r.top - HIT_PAD ||
|
||||||
|
e.clientY > r.bottom + HIT_PAD;
|
||||||
|
if (out) endPointer(e, { drifted: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerLeave = (e: React.PointerEvent<HTMLButtonElement>) => {
|
||||||
|
if (e.pointerType !== 'touch') endPointer(e, { drifted: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
if (inputRef.current === 'key') release({ drifted: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === ' ' || e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!e.repeat) begin('key');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyUp = (e: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||||
|
if (e.key === ' ' || e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (inputRef.current === 'key') release();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const button = buttonRef.current;
|
||||||
|
if (!button) return undefined;
|
||||||
|
const measure = () => {
|
||||||
|
button.style.setProperty('--hb-w', `${button.offsetWidth}px`);
|
||||||
|
button.style.setProperty('--hb-h', `${button.offsetHeight}px`);
|
||||||
|
};
|
||||||
|
measure();
|
||||||
|
const ro = new ResizeObserver(measure);
|
||||||
|
ro.observe(button);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (phase !== 'holding') return undefined;
|
||||||
|
const cancel = () => releaseRef.current({ drifted: true });
|
||||||
|
const onVisibility = () => {
|
||||||
|
if (document.hidden) cancel();
|
||||||
|
};
|
||||||
|
window.addEventListener('blur', cancel);
|
||||||
|
document.addEventListener('visibilitychange', onVisibility);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('blur', cancel);
|
||||||
|
document.removeEventListener('visibilitychange', onVisibility);
|
||||||
|
};
|
||||||
|
}, [phase]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const t = timers.current;
|
||||||
|
const m = motion.current;
|
||||||
|
return () => {
|
||||||
|
clearTimeout(t.complete);
|
||||||
|
clearTimeout(t.reset);
|
||||||
|
cancelAnimationFrame(m.raf);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const direction: HoldButtonDirection = fillDirection === 'up' ? 'up' : 'right';
|
||||||
|
const labels = (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className={`${LABEL_SPAN} group-data-[phase=done]:opacity-0 group-data-[phase=done]:blur-[2px]`}
|
||||||
|
aria-hidden={phase === 'done'}
|
||||||
|
>
|
||||||
|
{icon ? <span className="inline-flex flex-none [&>svg]:block">{icon}</span> : null}
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`${LABEL_SPAN} opacity-0 blur-[2px] group-data-[phase=done]:opacity-100 group-data-[phase=done]:blur-0`}
|
||||||
|
aria-hidden={phase !== 'done'}
|
||||||
|
>
|
||||||
|
{doneIcon ? <span className="inline-flex flex-none [&>svg]:block">{doneIcon}</span> : null}
|
||||||
|
{doneLabel}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const cssVars = {
|
||||||
|
'--hb-radius': `${radius}px`,
|
||||||
|
'--hb-bg': backgroundColor,
|
||||||
|
'--hb-fill': fillColor,
|
||||||
|
'--hb-text': textColor,
|
||||||
|
'--hb-fill-text': fillTextColor,
|
||||||
|
'--hb-hold': `${holdTime}ms`,
|
||||||
|
'--hb-cycles': holdTime / 1100,
|
||||||
|
'--hb-release': `${releaseTime}ms`,
|
||||||
|
'--hb-press': pressScale,
|
||||||
|
'--hb-wave': `${wave ? waveAmplitude : 0}px`,
|
||||||
|
'--hb-ease-out': 'cubic-bezier(0.23, 1, 0.32, 1)'
|
||||||
|
} as CSSProperties;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
ref={buttonRef}
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
className={`hb-root group relative isolate m-0 inline-grid cursor-pointer touch-manipulation select-none place-items-center border-0 font-medium leading-none tracking-[0.01em] outline-none [-webkit-tap-highlight-color:transparent] [-webkit-touch-callout:none] [background:var(--hb-bg)] [border-radius:var(--hb-radius)] [color:var(--hb-text)] shadow-[inset_0_1px_0_rgba(255,255,255,0.06)] [transition:transform_160ms_var(--hb-ease-out),background-color_160ms_ease,box-shadow_var(--hb-release)_var(--hb-ease-out)] [@media(hover:hover)_and_(pointer:fine)]:enabled:hover:[background:color-mix(in_srgb,var(--hb-bg)_92%,#fff)] data-[phase=holding]:data-[input=pointer]:[transform:scale(var(--hb-press))] data-[glow=true]:data-[phase=holding]:shadow-[${GLOW}] data-[glow=true]:data-[phase=done]:shadow-[${GLOW}] data-[glow=true]:data-[phase=holding]:[transition:transform_160ms_var(--hb-ease-out),background-color_160ms_ease,box-shadow_var(--hb-hold)_linear] focus-visible:[outline:2px_solid_var(--hb-fill)] focus-visible:outline-offset-[3px] disabled:pointer-events-none disabled:cursor-default disabled:opacity-50 contrast-more:[outline:1px_solid_var(--hb-text)] ${SIZES[size] || SIZES.md}${className ? ` ${className}` : ''}`}
|
||||||
|
data-phase={phase}
|
||||||
|
data-input={input ?? undefined}
|
||||||
|
data-direction={direction}
|
||||||
|
data-glow={glow ? 'true' : undefined}
|
||||||
|
aria-describedby={hintId}
|
||||||
|
style={cssVars}
|
||||||
|
onPointerDown={handlePointerDown}
|
||||||
|
onPointerMove={handlePointerMove}
|
||||||
|
onPointerUp={e => endPointer(e)}
|
||||||
|
onPointerCancel={e => endPointer(e, { drifted: true })}
|
||||||
|
onLostPointerCapture={e => endPointer(e, { drifted: true })}
|
||||||
|
onPointerLeave={handlePointerLeave}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onKeyUp={handleKeyUp}
|
||||||
|
onContextMenu={e => e.preventDefault()}
|
||||||
|
>
|
||||||
|
<style>{STYLE}</style>
|
||||||
|
<span
|
||||||
|
className="hb-pulse pointer-events-none absolute inset-0 z-0 opacity-0 [border-radius:var(--hb-radius)] group-data-[glow=true]:group-data-[phase=done]:[animation:hb-pulse_600ms_var(--hb-ease-out)_forwards]"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span className="hb-label relative z-[2] grid place-items-center">{labels}</span>
|
||||||
|
<span
|
||||||
|
className="pointer-events-none absolute inset-0 z-[3] [clip-path:inset(0_round_var(--hb-radius))]"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<span className="hb-fill absolute inset-0 grid place-items-center [background:var(--hb-fill)] [color:var(--hb-fill-text)]">
|
||||||
|
<span className="hb-label grid place-items-center">{labels}</span>
|
||||||
|
</span>
|
||||||
|
<span className="hb-crest absolute inset-0 grid place-items-center [background:var(--hb-fill)] [color:var(--hb-fill-text)]">
|
||||||
|
<span className="hb-label grid place-items-center">{labels}</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span id={hintId} className="absolute h-px w-px overflow-hidden whitespace-nowrap [clip-path:inset(50%)]">
|
||||||
|
Press and hold for {Math.round(holdTime / 100) / 10} seconds to confirm
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HoldButton;
|
||||||
@@ -0,0 +1,490 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useEffect, useId, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';
|
||||||
|
import { animate, motion, useMotionValue, useReducedMotion, useTransform } from 'motion/react';
|
||||||
|
import { HugeiconsIcon } from '@hugeicons/react';
|
||||||
|
import { Delete02Icon } from '@hugeicons/core-free-icons';
|
||||||
|
|
||||||
|
const HYST = 10;
|
||||||
|
const FLICK = 110;
|
||||||
|
const DECEL = 0.998;
|
||||||
|
const VMAX = 1500;
|
||||||
|
const EASE_OUT: [number, number, number, number] = [0.23, 1, 0.32, 1];
|
||||||
|
const SPRING_UI = { type: 'spring' as const, duration: 0.3, bounce: 0 };
|
||||||
|
const DEFAULT_ACTIONS: SwipeAction[] = [{ id: 'delete', label: 'Delete' }];
|
||||||
|
|
||||||
|
export interface SwipeAction {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
icon?: ReactNode;
|
||||||
|
color?: string;
|
||||||
|
dismiss?: boolean;
|
||||||
|
onSelect?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SwipeRowProps {
|
||||||
|
children?: ReactNode;
|
||||||
|
actions?: SwipeAction[];
|
||||||
|
actionColor?: string;
|
||||||
|
drawerColor?: string;
|
||||||
|
rowColor?: string;
|
||||||
|
textColor?: string;
|
||||||
|
height?: number;
|
||||||
|
radius?: number;
|
||||||
|
actionWidth?: number;
|
||||||
|
direction?: 'left' | 'right';
|
||||||
|
snapBounce?: number;
|
||||||
|
resistance?: number;
|
||||||
|
collapseMs?: number;
|
||||||
|
commitAt?: number;
|
||||||
|
fullSwipe?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
open?: boolean;
|
||||||
|
onOpenChange?: (open: boolean) => void;
|
||||||
|
onAction?: (action: SwipeAction) => void;
|
||||||
|
onCommit?: (action: SwipeAction) => void;
|
||||||
|
closeOnAction?: boolean;
|
||||||
|
haptic?: boolean;
|
||||||
|
label?: string;
|
||||||
|
className?: string;
|
||||||
|
style?: CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Sample = [number, number];
|
||||||
|
type Phase = 'idle' | 'committing' | 'collapsing';
|
||||||
|
|
||||||
|
interface Grip {
|
||||||
|
id: number;
|
||||||
|
x0: number;
|
||||||
|
y0: number;
|
||||||
|
grab: number | null;
|
||||||
|
moved: boolean;
|
||||||
|
hist: Sample[];
|
||||||
|
touch: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Live {
|
||||||
|
move: (e: PointerEvent) => void;
|
||||||
|
up: (e: PointerEvent) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
|
||||||
|
const rubber = (o: number, dim: number, c: number) => (o * dim * c) / (dim + c * Math.abs(o));
|
||||||
|
const unrubber = (y: number, dim: number, c: number) => (y * dim) / (c * Math.max(1, dim - Math.abs(y)));
|
||||||
|
const project = (v: number) => ((v / 1000) * DECEL) / (1 - DECEL);
|
||||||
|
const velocityOf = (hist: Sample[]) => {
|
||||||
|
if (hist.length < 2) return 0;
|
||||||
|
const a = hist[0];
|
||||||
|
const b = hist[hist.length - 1];
|
||||||
|
return ((b[1] - a[1]) / Math.max(1, b[0] - a[0])) * 1000;
|
||||||
|
};
|
||||||
|
const onColor = (hex: string) => {
|
||||||
|
const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex.trim());
|
||||||
|
if (!m) return '#ffffff';
|
||||||
|
const h = m[1].length === 3 ? [...m[1]].map(ch => ch + ch).join('') : m[1];
|
||||||
|
const r = parseInt(h.slice(0, 2), 16);
|
||||||
|
const g = parseInt(h.slice(2, 4), 16);
|
||||||
|
const b = parseInt(h.slice(4, 6), 16);
|
||||||
|
return (r * 299 + g * 587 + b * 114) / 1000 >= 150 ? '#111111' : '#ffffff';
|
||||||
|
};
|
||||||
|
const watchWindow = (live: { current: Live }) => {
|
||||||
|
const onMove = (e: PointerEvent) => {
|
||||||
|
if (e.isTrusted) live.current.move(e);
|
||||||
|
};
|
||||||
|
const onUp = (e: PointerEvent) => {
|
||||||
|
if (e.isTrusted) live.current.up(e);
|
||||||
|
};
|
||||||
|
window.addEventListener('pointermove', onMove);
|
||||||
|
window.addEventListener('pointerup', onUp);
|
||||||
|
window.addEventListener('pointercancel', onUp);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('pointermove', onMove);
|
||||||
|
window.removeEventListener('pointerup', onUp);
|
||||||
|
window.removeEventListener('pointercancel', onUp);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const SwipeRow: React.FC<SwipeRowProps> = ({
|
||||||
|
children,
|
||||||
|
actions = DEFAULT_ACTIONS,
|
||||||
|
actionColor = '#e5484d',
|
||||||
|
drawerColor = '#3f3f46',
|
||||||
|
rowColor = '#27272a',
|
||||||
|
textColor = '#f5f5f5',
|
||||||
|
height = 64,
|
||||||
|
radius = 16,
|
||||||
|
actionWidth = 80,
|
||||||
|
direction = 'left',
|
||||||
|
snapBounce = 0.2,
|
||||||
|
resistance = 0.55,
|
||||||
|
collapseMs = 200,
|
||||||
|
commitAt = 0.6,
|
||||||
|
fullSwipe = true,
|
||||||
|
disabled = false,
|
||||||
|
open: openProp,
|
||||||
|
onOpenChange,
|
||||||
|
onAction,
|
||||||
|
onCommit,
|
||||||
|
closeOnAction = true,
|
||||||
|
haptic = true,
|
||||||
|
label = 'List item',
|
||||||
|
className = '',
|
||||||
|
style
|
||||||
|
}) => {
|
||||||
|
const uid = useId();
|
||||||
|
const reduce = useReducedMotion();
|
||||||
|
const s = direction === 'left' ? -1 : 1;
|
||||||
|
const A = actionWidth;
|
||||||
|
const n = actions.length;
|
||||||
|
const D = n * A;
|
||||||
|
const c = clamp(resistance, 0.05, 1);
|
||||||
|
const primary = actions[0];
|
||||||
|
const [openState, setOpenState] = useState(false);
|
||||||
|
const [phase, setPhase] = useState<Phase>('idle');
|
||||||
|
const [say, setSay] = useState('');
|
||||||
|
const open = openProp ?? openState;
|
||||||
|
|
||||||
|
const root = useRef<HTMLDivElement>(null);
|
||||||
|
const surface = useRef<HTMLDivElement>(null);
|
||||||
|
const w = useRef(360);
|
||||||
|
const grip = useRef<Grip | null>(null);
|
||||||
|
const unwatch = useRef<(() => void) | null>(null);
|
||||||
|
const live = useRef<Live>({} as Live);
|
||||||
|
const foldTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||||
|
const heading = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const x = useMotionValue(0);
|
||||||
|
const spread = useMotionValue(0);
|
||||||
|
const landed = useMotionValue(0);
|
||||||
|
const commitPoint = () => Math.max(commitAt * w.current, D + A / 2);
|
||||||
|
const canCommit = () => fullSwipe && n > 0 && commitPoint() <= w.current;
|
||||||
|
const exposed = useTransform(x, v => s * v);
|
||||||
|
const surfaceXf = useTransform(x, v => `translateX(${v}px)`);
|
||||||
|
const railXf = useTransform(exposed, (e: number) => `translateX(${-s * Math.max(0, D - e)}px)`);
|
||||||
|
const shift = useTransform([exposed, spread], ([e, p]: number[]) => p * Math.max(0, e - A));
|
||||||
|
const blockXf = useTransform(shift, v => `translateX(${s * v}px)`);
|
||||||
|
const glyphXf = useTransform(
|
||||||
|
[shift, landed],
|
||||||
|
([v, l]: number[]) => `translateX(${-s * l * (v - (w.current - A) / 2)}px)`
|
||||||
|
);
|
||||||
|
|
||||||
|
const map = (raw: number) => {
|
||||||
|
const W = w.current;
|
||||||
|
if (raw < 0) return rubber(raw, W, c);
|
||||||
|
if (raw <= D) return raw;
|
||||||
|
if (!canCommit()) return D + rubber(raw - D, W, c);
|
||||||
|
const C = commitPoint();
|
||||||
|
const knee = D + (C - D) / c;
|
||||||
|
return raw <= knee ? D + c * (raw - D) : C + rubber(raw - knee, W, c);
|
||||||
|
};
|
||||||
|
const inv = (ex: number) => {
|
||||||
|
const W = w.current;
|
||||||
|
if (ex < 0) return unrubber(ex, W, c);
|
||||||
|
if (ex <= D) return ex;
|
||||||
|
if (!canCommit()) return D + unrubber(ex - D, W, c);
|
||||||
|
const C = commitPoint();
|
||||||
|
const knee = D + (C - D) / c;
|
||||||
|
return ex <= C ? D + (ex - D) / c : knee + unrubber(ex - C, W, c);
|
||||||
|
};
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const el = root.current;
|
||||||
|
if (!el) return undefined;
|
||||||
|
w.current = el.offsetWidth || w.current;
|
||||||
|
const observer = new ResizeObserver(entries => {
|
||||||
|
const width = entries[0]?.contentRect.width;
|
||||||
|
if (width) w.current = width;
|
||||||
|
});
|
||||||
|
observer.observe(el);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
clearTimeout(foldTimer.current);
|
||||||
|
unwatch.current?.();
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const setOpen = (next: boolean) => {
|
||||||
|
if (next === open) return;
|
||||||
|
setOpenState(next);
|
||||||
|
onOpenChange?.(next);
|
||||||
|
};
|
||||||
|
const settle = (target: number, v = 0) => {
|
||||||
|
heading.current = target;
|
||||||
|
if (reduce) {
|
||||||
|
animate(x, s * target, { duration: 0.2, ease: EASE_OUT });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const flick = Math.abs(v) >= FLICK;
|
||||||
|
animate(
|
||||||
|
x,
|
||||||
|
s * target,
|
||||||
|
flick
|
||||||
|
? { type: 'spring', duration: 0.4, bounce: snapBounce, velocity: s * clamp(v, -VMAX, VMAX) }
|
||||||
|
: { ...SPRING_UI, velocity: s * v }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const setSpread = (on: boolean) => {
|
||||||
|
if ((spread.get() === 1) === on) return;
|
||||||
|
if (reduce) spread.set(on ? 1 : 0);
|
||||||
|
else animate(spread, on ? 1 : 0, SPRING_UI);
|
||||||
|
if (on && primary) {
|
||||||
|
setSay(`Release to ${primary.label}`);
|
||||||
|
if (haptic && grip.current?.touch) navigator.vibrate?.(8);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const commit = (a: SwipeAction, viaKey: boolean, v = 0) => {
|
||||||
|
const leap = a === primary;
|
||||||
|
setPhase('committing');
|
||||||
|
setSay(a.label);
|
||||||
|
setOpen(false);
|
||||||
|
const fold = () => {
|
||||||
|
setPhase('collapsing');
|
||||||
|
foldTimer.current = setTimeout(() => {
|
||||||
|
onCommit?.(a);
|
||||||
|
a.onSelect?.();
|
||||||
|
}, collapseMs);
|
||||||
|
};
|
||||||
|
if (viaKey || reduce) {
|
||||||
|
if (leap) {
|
||||||
|
spread.set(1);
|
||||||
|
landed.set(1);
|
||||||
|
}
|
||||||
|
if (viaKey) {
|
||||||
|
x.set(s * w.current);
|
||||||
|
fold();
|
||||||
|
} else animate(x, s * w.current, { duration: 0.2, ease: EASE_OUT }).then(fold);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (leap) {
|
||||||
|
if (spread.get() < 1) animate(spread, 1, SPRING_UI);
|
||||||
|
animate(landed, 1, SPRING_UI);
|
||||||
|
}
|
||||||
|
animate(x, s * w.current, { ...SPRING_UI, velocity: s * v }).then(fold);
|
||||||
|
};
|
||||||
|
useEffect(() => {
|
||||||
|
if (openProp === undefined || grip.current || phase !== 'idle') return;
|
||||||
|
const target = open ? D : 0;
|
||||||
|
if (heading.current === target) return;
|
||||||
|
if (Math.abs(exposed.get() - target) > 0.5) settle(target);
|
||||||
|
}, [open, D]);
|
||||||
|
|
||||||
|
const down = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
if (disabled || n === 0 || phase !== 'idle' || grip.current || e.button !== 0) return;
|
||||||
|
x.stop();
|
||||||
|
heading.current = null;
|
||||||
|
grip.current = {
|
||||||
|
id: e.pointerId,
|
||||||
|
x0: e.clientX,
|
||||||
|
y0: e.clientY,
|
||||||
|
grab: null,
|
||||||
|
moved: false,
|
||||||
|
hist: [],
|
||||||
|
touch: e.pointerType === 'touch'
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
surface.current?.setPointerCapture(e.pointerId);
|
||||||
|
} catch {}
|
||||||
|
unwatch.current?.();
|
||||||
|
unwatch.current = watchWindow(live);
|
||||||
|
};
|
||||||
|
const move = (e: PointerEvent) => {
|
||||||
|
const g = grip.current;
|
||||||
|
if (!g || g.id !== e.pointerId) return;
|
||||||
|
if (g.grab === null) {
|
||||||
|
const dx = e.clientX - g.x0;
|
||||||
|
const dy = e.clientY - g.y0;
|
||||||
|
if (Math.abs(dx) < HYST || Math.abs(dx) < Math.abs(dy)) return;
|
||||||
|
g.grab = s * (g.x0 + Math.sign(dx) * HYST) - inv(exposed.get());
|
||||||
|
g.moved = true;
|
||||||
|
root.current?.setAttribute('data-dragging', '');
|
||||||
|
}
|
||||||
|
const ex = map(s * e.clientX - g.grab);
|
||||||
|
x.set(s * ex);
|
||||||
|
g.hist.push([performance.now(), ex]);
|
||||||
|
if (g.hist.length > 4) g.hist.shift();
|
||||||
|
setSpread(canCommit() && ex >= commitPoint());
|
||||||
|
};
|
||||||
|
const up = (e: PointerEvent) => {
|
||||||
|
const g = grip.current;
|
||||||
|
if (!g || g.id !== e.pointerId) return;
|
||||||
|
grip.current = null;
|
||||||
|
unwatch.current?.();
|
||||||
|
unwatch.current = null;
|
||||||
|
root.current?.removeAttribute('data-dragging');
|
||||||
|
try {
|
||||||
|
surface.current?.releasePointerCapture(e.pointerId);
|
||||||
|
} catch {}
|
||||||
|
const ex = exposed.get();
|
||||||
|
const v = velocityOf(g.hist);
|
||||||
|
if (!g.moved) {
|
||||||
|
if (open) {
|
||||||
|
setOpen(false);
|
||||||
|
settle(0);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (primary && canCommit() && ex >= commitPoint()) {
|
||||||
|
commit(primary, false, v);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const target = Math.abs(v) >= FLICK ? (v > 0 ? D : 0) : ex + project(v) > D / 2 ? D : 0;
|
||||||
|
setSpread(false);
|
||||||
|
setOpen(target === D);
|
||||||
|
settle(target, v);
|
||||||
|
};
|
||||||
|
live.current = { move, up };
|
||||||
|
|
||||||
|
const act = (a: SwipeAction, e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
if (phase !== 'idle') return;
|
||||||
|
onAction?.(a);
|
||||||
|
if (a === primary || a.dismiss) {
|
||||||
|
commit(a, e.detail === 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
a.onSelect?.();
|
||||||
|
if (!closeOnAction) return;
|
||||||
|
setOpen(false);
|
||||||
|
if (e.detail === 0) {
|
||||||
|
heading.current = 0;
|
||||||
|
x.set(0);
|
||||||
|
} else settle(0);
|
||||||
|
};
|
||||||
|
const openNow = () => {
|
||||||
|
heading.current = D;
|
||||||
|
x.set(s * D);
|
||||||
|
setOpen(true);
|
||||||
|
setSay(`${n} actions revealed`);
|
||||||
|
};
|
||||||
|
const closeNow = () => {
|
||||||
|
heading.current = 0;
|
||||||
|
x.set(0);
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
const onToggleKey = (e: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||||
|
if (disabled || phase !== 'idle' || n === 0) return;
|
||||||
|
const openKey = s < 0 ? 'ArrowLeft' : 'ArrowRight';
|
||||||
|
const closeKey = s < 0 ? 'ArrowRight' : 'ArrowLeft';
|
||||||
|
const toggle = e.key === 'Enter' || e.key === ' ';
|
||||||
|
if (e.key === openKey || (toggle && !open)) {
|
||||||
|
e.preventDefault();
|
||||||
|
openNow();
|
||||||
|
} else if (e.key === closeKey || e.key === 'Escape' || (toggle && open)) {
|
||||||
|
e.preventDefault();
|
||||||
|
closeNow();
|
||||||
|
} else if ((e.key === 'Delete' || e.key === 'Backspace') && open && primary && canCommit()) {
|
||||||
|
e.preventDefault();
|
||||||
|
commit(primary, true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onToggleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
if (e.detail !== 0 || disabled || phase !== 'idle' || n === 0) return;
|
||||||
|
if (open) closeNow();
|
||||||
|
else openNow();
|
||||||
|
};
|
||||||
|
|
||||||
|
const railId = `${uid}-rail`;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={root}
|
||||||
|
role="group"
|
||||||
|
aria-label={label}
|
||||||
|
className={`group relative overflow-hidden [height:var(--sr-h)] [transition:height_var(--sr-collapse)_cubic-bezier(0.23,1,0.32,1),margin-bottom_var(--sr-collapse)_cubic-bezier(0.23,1,0.32,1),opacity_var(--sr-collapse)_cubic-bezier(0.23,1,0.32,1)] data-[phase=collapsing]:h-0! data-[phase=collapsing]:mb-0! data-[phase=collapsing]:opacity-0 data-[disabled]:pointer-events-none data-[disabled]:opacity-55${className ? ` ${className}` : ''}`}
|
||||||
|
data-direction={direction}
|
||||||
|
data-open={open ? '' : undefined}
|
||||||
|
data-phase={phase}
|
||||||
|
data-disabled={disabled ? '' : undefined}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
'--sr-h': `${height}px`,
|
||||||
|
'--sr-r': `${radius}px`,
|
||||||
|
'--sr-a': `${A}px`,
|
||||||
|
'--sr-row': rowColor,
|
||||||
|
'--sr-text': textColor,
|
||||||
|
'--sr-drawer': drawerColor,
|
||||||
|
'--sr-on-drawer': onColor(drawerColor),
|
||||||
|
'--sr-action': actionColor,
|
||||||
|
'--sr-on-action': onColor(actionColor),
|
||||||
|
'--sr-collapse': `${collapseMs}ms`,
|
||||||
|
...style
|
||||||
|
} as CSSProperties
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="relative overflow-hidden [height:var(--sr-h)] [border-radius:var(--sr-r)] [background:var(--sr-row)]">
|
||||||
|
<motion.div
|
||||||
|
id={railId}
|
||||||
|
className="absolute inset-0 [background:var(--sr-drawer)]"
|
||||||
|
style={{ transform: railXf }}
|
||||||
|
inert={!open || undefined}
|
||||||
|
aria-hidden={!open}
|
||||||
|
>
|
||||||
|
{actions.slice(1).map((a, i) => (
|
||||||
|
<button
|
||||||
|
key={a.id}
|
||||||
|
type="button"
|
||||||
|
className="group/action absolute top-0 grid h-full cursor-pointer touch-manipulation place-items-center border-0 p-0 [font:inherit] outline-none select-none [width:var(--sr-a)] [-webkit-touch-callout:none] [-webkit-tap-highlight-color:transparent] after:pointer-events-none after:absolute after:inset-0 after:bg-white after:opacity-0 after:content-[''] [@media(hover:hover)_and_(pointer:fine)]:after:[transition:opacity_150ms_ease] [@media(hover:hover)_and_(pointer:fine)]:hover:after:opacity-[0.08]"
|
||||||
|
onClick={e => act(a, e)}
|
||||||
|
style={
|
||||||
|
s < 0
|
||||||
|
? { right: (i + 1) * A, background: a.color ?? drawerColor, color: onColor(a.color ?? drawerColor) }
|
||||||
|
: { left: (i + 1) * A, background: a.color ?? drawerColor, color: onColor(a.color ?? drawerColor) }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="grid justify-items-center gap-1 text-[11px] leading-none font-medium tracking-[0.01em] [transition:transform_160ms_cubic-bezier(0.23,1,0.32,1)] group-active/action:scale-[0.97] motion-reduce:group-active/action:scale-100">
|
||||||
|
{a.icon ? <span className="inline-flex">{a.icon}</span> : null}
|
||||||
|
<span>{a.label}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{primary ? (
|
||||||
|
<motion.div
|
||||||
|
className="absolute top-0 h-full w-full [background:var(--sr-action)] group-data-[direction=left]:[left:calc(100%-var(--sr-a))] group-data-[direction=right]:[right:calc(100%-var(--sr-a))]"
|
||||||
|
style={{ transform: blockXf }}
|
||||||
|
>
|
||||||
|
<motion.button
|
||||||
|
type="button"
|
||||||
|
className="group/action absolute top-0 grid h-full cursor-pointer touch-manipulation place-items-center border-0 bg-transparent p-0 [font:inherit] outline-none select-none [width:var(--sr-a)] [color:var(--sr-on-action)] [-webkit-touch-callout:none] [-webkit-tap-highlight-color:transparent] group-data-[direction=left]:left-0 group-data-[direction=right]:right-0 after:pointer-events-none after:absolute after:inset-0 after:bg-white after:opacity-0 after:content-[''] [@media(hover:hover)_and_(pointer:fine)]:after:[transition:opacity_150ms_ease] [@media(hover:hover)_and_(pointer:fine)]:hover:after:opacity-[0.08]"
|
||||||
|
style={{ transform: glyphXf }}
|
||||||
|
onClick={e => act(primary, e)}
|
||||||
|
>
|
||||||
|
<span className="grid justify-items-center gap-1 text-[11px] leading-none font-medium tracking-[0.01em] [transition:transform_160ms_cubic-bezier(0.23,1,0.32,1)] group-active/action:scale-[0.97] motion-reduce:group-active/action:scale-100">
|
||||||
|
<span className="inline-flex">
|
||||||
|
{primary.icon ?? <HugeiconsIcon icon={Delete02Icon} size={20} strokeWidth={2} />}
|
||||||
|
</span>
|
||||||
|
<span>{primary.label}</span>
|
||||||
|
</span>
|
||||||
|
</motion.button>
|
||||||
|
</motion.div>
|
||||||
|
) : null}
|
||||||
|
</motion.div>
|
||||||
|
<motion.div
|
||||||
|
ref={surface}
|
||||||
|
className="relative z-[1] flex h-full touch-pan-y items-center gap-3 px-4 [background:var(--sr-row)] [color:var(--sr-text)] [-webkit-touch-callout:none] [-webkit-tap-highlight-color:transparent] [@media(hover:hover)_and_(pointer:fine)]:cursor-grab [@media(hover:hover)_and_(pointer:fine)]:group-data-[dragging]:cursor-grabbing [@media(pointer:coarse)]:select-none group-data-[dragging]:select-none group-data-[dragging]:[&_*]:select-none"
|
||||||
|
style={{ transform: surfaceXf }}
|
||||||
|
onPointerDown={down}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute top-1/2 m-0 h-px w-px overflow-hidden border-0 bg-transparent p-0 [font:inherit] outline-none [clip-path:inset(50%)] [color:var(--sr-text)] group-data-[direction=left]:right-3 group-data-[direction=right]:left-3 focus-visible:h-6 focus-visible:w-auto focus-visible:-translate-y-1/2 focus-visible:overflow-visible focus-visible:rounded-xl focus-visible:px-2.5 focus-visible:text-xs focus-visible:whitespace-nowrap focus-visible:[clip-path:none] focus-visible:[background:color-mix(in_srgb,var(--sr-text)_12%,transparent)]"
|
||||||
|
tabIndex={disabled ? -1 : 0}
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-controls={railId}
|
||||||
|
aria-keyshortcuts={s < 0 ? 'ArrowLeft' : 'ArrowRight'}
|
||||||
|
onKeyDown={onToggleKey}
|
||||||
|
onClick={onToggleClick}
|
||||||
|
>
|
||||||
|
{n} {n === 1 ? 'action' : 'actions'}
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
<span className="sr-only" aria-live="polite">
|
||||||
|
{say}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SwipeRow;
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
import { useAos } from '@/store/aos'
|
import { useAos } from '@/store/aos'
|
||||||
import { SidebarNav } from './sidebar-nav'
|
import { SidebarNav } from './sidebar-nav'
|
||||||
import { TopBar } from './top-bar'
|
import { TopBar } from './top-bar'
|
||||||
@@ -38,10 +39,32 @@ import { MobileBottomNav } from './mobile-bottom-nav'
|
|||||||
|
|
||||||
export function AppShell() {
|
export function AppShell() {
|
||||||
const { view, sidebarOpen, setSidebarOpen, portalMode } = useAos()
|
const { view, sidebarOpen, setSidebarOpen, portalMode } = useAos()
|
||||||
|
const [mounted, setMounted] = useState(false)
|
||||||
|
|
||||||
|
// Hydration gate: wait for client mount before rendering interactive UI
|
||||||
|
// This prevents Radix UI useId() mismatches between server and client
|
||||||
|
useEffect(() => {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
setMounted(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
// Portal mode renders a completely separate experience
|
// Portal mode renders a completely separate experience
|
||||||
if (portalMode) return <PortalShell />
|
if (portalMode) return <PortalShell />
|
||||||
|
|
||||||
|
// Before mount: render a minimal skeleton to prevent hydration mismatch
|
||||||
|
if (!mounted) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen w-full bg-background">
|
||||||
|
<aside className="hidden w-64 shrink-0 border-r border-border md:block" />
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
|
<header className="h-16 border-b border-border" />
|
||||||
|
<main className="flex-1" />
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen w-full bg-background">
|
<div className="flex min-h-screen w-full bg-background">
|
||||||
{/* Desktop sidebar */}
|
{/* Desktop sidebar */}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import FuseButton from '@/components/FuseButton'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { Loader2 } from 'lucide-react'
|
||||||
|
|
||||||
|
// FuseButton wrapper for primary CTA actions with undo window.
|
||||||
|
// Used for: create deal, finalize invoice, run recurring, approve estimate.
|
||||||
|
export function FuseCTA({
|
||||||
|
label,
|
||||||
|
doneLabel = 'Erledigt',
|
||||||
|
undoLabel = 'Rückgängig',
|
||||||
|
onCommit,
|
||||||
|
onUndo,
|
||||||
|
disabled,
|
||||||
|
size = 'md',
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
doneLabel?: string
|
||||||
|
undoLabel?: string
|
||||||
|
onCommit: () => void | Promise<void>
|
||||||
|
onUndo?: () => void
|
||||||
|
disabled?: boolean
|
||||||
|
size?: 'sm' | 'md' | 'lg'
|
||||||
|
}) {
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
const handleCommit = async () => {
|
||||||
|
setBusy(true)
|
||||||
|
const t = toast.loading('Wird ausgeführt…')
|
||||||
|
try {
|
||||||
|
await onCommit()
|
||||||
|
toast.success(doneLabel, { id: t })
|
||||||
|
} catch {
|
||||||
|
toast.error('Fehlgeschlagen', { id: t })
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FuseButton
|
||||||
|
label={busy ? 'Lädt…' : label}
|
||||||
|
doneLabel={doneLabel}
|
||||||
|
undoLabel={undoLabel}
|
||||||
|
onCommit={handleCommit}
|
||||||
|
onUndo={onUndo}
|
||||||
|
disabled={disabled || busy}
|
||||||
|
size={size}
|
||||||
|
commitOn="fuseEnd"
|
||||||
|
settle="stay"
|
||||||
|
fuse="bottom"
|
||||||
|
fuseThickness={2}
|
||||||
|
undoWindow={4000}
|
||||||
|
color="oklch(0.98 0 0)"
|
||||||
|
background="oklch(0.2 0 0)"
|
||||||
|
fuseColor="oklch(0.5 0 0)"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import HoldButton from '@/components/HoldButton'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { Loader2, Check } from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
// HoldButton wrapper for dangerous actions (PRD §5.6: confirm before destructive operations)
|
||||||
|
// Used for: contract sign, invoice finalize, reseed, module disable
|
||||||
|
export function HoldToConfirm({
|
||||||
|
children,
|
||||||
|
onConfirm,
|
||||||
|
holdTime = 1200,
|
||||||
|
size = 'md',
|
||||||
|
className,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
onConfirm: () => void | Promise<void>
|
||||||
|
holdTime?: number
|
||||||
|
size?: 'sm' | 'md' | 'lg'
|
||||||
|
className?: string
|
||||||
|
disabled?: boolean
|
||||||
|
}) {
|
||||||
|
const [holding, setHolding] = useState(false)
|
||||||
|
|
||||||
|
const handleHold = async () => {
|
||||||
|
setHolding(true)
|
||||||
|
try {
|
||||||
|
await onConfirm()
|
||||||
|
} catch {
|
||||||
|
// error handled by caller
|
||||||
|
} finally {
|
||||||
|
setHolding(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HoldButton
|
||||||
|
onHold={handleHold}
|
||||||
|
holdTime={holdTime}
|
||||||
|
size={size}
|
||||||
|
disabled={disabled || holding}
|
||||||
|
backgroundColor="oklch(0.97 0 0)"
|
||||||
|
fillColor="oklch(0.2 0 0)"
|
||||||
|
textColor="oklch(0.2 0 0)"
|
||||||
|
fillTextColor="oklch(0.98 0 0)"
|
||||||
|
radius={8}
|
||||||
|
className={cn(className)}
|
||||||
|
>
|
||||||
|
{holding ? (
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<Loader2 className="size-3.5 animate-spin" />
|
||||||
|
Ausführen…
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
children
|
||||||
|
)}
|
||||||
|
</HoldButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import SlideCommit from '@/components/SlideCommit'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { Lock, Check, AlertTriangle } from 'lucide-react'
|
||||||
|
|
||||||
|
// SlideCommit wrapper for timesheet week lock (PRD §6.3: "lock after invoicing").
|
||||||
|
// User slides to confirm — prevents accidental week locking.
|
||||||
|
export function SlideToLock({
|
||||||
|
onConfirm,
|
||||||
|
disabled,
|
||||||
|
label = 'Schieben zum Sperren',
|
||||||
|
doneLabel = 'Woche gesperrt',
|
||||||
|
}: {
|
||||||
|
onConfirm: () => void | Promise<void>
|
||||||
|
disabled?: boolean
|
||||||
|
label?: string
|
||||||
|
doneLabel?: string
|
||||||
|
}) {
|
||||||
|
const [phase, setPhase] = useState<'idle' | 'pending' | 'done'>('idle')
|
||||||
|
|
||||||
|
const handleConfirm = async () => {
|
||||||
|
setPhase('pending')
|
||||||
|
try {
|
||||||
|
await onConfirm()
|
||||||
|
setPhase('done')
|
||||||
|
} catch {
|
||||||
|
setPhase('idle')
|
||||||
|
toast.error('Sperren fehlgeschlagen')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full max-w-sm">
|
||||||
|
<SlideCommit
|
||||||
|
label={label}
|
||||||
|
doneLabel={doneLabel}
|
||||||
|
onConfirm={handleConfirm}
|
||||||
|
onDone={() => {}}
|
||||||
|
onError={() => setPhase('idle')}
|
||||||
|
disabled={disabled || phase === 'done' || phase === 'pending'}
|
||||||
|
trackColor="oklch(0.95 0 0)"
|
||||||
|
handleColor="oklch(0.2 0 0)"
|
||||||
|
successColor="oklch(0.3 0 0)"
|
||||||
|
dangerColor="oklch(0.5 0 0)"
|
||||||
|
width={320}
|
||||||
|
height={48}
|
||||||
|
radius={10}
|
||||||
|
holdMs={400}
|
||||||
|
icon={phase === 'done' ? <Check className="size-5" /> : <Lock className="size-4" />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import SwipeRow from '@/components/SwipeRow'
|
||||||
|
import type { SwipeAction } from '@/components/SwipeRow'
|
||||||
|
import { Check, X, Clock, MapPin } from 'lucide-react'
|
||||||
|
import { formatRelative } from '@/lib/format'
|
||||||
|
|
||||||
|
const SCORE_LABELS: Record<string, string> = {
|
||||||
|
'new-site': 'Neue Website', redesign: 'Redesign', seo: 'SEO', aeo: 'AEO/GEO',
|
||||||
|
shop: 'Shop', maintenance: 'Wartung', accessibility: 'Barrierefreiheit',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SwipeableLeadData {
|
||||||
|
id: string
|
||||||
|
companyName: string
|
||||||
|
website: string | null
|
||||||
|
city: string | null
|
||||||
|
score: number
|
||||||
|
scoreLabel: string | null
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SwipeableLeadCard({
|
||||||
|
lead,
|
||||||
|
onApprove,
|
||||||
|
onReject,
|
||||||
|
onSnooze,
|
||||||
|
}: {
|
||||||
|
lead: SwipeableLeadData
|
||||||
|
onApprove: (id: string) => void
|
||||||
|
onReject: (id: string) => void
|
||||||
|
onSnooze: (id: string) => void
|
||||||
|
}) {
|
||||||
|
const actions: SwipeAction[] = [
|
||||||
|
{ id: 'approve', label: 'Genehmigen', icon: <Check className="size-5" />, color: 'oklch(0.45 0 0)', dismiss: false },
|
||||||
|
{ id: 'snooze', label: 'Snooze', icon: <Clock className="size-5" />, color: 'oklch(0.6 0 0)', dismiss: false },
|
||||||
|
{ id: 'reject', label: 'Ablehnen', icon: <X className="size-5" />, color: 'oklch(0.5 0 0)', dismiss: true },
|
||||||
|
]
|
||||||
|
|
||||||
|
const handleAction = (action: SwipeAction) => {
|
||||||
|
if (action.id === 'approve') onApprove(lead.id)
|
||||||
|
else if (action.id === 'snooze') onSnooze(lead.id)
|
||||||
|
else if (action.id === 'reject') onReject(lead.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SwipeRow
|
||||||
|
actions={actions}
|
||||||
|
actionWidth={72}
|
||||||
|
height={88}
|
||||||
|
fullSwipe={true}
|
||||||
|
commitAt={0.55}
|
||||||
|
rowColor="oklch(1 0 0)"
|
||||||
|
actionColor="oklch(0.97 0 0)"
|
||||||
|
onAction={handleAction}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 px-4 py-3">
|
||||||
|
<div className="flex size-12 shrink-0 flex-col items-center justify-center rounded-lg bg-foreground text-background">
|
||||||
|
<span className="text-lg font-bold leading-none">{lead.score}</span>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-semibold">{lead.companyName}</p>
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
{lead.website && <span className="truncate">{lead.website}</span>}
|
||||||
|
{lead.city && <><span>·</span><span className="flex items-center gap-0.5"><MapPin className="size-2.5" />{lead.city}</span></>}
|
||||||
|
</div>
|
||||||
|
{lead.scoreLabel && SCORE_LABELS[lead.scoreLabel] && (
|
||||||
|
<span className="mt-0.5 inline-block rounded bg-muted px-1.5 py-0.5 text-[10px] font-medium">
|
||||||
|
{SCORE_LABELS[lead.scoreLabel]}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="shrink-0 text-[10px] text-muted-foreground">{formatRelative(lead.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
</SwipeRow>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
} from '@/components/ui/dialog'
|
} from '@/components/ui/dialog'
|
||||||
import { PageHeader } from '../primitives'
|
import { PageHeader } from '../primitives'
|
||||||
import { formatEuro, formatDate, formatDateTime, daysUntil } from '@/lib/format'
|
import { formatEuro, formatDate, formatDateTime, daysUntil } from '@/lib/format'
|
||||||
|
import { HoldToConfirm } from '../hold-to-confirm'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
@@ -312,10 +313,17 @@ export function ContractsView() {
|
|||||||
<DialogFooter className="gap-2">
|
<DialogFooter className="gap-2">
|
||||||
<Button variant="outline" onClick={() => setSelected(null)}>Schließen</Button>
|
<Button variant="outline" onClick={() => setSelected(null)}>Schließen</Button>
|
||||||
{canSign && (
|
{canSign && (
|
||||||
<Button onClick={() => sign(selected)} disabled={signing} className="gap-1.5">
|
<HoldToConfirm
|
||||||
{signing ? <Loader2 className="size-3.5 animate-spin" /> : <PenLine className="size-3.5" />}
|
onConfirm={() => sign(selected)}
|
||||||
|
holdTime={1500}
|
||||||
|
size="md"
|
||||||
|
disabled={signing}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<PenLine className="size-3.5" />
|
||||||
E-Signatur anfordern
|
E-Signatur anfordern
|
||||||
</Button>
|
</span>
|
||||||
|
</HoldToConfirm>
|
||||||
)}
|
)}
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
Filter,
|
Filter,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import { SwipeableLeadCard } from '../swipeable-lead-card'
|
||||||
import { useFetch } from '@/hooks/use-fetch'
|
import { useFetch } from '@/hooks/use-fetch'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
@@ -207,6 +208,28 @@ export function LeadsView() {
|
|||||||
<div className="space-y-1.5 p-2">
|
<div className="space-y-1.5 p-2">
|
||||||
{filtered.map((l) => {
|
{filtered.map((l) => {
|
||||||
const meta = STATUS_META[l.status] ?? STATUS_META.pending
|
const meta = STATUS_META[l.status] ?? STATUS_META.pending
|
||||||
|
// Pending leads get SwipeRow (swipe to approve/reject/snooze)
|
||||||
|
if (l.status === 'pending') {
|
||||||
|
return (
|
||||||
|
<div key={l.id} className={cn('rounded-md border overflow-hidden', selected?.id === l.id && 'ring-1 ring-foreground/40')}>
|
||||||
|
<SwipeableLeadCard
|
||||||
|
lead={{
|
||||||
|
id: l.id,
|
||||||
|
companyName: l.companyName,
|
||||||
|
website: l.website,
|
||||||
|
city: l.city,
|
||||||
|
score: l.score,
|
||||||
|
scoreLabel: l.scoreLabel,
|
||||||
|
createdAt: l.createdAt,
|
||||||
|
}}
|
||||||
|
onApprove={(id) => { setSelectedId(id); review(id, 'approved') }}
|
||||||
|
onReject={(id) => { setSelectedId(id); review(id, 'rejected', 'Nicht zur Zielgruppe') }}
|
||||||
|
onSnooze={(id) => { setSelectedId(id); review(id, 'snoozed', 'Später erneut prüfen') }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Non-pending leads keep the simple button card
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={l.id}
|
key={l.id}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Badge } from '@/components/ui/badge'
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { PageHeader } from '../primitives'
|
import { PageHeader } from '../primitives'
|
||||||
|
import { FuseCTA } from '../fuse-cta'
|
||||||
import { formatEuro, formatEuroCompact, formatDate } from '@/lib/format'
|
import { formatEuro, formatEuroCompact, formatDate } from '@/lib/format'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
@@ -76,10 +77,13 @@ export function RecurringView() {
|
|||||||
title="Wiederkehrende Rechnungen"
|
title="Wiederkehrende Rechnungen"
|
||||||
description="Automatische Generierung von wiederkehrenden Rechnungen (Wartung/SLA-Verträge). Bei Mahnstufe 3: automatische Lizenz-Suspendierung (PRD §6.4)."
|
description="Automatische Generierung von wiederkehrenden Rechnungen (Wartung/SLA-Verträge). Bei Mahnstufe 3: automatische Lizenz-Suspendierung (PRD §6.4)."
|
||||||
actions={
|
actions={
|
||||||
<Button size="sm" onClick={runNow} disabled={running} className="gap-1.5">
|
<FuseCTA
|
||||||
{running ? <Loader2 className="size-4 animate-spin" /> : <Play className="size-4" />}
|
label="Jetzt ausführen"
|
||||||
Jetzt ausführen
|
doneLabel="Ausgeführt"
|
||||||
</Button>
|
onCommit={runNow}
|
||||||
|
disabled={running}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { formatMinutes } from '@/lib/format'
|
|||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { ChevronLeft, ChevronRight, Lock, Clock, TrendingUp, CheckCircle2, Calendar } from 'lucide-react'
|
import { ChevronLeft, ChevronRight, Lock, Clock, TrendingUp, CheckCircle2, Calendar } from 'lucide-react'
|
||||||
|
import { SlideToLock } from '../slide-to-lock'
|
||||||
|
|
||||||
interface DayData {
|
interface DayData {
|
||||||
date: string
|
date: string
|
||||||
@@ -83,10 +84,7 @@ export function TimesheetView() {
|
|||||||
description="Wochen-basierte Zeiterfassung mit PM-Freigabe (Lock). Nach Freigabe können Einträge nicht mehr geändert werden — GoBD-konform."
|
description="Wochen-basierte Zeiterfassung mit PM-Freigabe (Lock). Nach Freigabe können Einträge nicht mehr geändert werden — GoBD-konform."
|
||||||
actions={
|
actions={
|
||||||
!stats.allLocked && stats.totalEntries > 0 ? (
|
!stats.allLocked && stats.totalEntries > 0 ? (
|
||||||
<Button size="sm" onClick={lockWeek} disabled={locking} className="gap-1.5">
|
<SlideToLock onConfirm={lockWeek} disabled={locking} />
|
||||||
<Lock className="size-4" />
|
|
||||||
Woche sperren
|
|
||||||
</Button>
|
|
||||||
) : stats.allLocked ? (
|
) : stats.allLocked ? (
|
||||||
<Badge variant="outline" className="gap-1">
|
<Badge variant="outline" className="gap-1">
|
||||||
<CheckCircle2 className="size-3" />Gesperrt
|
<CheckCircle2 className="size-3" />Gesperrt
|
||||||
|
|||||||
Reference in new issue
Block a user