Compare commits
6 Commits
e01a7ed1ad
...
82d04e7a84
| Author | SHA1 | Date | |
|---|---|---|---|
| 82d04e7a84 | |||
| 1ad5603bf6 | |||
| 42989b1437 | |||
| f3350e0124 | |||
| 27492ee01f | |||
| 12849b2362 |
@@ -1,3 +0,0 @@
|
||||
POSTGRES_PASSWORD=
|
||||
POSTGRES_USER=
|
||||
POSTGRES_DB=
|
||||
291
bun.lock
291
bun.lock
@@ -5,13 +5,17 @@
|
||||
"": {
|
||||
"name": "ical-pwa",
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.3.0",
|
||||
"@internationalized/date": "^3.12.0",
|
||||
"@openrouter/sdk": "^0.11.2",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"better-auth": "^1.6.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -21,7 +25,7 @@
|
||||
"framer-motion": "^12.38.0",
|
||||
"ical.js": "^2.2.1",
|
||||
"idb": "^8.0.3",
|
||||
"lucide-react": "^0.539.0",
|
||||
"lucide-react": "^1.8.0",
|
||||
"nanoid": "^5.1.5",
|
||||
"next": "15.4.10",
|
||||
"next-themes": "^0.4.6",
|
||||
@@ -29,7 +33,8 @@
|
||||
"postgres": "^3.4.7",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "19.1.0",
|
||||
"react-day-picker": "^9.9.0",
|
||||
"react-aria-components": "^1.16.0",
|
||||
"react-day-picker": "^9.14.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-hook-form": "^7.66.0",
|
||||
"rrule": "^2.8.1",
|
||||
@@ -62,6 +67,12 @@
|
||||
"packages": {
|
||||
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
|
||||
|
||||
"@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
|
||||
|
||||
"@base-ui/react": ["@base-ui/react@1.3.0", "", { "dependencies": { "@babel/runtime": "^7.28.6", "@base-ui/utils": "0.2.6", "@floating-ui/react-dom": "^2.1.8", "@floating-ui/utils": "^0.2.11", "tabbable": "^6.4.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-FwpKqZbPz14AITp1CVgf4AjhKPe1OeeVKSBMdgD10zbFlj3QSWelmtCMLi2+/PFZZcIm3l87G7rwtCZJwHyXWA=="],
|
||||
|
||||
"@base-ui/utils": ["@base-ui/utils@0.2.6", "", { "dependencies": { "@babel/runtime": "^7.28.6", "@floating-ui/utils": "^0.2.11", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-yQ+qeuqohwhsNpoYDqqXaLllYAkPCP4vYdDrVo8FQXaAPfHWm1pG/Vm+jmGTA5JFS0BAIjookyapuJFY8F9PIw=="],
|
||||
|
||||
"@better-auth/core": ["@better-auth/core@1.6.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.39.0", "@standard-schema/spec": "^1.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/utils": "0.4.0", "@better-fetch/fetch": "1.1.21", "@cloudflare/workers-types": ">=4", "@opentelemetry/api": "^1.9.0", "better-call": "1.3.5", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1" }, "optionalPeers": ["@cloudflare/workers-types"] }, "sha512-LmdPTyKRDn6iCcXBGlOHOyzpJl1W/3w64zrEbhhHaWmtdpzQWlY8awlWBoDTL9eL4TAusr9dDvwIbMYTvEqaeA=="],
|
||||
|
||||
"@better-auth/drizzle-adapter": ["@better-auth/drizzle-adapter@1.6.0", "", { "peerDependencies": { "@better-auth/core": "^1.6.0", "@better-auth/utils": "0.4.0", "drizzle-orm": ">=0.41.0" }, "optionalPeers": ["drizzle-orm"] }, "sha512-iMgvZlrL4FI63CGaxLqE5rgA2Q9VVmc2fQIP7N5E79nGAEpHtztstHFPlen9RDLRJA4xa3wuyVaPSILylwE+LA=="],
|
||||
@@ -164,13 +175,23 @@
|
||||
|
||||
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="],
|
||||
|
||||
"@floating-ui/core": ["@floating-ui/core@1.7.3", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w=="],
|
||||
"@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="],
|
||||
|
||||
"@floating-ui/dom": ["@floating-ui/dom@1.7.4", "", { "dependencies": { "@floating-ui/core": "^1.7.3", "@floating-ui/utils": "^0.2.10" } }, "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA=="],
|
||||
"@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="],
|
||||
|
||||
"@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.6", "", { "dependencies": { "@floating-ui/dom": "^1.7.4" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw=="],
|
||||
"@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.8", "", { "dependencies": { "@floating-ui/dom": "^1.7.6" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A=="],
|
||||
|
||||
"@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
|
||||
"@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="],
|
||||
|
||||
"@formatjs/ecma402-abstract": ["@formatjs/ecma402-abstract@2.3.6", "", { "dependencies": { "@formatjs/fast-memoize": "2.2.7", "@formatjs/intl-localematcher": "0.6.2", "decimal.js": "^10.4.3", "tslib": "^2.8.0" } }, "sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw=="],
|
||||
|
||||
"@formatjs/fast-memoize": ["@formatjs/fast-memoize@2.2.7", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ=="],
|
||||
|
||||
"@formatjs/icu-messageformat-parser": ["@formatjs/icu-messageformat-parser@2.11.4", "", { "dependencies": { "@formatjs/ecma402-abstract": "2.3.6", "@formatjs/icu-skeleton-parser": "1.8.16", "tslib": "^2.8.0" } }, "sha512-7kR78cRrPNB4fjGFZg3Rmj5aah8rQj9KPzuLsmcSn4ipLXQvC04keycTI1F7kJYDwIXtT2+7IDEto842CfZBtw=="],
|
||||
|
||||
"@formatjs/icu-skeleton-parser": ["@formatjs/icu-skeleton-parser@1.8.16", "", { "dependencies": { "@formatjs/ecma402-abstract": "2.3.6", "tslib": "^2.8.0" } }, "sha512-H13E9Xl+PxBd8D5/6TVUluSpxGNvFSlN/b3coUp0e0JpuWXXnQDiavIpY3NnvSp4xhEMoXyyBvVfdFX8jglOHQ=="],
|
||||
|
||||
"@formatjs/intl-localematcher": ["@formatjs/intl-localematcher@0.6.2", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA=="],
|
||||
|
||||
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
|
||||
|
||||
@@ -230,6 +251,14 @@
|
||||
|
||||
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
|
||||
|
||||
"@internationalized/date": ["@internationalized/date@3.12.0", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-/PyIMzK29jtXaGU23qTvNZxvBXRtKbNnGDFD+PY6CZw/Y8Ex8pFUzkuCJCG9aOqmShjqhS9mPqP6Dk5onQY8rQ=="],
|
||||
|
||||
"@internationalized/message": ["@internationalized/message@3.1.8", "", { "dependencies": { "@swc/helpers": "^0.5.0", "intl-messageformat": "^10.1.0" } }, "sha512-Rwk3j/TlYZhn3HQ6PyXUV0XP9Uv42jqZGNegt0BXlxjE6G3+LwHjbQZAGHhCnCPdaA6Tvd3ma/7QzLlLkJxAWA=="],
|
||||
|
||||
"@internationalized/number": ["@internationalized/number@3.6.5", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-6hY4Kl4HPBvtfS62asS/R22JzNNy8vi/Ssev7x6EobfCp+9QIB2hKvI2EtbdJ0VSQacxVNtqhE/NmF/NZ0gm6g=="],
|
||||
|
||||
"@internationalized/string": ["@internationalized/string@3.2.7", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-D4OHBjrinH+PFZPvfCXvG28n2LSykWcJ7GIioQL+ok0LON15SdfoUssoHzzOUmVZLbRoREsQXVzA6r8JKsbP6A=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||
@@ -324,6 +353,8 @@
|
||||
|
||||
"@radix-ui/react-hover-card": ["@radix-ui/react-hover-card@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg=="],
|
||||
|
||||
"@radix-ui/react-icons": ["@radix-ui/react-icons@1.3.2", "", { "peerDependencies": { "react": "^16.x || ^17.x || ^18.x || ^19.0.0 || ^19.0.0-rc" } }, "sha512-fyQIhGDhzfc9pK2kH6Pl9c4BDJGfMkPqkyIgYDthyNYoNg3wVhoJMMh19WS4Up/1KMPFVpNsT2q3WmXn2N1m6g=="],
|
||||
|
||||
"@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="],
|
||||
|
||||
"@radix-ui/react-label": ["@radix-ui/react-label@2.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A=="],
|
||||
@@ -400,6 +431,222 @@
|
||||
|
||||
"@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
|
||||
|
||||
"@react-aria/autocomplete": ["@react-aria/autocomplete@3.0.0-rc.6", "", { "dependencies": { "@react-aria/combobox": "^3.15.0", "@react-aria/focus": "^3.21.5", "@react-aria/i18n": "^3.12.16", "@react-aria/interactions": "^3.27.1", "@react-aria/listbox": "^3.15.3", "@react-aria/searchfield": "^3.8.12", "@react-aria/textfield": "^3.18.5", "@react-aria/utils": "^3.33.1", "@react-stately/autocomplete": "3.0.0-beta.4", "@react-stately/combobox": "^3.13.0", "@react-types/autocomplete": "3.0.0-alpha.38", "@react-types/button": "^3.15.1", "@react-types/shared": "^3.33.1", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-uymUNJ8NW+dX7lmgkHE+SklAbxwktycAJcI5lBBw6KPZyc0EdMHC+/Fc5CUz3enIAhNwd2oxxogcSHknquMzQA=="],
|
||||
|
||||
"@react-aria/breadcrumbs": ["@react-aria/breadcrumbs@3.5.32", "", { "dependencies": { "@react-aria/i18n": "^3.12.16", "@react-aria/link": "^3.8.9", "@react-aria/utils": "^3.33.1", "@react-types/breadcrumbs": "^3.7.19", "@react-types/shared": "^3.33.1", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-S61vh5DJ2PXiXUwD7gk+pvS/b4VPrc3ZJOUZ0yVRLHkVESr5LhIZH+SAVgZkm1lzKyMRG+BH+fiRH/DZRSs7SA=="],
|
||||
|
||||
"@react-aria/button": ["@react-aria/button@3.14.5", "", { "dependencies": { "@react-aria/interactions": "^3.27.1", "@react-aria/toolbar": "3.0.0-beta.24", "@react-aria/utils": "^3.33.1", "@react-stately/toggle": "^3.9.5", "@react-types/button": "^3.15.1", "@react-types/shared": "^3.33.1", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-ZuLx+wQj9VQhH9BYe7t0JowmKnns2XrFHFNvIVBb5RwxL+CIycIOL7brhWKg2rGdxvlOom7jhVbcjSmtAaSyaQ=="],
|
||||
|
||||
"@react-aria/calendar": ["@react-aria/calendar@3.9.5", "", { "dependencies": { "@internationalized/date": "^3.12.0", "@react-aria/i18n": "^3.12.16", "@react-aria/interactions": "^3.27.1", "@react-aria/live-announcer": "^3.4.4", "@react-aria/utils": "^3.33.1", "@react-stately/calendar": "^3.9.3", "@react-types/button": "^3.15.1", "@react-types/calendar": "^3.8.3", "@react-types/shared": "^3.33.1", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-k0kvceYdZZu+DoeqephtlmIvh1CxqdFyoN52iqVzTz9O0pe5Xfhq7zxPGbeCp4pC61xzp8Lu/6uFA/YNfQQNag=="],
|
||||
|
||||
"@react-aria/checkbox": ["@react-aria/checkbox@3.16.5", "", { "dependencies": { "@react-aria/form": "^3.1.5", "@react-aria/interactions": "^3.27.1", "@react-aria/label": "^3.7.25", "@react-aria/toggle": "^3.12.5", "@react-aria/utils": "^3.33.1", "@react-stately/checkbox": "^3.7.5", "@react-stately/form": "^3.2.4", "@react-stately/toggle": "^3.9.5", "@react-types/checkbox": "^3.10.4", "@react-types/shared": "^3.33.1", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-ZhUT7ELuD52hb+Zpzw0ElLQiVOd5sKYahrh+PK3vq13Wk5TedBscALpjuXetI4pwFfdmAM1Lhgcsrd8+6AmyvA=="],
|
||||
|
||||
"@react-aria/collections": ["@react-aria/collections@3.0.3", "", { "dependencies": { "@react-aria/interactions": "^3.27.1", "@react-aria/ssr": "^3.9.10", "@react-aria/utils": "^3.33.1", "@react-types/shared": "^3.33.1", "@swc/helpers": "^0.5.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-lbC5DEbHeVFvVr4ke9y8D9Nynnr8G8UjVEBoFGRylpAaScU7SX1TN84QI+EjMbsdZ0/5P2H7gUTS+MYd+6U3Rg=="],
|
||||
|
||||
"@react-aria/color": ["@react-aria/color@3.1.5", "", { "dependencies": { "@react-aria/i18n": "^3.12.16", "@react-aria/interactions": "^3.27.1", "@react-aria/numberfield": "^3.12.5", "@react-aria/slider": "^3.8.5", "@react-aria/spinbutton": "^3.7.2", "@react-aria/textfield": "^3.18.5", "@react-aria/utils": "^3.33.1", "@react-aria/visually-hidden": "^3.8.31", "@react-stately/color": "^3.9.5", "@react-stately/form": "^3.2.4", "@react-types/color": "^3.1.4", "@react-types/shared": "^3.33.1", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-eysWdBRzE8WDhBzh1nfjyUgzseMokXGHjIoJo880T7IPJ8tTavfQni49pU1B2qWrNOWPyrwx4Bd9pzHyboxJSA=="],
|
||||
|
||||
"@react-aria/combobox": ["@react-aria/combobox@3.15.0", "", { "dependencies": { "@react-aria/focus": "^3.21.5", "@react-aria/i18n": "^3.12.16", "@react-aria/interactions": "^3.27.1", "@react-aria/listbox": "^3.15.3", "@react-aria/live-announcer": "^3.4.4", "@react-aria/menu": "^3.21.0", "@react-aria/overlays": "^3.31.2", "@react-aria/selection": "^3.27.2", "@react-aria/textfield": "^3.18.5", "@react-aria/utils": "^3.33.1", "@react-stately/collections": "^3.12.10", "@react-stately/combobox": "^3.13.0", "@react-stately/form": "^3.2.4", "@react-types/button": "^3.15.1", "@react-types/combobox": "^3.14.0", "@react-types/shared": "^3.33.1", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-qSjQTFwKl3x1jCP2NRSJ6doZqAp6c2GTfoiFwWjaWg1IewwLsglaW6NnzqRDFiqFbDGgXPn4MqtC1VYEJ3NEjA=="],
|
||||
|
||||
"@react-aria/datepicker": ["@react-aria/datepicker@3.16.1", "", { "dependencies": { "@internationalized/date": "^3.12.0", "@internationalized/number": "^3.6.5", "@internationalized/string": "^3.2.7", "@react-aria/focus": "^3.21.5", "@react-aria/form": "^3.1.5", "@react-aria/i18n": "^3.12.16", "@react-aria/interactions": "^3.27.1", "@react-aria/label": "^3.7.25", "@react-aria/spinbutton": "^3.7.2", "@react-aria/utils": "^3.33.1", "@react-stately/datepicker": "^3.16.1", "@react-stately/form": "^3.2.4", "@react-types/button": "^3.15.1", "@react-types/calendar": "^3.8.3", "@react-types/datepicker": "^3.13.5", "@react-types/dialog": "^3.5.24", "@react-types/shared": "^3.33.1", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-6BltCVWt09yefTkGjb2gViGCwoddx9HKJiZbY9u6Es/Q+VhwNJQRtczbnZ3K32p262hIknukNf/5nZaCOI1AKA=="],
|
||||
|
||||
"@react-aria/dialog": ["@react-aria/dialog@3.5.34", "", { "dependencies": { "@react-aria/interactions": "^3.27.1", "@react-aria/overlays": "^3.31.2", "@react-aria/utils": "^3.33.1", "@react-types/dialog": "^3.5.24", "@react-types/shared": "^3.33.1", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-/x53Q5ynpW5Kv9637WYu7SrDfj3woSp6jJRj8l6teGnWW/iNZWYJETgzHfbxx+HPKYATCZesRoIeO2LnYIXyEA=="],
|
||||
|
||||
"@react-aria/disclosure": ["@react-aria/disclosure@3.1.3", "", { "dependencies": { "@react-aria/ssr": "^3.9.10", "@react-aria/utils": "^3.33.1", "@react-stately/disclosure": "^3.0.11", "@react-types/button": "^3.15.1", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-S3k7Wqrj+x0sWcP88Z1stSr5TIZmKEmx2rU7RB1O1/jPpbw5mgKnjtiriOlTh+kwdK11FkeqgxyHzAcBAR+FMQ=="],
|
||||
|
||||
"@react-aria/dnd": ["@react-aria/dnd@3.11.6", "", { "dependencies": { "@internationalized/string": "^3.2.7", "@react-aria/i18n": "^3.12.16", "@react-aria/interactions": "^3.27.1", "@react-aria/live-announcer": "^3.4.4", "@react-aria/overlays": "^3.31.2", "@react-aria/utils": "^3.33.1", "@react-stately/collections": "^3.12.10", "@react-stately/dnd": "^3.7.4", "@react-types/button": "^3.15.1", "@react-types/shared": "^3.33.1", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-4YLHUeYJleF+moAYaYt8UZqujudPvpoaHR+QMkWIFzhfridVUhCr6ZjGWrzpSZY3r68k46TG7YCsi4IEiNnysw=="],
|
||||
|
||||
"@react-aria/focus": ["@react-aria/focus@3.21.5", "", { "dependencies": { "@react-aria/interactions": "^3.27.1", "@react-aria/utils": "^3.33.1", "@react-types/shared": "^3.33.1", "@swc/helpers": "^0.5.0", "clsx": "^2.0.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-V18fwCyf8zqgJdpLQeDU5ZRNd9TeOfBbhLgmX77Zr5ae9XwaoJ1R3SFJG1wCJX60t34AW+aLZSEEK+saQElf3Q=="],
|
||||
|
||||
"@react-aria/form": ["@react-aria/form@3.1.5", "", { "dependencies": { "@react-aria/interactions": "^3.27.1", "@react-aria/utils": "^3.33.1", "@react-stately/form": "^3.2.4", "@react-types/shared": "^3.33.1", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-BWlONgHn8hmaMkcS6AgMSLQeNqVBwqPNLhdqjDO/PCfzvV7O8NZw/dFeIzJwfG4aBfSpbHHRdXGdfrk3d8dylQ=="],
|
||||
|
||||
"@react-aria/grid": ["@react-aria/grid@3.14.8", "", { "dependencies": { "@react-aria/focus": "^3.21.5", "@react-aria/i18n": "^3.12.16", "@react-aria/interactions": "^3.27.1", "@react-aria/live-announcer": "^3.4.4", "@react-aria/selection": "^3.27.2", "@react-aria/utils": "^3.33.1", "@react-stately/collections": "^3.12.10", "@react-stately/grid": "^3.11.9", "@react-stately/selection": "^3.20.9", "@react-types/checkbox": "^3.10.4", "@react-types/grid": "^3.3.8", "@react-types/shared": "^3.33.1", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-X6rRFKDu/Kh6Sv8FBap3vjcb+z4jXkSOwkYnexIJp5kMTo5/Dqo55cCBio5B70Tanfv32Ev/6SpzYG7ryxnM9w=="],
|
||||
|
||||
"@react-aria/gridlist": ["@react-aria/gridlist@3.14.4", "", { "dependencies": { "@react-aria/focus": "^3.21.5", "@react-aria/grid": "^3.14.8", "@react-aria/i18n": "^3.12.16", "@react-aria/interactions": "^3.27.1", "@react-aria/selection": "^3.27.2", "@react-aria/utils": "^3.33.1", "@react-stately/list": "^3.13.4", "@react-stately/tree": "^3.9.6", "@react-types/shared": "^3.33.1", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-C/SbwC0qagZatoBrCjx8iZUex9apaJ8o8iRJ9eVHz0cpj7mXg6HuuotYGmDy9q67A2hve4I693RM1Cuwqwm+PQ=="],
|
||||
|
||||
"@react-aria/i18n": ["@react-aria/i18n@3.12.16", "", { "dependencies": { "@internationalized/date": "^3.12.0", "@internationalized/message": "^3.1.8", "@internationalized/number": "^3.6.5", "@internationalized/string": "^3.2.7", "@react-aria/ssr": "^3.9.10", "@react-aria/utils": "^3.33.1", "@react-types/shared": "^3.33.1", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-Km2CAz6MFQOUEaattaW+2jBdWOHUF8WX7VQoNbjlqElCP58nSaqi9yxTWUDRhAcn8/xFUnkFh4MFweNgtrHuEA=="],
|
||||
|
||||
"@react-aria/interactions": ["@react-aria/interactions@3.27.1", "", { "dependencies": { "@react-aria/ssr": "^3.9.10", "@react-aria/utils": "^3.33.1", "@react-stately/flags": "^3.1.2", "@react-types/shared": "^3.33.1", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-M3wLpTTmDflI0QGNK0PJNUaBXXfeBXue8ZxLMngfc1piHNiH4G5lUvWd9W14XVbqrSCVY8i8DfGrNYpyyZu0tw=="],
|
||||
|
||||
"@react-aria/label": ["@react-aria/label@3.7.25", "", { "dependencies": { "@react-aria/utils": "^3.33.1", "@react-types/shared": "^3.33.1", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-oNK3Pqj4LDPwEbQaoM/uCip4QvQmmwGOh08VeW+vzSi6TAwf+KoWTyH/tiAeB0CHWNDK0k3e1iTygTAt4wzBmg=="],
|
||||
|
||||
"@react-aria/landmark": ["@react-aria/landmark@3.0.10", "", { "dependencies": { "@react-aria/utils": "^3.33.1", "@react-types/shared": "^3.33.1", "@swc/helpers": "^0.5.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-GpNjJaI8/a6WxYDZgzTCLYSzPM6xp2pxCIQ4udiGbTCtxx13Trmm0cPABvPtzELidgolCf05em9Phr+3G0eE8A=="],
|
||||
|
||||
"@react-aria/link": ["@react-aria/link@3.8.9", "", { "dependencies": { "@react-aria/interactions": "^3.27.1", "@react-aria/utils": "^3.33.1", "@react-types/link": "^3.6.7", "@react-types/shared": "^3.33.1", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-UaAFBfs84/Qq6TxlMWkREqqNY6SFLukot+z2Aa1kC+VyStv1kWG6sE5QLjm4SBn1Q3CGRsefhB/5+taaIbB4Pw=="],
|
||||
|
||||
"@react-aria/listbox": ["@react-aria/listbox@3.15.3", "", { "dependencies": { "@react-aria/interactions": "^3.27.1", "@react-aria/label": "^3.7.25", "@react-aria/selection": "^3.27.2", "@react-aria/utils": "^3.33.1", "@react-stately/collections": "^3.12.10", "@react-stately/list": "^3.13.4", "@react-types/listbox": "^3.7.6", "@react-types/shared": "^3.33.1", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-C6YgiyrHS5sbS5UBdxGMhEs+EKJYotJgGVtl9l0ySXpBUXERiHJWLOyV7a8PwkUOmepbB4FaLD7Y9EUzGkrGlw=="],
|
||||
|
||||
"@react-aria/live-announcer": ["@react-aria/live-announcer@3.4.4", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-PTTBIjNRnrdJOIRTDGNifY2d//kA7GUAwRFJNOEwSNG4FW+Bq9awqLiflw0JkpyB0VNIwou6lqKPHZVLsGWOXA=="],
|
||||
|
||||
"@react-aria/menu": ["@react-aria/menu@3.21.0", "", { "dependencies": { "@react-aria/focus": "^3.21.5", "@react-aria/i18n": "^3.12.16", "@react-aria/interactions": "^3.27.1", "@react-aria/overlays": "^3.31.2", "@react-aria/selection": "^3.27.2", "@react-aria/utils": "^3.33.1", "@react-stately/collections": "^3.12.10", "@react-stately/menu": "^3.9.11", "@react-stately/selection": "^3.20.9", "@react-stately/tree": "^3.9.6", "@react-types/button": "^3.15.1", "@react-types/menu": "^3.10.7", "@react-types/shared": "^3.33.1", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-CKTVZ4izSE1eKIti6TbTtzJAUo+WT8O4JC0XZCYDBpa0f++lD19Kz9aY+iY1buv5xGI20gAfpO474E9oEd4aQA=="],
|
||||
|
||||
"@react-aria/meter": ["@react-aria/meter@3.4.30", "", { "dependencies": { "@react-aria/progress": "^3.4.30", "@react-types/meter": "^3.4.15", "@react-types/shared": "^3.33.1", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-ZmANKW7s/Z4QGylHi46nhwtQ47T1bfMsU9MysBu7ViXXNJ03F4b6JXCJlKL5o2goQ3NbfZ68GeWamIT0BWSgtw=="],
|
||||
|
||||
"@react-aria/numberfield": ["@react-aria/numberfield@3.12.5", "", { "dependencies": { "@react-aria/i18n": "^3.12.16", "@react-aria/interactions": "^3.27.1", "@react-aria/live-announcer": "^3.4.4", "@react-aria/spinbutton": "^3.7.2", "@react-aria/textfield": "^3.18.5", "@react-aria/utils": "^3.33.1", "@react-stately/form": "^3.2.4", "@react-stately/numberfield": "^3.11.0", "@react-types/button": "^3.15.1", "@react-types/numberfield": "^3.8.18", "@react-types/shared": "^3.33.1", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-Fi41IUWXEHLFIeJ/LHuZ9Azs8J/P563fZi37GSBkIq5P1pNt1rPgJJng5CNn4KsHxwqadTRUlbbZwbZraWDtRg=="],
|
||||
|
||||
"@react-aria/overlays": ["@react-aria/overlays@3.31.2", "", { "dependencies": { "@react-aria/focus": "^3.21.5", "@react-aria/i18n": "^3.12.16", "@react-aria/interactions": "^3.27.1", "@react-aria/ssr": "^3.9.10", "@react-aria/utils": "^3.33.1", "@react-aria/visually-hidden": "^3.8.31", "@react-stately/flags": "^3.1.2", "@react-stately/overlays": "^3.6.23", "@react-types/button": "^3.15.1", "@react-types/overlays": "^3.9.4", "@react-types/shared": "^3.33.1", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-78HYI08r6LvcfD34gyv19ArRIjy1qxOKuXl/jYnjLDyQzD4pVb634IQWcm0zt10RdKgyuH6HTqvuDOgZTLet7Q=="],
|
||||
|
||||
"@react-aria/progress": ["@react-aria/progress@3.4.30", "", { "dependencies": { "@react-aria/i18n": "^3.12.16", "@react-aria/label": "^3.7.25", "@react-aria/utils": "^3.33.1", "@react-types/progress": "^3.5.18", "@react-types/shared": "^3.33.1", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-S6OWVGgluSWYSd/A6O8CVjz83eeMUfkuWSra0ewAV9bmxZ7TP9pUmD3bGdqHZEl97nt5vHGjZ3eq/x8eCmzKhA=="],
|
||||
|
||||
"@react-aria/radio": ["@react-aria/radio@3.12.5", "", { "dependencies": { "@react-aria/focus": "^3.21.5", "@react-aria/form": "^3.1.5", "@react-aria/i18n": "^3.12.16", "@react-aria/interactions": "^3.27.1", "@react-aria/label": "^3.7.25", "@react-aria/utils": "^3.33.1", "@react-stately/radio": "^3.11.5", "@react-types/radio": "^3.9.4", "@react-types/shared": "^3.33.1", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-8CCJKJzfozEiWBPO9QAATG1rBGJEJ+xoqvHf9LKU2sPFGsA2/SRnLs6LB9fCG5R3spvaK1xz0any1fjWPl7x8A=="],
|
||||
|
||||
"@react-aria/searchfield": ["@react-aria/searchfield@3.8.12", "", { "dependencies": { "@react-aria/i18n": "^3.12.16", "@react-aria/textfield": "^3.18.5", "@react-aria/utils": "^3.33.1", "@react-stately/searchfield": "^3.5.19", "@react-types/button": "^3.15.1", "@react-types/searchfield": "^3.6.8", "@react-types/shared": "^3.33.1", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-kYlUHD/+mWzNroHoR8ojUxYBoMviRZn134WaKPFjfNUGZDOEuh4XzOoj+cjdJfe6N3mwTaYu6rJQtunSHIAfhA=="],
|
||||
|
||||
"@react-aria/select": ["@react-aria/select@3.17.3", "", { "dependencies": { "@react-aria/form": "^3.1.5", "@react-aria/i18n": "^3.12.16", "@react-aria/interactions": "^3.27.1", "@react-aria/label": "^3.7.25", "@react-aria/listbox": "^3.15.3", "@react-aria/menu": "^3.21.0", "@react-aria/selection": "^3.27.2", "@react-aria/utils": "^3.33.1", "@react-aria/visually-hidden": "^3.8.31", "@react-stately/select": "^3.9.2", "@react-types/button": "^3.15.1", "@react-types/select": "^3.12.2", "@react-types/shared": "^3.33.1", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-u0UFWw0S7q9oiSbjetDpRoLLIcC+L89uYlm+YfCrdT8ntbQgABNiJRxdVvxnhR0fR6MC9ASTTvuQnNHNn52+1A=="],
|
||||
|
||||
"@react-aria/selection": ["@react-aria/selection@3.27.2", "", { "dependencies": { "@react-aria/focus": "^3.21.5", "@react-aria/i18n": "^3.12.16", "@react-aria/interactions": "^3.27.1", "@react-aria/utils": "^3.33.1", "@react-stately/selection": "^3.20.9", "@react-types/shared": "^3.33.1", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-GbUSSLX/ciXix95KW1g+SLM9np7iXpIZrFDSXkC6oNx1uhy18eAcuTkeZE25+SY5USVUmEzjI3m/3JoSUcebbg=="],
|
||||
|
||||
"@react-aria/separator": ["@react-aria/separator@3.4.16", "", { "dependencies": { "@react-aria/utils": "^3.33.1", "@react-types/shared": "^3.33.1", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-RCUtQhDGnPxKzyG8KM79yOB0fSiEf8r/rxShidOVnGLiBW2KFmBa22/Gfc4jnqg/keN3dxvkSGoqmeXgctyp6g=="],
|
||||
|
||||
"@react-aria/slider": ["@react-aria/slider@3.8.5", "", { "dependencies": { "@react-aria/i18n": "^3.12.16", "@react-aria/interactions": "^3.27.1", "@react-aria/label": "^3.7.25", "@react-aria/utils": "^3.33.1", "@react-stately/slider": "^3.7.5", "@react-types/shared": "^3.33.1", "@react-types/slider": "^3.8.4", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-gqkJxznk141mE0JamXF5CXml9PDbPkBz8dyKlihtWHWX4yhEbVYdC9J0otE7iCR3zx69Bm7WHoTGL0BsdpKzVA=="],
|
||||
|
||||
"@react-aria/spinbutton": ["@react-aria/spinbutton@3.7.2", "", { "dependencies": { "@react-aria/i18n": "^3.12.16", "@react-aria/live-announcer": "^3.4.4", "@react-aria/utils": "^3.33.1", "@react-types/button": "^3.15.1", "@react-types/shared": "^3.33.1", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-adjE1wNCWlugvAtVXlXWPtIG9JWurEgYVn1Eeyh19x038+oXGvOsOAoKCXM+SnGleTWQ9J7pEZITFoEI3cVfAw=="],
|
||||
|
||||
"@react-aria/ssr": ["@react-aria/ssr@3.9.10", "", { "dependencies": { "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-hvTm77Pf+pMBhuBm760Li0BVIO38jv1IBws1xFm1NoL26PU+fe+FMW5+VZWyANR6nYL65joaJKZqOdTQMkO9IQ=="],
|
||||
|
||||
"@react-aria/switch": ["@react-aria/switch@3.7.11", "", { "dependencies": { "@react-aria/toggle": "^3.12.5", "@react-stately/toggle": "^3.9.5", "@react-types/shared": "^3.33.1", "@react-types/switch": "^3.5.17", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-dYVX71HiepBsKyeMaQgHbhqI+MQ3MVoTd5EnTbUjefIBnmQZavYj1/e4NUiUI4Ix+/C0HxL8ibDAv4NlSW3eLQ=="],
|
||||
|
||||
"@react-aria/table": ["@react-aria/table@3.17.11", "", { "dependencies": { "@react-aria/focus": "^3.21.5", "@react-aria/grid": "^3.14.8", "@react-aria/i18n": "^3.12.16", "@react-aria/interactions": "^3.27.1", "@react-aria/live-announcer": "^3.4.4", "@react-aria/utils": "^3.33.1", "@react-aria/visually-hidden": "^3.8.31", "@react-stately/collections": "^3.12.10", "@react-stately/flags": "^3.1.2", "@react-stately/table": "^3.15.4", "@react-types/checkbox": "^3.10.4", "@react-types/grid": "^3.3.8", "@react-types/shared": "^3.33.1", "@react-types/table": "^3.13.6", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-GkYmWPiW3OM+FUZxdS33teHXHXde7TjHuYgDDaG9phvg6cQTQjGilJozrzA3OfftTOq5VB8XcKTIQW3c0tpYsQ=="],
|
||||
|
||||
"@react-aria/tabs": ["@react-aria/tabs@3.11.1", "", { "dependencies": { "@react-aria/focus": "^3.21.5", "@react-aria/i18n": "^3.12.16", "@react-aria/selection": "^3.27.2", "@react-aria/utils": "^3.33.1", "@react-stately/tabs": "^3.8.9", "@react-types/shared": "^3.33.1", "@react-types/tabs": "^3.3.22", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-3Ppz7yaEDW9L7p9PE9yNOl5caLwNnnLQqI+MX/dwbWlw9HluHS7uIjb21oswNl6UbSxAWyENOka45+KN4Fkh7A=="],
|
||||
|
||||
"@react-aria/tag": ["@react-aria/tag@3.8.1", "", { "dependencies": { "@react-aria/gridlist": "^3.14.4", "@react-aria/i18n": "^3.12.16", "@react-aria/interactions": "^3.27.1", "@react-aria/label": "^3.7.25", "@react-aria/selection": "^3.27.2", "@react-aria/utils": "^3.33.1", "@react-stately/list": "^3.13.4", "@react-types/button": "^3.15.1", "@react-types/shared": "^3.33.1", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-VonpO++F8afXGDWc9VUxAc2wefyJpp1n9OGpbnB7zmqWiuPwO/RixjUdcH7iJkiC4vADwx9uLnhyD6kcwGV2ig=="],
|
||||
|
||||
"@react-aria/textfield": ["@react-aria/textfield@3.18.5", "", { "dependencies": { "@react-aria/form": "^3.1.5", "@react-aria/interactions": "^3.27.1", "@react-aria/label": "^3.7.25", "@react-aria/utils": "^3.33.1", "@react-stately/form": "^3.2.4", "@react-stately/utils": "^3.11.0", "@react-types/shared": "^3.33.1", "@react-types/textfield": "^3.12.8", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-ttwVSuwoV3RPaG2k2QzEXKeQNQ3mbdl/2yy6I4Tjrn1ZNkYHfVyJJ26AjenfSmj1kkTQoSAfZ8p+7rZp4n0xoQ=="],
|
||||
|
||||
"@react-aria/toast": ["@react-aria/toast@3.0.11", "", { "dependencies": { "@react-aria/i18n": "^3.12.16", "@react-aria/interactions": "^3.27.1", "@react-aria/landmark": "^3.0.10", "@react-aria/utils": "^3.33.1", "@react-stately/toast": "^3.1.3", "@react-types/button": "^3.15.1", "@react-types/shared": "^3.33.1", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-2DjZjBAvm8/CWbnZ6s7LjkYCkULKtjMve6GvhPTq98AthuEDLEiBvM1wa3xdecCRhZyRT1g6DXqVca0EfZ9fJA=="],
|
||||
|
||||
"@react-aria/toggle": ["@react-aria/toggle@3.12.5", "", { "dependencies": { "@react-aria/interactions": "^3.27.1", "@react-aria/utils": "^3.33.1", "@react-stately/toggle": "^3.9.5", "@react-types/checkbox": "^3.10.4", "@react-types/shared": "^3.33.1", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-XXVFLzcV8fr9mz7y/wfxEAhWvaBZ9jSfhCMuxH2bsivO7nTcMJ1jb4g2xJNwZgne17bMWNc7mKvW5dbsdlI6BA=="],
|
||||
|
||||
"@react-aria/toolbar": ["@react-aria/toolbar@3.0.0-beta.24", "", { "dependencies": { "@react-aria/focus": "^3.21.5", "@react-aria/i18n": "^3.12.16", "@react-aria/utils": "^3.33.1", "@react-types/shared": "^3.33.1", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-B2Rmpko7Ghi2RbNfsGdbR7I+RQBDhPGVE4bU3/EwHz+P/vNe5LyGPTeSwqaOMsQTF9lKNCkY8424dVTCr6RUMg=="],
|
||||
|
||||
"@react-aria/tooltip": ["@react-aria/tooltip@3.9.2", "", { "dependencies": { "@react-aria/interactions": "^3.27.1", "@react-aria/utils": "^3.33.1", "@react-stately/tooltip": "^3.5.11", "@react-types/shared": "^3.33.1", "@react-types/tooltip": "^3.5.2", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-VrgkPwHiEnAnBhoQ4W7kfry/RfVuRWrUPaJSp0+wKM6u0gg2tmn7OFRDXTxBAm/omQUguIdIjRWg7sf3zHH82A=="],
|
||||
|
||||
"@react-aria/tree": ["@react-aria/tree@3.1.7", "", { "dependencies": { "@react-aria/gridlist": "^3.14.4", "@react-aria/i18n": "^3.12.16", "@react-aria/selection": "^3.27.2", "@react-aria/utils": "^3.33.1", "@react-stately/tree": "^3.9.6", "@react-types/button": "^3.15.1", "@react-types/shared": "^3.33.1", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-C54yH5NmsOFa2Q+cg6B1BPr5KUlU9vLIoBnVrgrH237FRSXQPIbcM4VpmITAHq1VR7w6ayyS1hgTwFxo67ykWQ=="],
|
||||
|
||||
"@react-aria/utils": ["@react-aria/utils@3.33.1", "", { "dependencies": { "@react-aria/ssr": "^3.9.10", "@react-stately/flags": "^3.1.2", "@react-stately/utils": "^3.11.0", "@react-types/shared": "^3.33.1", "@swc/helpers": "^0.5.0", "clsx": "^2.0.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-kIx1Sj6bbAT0pdqCegHuPanR9zrLn5zMRiM7LN12rgRf55S19ptd9g3ncahArifYTRkfEU9VIn+q0HjfMqS9/w=="],
|
||||
|
||||
"@react-aria/virtualizer": ["@react-aria/virtualizer@4.1.13", "", { "dependencies": { "@react-aria/i18n": "^3.12.16", "@react-aria/interactions": "^3.27.1", "@react-aria/utils": "^3.33.1", "@react-stately/virtualizer": "^4.4.6", "@react-types/shared": "^3.33.1", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-d5KS+p8GXGNRbGPRE/N6jtth3et3KssQIz52h2+CAoAh7C3vvR64kkTaGdeywClvM+fSo8FxJuBrdfQvqC2ktQ=="],
|
||||
|
||||
"@react-aria/visually-hidden": ["@react-aria/visually-hidden@3.8.31", "", { "dependencies": { "@react-aria/interactions": "^3.27.1", "@react-aria/utils": "^3.33.1", "@react-types/shared": "^3.33.1", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-RTOHHa4n56a9A3criThqFHBifvZoV71+MCkSuNP2cKO662SUWjqKkd0tJt/mBRMEJPkys8K7Eirp6T8Wt5FFRA=="],
|
||||
|
||||
"@react-stately/autocomplete": ["@react-stately/autocomplete@3.0.0-beta.4", "", { "dependencies": { "@react-stately/utils": "^3.11.0", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-K2Uy7XEdseFvgwRQ8CyrYEHMupjVKEszddOapP8deNz4hntYvT1aRm0m+sKa5Kl/4kvg9c/3NZpQcrky/vRZIg=="],
|
||||
|
||||
"@react-stately/calendar": ["@react-stately/calendar@3.9.3", "", { "dependencies": { "@internationalized/date": "^3.12.0", "@react-stately/utils": "^3.11.0", "@react-types/calendar": "^3.8.3", "@react-types/shared": "^3.33.1", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-uw7fCZXoypSBBUsVkbNvJMQWTihZReRbyLIGG3o/ZM630N3OCZhb/h4Uxke4pNu7n527H0V1bAnZgAldIzOYqg=="],
|
||||
|
||||
"@react-stately/checkbox": ["@react-stately/checkbox@3.7.5", "", { "dependencies": { "@react-stately/form": "^3.2.4", "@react-stately/utils": "^3.11.0", "@react-types/checkbox": "^3.10.4", "@react-types/shared": "^3.33.1", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-K5R5ted7AxLB3sDkuVAazUdyRMraFT1imVqij2GuAiOUFvsZvbuocnDuFkBVKojyV3GpqLBvViV8IaCMc4hNIw=="],
|
||||
|
||||
"@react-stately/collections": ["@react-stately/collections@3.12.10", "", { "dependencies": { "@react-types/shared": "^3.33.1", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-wmF9VxJDyBujBuQ76vXj2g/+bnnj8fx5DdXgRmyfkkYhPB46+g2qnjbVGEvipo7bJuGxDftCUC4SN7l7xqUWfg=="],
|
||||
|
||||
"@react-stately/color": ["@react-stately/color@3.9.5", "", { "dependencies": { "@internationalized/number": "^3.6.5", "@internationalized/string": "^3.2.7", "@react-stately/form": "^3.2.4", "@react-stately/numberfield": "^3.11.0", "@react-stately/slider": "^3.7.5", "@react-stately/utils": "^3.11.0", "@react-types/color": "^3.1.4", "@react-types/shared": "^3.33.1", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-8pZxzXWDRuglzDwyTG7mLw2LQMCHIVNbVc9YmbsxbOjAL+lOqszo60KzyaFKVxeDQczSvrNTHcQZqlbNIC0eyQ=="],
|
||||
|
||||
"@react-stately/combobox": ["@react-stately/combobox@3.13.0", "", { "dependencies": { "@react-stately/collections": "^3.12.10", "@react-stately/form": "^3.2.4", "@react-stately/list": "^3.13.4", "@react-stately/overlays": "^3.6.23", "@react-stately/utils": "^3.11.0", "@react-types/combobox": "^3.14.0", "@react-types/shared": "^3.33.1", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-dX9g/cK1hjLRjcbWVF6keHxTQDGhKGB2QAgPhWcBmOK3qJv+2dQqsJ6YCGWn/Y2N2acoEseLrAA7+Qe4HWV9cg=="],
|
||||
|
||||
"@react-stately/data": ["@react-stately/data@3.15.2", "", { "dependencies": { "@react-types/shared": "^3.33.1", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-BsmeeGgFwOGwo0g9Waprdyt+846n3KhKggZfpEnp5+sC4dE4uW1VIYpdyupMfr3bQcmX123q6TegfNP3eszrUA=="],
|
||||
|
||||
"@react-stately/datepicker": ["@react-stately/datepicker@3.16.1", "", { "dependencies": { "@internationalized/date": "^3.12.0", "@internationalized/number": "^3.6.5", "@internationalized/string": "^3.2.7", "@react-stately/form": "^3.2.4", "@react-stately/overlays": "^3.6.23", "@react-stately/utils": "^3.11.0", "@react-types/datepicker": "^3.13.5", "@react-types/shared": "^3.33.1", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-BtAMDvxd1OZxkxjqq5tN5TYmp6Hm8+o3+IDA4qmem2/pfQfVbOZeWS2WitcPBImj4n4T+W1A5+PI7mT/6DUBVg=="],
|
||||
|
||||
"@react-stately/disclosure": ["@react-stately/disclosure@3.0.11", "", { "dependencies": { "@react-stately/utils": "^3.11.0", "@react-types/shared": "^3.33.1", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-/KjB/0HkxGWbhFAPztCP411LUKZCx9k8cKukrlGqrUWyvrcXlmza90j0g/CuxACBoV+DJP9V+4q+8ide0x750A=="],
|
||||
|
||||
"@react-stately/dnd": ["@react-stately/dnd@3.7.4", "", { "dependencies": { "@react-stately/selection": "^3.20.9", "@react-types/shared": "^3.33.1", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-YD0TVR5JkvTqskc1ouBpVKs6t/QS4RYCIyu8Ug8RgO122iIizuf2pfKnRLjYMdu5lXzBXGaIgd49dvnLzEXHIw=="],
|
||||
|
||||
"@react-stately/flags": ["@react-stately/flags@3.1.2", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-2HjFcZx1MyQXoPqcBGALwWWmgFVUk2TuKVIQxCbRq7fPyWXIl6VHcakCLurdtYC2Iks7zizvz0Idv48MQ38DWg=="],
|
||||
|
||||
"@react-stately/form": ["@react-stately/form@3.2.4", "", { "dependencies": { "@react-types/shared": "^3.33.1", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-qNBzun8SbLdgahryhKLqL1eqP+MXY6as82sVXYOOvUYLzgU5uuN8mObxYlxJgMI5akSdQJQV3RzyfVobPRE7Kw=="],
|
||||
|
||||
"@react-stately/grid": ["@react-stately/grid@3.11.9", "", { "dependencies": { "@react-stately/collections": "^3.12.10", "@react-stately/selection": "^3.20.9", "@react-types/grid": "^3.3.8", "@react-types/shared": "^3.33.1", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-qQY6F+27iZRn30dt0ZOrSetUmbmNJ0pLe9Weuqw3+XDVSuWT+2O/rO1UUYeK+mO0Acjzdv+IWiYbu9RKf2wS9w=="],
|
||||
|
||||
"@react-stately/layout": ["@react-stately/layout@4.6.0", "", { "dependencies": { "@react-stately/collections": "^3.12.10", "@react-stately/table": "^3.15.4", "@react-stately/virtualizer": "^4.4.6", "@react-types/grid": "^3.3.8", "@react-types/shared": "^3.33.1", "@react-types/table": "^3.13.6", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-kBenEsP03nh5rKgfqlVMPcoKTJv0v92CTvrAb5gYY8t9g8LOwzdL89Yannq7f5xv8LFck/MmRQlotpMt2InETg=="],
|
||||
|
||||
"@react-stately/list": ["@react-stately/list@3.13.4", "", { "dependencies": { "@react-stately/collections": "^3.12.10", "@react-stately/selection": "^3.20.9", "@react-stately/utils": "^3.11.0", "@react-types/shared": "^3.33.1", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-HHYSjA9VG7FPSAtpXAjQyM/V7qFHWGg88WmMrDt5QDlTBexwPuH0oFLnW0qaVZpAIxuWIsutZfxRAnme/NhhAA=="],
|
||||
|
||||
"@react-stately/menu": ["@react-stately/menu@3.9.11", "", { "dependencies": { "@react-stately/overlays": "^3.6.23", "@react-types/menu": "^3.10.7", "@react-types/shared": "^3.33.1", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-vYkpO9uV2OUecsIkrOc+Urdl/s1xw/ibNH/UXsp4PtjMnS6mK9q2kXZTM3WvMAKoh12iveUO+YkYCZQshmFLHQ=="],
|
||||
|
||||
"@react-stately/numberfield": ["@react-stately/numberfield@3.11.0", "", { "dependencies": { "@internationalized/number": "^3.6.5", "@react-stately/form": "^3.2.4", "@react-stately/utils": "^3.11.0", "@react-types/numberfield": "^3.8.18", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-rxfC047vL0LP4tanjinfjKAriAvdVL57Um5RUL5nHML8IOWCB3TBxegQkJ6to6goScC/oZhd0/Y2LSaiRuKbNw=="],
|
||||
|
||||
"@react-stately/overlays": ["@react-stately/overlays@3.6.23", "", { "dependencies": { "@react-stately/utils": "^3.11.0", "@react-types/overlays": "^3.9.4", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-RzWxots9A6gAzQMP4s8hOAHV7SbJRTFSlQbb6ly1nkWQXacOSZSFNGsKOaS0eIatfNPlNnW4NIkgtGws5UYzfw=="],
|
||||
|
||||
"@react-stately/radio": ["@react-stately/radio@3.11.5", "", { "dependencies": { "@react-stately/form": "^3.2.4", "@react-stately/utils": "^3.11.0", "@react-types/radio": "^3.9.4", "@react-types/shared": "^3.33.1", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-QxA779S4ea5icQ0ja7CeiNzY1cj7c9G9TN0m7maAIGiTSinZl2Ia8naZJ0XcbRRp+LBll7RFEdekne15TjvS/w=="],
|
||||
|
||||
"@react-stately/searchfield": ["@react-stately/searchfield@3.5.19", "", { "dependencies": { "@react-stately/utils": "^3.11.0", "@react-types/searchfield": "^3.6.8", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-URllgjbtTQEaOCfddbHpJSPKOzG3pE3ajQHJ7Df8qCoHTjKfL6hnm/vp7X5sxPaZaN7VLZ5kAQxTE8hpo6s0+A=="],
|
||||
|
||||
"@react-stately/select": ["@react-stately/select@3.9.2", "", { "dependencies": { "@react-stately/form": "^3.2.4", "@react-stately/list": "^3.13.4", "@react-stately/overlays": "^3.6.23", "@react-stately/utils": "^3.11.0", "@react-types/select": "^3.12.2", "@react-types/shared": "^3.33.1", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-oWn0bijuusp8YI7FRM/wgtPVqiIrgU/ZUfLKe/qJUmT8D+JFaMAJnyrAzKpx98TrgamgtXynF78ccpopPhgrKQ=="],
|
||||
|
||||
"@react-stately/selection": ["@react-stately/selection@3.20.9", "", { "dependencies": { "@react-stately/collections": "^3.12.10", "@react-stately/utils": "^3.11.0", "@react-types/shared": "^3.33.1", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-RhxRR5Wovg9EVi3pq7gBPK2BoKmP59tOXDMh2r1PbnGevg/7TNdR67DCEblcmXwHuBNS46ELfKdd0XGHqmS8nQ=="],
|
||||
|
||||
"@react-stately/slider": ["@react-stately/slider@3.7.5", "", { "dependencies": { "@react-stately/utils": "^3.11.0", "@react-types/shared": "^3.33.1", "@react-types/slider": "^3.8.4", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-OrQMNR5xamLYH52TXtvTgyw3EMwv+JI+1istQgEj1CHBjC9eZZqn5iNCN20tzm+uDPTH0EIGULFjjPIumqYUQg=="],
|
||||
|
||||
"@react-stately/table": ["@react-stately/table@3.15.4", "", { "dependencies": { "@react-stately/collections": "^3.12.10", "@react-stately/flags": "^3.1.2", "@react-stately/grid": "^3.11.9", "@react-stately/selection": "^3.20.9", "@react-stately/utils": "^3.11.0", "@react-types/grid": "^3.3.8", "@react-types/shared": "^3.33.1", "@react-types/table": "^3.13.6", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-fGaNyw3wv7JgRCNzgyDzpaaTFuSy5f4Qekch4UheMXDJX7dOeaMhUXeOfvnXCVg+BGM4ey/D82RvDOGvPy1Nww=="],
|
||||
|
||||
"@react-stately/tabs": ["@react-stately/tabs@3.8.9", "", { "dependencies": { "@react-stately/list": "^3.13.4", "@react-types/shared": "^3.33.1", "@react-types/tabs": "^3.3.22", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-AQ4Xrn6YzIolaVShCV9cnwOjBKPAOGP/PTp7wpSEtQbQ0HZzUDG2RG/M4baMeUB2jZ33b7ifXyPcK78o0uOftg=="],
|
||||
|
||||
"@react-stately/toast": ["@react-stately/toast@3.1.3", "", { "dependencies": { "@swc/helpers": "^0.5.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-mT9QJKmD523lqFpOp0VWZ6QHZENFK7HrodnNJDVc7g616s5GNmemdlkITV43fSY3tHeThCVvPu+Uzh7RvQ9mpQ=="],
|
||||
|
||||
"@react-stately/toggle": ["@react-stately/toggle@3.9.5", "", { "dependencies": { "@react-stately/utils": "^3.11.0", "@react-types/checkbox": "^3.10.4", "@react-types/shared": "^3.33.1", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-PVzXc788q3jH98Kvw1LYDL+wpVC14dCEKjOku8cSaqhEof6AJGaLR9yq+EF1yYSL2dxI6z8ghc0OozY8WrcFcA=="],
|
||||
|
||||
"@react-stately/tooltip": ["@react-stately/tooltip@3.5.11", "", { "dependencies": { "@react-stately/overlays": "^3.6.23", "@react-types/tooltip": "^3.5.2", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-o8PnFXbvDCuVZ4Ht9ahfS6KHwIZjXopvoQ2vUPxv920irdgWEeC+4omgDOnJ/xFvcpmmJAmSsrQsTQrTguDUQA=="],
|
||||
|
||||
"@react-stately/tree": ["@react-stately/tree@3.9.6", "", { "dependencies": { "@react-stately/collections": "^3.12.10", "@react-stately/selection": "^3.20.9", "@react-stately/utils": "^3.11.0", "@react-types/shared": "^3.33.1", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-JCuhGyX2A+PAMsx2pRSwArfqNFZJ9JSPkDaOQJS8MFPAsBe5HemvXsdmv9aBIMzlbCYcVq6EsrFnzbVVTBt/6w=="],
|
||||
|
||||
"@react-stately/utils": ["@react-stately/utils@3.11.0", "", { "dependencies": { "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-8LZpYowJ9eZmmYLpudbo/eclIRnbhWIJZ994ncmlKlouNzKohtM8qTC6B1w1pwUbiwGdUoyzLuQbeaIor5Dvcw=="],
|
||||
|
||||
"@react-stately/virtualizer": ["@react-stately/virtualizer@4.4.6", "", { "dependencies": { "@react-types/shared": "^3.33.1", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-9SfXgLFB61/8SXNLfg5ARx9jAK4m03Aw6/Cg8mdZN24SYarL4TKNRpfw8K/HHVU/bi6WHSJypk6Z/z19o/ztrg=="],
|
||||
|
||||
"@react-types/autocomplete": ["@react-types/autocomplete@3.0.0-alpha.38", "", { "dependencies": { "@react-types/combobox": "^3.14.0", "@react-types/searchfield": "^3.6.8", "@react-types/shared": "^3.33.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-0XrlVC8drzcrCNzybbkZdLcTofXEzBsHuaFevt5awW1J0xBJ+SMLIQMDeUYrvKjjwXUBlCtjJJpOvitGt4Z+KA=="],
|
||||
|
||||
"@react-types/breadcrumbs": ["@react-types/breadcrumbs@3.7.19", "", { "dependencies": { "@react-types/link": "^3.6.7", "@react-types/shared": "^3.33.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-AnkyYYmzaM2QFi/N0P/kQLM8tHOyFi7p397B/jEMucXDfwMw5Ny1ObCXeIEqbh8KrIa2Xp8SxmQlCV+8FPs4LA=="],
|
||||
|
||||
"@react-types/button": ["@react-types/button@3.15.1", "", { "dependencies": { "@react-types/shared": "^3.33.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-M1HtsKreJkigCnqceuIT22hDJBSStbPimnpmQmsl7SNyqCFY3+DHS7y/Sl3GvqCkzxF7j9UTL0dG38lGQ3K4xQ=="],
|
||||
|
||||
"@react-types/calendar": ["@react-types/calendar@3.8.3", "", { "dependencies": { "@internationalized/date": "^3.12.0", "@react-types/shared": "^3.33.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-fpH6WNXotzH0TlKHXXxtjeLZ7ko0sbyHmwDAwmDFyP7T0Iwn1YQZ+lhceLifvynlxuOgX6oBItyUKmkHQ0FouQ=="],
|
||||
|
||||
"@react-types/checkbox": ["@react-types/checkbox@3.10.4", "", { "dependencies": { "@react-types/shared": "^3.33.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-tYCG0Pd1usEz5hjvBEYcqcA0youx930Rss1QBIse9TgMekA1c2WmPDNupYV8phpO8Zuej3DL1WfBeXcgavK8aw=="],
|
||||
|
||||
"@react-types/color": ["@react-types/color@3.1.4", "", { "dependencies": { "@react-types/shared": "^3.33.1", "@react-types/slider": "^3.8.4" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-s+Xj4pvNBlJPpQ1Gr7bO1j4/tuwMUfdS9xIVFuiW5RvDsSybKTUJ/gqPzTxms94VDCRhLFocVn2STNdD2Erf6A=="],
|
||||
|
||||
"@react-types/combobox": ["@react-types/combobox@3.14.0", "", { "dependencies": { "@react-types/shared": "^3.33.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-zmSSS7BcCOD8rGT8eGbVy7UlL5qq1vm88fFn4WgFe+lfK33ne+E7yTzTxcPY2TCGSo5fY6xMj3OG79FfVNGbSg=="],
|
||||
|
||||
"@react-types/datepicker": ["@react-types/datepicker@3.13.5", "", { "dependencies": { "@internationalized/date": "^3.12.0", "@react-types/calendar": "^3.8.3", "@react-types/overlays": "^3.9.4", "@react-types/shared": "^3.33.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-j28Vz+xvbb4bj7+9Xbpc4WTvSitlBvt7YEaEGM/8ZQ5g4Jr85H2KwkmDwjzmMN2r6VMQMMYq9JEcemq5wWpfUQ=="],
|
||||
|
||||
"@react-types/dialog": ["@react-types/dialog@3.5.24", "", { "dependencies": { "@react-types/overlays": "^3.9.4", "@react-types/shared": "^3.33.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-NFurEP/zV0dA/41422lV1t+0oh6f/13n+VmLHZG8R13m1J3ql/kAXZ49zBSqkqANBO1ojyugWebk99IiR4pYOw=="],
|
||||
|
||||
"@react-types/form": ["@react-types/form@3.7.18", "", { "dependencies": { "@react-types/shared": "^3.33.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-0sBJW0+I9nJcF4SmKrYFEWAlehiebSTy7xqriqAXtqfTEdvzAYLGaAK2/7gx+wlNZeDTdW43CDRJ4XAhyhBqnw=="],
|
||||
|
||||
"@react-types/grid": ["@react-types/grid@3.3.8", "", { "dependencies": { "@react-types/shared": "^3.33.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-zJvXH8gc1e1VH2H3LRnHH/W2HIkLkZMH3Cu5pLcj0vDuLBSWpcr3Ikh3jZ+VUOZF0G1Jt1lO8pKIaqFzDLNmLQ=="],
|
||||
|
||||
"@react-types/link": ["@react-types/link@3.6.7", "", { "dependencies": { "@react-types/shared": "^3.33.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-1apXCFJgMC1uydc2KNENrps1qR642FqDpwlNWe254UTpRZn/hEZhA6ImVr8WhomfLJu672WyWA0rUOv4HT+/pQ=="],
|
||||
|
||||
"@react-types/listbox": ["@react-types/listbox@3.7.6", "", { "dependencies": { "@react-types/shared": "^3.33.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-335NYElKEByXMalAmeRPyulKIDd2cjOCQhLwvv2BtxO5zaJfZnBbhZs+XPd9zwU6YomyOxODKSHrwbNDx+Jf3w=="],
|
||||
|
||||
"@react-types/menu": ["@react-types/menu@3.10.7", "", { "dependencies": { "@react-types/overlays": "^3.9.4", "@react-types/shared": "^3.33.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-+p7ixZdvPDJZhisqdtWiiuJ9pteNfK5i19NB6wzAw5XkljbEzodNhwLv6rI96DY5XpbFso2kcjw7IWi+rAAGGQ=="],
|
||||
|
||||
"@react-types/meter": ["@react-types/meter@3.4.15", "", { "dependencies": { "@react-types/progress": "^3.5.18" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-9WjNphhLLM+TA4Ev1y2MkpugJ5JjTXseHh7ZWWx2veq5DrXMZYclkRpfUrUdLVKvaBIPQCgpQIj0TcQi+quR9A=="],
|
||||
|
||||
"@react-types/numberfield": ["@react-types/numberfield@3.8.18", "", { "dependencies": { "@react-types/shared": "^3.33.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-nLzk7YAG9yAUtSv+9R8LgCHsu8hJq8/A+m1KsKxvc8WmNJjIujSFgWvT21MWBiUgPBzJKGzAqpMDDa087mltJQ=="],
|
||||
|
||||
"@react-types/overlays": ["@react-types/overlays@3.9.4", "", { "dependencies": { "@react-types/shared": "^3.33.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-7Z9HaebMFyYBqtv3XVNHEmVkm7AiYviV7gv0c98elEN2Co+eQcKFGvwBM9Gy/lV57zlTqFX1EX/SAqkMEbCLOA=="],
|
||||
|
||||
"@react-types/progress": ["@react-types/progress@3.5.18", "", { "dependencies": { "@react-types/shared": "^3.33.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-mKeQn+KrHr1y0/k7KtrbeDGDaERH6i4f6yBwj/ZtYDCTNKMO3tPHJY6nzF0w/KKZLplIO+BjUbHXc2RVm8ovwQ=="],
|
||||
|
||||
"@react-types/radio": ["@react-types/radio@3.9.4", "", { "dependencies": { "@react-types/shared": "^3.33.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-TkMRY3sA1PcFZhhclu4IUzUTIir6MzNJj8h6WT8vO6Nug2kXJ72qigugVFBWJSE472mltduOErEAo0rtAYWbQA=="],
|
||||
|
||||
"@react-types/searchfield": ["@react-types/searchfield@3.6.8", "", { "dependencies": { "@react-types/shared": "^3.33.1", "@react-types/textfield": "^3.12.8" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-M2p7OVdMTMDmlBcHd4N2uCBwg3uJSNM4lmEyf09YD44N5wDAI0yogk52QBwsnhpe+i2s65UwCYgunB+QltRX8A=="],
|
||||
|
||||
"@react-types/select": ["@react-types/select@3.12.2", "", { "dependencies": { "@react-types/shared": "^3.33.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-AseOjfr3qM1W1qIWcbAe6NFpwZluVeQX/dmu9BYxjcnVvtoBLPMbE5zX/BPbv+N5eFYjoMyj7Ug9dqnI+LrlGw=="],
|
||||
|
||||
"@react-types/shared": ["@react-types/shared@3.33.1", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-oJHtjvLG43VjwemQDadlR5g/8VepK56B/xKO2XORPHt9zlW6IZs3tZrYlvH29BMvoqC7RtE7E5UjgbnbFtDGag=="],
|
||||
|
||||
"@react-types/slider": ["@react-types/slider@3.8.4", "", { "dependencies": { "@react-types/shared": "^3.33.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-C+xFVvfKREai9S/ekBDCVaGPOQYkNUAsQhjQnNsUAATaox4I6IYLmcIgLmljpMQWqAe+gZiWsIwacRYMez2Tew=="],
|
||||
|
||||
"@react-types/switch": ["@react-types/switch@3.5.17", "", { "dependencies": { "@react-types/shared": "^3.33.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-2GTPJvBCYI8YZ3oerHtXg+qikabIXCMJ6C2wcIJ5Xn0k9XOovowghfJi10OPB2GGyOiLBU74CczP5nx8adG90Q=="],
|
||||
|
||||
"@react-types/table": ["@react-types/table@3.13.6", "", { "dependencies": { "@react-types/grid": "^3.3.8", "@react-types/shared": "^3.33.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-eluL+iFfnVmFm7OSZrrFG9AUjw+tcv898zbv+NsZACa8oXG1v9AimhZfd+Mo8q/5+sX/9hguWNXFkSvmTjuVPQ=="],
|
||||
|
||||
"@react-types/tabs": ["@react-types/tabs@3.3.22", "", { "dependencies": { "@react-types/shared": "^3.33.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-HGwLD9dA3k3AGfRKGFBhNgxU9/LyRmxN0kxVj1ghA4L9S/qTOzS6GhrGNkGzsGxyVLV4JN8MLxjWN2o9QHnLEg=="],
|
||||
|
||||
"@react-types/textfield": ["@react-types/textfield@3.12.8", "", { "dependencies": { "@react-types/shared": "^3.33.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-wt6FcuE5AyntxsnPika/h3nf/DPmeAVbI018L9o6h+B/IL4sMWWdx663wx2KOOeHH8ejKGZQNPLhUKs4s1mVQA=="],
|
||||
|
||||
"@react-types/tooltip": ["@react-types/tooltip@3.5.2", "", { "dependencies": { "@react-types/overlays": "^3.9.4", "@react-types/shared": "^3.33.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-FvSuZ2WP08NEWefrpCdBYpEEZh/5TvqvGjq0wqGzWg2OPwpc14HjD8aE7I3MOuylXkD4MSlMjl7J4DlvlcCs3Q=="],
|
||||
|
||||
"@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="],
|
||||
|
||||
"@rushstack/eslint-patch": ["@rushstack/eslint-patch@1.15.0", "", {}, "sha512-ojSshQPKwVvSMR8yT2L/QtUkV5SXi/IfDiJ4/8d6UbTPjiHVmxZzUAzGD8Tzks1b9+qQkZa0isUOvYObedITaw=="],
|
||||
@@ -408,6 +655,8 @@
|
||||
|
||||
"@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
|
||||
|
||||
"@tabby_ai/hijri-converter": ["@tabby_ai/hijri-converter@1.0.5", "", {}, "sha512-r5bClKrcIusDoo049dSL8CawnHR6mRdDwhlQuIgZRNty68q0x8k3Lf1BtPAMxRf/GgnHBnIO4ujd3+GQdLWzxQ=="],
|
||||
|
||||
"@tailwindcss/node": ["@tailwindcss/node@4.1.17", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.17" } }, "sha512-csIkHIgLb3JisEFQ0vxr2Y57GUNYh447C8xzwj89U/8fdW8LhProdxvnVH6U8M2Y73QKiTIH+LWbK3V2BBZsAg=="],
|
||||
|
||||
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.17", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.17", "@tailwindcss/oxide-darwin-arm64": "4.1.17", "@tailwindcss/oxide-darwin-x64": "4.1.17", "@tailwindcss/oxide-freebsd-x64": "4.1.17", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.17", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.17", "@tailwindcss/oxide-linux-arm64-musl": "4.1.17", "@tailwindcss/oxide-linux-x64-gnu": "4.1.17", "@tailwindcss/oxide-linux-x64-musl": "4.1.17", "@tailwindcss/oxide-wasm32-wasi": "4.1.17", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.17", "@tailwindcss/oxide-win32-x64-msvc": "4.1.17" } }, "sha512-F0F7d01fmkQhsTjXezGBLdrl1KresJTcI3DB8EkScCldyKp3Msz4hub4uyYaVnk88BAS1g5DQjjF6F5qczheLA=="],
|
||||
@@ -624,6 +873,8 @@
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
|
||||
|
||||
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
|
||||
|
||||
"define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="],
|
||||
@@ -788,6 +1039,8 @@
|
||||
|
||||
"internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="],
|
||||
|
||||
"intl-messageformat": ["intl-messageformat@10.7.18", "", { "dependencies": { "@formatjs/ecma402-abstract": "2.3.6", "@formatjs/fast-memoize": "2.2.7", "@formatjs/icu-messageformat-parser": "2.11.4", "tslib": "^2.8.0" } }, "sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g=="],
|
||||
|
||||
"is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="],
|
||||
|
||||
"is-async-function": ["is-async-function@2.1.1", "", { "dependencies": { "async-function": "^1.0.0", "call-bound": "^1.0.3", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ=="],
|
||||
@@ -904,7 +1157,7 @@
|
||||
|
||||
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
|
||||
|
||||
"lucide-react": ["lucide-react@0.539.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-VVISr+VF2krO91FeuCrm1rSOLACQUYVy7NQkzrOty52Y8TlTPcXcMdQFj9bYzBgXbWCiywlwSZ3Z8u6a+6bMlg=="],
|
||||
"lucide-react": ["lucide-react@1.8.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-WuvlsjngSk7TnTBJ1hsCy3ql9V9VOdcPkd3PKcSmM34vJD8KG6molxz7m7zbYFgICwsanQWmJ13JlYs4Zp7Arw=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
@@ -1014,7 +1267,11 @@
|
||||
|
||||
"react": ["react@19.1.0", "", {}, "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg=="],
|
||||
|
||||
"react-day-picker": ["react-day-picker@9.11.3", "", { "dependencies": { "@date-fns/tz": "^1.4.1", "date-fns": "^4.1.0", "date-fns-jalali": "^4.1.0-0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-7lD12UvGbkyXqgzbYIGQTbl+x29B9bAf+k0pP5Dcs1evfpKk6zv4EdH/edNc8NxcmCiTNXr2HIYPrSZ3XvmVBg=="],
|
||||
"react-aria": ["react-aria@3.47.0", "", { "dependencies": { "@internationalized/string": "^3.2.7", "@react-aria/breadcrumbs": "^3.5.32", "@react-aria/button": "^3.14.5", "@react-aria/calendar": "^3.9.5", "@react-aria/checkbox": "^3.16.5", "@react-aria/color": "^3.1.5", "@react-aria/combobox": "^3.15.0", "@react-aria/datepicker": "^3.16.1", "@react-aria/dialog": "^3.5.34", "@react-aria/disclosure": "^3.1.3", "@react-aria/dnd": "^3.11.6", "@react-aria/focus": "^3.21.5", "@react-aria/gridlist": "^3.14.4", "@react-aria/i18n": "^3.12.16", "@react-aria/interactions": "^3.27.1", "@react-aria/label": "^3.7.25", "@react-aria/landmark": "^3.0.10", "@react-aria/link": "^3.8.9", "@react-aria/listbox": "^3.15.3", "@react-aria/menu": "^3.21.0", "@react-aria/meter": "^3.4.30", "@react-aria/numberfield": "^3.12.5", "@react-aria/overlays": "^3.31.2", "@react-aria/progress": "^3.4.30", "@react-aria/radio": "^3.12.5", "@react-aria/searchfield": "^3.8.12", "@react-aria/select": "^3.17.3", "@react-aria/selection": "^3.27.2", "@react-aria/separator": "^3.4.16", "@react-aria/slider": "^3.8.5", "@react-aria/ssr": "^3.9.10", "@react-aria/switch": "^3.7.11", "@react-aria/table": "^3.17.11", "@react-aria/tabs": "^3.11.1", "@react-aria/tag": "^3.8.1", "@react-aria/textfield": "^3.18.5", "@react-aria/toast": "^3.0.11", "@react-aria/tooltip": "^3.9.2", "@react-aria/tree": "^3.1.7", "@react-aria/utils": "^3.33.1", "@react-aria/visually-hidden": "^3.8.31", "@react-types/shared": "^3.33.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-nvahimIqdByl/PXk/xPkG30LPRzcin+/Uk0uFfwbbKRRFC9aa22a6BRULZLqVHwa9GaNyKe6CDUxO1Dde4v0kA=="],
|
||||
|
||||
"react-aria-components": ["react-aria-components@1.16.0", "", { "dependencies": { "@internationalized/date": "^3.12.0", "@internationalized/string": "^3.2.7", "@react-aria/autocomplete": "3.0.0-rc.6", "@react-aria/collections": "^3.0.3", "@react-aria/dnd": "^3.11.6", "@react-aria/focus": "^3.21.5", "@react-aria/interactions": "^3.27.1", "@react-aria/live-announcer": "^3.4.4", "@react-aria/overlays": "^3.31.2", "@react-aria/ssr": "^3.9.10", "@react-aria/textfield": "^3.18.5", "@react-aria/toolbar": "3.0.0-beta.24", "@react-aria/utils": "^3.33.1", "@react-aria/virtualizer": "^4.1.13", "@react-stately/autocomplete": "3.0.0-beta.4", "@react-stately/layout": "^4.6.0", "@react-stately/selection": "^3.20.9", "@react-stately/table": "^3.15.4", "@react-stately/utils": "^3.11.0", "@react-stately/virtualizer": "^4.4.6", "@react-types/form": "^3.7.18", "@react-types/grid": "^3.3.8", "@react-types/shared": "^3.33.1", "@react-types/table": "^3.13.6", "@swc/helpers": "^0.5.0", "client-only": "^0.0.1", "react-aria": "^3.47.0", "react-stately": "^3.45.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-MjHbTLpMFzzD2Tv5KbeXoZwPczuUWZcRavVvQQlNHRtXHH38D+sToMEYpNeir7Wh3K/XWtzeX3EujfJW6QNkrw=="],
|
||||
|
||||
"react-day-picker": ["react-day-picker@9.14.0", "", { "dependencies": { "@date-fns/tz": "^1.4.1", "@tabby_ai/hijri-converter": "1.0.5", "date-fns": "^4.1.0", "date-fns-jalali": "4.1.0-0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-tBaoDWjPwe0M5pGrum4H0SR6Lyk+BO9oHnp9JbKpGKW2mlraNPgP9BMfsg5pWpwrssARmeqk7YBl2oXutZTaHA=="],
|
||||
|
||||
"react-dom": ["react-dom@19.1.0", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.0" } }, "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g=="],
|
||||
|
||||
@@ -1026,12 +1283,16 @@
|
||||
|
||||
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
|
||||
|
||||
"react-stately": ["react-stately@3.45.0", "", { "dependencies": { "@react-stately/calendar": "^3.9.3", "@react-stately/checkbox": "^3.7.5", "@react-stately/collections": "^3.12.10", "@react-stately/color": "^3.9.5", "@react-stately/combobox": "^3.13.0", "@react-stately/data": "^3.15.2", "@react-stately/datepicker": "^3.16.1", "@react-stately/disclosure": "^3.0.11", "@react-stately/dnd": "^3.7.4", "@react-stately/form": "^3.2.4", "@react-stately/list": "^3.13.4", "@react-stately/menu": "^3.9.11", "@react-stately/numberfield": "^3.11.0", "@react-stately/overlays": "^3.6.23", "@react-stately/radio": "^3.11.5", "@react-stately/searchfield": "^3.5.19", "@react-stately/select": "^3.9.2", "@react-stately/selection": "^3.20.9", "@react-stately/slider": "^3.7.5", "@react-stately/table": "^3.15.4", "@react-stately/tabs": "^3.8.9", "@react-stately/toast": "^3.1.3", "@react-stately/toggle": "^3.9.5", "@react-stately/tooltip": "^3.5.11", "@react-stately/tree": "^3.9.6", "@react-types/shared": "^3.33.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-G3bYr0BIiookpt4H05VeZUuVS/FslQAj2TeT8vDfCiL314Y+LtPXIPe/a3eamCA0wljy7z1EDYKV50Qbz7pcJg=="],
|
||||
|
||||
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
|
||||
|
||||
"reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="],
|
||||
|
||||
"regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="],
|
||||
|
||||
"reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="],
|
||||
|
||||
"resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
|
||||
|
||||
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
|
||||
@@ -1114,6 +1375,8 @@
|
||||
|
||||
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
||||
|
||||
"tabbable": ["tabbable@6.4.0", "", {}, "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg=="],
|
||||
|
||||
"tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.1.17", "", {}, "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q=="],
|
||||
@@ -1196,6 +1459,8 @@
|
||||
|
||||
"@radix-ui/react-popover/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-popper/@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.6", "", { "dependencies": { "@floating-ui/dom": "^1.7.4" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw=="],
|
||||
|
||||
"@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-select/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
@@ -1290,6 +1555,8 @@
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="],
|
||||
|
||||
"@radix-ui/react-popper/@floating-ui/react-dom/@floating-ui/dom": ["@floating-ui/dom@1.7.4", "", { "dependencies": { "@floating-ui/core": "^1.7.3", "@floating-ui/utils": "^0.2.10" } }, "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
|
||||
|
||||
"next/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
@@ -1345,5 +1612,9 @@
|
||||
"tsx/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-2BR5M8CPbptC1AK5JbJT1fWrHLvejwZidKx3UMSF0ecHMa+smhi16drIrCEggkgviBwLYd5nwrFLSl5Kho96RQ=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.1", "", { "os": "win32", "cpu": "x64" }, "sha512-d5X6RMYv6taIymSk8JBP+nxv8DQAMY6A51GPgusqLdK9wBz5wWIXy1KjTck6HnjE9hqJzJRdk+1p/t5soSbCtw=="],
|
||||
|
||||
"@radix-ui/react-popper/@floating-ui/react-dom/@floating-ui/dom/@floating-ui/core": ["@floating-ui/core@1.7.3", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w=="],
|
||||
|
||||
"@radix-ui/react-popper/@floating-ui/react-dom/@floating-ui/dom/@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
@@ -17,5 +18,8 @@
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
"registries": {
|
||||
"@diceui": "https://diceui.com/r/{name}.json",
|
||||
"@coss": "https://coss.com/ui/r/{name}.json"
|
||||
}
|
||||
}
|
||||
|
||||
13
package.json
13
package.json
@@ -10,13 +10,17 @@
|
||||
"type:check": "tsgo"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.3.0",
|
||||
"@internationalized/date": "^3.12.0",
|
||||
"@openrouter/sdk": "^0.11.2",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"better-auth": "^1.6.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -26,7 +30,7 @@
|
||||
"framer-motion": "^12.38.0",
|
||||
"ical.js": "^2.2.1",
|
||||
"idb": "^8.0.3",
|
||||
"lucide-react": "^0.539.0",
|
||||
"lucide-react": "^1.8.0",
|
||||
"nanoid": "^5.1.5",
|
||||
"next": "15.4.10",
|
||||
"next-themes": "^0.4.6",
|
||||
@@ -34,7 +38,8 @@
|
||||
"postgres": "^3.4.7",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "19.1.0",
|
||||
"react-day-picker": "^9.9.0",
|
||||
"react-aria-components": "^1.16.0",
|
||||
"react-day-picker": "^9.14.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-hook-form": "^7.66.0",
|
||||
"rrule": "^2.8.1",
|
||||
|
||||
@@ -2,6 +2,7 @@ import { headers } from "next/headers";
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { buildMultimodalMessages } from "@/lib/ai-event-messages";
|
||||
import { getAiDisabledMessage, isAdminAiEnabled } from "@/lib/ai-feature-flags";
|
||||
import { extractJsonFromText } from "@/lib/json-utils";
|
||||
import { openRouterClient } from "@/lib/openrouter-client";
|
||||
import { AiEventRequestSchema, AiEventResponseSchema } from "@/lib/types";
|
||||
@@ -86,6 +87,13 @@ const callMultimodal = async (
|
||||
};
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (!isAdminAiEnabled()) {
|
||||
return NextResponse.json(
|
||||
{ error: getAiDisabledMessage() },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
const session = await auth.api.getSession({
|
||||
headers: await headers(),
|
||||
});
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import { headers } from "next/headers";
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { getAiDisabledMessage, isAdminAiEnabled } from "@/lib/ai-feature-flags";
|
||||
import { openRouterClient } from "@/lib/openrouter-client";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (!isAdminAiEnabled()) {
|
||||
return NextResponse.json(
|
||||
{ error: getAiDisabledMessage() },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
const session = await auth.api.getSession({
|
||||
headers: await headers(),
|
||||
});
|
||||
|
||||
61
src/app/api/location-autocomplete/route.ts
Normal file
61
src/app/api/location-autocomplete/route.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
getGoogleMapsLocationCapability,
|
||||
getGoogleMapsServerApiKey,
|
||||
mapGooglePlacesSuggestions,
|
||||
} from "@/lib/google-maps";
|
||||
|
||||
const AUTOCOMPLETE_FIELD_MASK = [
|
||||
"suggestions.placePrediction.place",
|
||||
"suggestions.placePrediction.placeId",
|
||||
"suggestions.placePrediction.text.text",
|
||||
"suggestions.placePrediction.structuredFormat.mainText.text",
|
||||
"suggestions.placePrediction.structuredFormat.secondaryText.text",
|
||||
].join(",");
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const capability = getGoogleMapsLocationCapability();
|
||||
if (!capability.enabled) {
|
||||
return NextResponse.json({ suggestions: [] }, { status: 503 });
|
||||
}
|
||||
|
||||
const apiKey = getGoogleMapsServerApiKey();
|
||||
if (!apiKey) {
|
||||
return NextResponse.json({ suggestions: [] }, { status: 503 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const input = searchParams.get("input")?.trim();
|
||||
const sessionToken = searchParams.get("sessionToken")?.trim();
|
||||
|
||||
if (!input) {
|
||||
return NextResponse.json({ suggestions: [] });
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
"https://places.googleapis.com/v1/places:autocomplete",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Goog-Api-Key": apiKey,
|
||||
"X-Goog-FieldMask": AUTOCOMPLETE_FIELD_MASK,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
input,
|
||||
sessionToken: sessionToken || undefined,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json({ suggestions: [] }, { status: 502 });
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as Parameters<
|
||||
typeof mapGooglePlacesSuggestions
|
||||
>[0];
|
||||
return NextResponse.json({
|
||||
suggestions: mapGooglePlacesSuggestions(payload),
|
||||
});
|
||||
}
|
||||
44
src/app/demo/combined-date-picker/page.tsx
Normal file
44
src/app/demo/combined-date-picker/page.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { CombinedDatePickerDemo } from "@/components/ui/combined-date-picker-demo";
|
||||
|
||||
export default function CombinedDatePickerDemoPage() {
|
||||
return (
|
||||
<div className="mx-auto flex min-h-screen w-full max-w-4xl flex-col gap-6 px-4 py-10 sm:px-6 lg:px-8">
|
||||
<div className="flex flex-col gap-3">
|
||||
<Badge variant="outline" className="w-fit">
|
||||
Demo Route
|
||||
</Badge>
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-3xl font-semibold tracking-tight">
|
||||
Date & Time Picker
|
||||
</h1>
|
||||
<p className="max-w-2xl text-sm text-muted-foreground sm:text-base">
|
||||
Inline date input paired with a locale-aware time picker. The return
|
||||
calendar disables dates before departure, and time fields switch
|
||||
between 24-hour and 12-hour formats automatically.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Flight booking</CardTitle>
|
||||
<CardDescription>
|
||||
Departure and return with date constraints and locale-aware time
|
||||
validation.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CombinedDatePickerDemo />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -204,6 +204,56 @@
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.glass-surface {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
oklch(1 0 0 / 0.78),
|
||||
oklch(0.985 0.003 247 / 0.92)
|
||||
);
|
||||
border: 1px solid oklch(0.89 0.005 247 / 0.95);
|
||||
border-radius: calc(var(--radius) + 0.5rem);
|
||||
box-shadow: 0 10px 30px oklch(0.3 0.01 260 / 0.08);
|
||||
backdrop-filter: blur(18px) saturate(1.08);
|
||||
}
|
||||
.dark .glass-surface {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
oklch(0.23 0.015 265 / 0.72),
|
||||
oklch(0.18 0.012 265 / 0.88)
|
||||
);
|
||||
border-color: oklch(1 0 0 / 0.09);
|
||||
box-shadow: 0 18px 40px oklch(0 0 0 / 0.35);
|
||||
}
|
||||
.glass-panel {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
oklch(1 0 0 / 0.84),
|
||||
oklch(0.992 0.002 247 / 0.96)
|
||||
);
|
||||
border: 1px solid oklch(0.89 0.005 247 / 0.95);
|
||||
border-radius: calc(var(--radius) + 0.75rem);
|
||||
box-shadow: 0 14px 36px oklch(0.3 0.01 260 / 0.08);
|
||||
backdrop-filter: blur(20px) saturate(1.08);
|
||||
}
|
||||
.dark .glass-panel {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
oklch(0.22 0.014 265 / 0.78),
|
||||
oklch(0.17 0.012 265 / 0.9)
|
||||
);
|
||||
border-color: oklch(1 0 0 / 0.1);
|
||||
box-shadow: 0 22px 48px oklch(0 0 0 / 0.36);
|
||||
}
|
||||
.glass-subtle {
|
||||
background: oklch(0.98 0.003 247 / 0.72);
|
||||
border: 1px solid oklch(0.9 0.004 247 / 0.95);
|
||||
border-radius: calc(var(--radius) + 0.5rem);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
.dark .glass-subtle {
|
||||
background: oklch(0.25 0.012 265 / 0.42);
|
||||
border-color: oklch(1 0 0 / 0.08);
|
||||
}
|
||||
/* Light: subtle card with border; Dark: glass panel */
|
||||
.glass {
|
||||
background: oklch(1 0 0 / 0.7);
|
||||
@@ -216,16 +266,24 @@
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
.glass-card {
|
||||
background: oklch(0.995 0.001 247);
|
||||
border: 1px solid oklch(0.9 0.005 247);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 1px 3px oklch(0.3 0.01 260 / 0.06);
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
oklch(1 0 0 / 0.72),
|
||||
oklch(0.99 0.002 247 / 0.94)
|
||||
);
|
||||
border: 1px solid oklch(0.9 0.005 247 / 0.95);
|
||||
border-radius: calc(var(--radius) + 0.25rem);
|
||||
box-shadow: 0 8px 20px oklch(0.3 0.01 260 / 0.06);
|
||||
backdrop-filter: blur(16px) saturate(1.06);
|
||||
}
|
||||
.dark .glass-card {
|
||||
backdrop-filter: blur(16px);
|
||||
background: oklch(1 0 0 / 0.05);
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
oklch(0.24 0.015 265 / 0.52),
|
||||
oklch(0.18 0.012 265 / 0.72)
|
||||
);
|
||||
border-color: oklch(1 0 0 / 0.08);
|
||||
box-shadow: none;
|
||||
box-shadow: 0 14px 32px oklch(0 0 0 / 0.26);
|
||||
}
|
||||
.glass-strong {
|
||||
background: oklch(0.995 0.001 247 / 0.97);
|
||||
@@ -240,3 +298,10 @@
|
||||
box-shadow: 0 4px 20px oklch(0 0 0 / 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
@utility scrollbar-none {
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
139
src/app/page.tsx
139
src/app/page.tsx
@@ -10,10 +10,22 @@ import { EventDialog } from "@/components/event-dialog";
|
||||
import { EventsList } from "@/components/events-list";
|
||||
import { IcsFilePicker } from "@/components/ics-file-picker";
|
||||
import { ModeToggle } from "@/components/mode-toggle";
|
||||
import { SettingsPanel } from "@/components/settings-panel";
|
||||
import SignIn from "@/components/sign-in";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getAiCreateOutcome } from "@/lib/ai-create-flow";
|
||||
import {
|
||||
getAiDisabledMessage,
|
||||
isClientAiEnabled,
|
||||
} from "@/lib/ai-feature-flags";
|
||||
import { useSession } from "@/lib/auth-client";
|
||||
import { MAX_IMAGE_SIZE_BYTES } from "@/lib/constants";
|
||||
import {
|
||||
type EventFormValues,
|
||||
getDefaultEventFormValues,
|
||||
getEventFormValuesFromEvent,
|
||||
} from "@/lib/event-form";
|
||||
import {
|
||||
saveEvent as addEvent,
|
||||
clearEvents,
|
||||
@@ -23,12 +35,24 @@ import {
|
||||
} from "@/lib/events-db";
|
||||
import { generateICS, parseICS } from "@/lib/ical";
|
||||
import { appendImagesDeduped } from "@/lib/multi-image";
|
||||
import {
|
||||
getDefaultEventFormValues,
|
||||
getEventFormValuesFromEvent,
|
||||
type EventFormValues,
|
||||
} from "@/lib/event-form";
|
||||
import type { CalendarEvent } from "@/lib/types";
|
||||
import {
|
||||
APP_ACTION_BAR_CLASSES,
|
||||
APP_HEADER_SURFACE_CLASSES,
|
||||
APP_NAV_SURFACE_CLASSES,
|
||||
APP_SECTION_SURFACE_CLASSES,
|
||||
getConnectionBadgeClasses,
|
||||
} from "@/lib/ui-shell-contract";
|
||||
import { useUserSettings } from "@/lib/user-settings";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const APP_FRAME_CLASSES =
|
||||
"mx-auto flex min-h-screen w-full max-w-3xl flex-col px-4 pb-24 pt-4 sm:px-6 lg:px-8";
|
||||
|
||||
const NAV_BUTTON_CLASSES = "flex-1 gap-2";
|
||||
|
||||
const getNavButtonClasses = (isActive: boolean) =>
|
||||
cn(NAV_BUTTON_CLASSES, isActive ? "text-primary" : "text-muted-foreground");
|
||||
|
||||
const fileToBase64 = (file: File): Promise<string> =>
|
||||
new Promise((resolve, reject) => {
|
||||
@@ -46,6 +70,7 @@ const validateImageFile = (file: File): string | null => {
|
||||
};
|
||||
|
||||
export default function HomePage() {
|
||||
const [activeView, setActiveView] = useState<"list" | "settings">("list");
|
||||
const [events, setEvents] = useState<CalendarEvent[]>([]);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
@@ -53,9 +78,8 @@ export default function HomePage() {
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [isOnline, setIsOnline] = useState(true);
|
||||
|
||||
const [dialogInitialValues, setDialogInitialValues] = useState<EventFormValues>(
|
||||
getDefaultEventFormValues(),
|
||||
);
|
||||
const [dialogInitialValues, setDialogInitialValues] =
|
||||
useState<EventFormValues>(getDefaultEventFormValues());
|
||||
|
||||
// AI
|
||||
const [aiPrompt, setAiPrompt] = useState("");
|
||||
@@ -70,6 +94,9 @@ export default function HomePage() {
|
||||
const [imageFiles, setImageFiles] = useState<File[]>([]);
|
||||
const [imageBase64s, setImageBase64s] = useState<string[]>([]);
|
||||
const [imagePreviews, setImagePreviews] = useState<string[]>([]);
|
||||
const { hasLoadedSettings, settings, updateSettings } = useUserSettings();
|
||||
const adminAiEnabled = isClientAiEnabled();
|
||||
const canUseAi = adminAiEnabled && settings.aiEnabled;
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
@@ -234,6 +261,14 @@ export default function HomePage() {
|
||||
const runAiCreate = async (promptOverride?: string) => {
|
||||
const nextPrompt = promptOverride?.trim() ?? aiPrompt.trim();
|
||||
if (!nextPrompt && imageBase64s.length === 0) return;
|
||||
if (!canUseAi) {
|
||||
toast.error(
|
||||
adminAiEnabled
|
||||
? "AI is turned off in Settings."
|
||||
: getAiDisabledMessage(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (promptOverride) {
|
||||
setAiPrompt(nextPrompt);
|
||||
@@ -260,13 +295,21 @@ export default function HomePage() {
|
||||
throw new Error("Please sign in to use AI features.");
|
||||
}
|
||||
|
||||
if (res.status === 403) {
|
||||
const errorBody = (await res.json()) as { error?: string };
|
||||
throw new Error(errorBody.error ?? getAiDisabledMessage());
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!Array.isArray(data) || data.length === 0) {
|
||||
throw new Error("AI did not return event data.");
|
||||
}
|
||||
|
||||
if (data.length === 1) {
|
||||
if (
|
||||
getAiCreateOutcome(data.length, settings.skipAiReview) ===
|
||||
"review-single"
|
||||
) {
|
||||
populateEventForm(data[0]);
|
||||
setDialogSource("ai");
|
||||
setAiPrompt("");
|
||||
@@ -277,7 +320,11 @@ export default function HomePage() {
|
||||
|
||||
await persistAiEvents(data);
|
||||
setAiPrompt("");
|
||||
setSummary(`Added ${data.length} AI-generated events.`);
|
||||
setSummary(
|
||||
data.length === 1
|
||||
? "Added 1 AI-generated event."
|
||||
: `Added ${data.length} AI-generated events.`,
|
||||
);
|
||||
setSummaryUpdated(new Date().toLocaleString());
|
||||
handleImagesClear();
|
||||
if (promptOverride) {
|
||||
@@ -302,6 +349,16 @@ export default function HomePage() {
|
||||
|
||||
// AI Summarize Events
|
||||
const handleAiSummarize = async () => {
|
||||
if (!canUseAi) {
|
||||
setSummary(
|
||||
adminAiEnabled
|
||||
? "AI is turned off in Settings."
|
||||
: getAiDisabledMessage(),
|
||||
);
|
||||
setSummaryUpdated(new Date().toLocaleString());
|
||||
return;
|
||||
}
|
||||
|
||||
if (!events.length) {
|
||||
setSummary("No events to summarize.");
|
||||
setSummaryUpdated(new Date().toLocaleString());
|
||||
@@ -314,6 +371,20 @@ export default function HomePage() {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ events }),
|
||||
});
|
||||
|
||||
if (res.status === 401) {
|
||||
setSummary("Please sign in to use AI features.");
|
||||
setSummaryUpdated(new Date().toLocaleString());
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.status === 403) {
|
||||
const errorBody = (await res.json()) as { error?: string };
|
||||
setSummary(errorBody.error ?? getAiDisabledMessage());
|
||||
setSummaryUpdated(new Date().toLocaleString());
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
if (data.summary) {
|
||||
setSummary(data.summary);
|
||||
@@ -344,8 +415,8 @@ export default function HomePage() {
|
||||
onImport={handleImport}
|
||||
onImageDrop={(file) => handleImagesSelect([file])}
|
||||
>
|
||||
<div className="mx-auto flex min-h-screen w-full max-w-3xl flex-col px-4 pb-24 pt-4 sm:px-6 lg:px-8">
|
||||
<header className="mb-4 flex items-center justify-between rounded-2xl border border-border/70 bg-background/80 px-4 py-3 shadow-sm backdrop-blur-sm">
|
||||
<div className={APP_FRAME_CLASSES}>
|
||||
<header className={APP_HEADER_SURFACE_CLASSES}>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-medium uppercase tracking-[0.22em] text-muted-foreground">
|
||||
Offline-first iCal editor
|
||||
@@ -354,22 +425,35 @@ export default function HomePage() {
|
||||
LocalCal
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="inline-flex items-center gap-1.5 rounded-full border border-border/70 bg-muted/50 px-2.5 py-1 text-xs text-muted-foreground">
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={getConnectionBadgeClasses(isOnline)}
|
||||
>
|
||||
{isOnline ? (
|
||||
<Wifi className="h-3 w-3" />
|
||||
) : (
|
||||
<WifiOff className="h-3 w-3" />
|
||||
)}
|
||||
<span>{isOnline ? "Online ready" : "Offline mode"}</span>
|
||||
</div>
|
||||
</Badge>
|
||||
<SignIn />
|
||||
<ModeToggle />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 space-y-4">
|
||||
<section className="rounded-[1.5rem] border border-border/70 bg-card/95 p-4 shadow-sm sm:p-5">
|
||||
{activeView === "settings" ? (
|
||||
<SettingsPanel
|
||||
adminAiEnabled={adminAiEnabled}
|
||||
className={APP_SECTION_SURFACE_CLASSES}
|
||||
hasLoadedSettings={hasLoadedSettings}
|
||||
onSettingsChange={updateSettings}
|
||||
settings={settings}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<section className={APP_SECTION_SURFACE_CLASSES}>
|
||||
<div className="mb-4 flex items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-primary">
|
||||
@@ -379,13 +463,15 @@ export default function HomePage() {
|
||||
Paste details. Generate draft. Review before saving.
|
||||
</h2>
|
||||
<p className="max-w-2xl text-sm leading-relaxed text-muted-foreground">
|
||||
Type or paste a natural-language description, then generate a
|
||||
draft event for review in the event modal.
|
||||
Type or paste a natural-language description, then
|
||||
generate a draft event for review in the event modal.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AIToolbar
|
||||
adminAiEnabled={adminAiEnabled}
|
||||
aiEnabled={settings.aiEnabled}
|
||||
isAuthenticated={!!session?.user}
|
||||
isPending={isPending}
|
||||
aiPrompt={aiPrompt}
|
||||
@@ -404,7 +490,7 @@ export default function HomePage() {
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[1.5rem] border border-border/70 bg-card/95 p-4 shadow-sm sm:p-5">
|
||||
<section className={APP_SECTION_SURFACE_CLASSES}>
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-muted-foreground">
|
||||
@@ -419,7 +505,7 @@ export default function HomePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 rounded-2xl border border-border/70 bg-muted/35 p-3">
|
||||
<div className={APP_ACTION_BAR_CLASSES}>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<IcsFilePicker
|
||||
onFileSelect={handleImport}
|
||||
@@ -475,13 +561,17 @@ export default function HomePage() {
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<nav className="fixed inset-x-4 bottom-4 mx-auto flex max-w-3xl items-center justify-between rounded-2xl border border-border/70 bg-background/90 px-3 py-2 shadow-lg backdrop-blur-sm sm:inset-x-6 lg:inset-x-8">
|
||||
<nav className={APP_NAV_SURFACE_CLASSES}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="flex-1 gap-2 text-primary"
|
||||
className={getNavButtonClasses(activeView === "list")}
|
||||
aria-pressed={activeView === "list"}
|
||||
onClick={() => setActiveView("list")}
|
||||
>
|
||||
<CalendarDays className="h-4 w-4" />
|
||||
List
|
||||
@@ -498,8 +588,9 @@ export default function HomePage() {
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="flex-1 gap-2 text-muted-foreground"
|
||||
disabled
|
||||
className={getNavButtonClasses(activeView === "settings")}
|
||||
aria-pressed={activeView === "settings"}
|
||||
onClick={() => setActiveView("settings")}
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
Settings
|
||||
|
||||
@@ -71,6 +71,8 @@ function ShortcutsList({ os }: { os: Os }) {
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface AIToolbarProps {
|
||||
adminAiEnabled: boolean;
|
||||
aiEnabled: boolean;
|
||||
isAuthenticated: boolean;
|
||||
isPending: boolean;
|
||||
aiPrompt: string;
|
||||
@@ -94,6 +96,8 @@ interface AIToolbarProps {
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export const AIToolbar = ({
|
||||
adminAiEnabled,
|
||||
aiEnabled,
|
||||
isAuthenticated,
|
||||
isPending,
|
||||
aiPrompt,
|
||||
@@ -255,10 +259,28 @@ export const AIToolbar = ({
|
||||
}
|
||||
|
||||
const hasImages = imagePreviews.length > 0;
|
||||
const canUseAi = adminAiEnabled && aiEnabled;
|
||||
const showDisabledState = isAuthenticated && !canUseAi;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{isAuthenticated ? (
|
||||
{showDisabledState ? (
|
||||
<div className="flex items-center gap-3 py-2">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium leading-tight text-foreground">
|
||||
AI integrations are unavailable
|
||||
</p>
|
||||
<p className="mt-0.5 text-sm leading-relaxed text-muted-foreground">
|
||||
{adminAiEnabled
|
||||
? "AI has been turned off in this browser from Settings."
|
||||
: "AI integrations are currently disabled by the administrator."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : isAuthenticated ? (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-2xl border border-border/70 bg-background/90 shadow-sm focus-within:ring-2 focus-within:ring-primary/30">
|
||||
<Textarea
|
||||
@@ -316,7 +338,7 @@ export const AIToolbar = ({
|
||||
size="sm"
|
||||
className="h-7 max-w-full rounded-full px-2.5 text-[11px]"
|
||||
onClick={() => onAiTemplateSelect(prompt)}
|
||||
disabled={aiLoading}
|
||||
disabled={aiLoading || !canUseAi}
|
||||
>
|
||||
<span className="truncate">{prompt}</span>
|
||||
</Button>
|
||||
@@ -371,7 +393,7 @@ export const AIToolbar = ({
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<ImagePicker
|
||||
onFilesSelect={onImagesSelect}
|
||||
disabled={aiLoading}
|
||||
disabled={aiLoading || !canUseAi}
|
||||
multiple
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -425,7 +447,7 @@ export const AIToolbar = ({
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onAiSummarize}
|
||||
disabled={aiLoading}
|
||||
disabled={aiLoading || !canUseAi}
|
||||
className="h-9 gap-1.5 rounded-xl px-3 text-xs text-muted-foreground hover:text-primary"
|
||||
>
|
||||
<Bot className="h-3 w-3" />
|
||||
@@ -437,7 +459,9 @@ export const AIToolbar = ({
|
||||
size="sm"
|
||||
className="h-10 gap-1.5 rounded-xl px-4 text-xs"
|
||||
onClick={onAiCreate}
|
||||
disabled={aiLoading || (!aiPrompt.trim() && !hasImages)}
|
||||
disabled={
|
||||
aiLoading || !canUseAi || (!aiPrompt.trim() && !hasImages)
|
||||
}
|
||||
>
|
||||
{aiLoading ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
@@ -456,11 +480,11 @@ export const AIToolbar = ({
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-foreground leading-tight">
|
||||
Generate event drafts with AI
|
||||
Sign in required to generate event drafts with AI
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Paste natural language or a flyer, then review the filled event
|
||||
before saving.
|
||||
Sign in to turn natural language or flyers into event drafts, then
|
||||
review or save them from your calendar workflow.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -18,13 +18,6 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface DateTimePickerProps {
|
||||
@@ -35,6 +28,14 @@ interface DateTimePickerProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
type QuickShortcut = "Today" | "Next week" | "Next month";
|
||||
|
||||
interface QuickShortcutResult {
|
||||
keepOpen: true;
|
||||
nextMonth: Date;
|
||||
nextValue: string;
|
||||
}
|
||||
|
||||
/** Parse the incoming ISO / date string into a Date object, or return undefined. */
|
||||
function parseValue(value: string): Date | undefined {
|
||||
if (!value) return undefined;
|
||||
@@ -65,8 +66,55 @@ function buildValue(
|
||||
return `${format(date, "yyyy-MM-dd")}T${hh}:${mm}:00`;
|
||||
}
|
||||
|
||||
const HOURS = Array.from({ length: 24 }, (_, i) => i);
|
||||
const MINUTES = [0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55];
|
||||
function resolveQuickShortcutDate(shortcut: QuickShortcut, now: Date): Date {
|
||||
const today = startOfDay(now);
|
||||
|
||||
if (shortcut === "Today") return today;
|
||||
if (shortcut === "Next week") return startOfDay(addWeeks(today, 1));
|
||||
|
||||
return startOfDay(addMonths(today, 1));
|
||||
}
|
||||
|
||||
function getTimeParts(value: string, allDay: boolean) {
|
||||
const parsed = parseValue(value);
|
||||
|
||||
if (!parsed || allDay) {
|
||||
return { hour: 0, minute: 0 };
|
||||
}
|
||||
|
||||
return {
|
||||
hour: parsed.getHours(),
|
||||
minute: parsed.getMinutes(),
|
||||
};
|
||||
}
|
||||
|
||||
export function getCalendarMonthForValue(
|
||||
value: string,
|
||||
fallbackDate: Date,
|
||||
): Date {
|
||||
return parseValue(value) ?? fallbackDate;
|
||||
}
|
||||
|
||||
export function applyQuickDateShortcut({
|
||||
shortcut,
|
||||
value,
|
||||
allDay,
|
||||
now,
|
||||
}: {
|
||||
shortcut: QuickShortcut;
|
||||
value: string;
|
||||
allDay: boolean;
|
||||
now: Date;
|
||||
}): QuickShortcutResult {
|
||||
const nextMonth = resolveQuickShortcutDate(shortcut, now);
|
||||
const { hour, minute } = getTimeParts(value, allDay);
|
||||
|
||||
return {
|
||||
keepOpen: true,
|
||||
nextMonth,
|
||||
nextValue: buildValue(nextMonth, hour, minute, allDay),
|
||||
};
|
||||
}
|
||||
|
||||
export function DateTimePicker({
|
||||
value,
|
||||
@@ -76,46 +124,41 @@ export function DateTimePicker({
|
||||
className,
|
||||
}: DateTimePickerProps) {
|
||||
const parsed = parseValue(value);
|
||||
|
||||
// Derive hour/minute from the current value (fallback to 0:00)
|
||||
const currentHour = parsed && !allDay ? parsed.getHours() : 0;
|
||||
const currentMinute = parsed && !allDay ? parsed.getMinutes() : 0;
|
||||
|
||||
// Snap current minute to nearest MINUTES bucket for the select
|
||||
const snappedMinute = MINUTES.reduce((prev, curr) =>
|
||||
Math.abs(curr - currentMinute) < Math.abs(prev - currentMinute)
|
||||
? curr
|
||||
: prev,
|
||||
const { hour: currentHour, minute: currentMinute } = getTimeParts(
|
||||
value,
|
||||
allDay,
|
||||
);
|
||||
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [visibleMonth, setVisibleMonth] = React.useState(() =>
|
||||
getCalendarMonthForValue(value, new Date()),
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
setVisibleMonth(getCalendarMonthForValue(value, new Date()));
|
||||
}, [value]);
|
||||
|
||||
const handleDaySelect = (day: Date | undefined) => {
|
||||
if (!day) return;
|
||||
onChange(buildValue(day, currentHour, snappedMinute, allDay));
|
||||
onChange(buildValue(day, currentHour, currentMinute, allDay));
|
||||
setVisibleMonth(day);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleHourChange = (h: string) => {
|
||||
const base = parsed ?? new Date();
|
||||
onChange(buildValue(base, Number(h), snappedMinute, allDay));
|
||||
const handleQuickSelect = (shortcut: QuickShortcut) => {
|
||||
const result = applyQuickDateShortcut({
|
||||
shortcut,
|
||||
value,
|
||||
allDay,
|
||||
now: new Date(),
|
||||
});
|
||||
|
||||
onChange(result.nextValue);
|
||||
setVisibleMonth(result.nextMonth);
|
||||
setOpen(result.keepOpen);
|
||||
};
|
||||
|
||||
const handleMinuteChange = (m: string) => {
|
||||
const base = parsed ?? new Date();
|
||||
onChange(buildValue(base, currentHour, Number(m), allDay));
|
||||
};
|
||||
|
||||
const handleQuickSelect = (day: Date) => {
|
||||
onChange(buildValue(day, currentHour, snappedMinute, allDay));
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const quickOptions: { label: string; date: Date }[] = [
|
||||
{ label: "Today", date: startOfDay(new Date()) },
|
||||
{ label: "Next week", date: startOfDay(addWeeks(new Date(), 1)) },
|
||||
{ label: "Next month", date: startOfDay(addMonths(new Date(), 1)) },
|
||||
];
|
||||
const quickOptions: QuickShortcut[] = ["Today", "Next week", "Next month"];
|
||||
|
||||
const displayLabel = parsed
|
||||
? allDay
|
||||
@@ -124,7 +167,7 @@ export function DateTimePicker({
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center gap-2", className)}>
|
||||
<div className={cn("w-full", className)}>
|
||||
{/* Date popover trigger */}
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
@@ -142,15 +185,19 @@ export function DateTimePicker({
|
||||
</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start" sideOffset={8}>
|
||||
<div className="flex gap-1 border-b border-border px-3 py-2">
|
||||
{quickOptions.map(({ label, date }) => (
|
||||
<PopoverContent
|
||||
className="w-[min(22rem,calc(100vw-2rem))] p-0"
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
>
|
||||
<div className="flex flex-wrap gap-1 border-b border-border px-3 py-2">
|
||||
{quickOptions.map((label) => (
|
||||
<Button
|
||||
key={label}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleQuickSelect(date)}
|
||||
onClick={() => handleQuickSelect(label)}
|
||||
className="h-auto px-2 py-1 text-xs text-muted-foreground"
|
||||
>
|
||||
{label}
|
||||
@@ -159,46 +206,14 @@ export function DateTimePicker({
|
||||
</div>
|
||||
<Calendar
|
||||
mode="single"
|
||||
month={visibleMonth}
|
||||
onMonthChange={setVisibleMonth}
|
||||
selected={parsed}
|
||||
onSelect={handleDaySelect}
|
||||
initialFocus
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Time selects — only when !allDay */}
|
||||
{!allDay && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Select value={String(currentHour)} onValueChange={handleHourChange}>
|
||||
<SelectTrigger className="w-[62px] px-2 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-56">
|
||||
{HOURS.map((h) => (
|
||||
<SelectItem key={h} value={String(h)} className="text-xs">
|
||||
{String(h).padStart(2, "0")}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-muted-foreground text-xs select-none">:</span>
|
||||
<Select
|
||||
value={String(snappedMinute)}
|
||||
onValueChange={handleMinuteChange}
|
||||
>
|
||||
<SelectTrigger className="w-[62px] px-2 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MINUTES.map((m) => (
|
||||
<SelectItem key={m} value={String(m)} className="text-xs">
|
||||
{String(m).padStart(2, "0")}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,9 @@ interface EventCardProps {
|
||||
onDelete: (eventId: string) => void;
|
||||
}
|
||||
|
||||
export const EVENT_CARD_SURFACE_CLASSES =
|
||||
"glass-card group cursor-pointer p-4 transition-[background-color,border-color,transform] duration-150 hover:-translate-y-0.5 hover:bg-accent/30 hover:border-primary/15";
|
||||
|
||||
export const EventCard = ({ event, onEdit, onDelete }: EventCardProps) => {
|
||||
const handleEdit = () => {
|
||||
onEdit({
|
||||
@@ -50,10 +53,12 @@ export const EventCard = ({ event, onEdit, onDelete }: EventCardProps) => {
|
||||
exit={{ opacity: 0, y: -8, transition: { duration: 0.15 } }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<div className="glass-card group cursor-pointer p-4 transition-colors duration-150 hover:bg-accent/50">
|
||||
<div className={EVENT_CARD_SURFACE_CLASSES}>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="min-w-0 flex-1 space-y-1.5">
|
||||
<h3 className="truncate text-sm font-medium leading-snug">{event.title}</h3>
|
||||
<h3 className="truncate text-sm font-medium leading-snug">
|
||||
{event.title}
|
||||
</h3>
|
||||
|
||||
{event.description && (
|
||||
<p className="line-clamp-2 text-xs leading-relaxed text-muted-foreground">
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
import { addHours, addMinutes, isValid, parseISO } from "date-fns";
|
||||
import { useEffect } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { LucideMapPin } from "lucide-react";
|
||||
import { DateTimePicker } from "@/components/date-time-picker";
|
||||
import { LocationAutocomplete } from "@/components/location-autocomplete";
|
||||
import { RecurrencePicker } from "@/components/recurrence-picker";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
@@ -19,12 +19,12 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { validateRecurrence, parseRecurrenceRule } from "@/lib/recurrence";
|
||||
import {
|
||||
type EventFormValues,
|
||||
getDefaultEventFormValues,
|
||||
validateEventFormValues,
|
||||
type EventFormValues,
|
||||
} from "@/lib/event-form";
|
||||
import { parseRecurrenceRule, validateRecurrence } from "@/lib/recurrence";
|
||||
|
||||
interface EventDialogProps {
|
||||
open: boolean;
|
||||
@@ -46,7 +46,11 @@ export const EventDialog = ({
|
||||
onReset,
|
||||
}: EventDialogProps) => {
|
||||
const isAiDraft = dialogSource === "ai" && !editingId;
|
||||
const titleText = editingId ? "Edit Event" : isAiDraft ? "Review AI Draft" : "New Event";
|
||||
const titleText = editingId
|
||||
? "Edit Event"
|
||||
: isAiDraft
|
||||
? "Review AI Draft"
|
||||
: "New Event";
|
||||
const descriptionText = editingId
|
||||
? "Update the event details below. Title and start date are required."
|
||||
: isAiDraft
|
||||
@@ -89,11 +93,16 @@ export const EventDialog = ({
|
||||
{ label: "+3 hours", minutes: 180 },
|
||||
];
|
||||
|
||||
const handleApplyDuration = (minutes: number, currentAllDay: boolean, currentStart: string) => {
|
||||
const handleApplyDuration = (
|
||||
minutes: number,
|
||||
currentAllDay: boolean,
|
||||
currentStart: string,
|
||||
) => {
|
||||
if (!currentStart) return;
|
||||
const base = parseISO(currentStart);
|
||||
if (!isValid(base)) return;
|
||||
const next = minutes < 60 ? addMinutes(base, minutes) : addHours(base, minutes / 60);
|
||||
const next =
|
||||
minutes < 60 ? addMinutes(base, minutes) : addHours(base, minutes / 60);
|
||||
const pad = (value: number) => String(value).padStart(2, "0");
|
||||
const result = currentAllDay
|
||||
? `${next.getFullYear()}-${pad(next.getMonth() + 1)}-${pad(next.getDate())}`
|
||||
@@ -108,14 +117,18 @@ export const EventDialog = ({
|
||||
for (const [fieldName, messages] of Object.entries(fieldErrors)) {
|
||||
const firstMessage = messages?.[0];
|
||||
if (firstMessage) {
|
||||
setError(fieldName as keyof EventFormValues, { message: firstMessage });
|
||||
setError(fieldName as keyof EventFormValues, {
|
||||
message: firstMessage,
|
||||
});
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (values.recurrenceRule) {
|
||||
const recurrenceValidation = validateRecurrence(parseRecurrenceRule(values.recurrenceRule));
|
||||
const recurrenceValidation = validateRecurrence(
|
||||
parseRecurrenceRule(values.recurrenceRule),
|
||||
);
|
||||
if (!recurrenceValidation.isValid) {
|
||||
setError("recurrenceRule", {
|
||||
message:
|
||||
@@ -143,14 +156,22 @@ export const EventDialog = ({
|
||||
<form className="space-y-3" onSubmit={onSubmit}>
|
||||
{isAiDraft && (
|
||||
<div className="rounded-md border border-primary/20 bg-primary/5 px-3 py-2 text-xs leading-relaxed text-primary">
|
||||
This draft was generated from natural language. Double-check dates, times, location, recurrence, and links before saving.
|
||||
This draft was generated from natural language. Double-check
|
||||
dates, times, location, recurrence, and links before saving.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="event-title">Title</Label>
|
||||
<Input id="event-title" placeholder="Event title" className="font-medium" {...register("title")} />
|
||||
{errors.title && <p className="text-xs text-destructive">{errors.title.message}</p>}
|
||||
<Input
|
||||
id="event-title"
|
||||
placeholder="Event title"
|
||||
className="font-medium"
|
||||
{...register("title")}
|
||||
/>
|
||||
{errors.title && (
|
||||
<p className="text-xs text-destructive">{errors.title.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
@@ -163,18 +184,27 @@ export const EventDialog = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="event-location">Location</Label>
|
||||
<div className="relative">
|
||||
<LucideMapPin className="absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground/50" />
|
||||
<Input id="event-location" placeholder="Location" className="pl-8" {...register("location")} />
|
||||
</div>
|
||||
<Controller
|
||||
name="location"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<LocationAutocomplete
|
||||
id="event-location"
|
||||
onChange={field.onChange}
|
||||
value={field.value}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="event-url">URL</Label>
|
||||
<Input id="event-url" placeholder="URL" {...register("url")} />
|
||||
{errors.url && <p className="text-xs text-destructive">{errors.url.message}</p>}
|
||||
{errors.url && (
|
||||
<p className="text-xs text-destructive">{errors.url.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -182,11 +212,17 @@ export const EventDialog = ({
|
||||
name="recurrenceRule"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<RecurrencePicker value={field.value} onChange={field.onChange} start={start} />
|
||||
<RecurrencePicker
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
start={start}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors.recurrenceRule && (
|
||||
<p className="text-xs text-destructive">{errors.recurrenceRule.message}</p>
|
||||
<p className="text-xs text-destructive">
|
||||
{errors.recurrenceRule.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 py-1">
|
||||
@@ -197,11 +233,16 @@ export const EventDialog = ({
|
||||
<Checkbox
|
||||
id="event-all-day"
|
||||
checked={field.value}
|
||||
onCheckedChange={(checked) => field.onChange(checked === true)}
|
||||
onCheckedChange={(checked) =>
|
||||
field.onChange(checked === true)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Label htmlFor="event-all-day" className="cursor-pointer text-sm font-normal">
|
||||
<Label
|
||||
htmlFor="event-all-day"
|
||||
className="cursor-pointer text-sm font-normal"
|
||||
>
|
||||
All day
|
||||
</Label>
|
||||
</div>
|
||||
@@ -211,7 +252,12 @@ export const EventDialog = ({
|
||||
name="start"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<DateTimePicker value={field.value} onChange={field.onChange} allDay={allDay} placeholder="Start date" />
|
||||
<DateTimePicker
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
allDay={allDay}
|
||||
placeholder="Start date"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{!allDay && (
|
||||
@@ -227,7 +273,9 @@ export const EventDialog = ({
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={!start}
|
||||
onClick={() => handleApplyDuration(minutes, allDay, start)}
|
||||
onClick={() =>
|
||||
handleApplyDuration(minutes, allDay, start)
|
||||
}
|
||||
className="px-2 py-1 text-xs text-muted-foreground"
|
||||
>
|
||||
{label}
|
||||
@@ -241,15 +289,28 @@ export const EventDialog = ({
|
||||
name="end"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<DateTimePicker value={field.value} onChange={field.onChange} allDay={allDay} placeholder="End date" />
|
||||
<DateTimePicker
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
allDay={allDay}
|
||||
placeholder="End date"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors.start && <p className="text-xs text-destructive">{errors.start.message}</p>}
|
||||
{errors.end && <p className="text-xs text-destructive">{errors.end.message}</p>}
|
||||
{errors.start && (
|
||||
<p className="text-xs text-destructive">{errors.start.message}</p>
|
||||
)}
|
||||
{errors.end && (
|
||||
<p className="text-xs text-destructive">{errors.end.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button type="button" variant="ghost" onClick={() => handleOpenChange(false)}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => handleOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit">{saveLabel}</Button>
|
||||
|
||||
222
src/components/location-autocomplete.tsx
Normal file
222
src/components/location-autocomplete.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
"use client";
|
||||
|
||||
import { LucideMapPin } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
type GoogleMapsLocationCapability,
|
||||
type GoogleMapsSuggestion,
|
||||
getGoogleMapsLocationCapability,
|
||||
getGoogleMapsPlaceLabel,
|
||||
getLocationAutocompletePlaceholder,
|
||||
} from "@/lib/google-maps";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface LocationAutocompleteProps {
|
||||
capability?: GoogleMapsLocationCapability;
|
||||
className?: string;
|
||||
id: string;
|
||||
onChange: (value: string) => void;
|
||||
value: string;
|
||||
}
|
||||
|
||||
const SEARCH_HELP_TEXT = "Search Google Maps or keep typing a custom location.";
|
||||
const SEARCH_UNAVAILABLE_TEXT =
|
||||
"Google Maps search is unavailable right now. You can still type a location.";
|
||||
|
||||
export const LocationAutocomplete = ({
|
||||
capability,
|
||||
className,
|
||||
id,
|
||||
onChange,
|
||||
value,
|
||||
}: LocationAutocompleteProps) => {
|
||||
const resolvedCapability = capability ?? getGoogleMapsLocationCapability();
|
||||
|
||||
if (!resolvedCapability.enabled) {
|
||||
return (
|
||||
<ManualLocationInput
|
||||
className={className}
|
||||
id={id}
|
||||
onChange={onChange}
|
||||
placeholder={getLocationAutocompletePlaceholder(resolvedCapability)}
|
||||
value={value}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ServerLocationInput
|
||||
capability={resolvedCapability}
|
||||
className={className}
|
||||
id={id}
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const ManualLocationInput = ({
|
||||
className,
|
||||
id,
|
||||
onChange,
|
||||
placeholder,
|
||||
value,
|
||||
}: Omit<LocationAutocompleteProps, "capability"> & { placeholder: string }) => {
|
||||
return (
|
||||
<div className="space-y-1.5" data-location-mode="manual">
|
||||
<div className="relative">
|
||||
<LucideMapPin className="absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground/50" />
|
||||
<Input
|
||||
className={cn("pl-8", className)}
|
||||
id={id}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ServerLocationInput = ({
|
||||
capability,
|
||||
className,
|
||||
id,
|
||||
onChange,
|
||||
value,
|
||||
}: Omit<LocationAutocompleteProps, "capability"> & {
|
||||
capability: GoogleMapsLocationCapability;
|
||||
}) => {
|
||||
const [hasSearchError, setHasSearchError] = useState(false);
|
||||
const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false);
|
||||
const [suggestions, setSuggestions] = useState<GoogleMapsSuggestion[]>([]);
|
||||
const sessionTokenRef = useRef<string | null>(null);
|
||||
const skipNextLookupRef = useRef(false);
|
||||
|
||||
const helperText = useMemo(() => {
|
||||
return hasSearchError ? SEARCH_UNAVAILABLE_TEXT : SEARCH_HELP_TEXT;
|
||||
}, [hasSearchError]);
|
||||
|
||||
useEffect(() => {
|
||||
if (skipNextLookupRef.current) {
|
||||
skipNextLookupRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const query = value.trim();
|
||||
if (!query || hasSearchError) {
|
||||
setSuggestions([]);
|
||||
setIsLoadingSuggestions(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sessionTokenRef.current) {
|
||||
sessionTokenRef.current = crypto.randomUUID();
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
setIsLoadingSuggestions(true);
|
||||
|
||||
void fetch(
|
||||
`/api/location-autocomplete?input=${encodeURIComponent(query)}&sessionToken=${encodeURIComponent(sessionTokenRef.current)}`,
|
||||
{ signal: controller.signal },
|
||||
)
|
||||
.then(async (response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error("Autocomplete request failed");
|
||||
}
|
||||
|
||||
return (await response.json()) as {
|
||||
suggestions?: GoogleMapsSuggestion[];
|
||||
};
|
||||
})
|
||||
.then((payload) => {
|
||||
setSuggestions(payload.suggestions ?? []);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (controller.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.error("Location autocomplete failed", error);
|
||||
setHasSearchError(true);
|
||||
setSuggestions([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) {
|
||||
setIsLoadingSuggestions(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => controller.abort();
|
||||
}, [hasSearchError, value]);
|
||||
|
||||
const handleSuggestionSelect = (suggestion: GoogleMapsSuggestion) => {
|
||||
const nextValue = getGoogleMapsPlaceLabel({
|
||||
formattedAddress: suggestion.formattedAddress,
|
||||
name: suggestion.text,
|
||||
predictionText: suggestion.text,
|
||||
});
|
||||
|
||||
if (!nextValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
skipNextLookupRef.current = true;
|
||||
sessionTokenRef.current = null;
|
||||
setSuggestions([]);
|
||||
onChange(nextValue);
|
||||
};
|
||||
|
||||
const showSuggestions = suggestions.length > 0 && !hasSearchError;
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5" data-location-mode="google-maps-server">
|
||||
<div className="relative">
|
||||
<LucideMapPin className="absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground/50" />
|
||||
<Input
|
||||
aria-autocomplete="list"
|
||||
aria-expanded={showSuggestions}
|
||||
className={cn("pl-8", className)}
|
||||
id={id}
|
||||
onChange={(event) => {
|
||||
setHasSearchError(false);
|
||||
onChange(event.target.value);
|
||||
}}
|
||||
placeholder={getLocationAutocompletePlaceholder(capability)}
|
||||
role="combobox"
|
||||
value={value}
|
||||
/>
|
||||
{showSuggestions ? (
|
||||
<div className="absolute left-0 right-0 top-[calc(100%+0.375rem)] z-20 rounded-md border border-border/60 bg-background/95 p-1 shadow-lg backdrop-blur-sm">
|
||||
<div className="space-y-1" role="listbox">
|
||||
{suggestions.map((suggestion) => {
|
||||
const suggestionKey =
|
||||
suggestion.placeId || suggestion.text || "suggestion";
|
||||
|
||||
return (
|
||||
<div key={suggestionKey}>
|
||||
<button
|
||||
className="w-full rounded-sm px-3 py-2 text-left text-sm text-foreground transition hover:bg-accent hover:text-accent-foreground"
|
||||
onClick={() => handleSuggestionSelect(suggestion)}
|
||||
type="button"
|
||||
>
|
||||
{suggestion.text || "Use this place"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{helperText}</p>
|
||||
{isLoadingSuggestions ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Loading place suggestions…
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -15,11 +15,11 @@ import {
|
||||
getRecurrencePreview,
|
||||
getWeekdayOptions,
|
||||
parseRecurrenceRule,
|
||||
type RecurrenceFormValue,
|
||||
recurrenceFrequencyLabels,
|
||||
type SupportedRecurrenceFrequency,
|
||||
serializeRecurrenceRule,
|
||||
validateRecurrence,
|
||||
type RecurrenceFormValue,
|
||||
type SupportedRecurrenceFrequency,
|
||||
type Weekday,
|
||||
} from "@/lib/recurrence";
|
||||
|
||||
@@ -39,10 +39,7 @@ const getStartWeekday = (start?: string): Weekday => {
|
||||
return weekdays[jsDay] ?? "MO";
|
||||
};
|
||||
|
||||
const updateWeekdays = (
|
||||
current: Weekday[],
|
||||
day: Weekday,
|
||||
): Weekday[] => {
|
||||
const updateWeekdays = (current: Weekday[], day: Weekday): Weekday[] => {
|
||||
return current.includes(day)
|
||||
? current.filter((existingDay) => existingDay !== day)
|
||||
: [...current, day];
|
||||
@@ -95,11 +92,13 @@ export function RecurrencePicker({ value, start, onChange }: Props) {
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(recurrenceFrequencyLabels).map(([optionValue, label]) => (
|
||||
{Object.entries(recurrenceFrequencyLabels).map(
|
||||
([optionValue, label]) => (
|
||||
<SelectItem key={optionValue} value={optionValue}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
),
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -108,7 +107,12 @@ export function RecurrencePicker({ value, start, onChange }: Props) {
|
||||
<>
|
||||
<div>
|
||||
<Label htmlFor="interval" className="text-xs text-muted-foreground">
|
||||
Interval (every {recurrence.interval} {recurrence.freq === "DAILY" ? "day" : recurrence.freq === "WEEKLY" ? "week" : "month"}
|
||||
Interval (every {recurrence.interval}{" "}
|
||||
{recurrence.freq === "DAILY"
|
||||
? "day"
|
||||
: recurrence.freq === "WEEKLY"
|
||||
? "week"
|
||||
: "month"}
|
||||
{recurrence.interval > 1 ? "s" : ""})
|
||||
</Label>
|
||||
<Input
|
||||
@@ -117,7 +121,9 @@ export function RecurrencePicker({ value, start, onChange }: Props) {
|
||||
min={1}
|
||||
value={recurrence.interval}
|
||||
onChange={(event) =>
|
||||
update({ interval: Number.parseInt(event.target.value, 10) || 1 })
|
||||
update({
|
||||
interval: Number.parseInt(event.target.value, 10) || 1,
|
||||
})
|
||||
}
|
||||
className="mt-1.5 w-24"
|
||||
/>
|
||||
@@ -125,7 +131,9 @@ export function RecurrencePicker({ value, start, onChange }: Props) {
|
||||
|
||||
{recurrence.freq === "WEEKLY" && (
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">Days of the week</Label>
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Days of the week
|
||||
</Label>
|
||||
<div className="mt-1.5 flex flex-wrap gap-3">
|
||||
{weekdayOptions.map(({ value: day, label }) => (
|
||||
<div key={day} className="flex items-center gap-1.5">
|
||||
@@ -136,7 +144,10 @@ export function RecurrencePicker({ value, start, onChange }: Props) {
|
||||
update({ byDay: updateWeekdays(recurrence.byDay, day) })
|
||||
}
|
||||
/>
|
||||
<Label htmlFor={day} className="cursor-pointer text-xs font-normal">
|
||||
<Label
|
||||
htmlFor={day}
|
||||
className="cursor-pointer text-xs font-normal"
|
||||
>
|
||||
{label}
|
||||
</Label>
|
||||
</div>
|
||||
@@ -186,10 +197,14 @@ export function RecurrencePicker({ value, start, onChange }: Props) {
|
||||
</div>
|
||||
|
||||
{validation.errors.count && (
|
||||
<p className="text-xs text-destructive">{validation.errors.count}</p>
|
||||
<p className="text-xs text-destructive">
|
||||
{validation.errors.count}
|
||||
</p>
|
||||
)}
|
||||
{validation.errors.until && (
|
||||
<p className="text-xs text-destructive">{validation.errors.until}</p>
|
||||
<p className="text-xs text-destructive">
|
||||
{validation.errors.until}
|
||||
</p>
|
||||
)}
|
||||
{validation.errors.rule && (
|
||||
<p className="text-xs text-destructive">{validation.errors.rule}</p>
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { format, parseISO } from "date-fns";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { formatRecurrenceText, getRecurrencePreview, parseRecurrenceRule } from "@/lib/recurrence";
|
||||
import {
|
||||
formatRecurrenceText,
|
||||
getRecurrencePreview,
|
||||
parseRecurrenceRule,
|
||||
} from "@/lib/recurrence";
|
||||
|
||||
interface RRuleDisplayProps {
|
||||
rrule?: string;
|
||||
@@ -12,17 +16,25 @@ export function RRuleDisplay({ rrule, className, start }: RRuleDisplayProps) {
|
||||
if (!rrule) return null;
|
||||
|
||||
const humanText = formatRecurrenceText(rrule);
|
||||
const preview = start ? getRecurrencePreview(parseRecurrenceRule(rrule), start, 3) : [];
|
||||
const preview = start
|
||||
? getRecurrencePreview(parseRecurrenceRule(rrule), start, 3)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Badge variant="secondary" className="h-5 text-[10px] font-normal capitalize">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-5 text-[10px] font-normal capitalize"
|
||||
>
|
||||
{humanText ?? rrule}
|
||||
</Badge>
|
||||
{preview.length > 0 && (
|
||||
<Badge variant="outline" className="h-5 text-[10px] font-normal">
|
||||
Next: {preview.map((value) => format(parseISO(value), "MMM d")).join(", ")}
|
||||
Next:{" "}
|
||||
{preview
|
||||
.map((value) => format(parseISO(value), "MMM d"))
|
||||
.join(", ")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
179
src/components/settings-panel.tsx
Normal file
179
src/components/settings-panel.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import { Sparkles, Zap } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import type { UserSettings } from "@/lib/user-settings";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface SettingsPanelProps {
|
||||
adminAiEnabled: boolean;
|
||||
className?: string;
|
||||
hasLoadedSettings: boolean;
|
||||
onSettingsChange: (changes: Partial<UserSettings>) => void;
|
||||
settings: UserSettings;
|
||||
}
|
||||
|
||||
const settingRowClasses =
|
||||
"rounded-2xl border border-border/70 bg-background/55 p-4 shadow-sm backdrop-blur-sm";
|
||||
|
||||
export function SettingsPanel({
|
||||
adminAiEnabled,
|
||||
className,
|
||||
hasLoadedSettings,
|
||||
onSettingsChange,
|
||||
settings,
|
||||
}: SettingsPanelProps) {
|
||||
const valuePrefix = hasLoadedSettings
|
||||
? "Current preference"
|
||||
: "Default value";
|
||||
const summaryTitle = hasLoadedSettings
|
||||
? "Current values"
|
||||
: "Default values while settings load";
|
||||
const summaryDescription = hasLoadedSettings
|
||||
? "These values come from LocalCal's saved in-browser settings and can feed future app behavior."
|
||||
: "These defaults render first, then any saved values replace them once app settings finish loading.";
|
||||
const panelTitle = "App preferences";
|
||||
|
||||
return (
|
||||
<section className={className}>
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-primary">
|
||||
Settings
|
||||
</p>
|
||||
<h2 className="text-lg font-semibold tracking-tight">{panelTitle}</h2>
|
||||
<p className="max-w-2xl text-sm leading-relaxed text-muted-foreground">
|
||||
These typed preferences are saved in this browser for LocalCal so
|
||||
future AI workflow flags can read from one shared model.
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="self-start rounded-full px-3 py-1">
|
||||
{hasLoadedSettings ? "Settings ready" : "Checking saved settings"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-4 lg:grid-cols-[minmax(0,1.7fr)_minmax(18rem,1fr)]">
|
||||
<div className="grid gap-4">
|
||||
<div className={settingRowClasses}>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 rounded-full bg-primary/10 p-2 text-primary">
|
||||
<Zap className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 space-y-3">
|
||||
<div className="space-y-1">
|
||||
<Label
|
||||
htmlFor="settings-skip-ai-review"
|
||||
className="cursor-pointer"
|
||||
>
|
||||
Prefer direct AI event creation
|
||||
</Label>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
When enabled, a single AI-generated event will be created
|
||||
directly instead of opening the review modal.
|
||||
</p>
|
||||
</div>
|
||||
<Label
|
||||
htmlFor="settings-skip-ai-review"
|
||||
className="min-h-11 cursor-pointer justify-between rounded-xl border border-border/60 bg-muted/35 px-3 py-2"
|
||||
>
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{settings.skipAiReview
|
||||
? `${valuePrefix}: on`
|
||||
: `${valuePrefix}: off`}
|
||||
</span>
|
||||
<Checkbox
|
||||
id="settings-skip-ai-review"
|
||||
checked={settings.skipAiReview}
|
||||
className="size-5"
|
||||
disabled={!hasLoadedSettings}
|
||||
onCheckedChange={(checked) => {
|
||||
onSettingsChange({ skipAiReview: checked === true });
|
||||
}}
|
||||
/>
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={settingRowClasses}>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 rounded-full bg-primary/10 p-2 text-primary">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 space-y-3">
|
||||
<div className="space-y-1">
|
||||
<Label
|
||||
htmlFor="settings-ai-enabled"
|
||||
className="cursor-pointer"
|
||||
>
|
||||
Enable AI integrations for this browser
|
||||
</Label>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
Turn AI creation and summaries on or off locally.
|
||||
Admin-level AI disablement still overrides this preference.
|
||||
</p>
|
||||
</div>
|
||||
<Label
|
||||
htmlFor="settings-ai-enabled"
|
||||
className="min-h-11 cursor-pointer justify-between rounded-xl border border-border/60 bg-muted/35 px-3 py-2"
|
||||
>
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{settings.aiEnabled
|
||||
? `${valuePrefix}: enabled`
|
||||
: `${valuePrefix}: disabled`}
|
||||
</span>
|
||||
<Checkbox
|
||||
id="settings-ai-enabled"
|
||||
checked={settings.aiEnabled}
|
||||
className="size-5"
|
||||
disabled={!hasLoadedSettings || !adminAiEnabled}
|
||||
onCheckedChange={(checked) => {
|
||||
onSettingsChange({ aiEnabled: checked === true });
|
||||
}}
|
||||
/>
|
||||
</Label>
|
||||
{!adminAiEnabled && (
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
AI is disabled by the administrator, so this local
|
||||
preference is read-only.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={cn(settingRowClasses, "space-y-3")}>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{summaryTitle}</p>
|
||||
<p className="mt-1 text-sm leading-relaxed text-muted-foreground">
|
||||
{summaryDescription}
|
||||
</p>
|
||||
</div>
|
||||
<dl className="grid gap-3 sm:grid-cols-2 lg:grid-cols-1">
|
||||
<div className="rounded-xl border border-border/60 bg-muted/35 p-3">
|
||||
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Direct create preference
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm font-medium">
|
||||
{settings.skipAiReview
|
||||
? `${valuePrefix}: on`
|
||||
: `${valuePrefix}: off`}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border/60 bg-muted/35 p-3">
|
||||
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
AI integrations
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm font-medium">
|
||||
{settings.aiEnabled
|
||||
? `${valuePrefix}: enabled`
|
||||
: `${valuePrefix}: disabled`}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
185
src/components/ui/combined-date-picker-demo.tsx
Normal file
185
src/components/ui/combined-date-picker-demo.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
"use client";
|
||||
|
||||
import { addDays } from "date-fns";
|
||||
import * as React from "react";
|
||||
import { DatePicker } from "@/components/ui/date-picker";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
TimePicker,
|
||||
TimePickerContent,
|
||||
TimePickerHour,
|
||||
TimePickerInput,
|
||||
TimePickerInputGroup,
|
||||
TimePickerLabel,
|
||||
TimePickerMinute,
|
||||
TimePickerPeriod,
|
||||
TimePickerSeparator,
|
||||
TimePickerTrigger,
|
||||
} from "@/components/ui/time-picker";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function getIs12Hour(locale?: string): boolean {
|
||||
const testDate = new Date(2000, 0, 1, 13, 0, 0);
|
||||
const formatted = new Intl.DateTimeFormat(locale, {
|
||||
hour: "numeric",
|
||||
}).format(testDate);
|
||||
|
||||
return /am|pm/i.test(formatted) || !formatted.includes("13");
|
||||
}
|
||||
|
||||
interface FlightTimePickerProps {
|
||||
defaultValue: string;
|
||||
id: string;
|
||||
is12Hour: boolean;
|
||||
label: string;
|
||||
locale: string | undefined;
|
||||
}
|
||||
|
||||
function FlightTimePicker({
|
||||
defaultValue,
|
||||
id,
|
||||
is12Hour,
|
||||
label,
|
||||
locale,
|
||||
}: FlightTimePickerProps) {
|
||||
return (
|
||||
<TimePicker
|
||||
key={`${id}-${locale ?? "default"}`}
|
||||
defaultValue={defaultValue}
|
||||
locale={locale}
|
||||
className="w-28 shrink-0"
|
||||
>
|
||||
<TimePickerLabel className="sr-only">{label}</TimePickerLabel>
|
||||
<TimePickerInputGroup className="bg-background">
|
||||
<TimePickerInput id={id} segment="hour" />
|
||||
<TimePickerSeparator />
|
||||
<TimePickerInput segment="minute" />
|
||||
{is12Hour ? (
|
||||
<TimePickerInput className="ml-1" segment="period" />
|
||||
) : null}
|
||||
<TimePickerTrigger aria-label={`Open ${label.toLowerCase()} picker`} />
|
||||
</TimePickerInputGroup>
|
||||
<TimePickerContent className="max-w-none w-max overflow-hidden p-0">
|
||||
<div className="flex">
|
||||
<div className="flex flex-col border-r">
|
||||
<div className="border-b px-2 py-1.5 text-center text-xs font-semibold text-muted-foreground">
|
||||
Hr
|
||||
</div>
|
||||
<TimePickerHour className="min-w-12 border-none" format="2-digit" />
|
||||
</div>
|
||||
<div className={cn("flex flex-col", is12Hour && "border-r")}>
|
||||
<div className="border-b px-2 py-1.5 text-center text-xs font-semibold text-muted-foreground">
|
||||
Min
|
||||
</div>
|
||||
<TimePickerMinute className="min-w-12 border-none" />
|
||||
</div>
|
||||
{is12Hour ? (
|
||||
<div className="flex flex-col">
|
||||
<div className="border-b px-2 py-1.5 text-center text-xs font-semibold text-muted-foreground">
|
||||
AM/PM
|
||||
</div>
|
||||
<TimePickerPeriod className="min-w-14 border-none" />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</TimePickerContent>
|
||||
</TimePicker>
|
||||
);
|
||||
}
|
||||
|
||||
export function CombinedDatePickerDemo() {
|
||||
const dateFromId = React.useId();
|
||||
const dateToId = React.useId();
|
||||
const timeFromId = React.useId();
|
||||
const timeToId = React.useId();
|
||||
const today = React.useMemo(() => new Date(), []);
|
||||
const departureDefault = React.useMemo(() => today, [today]);
|
||||
const returnDefault = React.useMemo(() => addDays(today, 7), [today]);
|
||||
const [openFrom, setOpenFrom] = React.useState(false);
|
||||
const [openTo, setOpenTo] = React.useState(false);
|
||||
const [dateFrom, setDateFrom] = React.useState<Date | undefined>(
|
||||
departureDefault,
|
||||
);
|
||||
const [dateTo, setDateTo] = React.useState<Date | undefined>(returnDefault);
|
||||
const [monthFrom, setMonthFrom] = React.useState(departureDefault);
|
||||
const [monthTo, setMonthTo] = React.useState(returnDefault);
|
||||
const [timeLocale, setTimeLocale] = React.useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [mounted, setMounted] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setMounted(true);
|
||||
setTimeLocale(
|
||||
new Intl.DateTimeFormat(undefined, {
|
||||
hour: "numeric",
|
||||
}).resolvedOptions().locale,
|
||||
);
|
||||
}, []);
|
||||
|
||||
const is12Hour = mounted ? getIs12Hour(timeLocale) : false;
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-sm min-w-0 flex-col gap-6">
|
||||
<div className="flex items-end gap-4">
|
||||
<div className="flex flex-1 flex-col gap-3">
|
||||
<Label htmlFor={dateFromId} className="px-1">
|
||||
Departure date
|
||||
</Label>
|
||||
<DatePicker
|
||||
id={dateFromId}
|
||||
date={dateFrom}
|
||||
variant="input"
|
||||
open={openFrom}
|
||||
onOpenChange={setOpenFrom}
|
||||
month={monthFrom}
|
||||
onMonthChange={setMonthFrom}
|
||||
onSelect={(nextDate) => {
|
||||
setDateFrom(nextDate);
|
||||
if (nextDate && dateTo && nextDate > dateTo) {
|
||||
setDateTo(nextDate);
|
||||
setMonthTo(nextDate);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<FlightTimePicker
|
||||
id={timeFromId}
|
||||
defaultValue="09:30"
|
||||
is12Hour={is12Hour}
|
||||
label="Departure time"
|
||||
locale={timeLocale}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-end gap-4">
|
||||
<div className="flex flex-1 flex-col gap-3">
|
||||
<Label htmlFor={dateToId} className="px-1">
|
||||
Return date
|
||||
</Label>
|
||||
<DatePicker
|
||||
id={dateToId}
|
||||
date={dateTo}
|
||||
variant="input"
|
||||
open={openTo}
|
||||
onOpenChange={setOpenTo}
|
||||
month={monthTo}
|
||||
onMonthChange={setMonthTo}
|
||||
disabled={dateFrom ? { before: dateFrom } : undefined}
|
||||
onSelect={setDateTo}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<FlightTimePicker
|
||||
id={timeToId}
|
||||
defaultValue="18:30"
|
||||
is12Hour={is12Hour}
|
||||
label="Return time"
|
||||
locale={timeLocale}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
307
src/components/ui/combobox.tsx
Normal file
307
src/components/ui/combobox.tsx
Normal file
@@ -0,0 +1,307 @@
|
||||
"use client";
|
||||
|
||||
import { Combobox as ComboboxPrimitive } from "@base-ui/react";
|
||||
import { CheckIcon, ChevronDownIcon, XIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupInput,
|
||||
} from "@/components/ui/input-group";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Combobox = ComboboxPrimitive.Root;
|
||||
|
||||
function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) {
|
||||
return <ComboboxPrimitive.Value data-slot="combobox-value" {...props} />;
|
||||
}
|
||||
|
||||
function ComboboxTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ComboboxPrimitive.Trigger.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Trigger
|
||||
data-slot="combobox-trigger"
|
||||
className={cn("[&_svg:not([class*='size-'])]:size-4", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDownIcon
|
||||
data-slot="combobox-trigger-icon"
|
||||
className="pointer-events-none size-4 text-muted-foreground"
|
||||
/>
|
||||
</ComboboxPrimitive.Trigger>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Clear
|
||||
data-slot="combobox-clear"
|
||||
render={<Button variant="ghost" size="icon" className="size-6 p-0" />}
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
>
|
||||
<XIcon className="pointer-events-none" />
|
||||
</ComboboxPrimitive.Clear>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxInput({
|
||||
className,
|
||||
children,
|
||||
disabled = false,
|
||||
showTrigger = true,
|
||||
showClear = false,
|
||||
...props
|
||||
}: ComboboxPrimitive.Input.Props & {
|
||||
showTrigger?: boolean;
|
||||
showClear?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<InputGroup className={cn("w-auto", className)}>
|
||||
<ComboboxPrimitive.Input
|
||||
render={<InputGroupInput disabled={disabled} />}
|
||||
{...props}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
{showTrigger && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
asChild
|
||||
data-slot="input-group-button"
|
||||
className="size-6 p-0 group-has-[data-slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent"
|
||||
disabled={disabled}
|
||||
>
|
||||
<ComboboxTrigger />
|
||||
</Button>
|
||||
)}
|
||||
{showClear && <ComboboxClear disabled={disabled} />}
|
||||
</InputGroupAddon>
|
||||
{children}
|
||||
</InputGroup>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxContent({
|
||||
className,
|
||||
side = "bottom",
|
||||
sideOffset = 6,
|
||||
align = "start",
|
||||
alignOffset = 0,
|
||||
anchor,
|
||||
...props
|
||||
}: ComboboxPrimitive.Popup.Props &
|
||||
Pick<
|
||||
ComboboxPrimitive.Positioner.Props,
|
||||
"side" | "align" | "sideOffset" | "alignOffset" | "anchor"
|
||||
>) {
|
||||
return (
|
||||
<ComboboxPrimitive.Portal>
|
||||
<ComboboxPrimitive.Positioner
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
anchor={anchor}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<ComboboxPrimitive.Popup
|
||||
data-slot="combobox-content"
|
||||
data-chips={!!anchor}
|
||||
className={cn(
|
||||
"group/combobox-content relative max-h-96 w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) overflow-hidden rounded-md bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[chips=true]:min-w-(--anchor-width) data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:border-input/30 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:shadow-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</ComboboxPrimitive.Positioner>
|
||||
</ComboboxPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.List
|
||||
data-slot="combobox-list"
|
||||
className={cn(
|
||||
"max-h-[min(calc(--spacing(96)---spacing(9)),calc(var(--available-height)---spacing(9)))] scroll-py-1 overflow-y-auto p-1 data-empty:p-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ComboboxPrimitive.Item.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Item
|
||||
data-slot="combobox-item"
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ComboboxPrimitive.ItemIndicator
|
||||
data-slot="combobox-item-indicator"
|
||||
render={
|
||||
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
|
||||
}
|
||||
>
|
||||
<CheckIcon className="pointer-events-none size-4 pointer-coarse:size-5" />
|
||||
</ComboboxPrimitive.ItemIndicator>
|
||||
</ComboboxPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Group
|
||||
data-slot="combobox-group"
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxLabel({
|
||||
className,
|
||||
...props
|
||||
}: ComboboxPrimitive.GroupLabel.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.GroupLabel
|
||||
data-slot="combobox-label"
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-xs text-muted-foreground pointer-coarse:px-3 pointer-coarse:py-2 pointer-coarse:text-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Collection data-slot="combobox-collection" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Empty
|
||||
data-slot="combobox-empty"
|
||||
className={cn(
|
||||
"hidden w-full justify-center py-2 text-center text-sm text-muted-foreground group-data-empty/combobox-content:flex",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxSeparator({
|
||||
className,
|
||||
...props
|
||||
}: ComboboxPrimitive.Separator.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Separator
|
||||
data-slot="combobox-separator"
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxChips({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithRef<typeof ComboboxPrimitive.Chips> &
|
||||
ComboboxPrimitive.Chips.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Chips
|
||||
data-slot="combobox-chips"
|
||||
className={cn(
|
||||
"flex min-h-9 flex-wrap items-center gap-1.5 rounded-md border border-input bg-transparent bg-clip-padding px-2.5 py-1.5 text-sm shadow-xs transition-[color,box-shadow] focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50 has-aria-invalid:border-destructive has-aria-invalid:ring-[3px] has-aria-invalid:ring-destructive/20 has-data-[slot=combobox-chip]:px-1.5 dark:bg-input/30 dark:has-aria-invalid:border-destructive/50 dark:has-aria-invalid:ring-destructive/40",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxChip({
|
||||
className,
|
||||
children,
|
||||
showRemove = true,
|
||||
...props
|
||||
}: ComboboxPrimitive.Chip.Props & {
|
||||
showRemove?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ComboboxPrimitive.Chip
|
||||
data-slot="combobox-chip"
|
||||
className={cn(
|
||||
"flex h-[calc(--spacing(5.5))] w-fit items-center justify-center gap-1 rounded-sm bg-muted px-1.5 text-xs font-medium whitespace-nowrap text-foreground has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50 has-data-[slot=combobox-chip-remove]:pr-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showRemove && (
|
||||
<ComboboxPrimitive.ChipRemove
|
||||
render={<Button variant="ghost" size="icon" className="size-5 p-0" />}
|
||||
className="-ml-1 opacity-50 hover:opacity-100"
|
||||
data-slot="combobox-chip-remove"
|
||||
>
|
||||
<XIcon className="pointer-events-none" />
|
||||
</ComboboxPrimitive.ChipRemove>
|
||||
)}
|
||||
</ComboboxPrimitive.Chip>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxChipsInput({
|
||||
className,
|
||||
...props
|
||||
}: ComboboxPrimitive.Input.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Input
|
||||
data-slot="combobox-chip-input"
|
||||
className={cn("min-w-16 flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function useComboboxAnchor() {
|
||||
return React.useRef<HTMLDivElement | null>(null);
|
||||
}
|
||||
|
||||
export {
|
||||
Combobox,
|
||||
ComboboxChip,
|
||||
ComboboxChips,
|
||||
ComboboxChipsInput,
|
||||
ComboboxCollection,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxGroup,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxLabel,
|
||||
ComboboxList,
|
||||
ComboboxSeparator,
|
||||
ComboboxTrigger,
|
||||
ComboboxValue,
|
||||
useComboboxAnchor,
|
||||
};
|
||||
322
src/components/ui/date-picker.tsx
Normal file
322
src/components/ui/date-picker.tsx
Normal file
@@ -0,0 +1,322 @@
|
||||
"use client";
|
||||
|
||||
import { addDays, format, isValid, parse } from "date-fns";
|
||||
import { CalendarIcon, ChevronDownIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import type { DropdownProps, Matcher } from "react-day-picker";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
} from "@/components/ui/combobox";
|
||||
import { Field, FieldLabel } from "@/components/ui/field";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
|
||||
interface DropdownItem {
|
||||
disabled?: boolean;
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
function CalendarDropdown(props: DropdownProps) {
|
||||
const { options, value, onChange, "aria-label": ariaLabel } = props;
|
||||
|
||||
const items: DropdownItem[] =
|
||||
options?.map((option) => ({
|
||||
disabled: option.disabled,
|
||||
label: option.label,
|
||||
value: option.value.toString(),
|
||||
})) ?? [];
|
||||
|
||||
const selectedItem = items.find((item) => item.value === value?.toString());
|
||||
|
||||
const handleValueChange = (newValue: DropdownItem | null) => {
|
||||
if (onChange && newValue) {
|
||||
const syntheticEvent = {
|
||||
target: { value: newValue.value },
|
||||
} as React.ChangeEvent<HTMLSelectElement>;
|
||||
onChange(syntheticEvent);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative flex items-center sm:hidden">
|
||||
<select
|
||||
aria-label={ariaLabel}
|
||||
className="absolute inset-0 z-10 w-full cursor-pointer opacity-0"
|
||||
value={value?.toString() ?? ""}
|
||||
onChange={onChange}
|
||||
>
|
||||
{options?.map((option) => (
|
||||
<option
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
disabled={option.disabled}
|
||||
>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="pointer-events-none h-8 w-full justify-between gap-2 px-2 font-medium"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{selectedItem?.label}
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="hidden sm:block">
|
||||
<Combobox
|
||||
aria-label={ariaLabel}
|
||||
autoHighlight
|
||||
items={items}
|
||||
onValueChange={handleValueChange}
|
||||
value={selectedItem}
|
||||
>
|
||||
<ComboboxInput
|
||||
className="mx-1 h-8 min-w-[90px] border-none bg-transparent shadow-none outline-none ring-0 focus-within:ring-0 before:hidden hover:bg-accent hover:text-accent-foreground focus-within:bg-accent focus-within:text-accent-foreground **:[input]:w-0 **:[input]:flex-1 **:[input]:cursor-pointer **:[input]:text-center **:[input]:font-medium"
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
/>
|
||||
<ComboboxContent aria-label={ariaLabel}>
|
||||
<ComboboxEmpty>No items found.</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(item: DropdownItem) => (
|
||||
<ComboboxItem
|
||||
disabled={item.disabled}
|
||||
key={item.value}
|
||||
value={item}
|
||||
>
|
||||
{item.label}
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export interface DatePickerProps {
|
||||
date?: Date;
|
||||
onSelect?: (date: Date | undefined) => void;
|
||||
startMonth?: Date;
|
||||
endMonth?: Date;
|
||||
disabled?: Matcher | Matcher[];
|
||||
label?: string;
|
||||
id?: string;
|
||||
variant?: "button" | "input";
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
month?: Date;
|
||||
onMonthChange?: (month: Date) => void;
|
||||
}
|
||||
|
||||
export function DatePicker({
|
||||
date,
|
||||
onSelect,
|
||||
startMonth = new Date(1900, 0, 1),
|
||||
endMonth = new Date(2100, 11, 31),
|
||||
disabled,
|
||||
label,
|
||||
id: providedId,
|
||||
variant = "button",
|
||||
open: controlledOpen,
|
||||
onOpenChange: controlledOnOpenChange,
|
||||
month: controlledMonth,
|
||||
onMonthChange: controlledOnMonthChange,
|
||||
}: DatePickerProps) {
|
||||
const fallbackId = React.useId();
|
||||
const id = providedId ?? fallbackId;
|
||||
|
||||
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false);
|
||||
const open = controlledOpen ?? uncontrolledOpen;
|
||||
const setOpen = controlledOnOpenChange ?? setUncontrolledOpen;
|
||||
|
||||
const today = new Date();
|
||||
|
||||
const [uncontrolledMonth, setUncontrolledMonth] = React.useState(
|
||||
date || today,
|
||||
);
|
||||
const month = controlledMonth ?? uncontrolledMonth;
|
||||
const setMonth = controlledOnMonthChange ?? setUncontrolledMonth;
|
||||
|
||||
const [inputValue, setInputValue] = React.useState(() =>
|
||||
date ? format(date, "yyyy-MM-dd") : "",
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
setInputValue(date ? format(date, "yyyy-MM-dd") : "");
|
||||
}, [date]);
|
||||
|
||||
const handleSelect = (selectedDate: Date | undefined) => {
|
||||
onSelect?.(selectedDate);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const nextValue = event.target.value;
|
||||
const numericValue = nextValue.replace(/\D/g, "");
|
||||
|
||||
let formattedValue = "";
|
||||
if (numericValue.length > 0) {
|
||||
formattedValue += numericValue.substring(0, 4);
|
||||
}
|
||||
if (numericValue.length >= 5) {
|
||||
formattedValue += `-${numericValue.substring(4, 6)}`;
|
||||
}
|
||||
if (numericValue.length >= 7) {
|
||||
formattedValue += `-${numericValue.substring(6, 8)}`;
|
||||
}
|
||||
|
||||
setInputValue(formattedValue);
|
||||
|
||||
if (!formattedValue) {
|
||||
onSelect?.(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
if (formattedValue.length === 10) {
|
||||
const nextDate = parse(formattedValue, "yyyy-MM-dd", new Date());
|
||||
if (isValid(nextDate)) {
|
||||
onSelect?.(nextDate);
|
||||
setMonth(nextDate);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const triggerContent =
|
||||
variant === "input" ? (
|
||||
<div className="flex h-10 w-full cursor-text items-center gap-0.5 rounded-md border border-input bg-background px-3 py-2 shadow-xs outline-none transition-shadow has-[input:focus]:border-ring has-[input:focus]:ring-[3px] has-[input:focus]:ring-ring/50">
|
||||
<input
|
||||
id={id}
|
||||
type="text"
|
||||
inputMode="text"
|
||||
placeholder="YYYY-MM-DD"
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
className="inline-flex h-full w-full min-w-0 border-0 bg-transparent text-sm tabular-nums font-mono outline-none transition-colors focus:bg-transparent disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="ml-auto size-7 shrink-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<CalendarIcon aria-hidden="true" className="size-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</div>
|
||||
) : (
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
id={id}
|
||||
className="w-full justify-start font-normal"
|
||||
variant="outline"
|
||||
>
|
||||
<CalendarIcon aria-hidden="true" className="mr-2 h-4 w-4" />
|
||||
{date ? format(date, "PPP") : <span>Pick a date</span>}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
);
|
||||
|
||||
const popover = (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
{triggerContent}
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<div className="flex max-sm:flex-col">
|
||||
<div className="relative py-1 ps-1 max-sm:order-1 max-sm:border-t">
|
||||
<div className="flex h-full flex-col sm:border-e sm:pe-3">
|
||||
<Button
|
||||
className="w-full justify-start"
|
||||
onClick={() => {
|
||||
handleSelect(today);
|
||||
setMonth(today);
|
||||
}}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
Today
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full justify-start"
|
||||
onClick={() => {
|
||||
const tomorrow = addDays(today, 1);
|
||||
handleSelect(tomorrow);
|
||||
setMonth(tomorrow);
|
||||
}}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
Tomorrow
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full justify-start"
|
||||
onClick={() => {
|
||||
const in3Days = addDays(today, 3);
|
||||
handleSelect(in3Days);
|
||||
setMonth(in3Days);
|
||||
}}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
In 3 days
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full justify-start"
|
||||
onClick={() => {
|
||||
const inAWeek = addDays(today, 7);
|
||||
handleSelect(inAWeek);
|
||||
setMonth(inAWeek);
|
||||
}}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
In a week
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Calendar
|
||||
className="max-sm:pb-3 sm:ps-2"
|
||||
mode="single"
|
||||
captionLayout="dropdown"
|
||||
components={{ Dropdown: CalendarDropdown }}
|
||||
startMonth={startMonth}
|
||||
endMonth={endMonth}
|
||||
disabled={disabled}
|
||||
month={month}
|
||||
onMonthChange={setMonth}
|
||||
onSelect={handleSelect}
|
||||
selected={date}
|
||||
/>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
if (label) {
|
||||
return (
|
||||
<Field>
|
||||
<FieldLabel htmlFor={id}>{label}</FieldLabel>
|
||||
{popover}
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
return popover;
|
||||
}
|
||||
246
src/components/ui/field.tsx
Normal file
246
src/components/ui/field.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
"use client";
|
||||
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { useMemo } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
|
||||
return (
|
||||
<fieldset
|
||||
data-slot="field-set"
|
||||
className={cn(
|
||||
"flex flex-col gap-6",
|
||||
"has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldLegend({
|
||||
className,
|
||||
variant = "legend",
|
||||
...props
|
||||
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
|
||||
return (
|
||||
<legend
|
||||
data-slot="field-legend"
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"mb-3 font-medium",
|
||||
"data-[variant=legend]:text-base",
|
||||
"data-[variant=label]:text-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-group"
|
||||
className={cn(
|
||||
"group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const fieldVariants = cva(
|
||||
"group/field flex w-full gap-3 data-[invalid=true]:text-destructive",
|
||||
{
|
||||
variants: {
|
||||
orientation: {
|
||||
vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"],
|
||||
horizontal: [
|
||||
"flex-row items-center",
|
||||
"[&>[data-slot=field-label]]:flex-auto",
|
||||
"has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||
],
|
||||
responsive: [
|
||||
"flex-col @md/field-group:flex-row @md/field-group:items-center [&>*]:w-full @md/field-group:[&>*]:w-auto [&>.sr-only]:w-auto",
|
||||
"@md/field-group:[&>[data-slot=field-label]]:flex-auto",
|
||||
"@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||
],
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: "vertical",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Field({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<"fieldset"> & VariantProps<typeof fieldVariants>) {
|
||||
return (
|
||||
<fieldset
|
||||
data-slot="field"
|
||||
data-orientation={orientation}
|
||||
className={cn(fieldVariants({ orientation }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-content"
|
||||
className={cn(
|
||||
"group/field-content flex flex-1 flex-col gap-1.5 leading-snug",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Label>) {
|
||||
return (
|
||||
<Label
|
||||
data-slot="field-label"
|
||||
className={cn(
|
||||
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50",
|
||||
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4",
|
||||
"has-data-[state=checked]:border-primary has-data-[state=checked]:bg-primary/5 dark:has-data-[state=checked]:bg-primary/10",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-label"
|
||||
className={cn(
|
||||
"flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<p
|
||||
data-slot="field-description"
|
||||
className={cn(
|
||||
"text-sm leading-normal font-normal text-muted-foreground group-has-[[data-orientation=horizontal]]/field:text-balance",
|
||||
"last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5",
|
||||
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-separator"
|
||||
data-content={!!children}
|
||||
className={cn(
|
||||
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Separator className="absolute inset-0 top-1/2" />
|
||||
{children && (
|
||||
<span
|
||||
className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
|
||||
data-slot="field-separator-content"
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldError({
|
||||
className,
|
||||
children,
|
||||
errors,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
errors?: Array<{ message?: string } | undefined>;
|
||||
}) {
|
||||
const content = useMemo(() => {
|
||||
if (children) {
|
||||
return children;
|
||||
}
|
||||
|
||||
if (!errors?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const uniqueErrors = [
|
||||
...new Map(errors.map((error) => [error?.message, error])).values(),
|
||||
];
|
||||
|
||||
if (uniqueErrors?.length === 1) {
|
||||
return uniqueErrors[0]?.message;
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="ml-4 flex list-disc flex-col gap-1">
|
||||
{uniqueErrors.map(
|
||||
(error) =>
|
||||
error?.message && <li key={error.message}>{error.message}</li>,
|
||||
)}
|
||||
</ul>
|
||||
);
|
||||
}, [children, errors]);
|
||||
|
||||
if (!content) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
data-slot="field-error"
|
||||
className={cn("text-sm font-normal text-destructive", className)}
|
||||
{...props}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Field,
|
||||
FieldContent,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
FieldLegend,
|
||||
FieldSeparator,
|
||||
FieldSet,
|
||||
FieldTitle,
|
||||
};
|
||||
99
src/components/ui/input-group.tsx
Normal file
99
src/components/ui/input-group.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import type * as React from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const inputGroupAddonVariants = cva(
|
||||
"flex h-auto cursor-text select-none items-center justify-center gap-2 leading-none [&>kbd]:rounded-[calc(var(--radius)-5px)] in-[[data-slot=input-group]:has([data-slot=input-control],[data-slot=textarea-control])]:[&_svg:not([class*='size-'])]:size-4.5 sm:in-[[data-slot=input-group]:has([data-slot=input-control],[data-slot=textarea-control])]:[&_svg:not([class*='size-'])]:size-4 [&_svg]:-mx-0.5 not-has-[button]:**:[svg:not([class*='opacity-'])]:opacity-80",
|
||||
{
|
||||
defaultVariants: {
|
||||
align: "inline-start",
|
||||
},
|
||||
variants: {
|
||||
align: {
|
||||
"block-end":
|
||||
"order-last w-full justify-start px-[calc(--spacing(3)-1px)] pb-[calc(--spacing(3)-1px)] [.border-t]:pt-[calc(--spacing(3)-1px)] [[data-size=sm]+&]:px-[calc(--spacing(2.5)-1px)]",
|
||||
"block-start":
|
||||
"order-first w-full justify-start px-[calc(--spacing(3)-1px)] pt-[calc(--spacing(3)-1px)] [.border-b]:pb-[calc(--spacing(3)-1px)] [[data-size=sm]+&]:px-[calc(--spacing(2.5)-1px)]",
|
||||
"inline-end":
|
||||
"order-last pe-[calc(--spacing(3)-1px)] has-[>:last-child[data-slot=badge]]:-me-1.5 has-[>button]:-me-2 has-[>kbd:last-child]:me-[-0.35rem] [[data-size=sm]+&]:pe-[calc(--spacing(2.5)-1px)]",
|
||||
"inline-start":
|
||||
"order-first ps-[calc(--spacing(3)-1px)] has-[>:last-child[data-slot=badge]]:-ms-1.5 has-[>button]:-ms-2 has-[>kbd:last-child]:ms-[-0.35rem] [[data-size=sm]+&]:ps-[calc(--spacing(2.5)-1px)]",
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export function InputGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">): React.ReactElement {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative inline-flex w-full min-w-0 items-center rounded-lg border border-input bg-background not-dark:bg-clip-padding text-base text-foreground shadow-xs/5 ring-ring/24 transition-shadow before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] not-has-[input:disabled,textarea:disabled]:not-has-[input:focus-visible,textarea:focus-visible]:not-has-[input[aria-invalid],textarea[aria-invalid]]:before:shadow-[0_1px_--theme(--color-black/4%)] has-[input:focus-visible,textarea:focus-visible]:has-[input[aria-invalid],textarea[aria-invalid]]:border-destructive/64 has-[input:focus-visible,textarea:focus-visible]:has-[input[aria-invalid],textarea[aria-invalid]]:ring-destructive/16 has-[textarea]:h-auto has-data-[align=block-end]:h-auto has-data-[align=block-start]:h-auto has-data-[align=block-end]:flex-col has-data-[align=block-start]:flex-col has-[input:focus-visible,textarea:focus-visible]:border-ring has-[input[aria-invalid],textarea[aria-invalid]]:border-destructive/36 has-autofill:bg-foreground/4 has-[input:disabled,textarea:disabled]:opacity-64 has-[input:disabled,textarea:disabled,input:focus-visible,textarea:focus-visible,input[aria-invalid],textarea[aria-invalid]]:shadow-none has-[input:focus-visible,textarea:focus-visible]:ring-[3px] sm:text-sm dark:bg-input/32 dark:has-autofill:bg-foreground/8 dark:has-[input[aria-invalid],textarea[aria-invalid]]:ring-destructive/24 dark:not-has-[input:disabled,textarea:disabled]:not-has-[input:focus-visible,textarea:focus-visible]:not-has-[input[aria-invalid],textarea[aria-invalid]]:before:shadow-[0_-1px_--theme(--color-white/6%)] has-data-[align=inline-start]:**:[[data-size=sm]_input]:ps-1.5 has-data-[align=inline-end]:**:[[data-size=sm]_input]:pe-1.5 *:[[data-slot=input-control],[data-slot=textarea-control]]:contents *:[[data-slot=input-control],[data-slot=textarea-control]]:before:hidden has-[[data-align=block-start],[data-align=block-end]]:**:[input]:h-auto has-data-[align=inline-start]:**:[input]:ps-2 has-data-[align=inline-end]:**:[input]:pe-2 has-data-[align=block-end]:**:[input]:pt-1.5 has-data-[align=block-start]:**:[input]:pb-1.5 **:[textarea]:min-h-20.5 **:[textarea]:resize-none **:[textarea]:py-[calc(--spacing(3)-1px)] **:[textarea]:max-sm:min-h-23.5 **:[textarea_button]:rounded-[calc(var(--radius-md)-1px)]",
|
||||
className,
|
||||
)}
|
||||
data-slot="input-group"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function InputGroupAddon({
|
||||
className,
|
||||
align = "inline-start",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> &
|
||||
VariantProps<typeof inputGroupAddonVariants>): React.ReactElement {
|
||||
return (
|
||||
<div
|
||||
className={cn(inputGroupAddonVariants({ align }), className)}
|
||||
data-align={align}
|
||||
data-slot="input-group-addon"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function InputGroupText({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">): React.ReactElement {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"line-clamp-1 flex items-center gap-2 whitespace-nowrap text-muted-foreground leading-none in-[[data-slot=input-group]:has([data-slot=input-control],[data-slot=textarea-control])]:[&_svg:not([class*='size-'])]:size-4.5 sm:in-[[data-slot=input-group]:has([data-slot=input-control],[data-slot=textarea-control])]:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:-mx-0.5",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function InputGroupInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Input>): React.ReactElement {
|
||||
return (
|
||||
<Input
|
||||
className={cn("border-0 bg-transparent shadow-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function InputGroupTextarea({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Textarea>): React.ReactElement {
|
||||
return (
|
||||
<Textarea
|
||||
className={cn("border-0 bg-transparent shadow-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
17
src/components/ui/spinner.tsx
Normal file
17
src/components/ui/spinner.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Loader2Icon } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Spinner({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Loader2Icon>): React.ReactElement {
|
||||
return (
|
||||
<Loader2Icon
|
||||
aria-label="Loading"
|
||||
className={cn("animate-spin", className)}
|
||||
role="status"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
2228
src/components/ui/time-picker.tsx
Normal file
2228
src/components/ui/time-picker.tsx
Normal file
File diff suppressed because it is too large
Load Diff
160
src/components/visually-hidden-input.tsx
Normal file
160
src/components/visually-hidden-input.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
type InputValue = string[] | string;
|
||||
|
||||
interface VisuallyHiddenInputProps<T = InputValue>
|
||||
extends Omit<
|
||||
React.InputHTMLAttributes<HTMLInputElement>,
|
||||
"value" | "checked" | "onReset"
|
||||
> {
|
||||
value?: T;
|
||||
checked?: boolean;
|
||||
control: HTMLElement | null;
|
||||
bubbles?: boolean;
|
||||
}
|
||||
|
||||
function VisuallyHiddenInput<T = InputValue>(
|
||||
props: VisuallyHiddenInputProps<T>,
|
||||
) {
|
||||
const {
|
||||
control,
|
||||
value,
|
||||
checked,
|
||||
bubbles = true,
|
||||
type = "hidden",
|
||||
style,
|
||||
...inputProps
|
||||
} = props;
|
||||
|
||||
const isCheckInput = React.useMemo(
|
||||
() => type === "checkbox" || type === "radio" || type === "switch",
|
||||
[type],
|
||||
);
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const prevValueRef = React.useRef<{
|
||||
value: T | boolean | undefined;
|
||||
previous: T | boolean | undefined;
|
||||
}>({
|
||||
value: isCheckInput ? checked : value,
|
||||
previous: isCheckInput ? checked : value,
|
||||
});
|
||||
|
||||
const prevValue = React.useMemo(() => {
|
||||
const currentValue = isCheckInput ? checked : value;
|
||||
if (prevValueRef.current.value !== currentValue) {
|
||||
prevValueRef.current.previous = prevValueRef.current.value;
|
||||
prevValueRef.current.value = currentValue;
|
||||
}
|
||||
return prevValueRef.current.previous;
|
||||
}, [isCheckInput, value, checked]);
|
||||
|
||||
const [controlSize, setControlSize] = React.useState<{
|
||||
width?: number;
|
||||
height?: number;
|
||||
}>({});
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (!control) {
|
||||
setControlSize({});
|
||||
return;
|
||||
}
|
||||
|
||||
setControlSize({
|
||||
width: control.offsetWidth,
|
||||
height: control.offsetHeight,
|
||||
});
|
||||
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
if (!Array.isArray(entries) || !entries.length) return;
|
||||
|
||||
const entry = entries[0];
|
||||
if (!entry) return;
|
||||
|
||||
let width: number;
|
||||
let height: number;
|
||||
|
||||
if ("borderBoxSize" in entry) {
|
||||
const borderSizeEntry = entry.borderBoxSize;
|
||||
const borderSize = Array.isArray(borderSizeEntry)
|
||||
? borderSizeEntry[0]
|
||||
: borderSizeEntry;
|
||||
width = borderSize.inlineSize;
|
||||
height = borderSize.blockSize;
|
||||
} else {
|
||||
width = control.offsetWidth;
|
||||
height = control.offsetHeight;
|
||||
}
|
||||
|
||||
setControlSize({ width, height });
|
||||
});
|
||||
|
||||
resizeObserver.observe(control, { box: "border-box" });
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [control]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const input = inputRef.current;
|
||||
if (!input) return;
|
||||
|
||||
const inputProto = window.HTMLInputElement.prototype;
|
||||
const propertyKey = isCheckInput ? "checked" : "value";
|
||||
const eventType = isCheckInput ? "click" : "input";
|
||||
const currentValue = isCheckInput ? checked : value;
|
||||
|
||||
const serializedCurrentValue = isCheckInput
|
||||
? checked
|
||||
: typeof value === "object" && value !== null
|
||||
? JSON.stringify(value)
|
||||
: value;
|
||||
|
||||
const descriptor = Object.getOwnPropertyDescriptor(inputProto, propertyKey);
|
||||
|
||||
const setter = descriptor?.set;
|
||||
|
||||
if (prevValue !== currentValue && setter) {
|
||||
const event = new Event(eventType, { bubbles });
|
||||
setter.call(input, serializedCurrentValue);
|
||||
input.dispatchEvent(event);
|
||||
}
|
||||
}, [prevValue, value, checked, bubbles, isCheckInput]);
|
||||
|
||||
const composedStyle = React.useMemo<React.CSSProperties>(() => {
|
||||
return {
|
||||
...style,
|
||||
...(controlSize.width !== undefined && controlSize.height !== undefined
|
||||
? controlSize
|
||||
: {}),
|
||||
border: 0,
|
||||
clip: "rect(0 0 0 0)",
|
||||
clipPath: "inset(50%)",
|
||||
height: "1px",
|
||||
margin: "-1px",
|
||||
overflow: "hidden",
|
||||
padding: 0,
|
||||
position: "absolute",
|
||||
whiteSpace: "nowrap",
|
||||
width: "1px",
|
||||
};
|
||||
}, [style, controlSize]);
|
||||
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
{...inputProps}
|
||||
ref={inputRef}
|
||||
aria-hidden={isCheckInput}
|
||||
tabIndex={-1}
|
||||
defaultChecked={isCheckInput ? checked : undefined}
|
||||
style={composedStyle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { VisuallyHiddenInput };
|
||||
15
src/hooks/use-as-ref.ts
Normal file
15
src/hooks/use-as-ref.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { useIsomorphicLayoutEffect } from "@/hooks/use-isomorphic-layout-effect";
|
||||
|
||||
function useAsRef<T>(props: T) {
|
||||
const ref = React.useRef<T>(props);
|
||||
|
||||
useIsomorphicLayoutEffect(() => {
|
||||
ref.current = props;
|
||||
});
|
||||
|
||||
return ref;
|
||||
}
|
||||
|
||||
export { useAsRef };
|
||||
6
src/hooks/use-isomorphic-layout-effect.ts
Normal file
6
src/hooks/use-isomorphic-layout-effect.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import * as React from "react";
|
||||
|
||||
const useIsomorphicLayoutEffect =
|
||||
typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect;
|
||||
|
||||
export { useIsomorphicLayoutEffect };
|
||||
13
src/hooks/use-lazy-ref.ts
Normal file
13
src/hooks/use-lazy-ref.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import * as React from "react";
|
||||
|
||||
function useLazyRef<T>(fn: () => T) {
|
||||
const ref = React.useRef<T | null>(null);
|
||||
|
||||
if (ref.current === null) {
|
||||
ref.current = fn();
|
||||
}
|
||||
|
||||
return ref as React.RefObject<T>;
|
||||
}
|
||||
|
||||
export { useLazyRef };
|
||||
15
src/lib/ai-create-flow.ts
Normal file
15
src/lib/ai-create-flow.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export type AiCreateOutcome =
|
||||
| "review-single"
|
||||
| "create-single"
|
||||
| "create-multiple";
|
||||
|
||||
export const getAiCreateOutcome = (
|
||||
eventCount: number,
|
||||
skipAiReview: boolean,
|
||||
): AiCreateOutcome => {
|
||||
if (eventCount > 1) {
|
||||
return "create-multiple";
|
||||
}
|
||||
|
||||
return skipAiReview ? "create-single" : "review-single";
|
||||
};
|
||||
32
src/lib/ai-feature-flags.ts
Normal file
32
src/lib/ai-feature-flags.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
const ENABLED_VALUES = new Set(["1", "true", "yes", "on"]);
|
||||
const DISABLED_VALUES = new Set(["0", "false", "no", "off"]);
|
||||
|
||||
const normalizeFlagValue = (value?: string) => value?.trim().toLowerCase();
|
||||
|
||||
export const isAiFlagEnabled = (value?: string): boolean => {
|
||||
const normalizedValue = normalizeFlagValue(value);
|
||||
if (!normalizedValue) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (ENABLED_VALUES.has(normalizedValue)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (DISABLED_VALUES.has(normalizedValue)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const isAdminAiEnabled = (
|
||||
env: Record<string, string | undefined> = process.env,
|
||||
) => isAiFlagEnabled(env.AI_ENABLED);
|
||||
|
||||
export const isClientAiEnabled = (
|
||||
env: Record<string, string | undefined> = process.env,
|
||||
) => isAiFlagEnabled(env.NEXT_PUBLIC_AI_ENABLED ?? env.AI_ENABLED);
|
||||
|
||||
export const getAiDisabledMessage = () =>
|
||||
"AI integrations are currently disabled by the administrator.";
|
||||
62
src/lib/compose-refs.ts
Normal file
62
src/lib/compose-refs.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import * as React from "react";
|
||||
|
||||
type PossibleRef<T> = React.Ref<T> | undefined;
|
||||
|
||||
/**
|
||||
* Set a given ref to a given value
|
||||
* This utility takes care of different types of refs: callback refs and RefObject(s)
|
||||
*/
|
||||
function setRef<T>(ref: PossibleRef<T>, value: T) {
|
||||
if (typeof ref === "function") {
|
||||
return ref(value);
|
||||
}
|
||||
|
||||
if (ref !== null && ref !== undefined) {
|
||||
ref.current = value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A utility to compose multiple refs together
|
||||
* Accepts callback refs and RefObject(s)
|
||||
*/
|
||||
function composeRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
|
||||
return (node) => {
|
||||
let hasCleanup = false;
|
||||
const cleanups = refs.map((ref) => {
|
||||
const cleanup = setRef(ref, node);
|
||||
if (!hasCleanup && typeof cleanup === "function") {
|
||||
hasCleanup = true;
|
||||
}
|
||||
return cleanup;
|
||||
});
|
||||
|
||||
// React <19 will log an error to the console if a callback ref returns a
|
||||
// value. We don't use ref cleanups internally so this will only happen if a
|
||||
// user's ref callback returns a value, which we only expect if they are
|
||||
// using the cleanup functionality added in React 19.
|
||||
if (hasCleanup) {
|
||||
return () => {
|
||||
for (let i = 0; i < cleanups.length; i++) {
|
||||
const cleanup = cleanups[i];
|
||||
if (typeof cleanup === "function") {
|
||||
cleanup();
|
||||
} else {
|
||||
setRef(refs[i], null);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A custom hook that composes multiple refs
|
||||
* Accepts callback refs and RefObject(s)
|
||||
*/
|
||||
function useComposedRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: we want to memoize by all values
|
||||
return React.useCallback(composeRefs(...refs), refs);
|
||||
}
|
||||
|
||||
export { composeRefs, useComposedRefs };
|
||||
@@ -1,10 +1,4 @@
|
||||
import {
|
||||
format,
|
||||
isSameDay,
|
||||
isToday,
|
||||
isTomorrow,
|
||||
parseISO,
|
||||
} from "date-fns";
|
||||
import { format, isSameDay, isToday, isTomorrow, parseISO } from "date-fns";
|
||||
import type { CalendarEvent } from "@/lib/types";
|
||||
|
||||
const getFriendlyDayLabel = (value: Date): string => {
|
||||
@@ -13,7 +7,10 @@ const getFriendlyDayLabel = (value: Date): string => {
|
||||
return format(value, "MMM d, yyyy");
|
||||
};
|
||||
|
||||
export const formatEventStartLabel = (start: string, allDay?: boolean): string => {
|
||||
export const formatEventStartLabel = (
|
||||
start: string,
|
||||
allDay?: boolean,
|
||||
): string => {
|
||||
const parsed = parseISO(start);
|
||||
const dayLabel = getFriendlyDayLabel(parsed);
|
||||
|
||||
@@ -21,12 +18,16 @@ export const formatEventStartLabel = (start: string, allDay?: boolean): string =
|
||||
return `${dayLabel} · ${format(parsed, "HH:mm")}`;
|
||||
};
|
||||
|
||||
export const formatEventRangeLabel = (event: Pick<CalendarEvent, "start" | "end" | "allDay">): string => {
|
||||
export const formatEventRangeLabel = (
|
||||
event: Pick<CalendarEvent, "start" | "end" | "allDay">,
|
||||
): string => {
|
||||
const startDate = parseISO(event.start);
|
||||
const startLabel = getFriendlyDayLabel(startDate);
|
||||
|
||||
if (event.allDay || !event.end) {
|
||||
return event.allDay ? startLabel : `${startLabel} · ${format(startDate, "HH:mm")}`;
|
||||
return event.allDay
|
||||
? startLabel
|
||||
: `${startLabel} · ${format(startDate, "HH:mm")}`;
|
||||
}
|
||||
|
||||
const endDate = parseISO(event.end);
|
||||
|
||||
@@ -39,7 +39,10 @@ const eventFormSchema = z
|
||||
if (value.end) {
|
||||
const startDate = parseISO(value.start);
|
||||
const endDate = parseISO(value.end);
|
||||
if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime())) {
|
||||
if (
|
||||
Number.isNaN(startDate.getTime()) ||
|
||||
Number.isNaN(endDate.getTime())
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["end"],
|
||||
|
||||
170
src/lib/google-maps.ts
Normal file
170
src/lib/google-maps.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
export interface GoogleMapsLocationConfig {
|
||||
enabled?: boolean | null;
|
||||
region?: string | null;
|
||||
publicEnabled?: boolean | null;
|
||||
serverApiKey?: string | null;
|
||||
}
|
||||
|
||||
export interface GoogleMapsLocationCapability {
|
||||
enabled: boolean;
|
||||
reason: "configured" | "missing_server_api_key" | "disabled";
|
||||
region: string;
|
||||
}
|
||||
|
||||
export interface GoogleMapsPlaceLabelSource {
|
||||
displayName?: string | { text?: string | null } | null;
|
||||
formattedAddress?: string | null;
|
||||
name?: string | null;
|
||||
predictionText?: string | null;
|
||||
}
|
||||
|
||||
export interface GoogleMapsSuggestion {
|
||||
formattedAddress?: string;
|
||||
placeId?: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
const DEFAULT_GOOGLE_MAPS_REGION = "us";
|
||||
|
||||
const normalizeConfigValue = (value?: string | null) => {
|
||||
const trimmedValue = value?.trim();
|
||||
return trimmedValue ? trimmedValue : null;
|
||||
};
|
||||
|
||||
const getDisplayNameText = (
|
||||
displayName?: string | { text?: string | null } | null,
|
||||
) => {
|
||||
if (typeof displayName === "string") {
|
||||
return normalizeConfigValue(displayName);
|
||||
}
|
||||
|
||||
return normalizeConfigValue(displayName?.text);
|
||||
};
|
||||
|
||||
const parseEnabledValue = (value?: string | null) => {
|
||||
const normalizedValue = normalizeConfigValue(value)?.toLowerCase();
|
||||
|
||||
if (!normalizedValue) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !["0", "false", "no", "off"].includes(normalizedValue);
|
||||
};
|
||||
|
||||
export const getGoogleMapsLocationCapability = (
|
||||
config: GoogleMapsLocationConfig = {},
|
||||
): GoogleMapsLocationCapability => {
|
||||
const enabled =
|
||||
typeof config.enabled === "boolean"
|
||||
? config.enabled
|
||||
: typeof config.publicEnabled === "boolean"
|
||||
? config.publicEnabled
|
||||
: parseEnabledValue(
|
||||
process.env.NEXT_PUBLIC_GOOGLE_MAPS_AUTOCOMPLETE_ENABLED,
|
||||
);
|
||||
const serverApiKey = normalizeConfigValue(
|
||||
config.serverApiKey ?? process.env.GOOGLE_MAPS_API_KEY,
|
||||
);
|
||||
const region =
|
||||
normalizeConfigValue(config.region ?? process.env.GOOGLE_MAPS_REGION) ??
|
||||
DEFAULT_GOOGLE_MAPS_REGION;
|
||||
|
||||
if (!enabled) {
|
||||
return {
|
||||
enabled: false,
|
||||
reason: "disabled",
|
||||
region,
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
return {
|
||||
enabled: true,
|
||||
reason: "configured",
|
||||
region,
|
||||
};
|
||||
}
|
||||
|
||||
if (!serverApiKey) {
|
||||
return {
|
||||
enabled: false,
|
||||
reason: "missing_server_api_key",
|
||||
region,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
reason: "configured",
|
||||
region,
|
||||
};
|
||||
};
|
||||
|
||||
export const getGoogleMapsServerApiKey = () =>
|
||||
normalizeConfigValue(process.env.GOOGLE_MAPS_API_KEY);
|
||||
|
||||
export const getGoogleMapsPlaceLabel = (source: GoogleMapsPlaceLabelSource) => {
|
||||
return (
|
||||
normalizeConfigValue(source.predictionText) ??
|
||||
getDisplayNameText(source.displayName) ??
|
||||
normalizeConfigValue(source.formattedAddress) ??
|
||||
normalizeConfigValue(source.name) ??
|
||||
""
|
||||
);
|
||||
};
|
||||
|
||||
export const getLocationAutocompletePlaceholder = (
|
||||
capability: GoogleMapsLocationCapability,
|
||||
) => {
|
||||
return capability.enabled
|
||||
? "Search Google Maps or type a location"
|
||||
: "Location";
|
||||
};
|
||||
|
||||
interface GooglePlacesAutocompleteResponse {
|
||||
suggestions?: Array<{
|
||||
placePrediction?: {
|
||||
place?: string;
|
||||
placeId?: string;
|
||||
text?: { text?: string | null };
|
||||
structuredFormat?: {
|
||||
mainText?: { text?: string | null };
|
||||
secondaryText?: { text?: string | null };
|
||||
};
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
export const mapGooglePlacesSuggestions = (
|
||||
payload: GooglePlacesAutocompleteResponse,
|
||||
): GoogleMapsSuggestion[] => {
|
||||
const suggestions: GoogleMapsSuggestion[] = [];
|
||||
|
||||
for (const suggestion of payload.suggestions ?? []) {
|
||||
const placePrediction = suggestion.placePrediction;
|
||||
const text =
|
||||
normalizeConfigValue(placePrediction?.text?.text) ??
|
||||
normalizeConfigValue(placePrediction?.structuredFormat?.mainText?.text) ??
|
||||
"";
|
||||
|
||||
if (!text) {
|
||||
continue;
|
||||
}
|
||||
|
||||
suggestions.push({
|
||||
formattedAddress:
|
||||
normalizeConfigValue(
|
||||
placePrediction?.structuredFormat?.secondaryText?.text,
|
||||
) ?? undefined,
|
||||
placeId:
|
||||
normalizeConfigValue(placePrediction?.placeId) ??
|
||||
normalizeConfigValue(
|
||||
placePrediction?.place?.replace(/^places\//, ""),
|
||||
) ??
|
||||
undefined,
|
||||
text,
|
||||
});
|
||||
}
|
||||
|
||||
return suggestions;
|
||||
};
|
||||
@@ -2,7 +2,11 @@ import { format, parseISO } from "date-fns";
|
||||
import { RRule, rrulestr } from "rrule";
|
||||
import type { Frequency, Weekday } from "@/lib/rfc5545-types";
|
||||
|
||||
export type SupportedRecurrenceFrequency = "NONE" | "DAILY" | "WEEKLY" | "MONTHLY";
|
||||
export type SupportedRecurrenceFrequency =
|
||||
| "NONE"
|
||||
| "DAILY"
|
||||
| "WEEKLY"
|
||||
| "MONTHLY";
|
||||
|
||||
export interface RecurrenceFormValue {
|
||||
freq: SupportedRecurrenceFrequency;
|
||||
@@ -23,7 +27,10 @@ const EMPTY_RECURRENCE: RecurrenceFormValue = {
|
||||
byDay: [],
|
||||
};
|
||||
|
||||
const RULE_FREQUENCIES: Record<Exclude<SupportedRecurrenceFrequency, "NONE">, number> = {
|
||||
const RULE_FREQUENCIES: Record<
|
||||
Exclude<SupportedRecurrenceFrequency, "NONE">,
|
||||
number
|
||||
> = {
|
||||
DAILY: RRule.DAILY,
|
||||
WEEKLY: RRule.WEEKLY,
|
||||
MONTHLY: RRule.MONTHLY,
|
||||
@@ -58,7 +65,10 @@ const sortWeekdays = (days: string[] | undefined): Weekday[] => {
|
||||
if (!days?.length) return [];
|
||||
return [...days]
|
||||
.filter((day): day is Weekday => WEEKDAY_ORDER.includes(day as Weekday))
|
||||
.sort((left, right) => WEEKDAY_ORDER.indexOf(left) - WEEKDAY_ORDER.indexOf(right));
|
||||
.sort(
|
||||
(left, right) =>
|
||||
WEEKDAY_ORDER.indexOf(left) - WEEKDAY_ORDER.indexOf(right),
|
||||
);
|
||||
};
|
||||
|
||||
const toUntilDate = (until: string): Date => {
|
||||
@@ -105,7 +115,10 @@ export const validateRecurrence = (
|
||||
errors.rule = "Interval must be at least 1.";
|
||||
}
|
||||
|
||||
if (value.count !== undefined && (!Number.isInteger(value.count) || value.count < 1)) {
|
||||
if (
|
||||
value.count !== undefined &&
|
||||
(!Number.isInteger(value.count) || value.count < 1)
|
||||
) {
|
||||
errors.count = "Count must be a whole number greater than 0.";
|
||||
}
|
||||
|
||||
@@ -164,14 +177,22 @@ export const serializeRecurrenceRule = (
|
||||
|
||||
const validation = validateRecurrence(value);
|
||||
if (!validation.isValid) {
|
||||
throw new Error(validation.errors.rule || validation.errors.count || validation.errors.until || "Invalid recurrence.");
|
||||
throw new Error(
|
||||
validation.errors.rule ||
|
||||
validation.errors.count ||
|
||||
validation.errors.until ||
|
||||
"Invalid recurrence.",
|
||||
);
|
||||
}
|
||||
|
||||
const options = toRRuleOptions(value, dtstart);
|
||||
if (!options) return undefined;
|
||||
|
||||
const rule = new RRule(options);
|
||||
const ruleLine = rule.toString().split("\n").find((line) => line.startsWith("RRULE:"));
|
||||
const ruleLine = rule
|
||||
.toString()
|
||||
.split("\n")
|
||||
.find((line) => line.startsWith("RRULE:"));
|
||||
if (!ruleLine) return undefined;
|
||||
|
||||
const entries = ruleLine.replace(/^RRULE:/, "").split(";");
|
||||
@@ -181,9 +202,14 @@ export const serializeRecurrenceRule = (
|
||||
const rightKey = right.split("=")[0] ?? "";
|
||||
const leftIndex = orderedKeys.indexOf(leftKey);
|
||||
const rightIndex = orderedKeys.indexOf(rightKey);
|
||||
const normalizedLeftIndex = leftIndex === -1 ? Number.MAX_SAFE_INTEGER : leftIndex;
|
||||
const normalizedRightIndex = rightIndex === -1 ? Number.MAX_SAFE_INTEGER : rightIndex;
|
||||
return normalizedLeftIndex - normalizedRightIndex || leftKey.localeCompare(rightKey);
|
||||
const normalizedLeftIndex =
|
||||
leftIndex === -1 ? Number.MAX_SAFE_INTEGER : leftIndex;
|
||||
const normalizedRightIndex =
|
||||
rightIndex === -1 ? Number.MAX_SAFE_INTEGER : rightIndex;
|
||||
return (
|
||||
normalizedLeftIndex - normalizedRightIndex ||
|
||||
leftKey.localeCompare(rightKey)
|
||||
);
|
||||
});
|
||||
|
||||
return sorted.join(";");
|
||||
@@ -213,7 +239,10 @@ export const formatRecurrenceText = (rule?: string): string | null => {
|
||||
return rrulestr(`RRULE:${rule}`).toText();
|
||||
};
|
||||
|
||||
export const getWeekdayOptions = (): Array<{ value: Weekday; label: string }> => [
|
||||
export const getWeekdayOptions = (): Array<{
|
||||
value: Weekday;
|
||||
label: string;
|
||||
}> => [
|
||||
{ value: "MO", label: "Mon" },
|
||||
{ value: "TU", label: "Tue" },
|
||||
{ value: "WE", label: "Wed" },
|
||||
@@ -228,7 +257,10 @@ export const isWeekdayPreset = (value: RecurrenceFormValue): boolean =>
|
||||
value.interval === 1 &&
|
||||
value.byDay.join(",") === ["MO", "TU", "WE", "TH", "FR"].join(",");
|
||||
|
||||
export const recurrenceFrequencyLabels: Record<SupportedRecurrenceFrequency, string> = {
|
||||
export const recurrenceFrequencyLabels: Record<
|
||||
SupportedRecurrenceFrequency,
|
||||
string
|
||||
> = {
|
||||
NONE: "Does not repeat",
|
||||
DAILY: "Daily",
|
||||
WEEKLY: "Weekly",
|
||||
|
||||
22
src/lib/ui-shell-contract.ts
Normal file
22
src/lib/ui-shell-contract.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const APP_HEADER_SURFACE_CLASSES =
|
||||
"glass-surface mb-4 flex items-center justify-between gap-3 px-4 py-3";
|
||||
|
||||
export const APP_SECTION_SURFACE_CLASSES = "glass-panel p-4 sm:p-5";
|
||||
|
||||
export const APP_ACTION_BAR_CLASSES = "glass-subtle mb-4 p-3";
|
||||
|
||||
export const APP_NAV_SURFACE_CLASSES =
|
||||
"glass-surface fixed inset-x-4 bottom-4 mx-auto flex max-w-3xl items-center justify-between px-3 py-2 sm:inset-x-6 lg:inset-x-8";
|
||||
|
||||
const CONNECTION_BADGE_BASE_CLASSES =
|
||||
"gap-1.5 border px-2.5 py-1 text-xs font-medium shadow-none";
|
||||
|
||||
export const getConnectionBadgeClasses = (isOnline: boolean) =>
|
||||
cn(
|
||||
CONNECTION_BADGE_BASE_CLASSES,
|
||||
isOnline
|
||||
? "border-emerald-500/35 bg-emerald-500/15 text-emerald-700 dark:border-emerald-400/25 dark:bg-emerald-500/15 dark:text-emerald-300 [&>svg]:text-emerald-500"
|
||||
: "border-border/70 bg-muted/55 text-muted-foreground dark:bg-muted/35 [&>svg]:text-muted-foreground",
|
||||
);
|
||||
118
src/lib/user-settings.ts
Normal file
118
src/lib/user-settings.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export interface UserSettings {
|
||||
aiEnabled: boolean;
|
||||
skipAiReview: boolean;
|
||||
}
|
||||
|
||||
export const USER_SETTINGS_STORAGE_KEY = "localcal:user-settings";
|
||||
|
||||
const DEFAULT_USER_SETTINGS: UserSettings = {
|
||||
aiEnabled: true,
|
||||
skipAiReview: false,
|
||||
};
|
||||
|
||||
export const getDefaultUserSettings = (): UserSettings => ({
|
||||
...DEFAULT_USER_SETTINGS,
|
||||
});
|
||||
|
||||
type UserSettingsStorage = Pick<Storage, "getItem" | "setItem">;
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null;
|
||||
|
||||
const parseUserSettings = (value: unknown): UserSettings => {
|
||||
if (!isRecord(value)) {
|
||||
return getDefaultUserSettings();
|
||||
}
|
||||
|
||||
return {
|
||||
aiEnabled:
|
||||
typeof value.aiEnabled === "boolean"
|
||||
? value.aiEnabled
|
||||
: DEFAULT_USER_SETTINGS.aiEnabled,
|
||||
skipAiReview:
|
||||
typeof value.skipAiReview === "boolean"
|
||||
? value.skipAiReview
|
||||
: DEFAULT_USER_SETTINGS.skipAiReview,
|
||||
};
|
||||
};
|
||||
|
||||
export const loadUserSettings = (
|
||||
storage?: UserSettingsStorage,
|
||||
): UserSettings => {
|
||||
if (!storage) {
|
||||
return getDefaultUserSettings();
|
||||
}
|
||||
|
||||
try {
|
||||
const storedValue = storage.getItem(USER_SETTINGS_STORAGE_KEY);
|
||||
if (!storedValue) {
|
||||
return getDefaultUserSettings();
|
||||
}
|
||||
|
||||
return parseUserSettings(JSON.parse(storedValue));
|
||||
} catch {
|
||||
return getDefaultUserSettings();
|
||||
}
|
||||
};
|
||||
|
||||
export const saveUserSettings = (
|
||||
settings: UserSettings,
|
||||
storage?: UserSettingsStorage,
|
||||
): UserSettings => {
|
||||
if (!storage) {
|
||||
return settings;
|
||||
}
|
||||
|
||||
try {
|
||||
storage.setItem(USER_SETTINGS_STORAGE_KEY, JSON.stringify(settings));
|
||||
} catch {
|
||||
return settings;
|
||||
}
|
||||
|
||||
return settings;
|
||||
};
|
||||
|
||||
const getBrowserStorage = (): UserSettingsStorage | undefined => {
|
||||
if (typeof window === "undefined") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
return window.localStorage;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
export const useUserSettings = () => {
|
||||
const [settings, setSettings] = useState<UserSettings>(
|
||||
getDefaultUserSettings,
|
||||
);
|
||||
const [hasLoadedSettings, setHasLoadedSettings] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setSettings(loadUserSettings(getBrowserStorage()));
|
||||
setHasLoadedSettings(true);
|
||||
}, []);
|
||||
|
||||
const updateSettings = (changes: Partial<UserSettings>) => {
|
||||
setSettings((currentSettings) => {
|
||||
const nextSettings = {
|
||||
...currentSettings,
|
||||
...changes,
|
||||
};
|
||||
|
||||
saveUserSettings(nextSettings, getBrowserStorage());
|
||||
|
||||
return nextSettings;
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
hasLoadedSettings,
|
||||
settings,
|
||||
updateSettings,
|
||||
};
|
||||
};
|
||||
17
tests/ai-create-settings.test.ts
Normal file
17
tests/ai-create-settings.test.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { getAiCreateOutcome } from "@/lib/ai-create-flow";
|
||||
|
||||
describe("AI create flow settings", () => {
|
||||
test("single AI event stays in review flow when skip-review is disabled", () => {
|
||||
expect(getAiCreateOutcome(1, false)).toBe("review-single");
|
||||
});
|
||||
|
||||
test("single AI event is created directly when skip-review is enabled", () => {
|
||||
expect(getAiCreateOutcome(1, true)).toBe("create-single");
|
||||
});
|
||||
|
||||
test("multi-event AI responses always create directly regardless of skip-review", () => {
|
||||
expect(getAiCreateOutcome(2, false)).toBe("create-multiple");
|
||||
expect(getAiCreateOutcome(3, true)).toBe("create-multiple");
|
||||
});
|
||||
});
|
||||
35
tests/ai-routes.test.ts
Normal file
35
tests/ai-routes.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
getAiDisabledMessage,
|
||||
isAdminAiEnabled,
|
||||
isAiFlagEnabled,
|
||||
isClientAiEnabled,
|
||||
} from "@/lib/ai-feature-flags";
|
||||
|
||||
describe("AI feature flags", () => {
|
||||
test("AI admin flag defaults to enabled when unset", () => {
|
||||
expect(isAdminAiEnabled({})).toBe(true);
|
||||
});
|
||||
|
||||
test("AI admin flag disables routes when explicitly false", () => {
|
||||
expect(isAdminAiEnabled({ AI_ENABLED: "false" })).toBe(false);
|
||||
expect(isAdminAiEnabled({ AI_ENABLED: "0" })).toBe(false);
|
||||
});
|
||||
|
||||
test("AI client flag follows NEXT_PUBLIC_AI_ENABLED before server fallback", () => {
|
||||
expect(
|
||||
isClientAiEnabled({ NEXT_PUBLIC_AI_ENABLED: "false", AI_ENABLED: "true" }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("truthy and falsy parsing remains explicit and predictable", () => {
|
||||
expect(isAiFlagEnabled("yes")).toBe(true);
|
||||
expect(isAiFlagEnabled("off")).toBe(false);
|
||||
expect(isAiFlagEnabled("unexpected-value")).toBe(true);
|
||||
});
|
||||
|
||||
test("disabled message explains the admin-controlled state", () => {
|
||||
expect(getAiDisabledMessage().toLowerCase()).toContain("disabled");
|
||||
expect(getAiDisabledMessage().toLowerCase()).toContain("administrator");
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { getAiDisabledMessage } from "@/lib/ai-feature-flags";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -35,6 +36,9 @@ const LOCKED_AI_CTA_CLASSES =
|
||||
const LOCKED_AI_TEXT_CLASSES =
|
||||
"text-sm font-medium text-foreground";
|
||||
|
||||
const DISABLED_AI_TEXT_CLASSES =
|
||||
"text-sm leading-relaxed text-muted-foreground";
|
||||
|
||||
/** Data zone: neutral surface, clearly secondary to AI zone */
|
||||
const DATA_ZONE_CLASSES =
|
||||
"flex items-center gap-2 flex-wrap";
|
||||
@@ -86,6 +90,23 @@ describe("AI zone – locked state CTA (unauthenticated)", () => {
|
||||
const resolved = cn(LOCKED_AI_TEXT_CLASSES);
|
||||
expect(resolved).toContain("font-medium");
|
||||
});
|
||||
|
||||
test("locked CTA copy clearly requires signing in", () => {
|
||||
const copy = "Sign in required to generate event drafts with AI";
|
||||
expect(copy.toLowerCase()).toContain("sign in");
|
||||
expect(copy.toLowerCase()).toContain("required");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AI zone – disabled state", () => {
|
||||
test("disabled AI body text stays muted because it is informative, not a CTA", () => {
|
||||
const resolved = cn(DISABLED_AI_TEXT_CLASSES);
|
||||
expect(resolved).toContain("text-muted-foreground");
|
||||
});
|
||||
|
||||
test("admin-disabled copy explains the unavailable state", () => {
|
||||
expect(getAiDisabledMessage().toLowerCase()).toContain("disabled");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Cycle 2: Data zone action buttons ──────────────────────────────────────
|
||||
|
||||
72
tests/date-time-picker.test.ts
Normal file
72
tests/date-time-picker.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
applyQuickDateShortcut,
|
||||
getCalendarMonthForValue,
|
||||
} from "@/components/date-time-picker";
|
||||
|
||||
describe("DateTimePicker quick shortcuts", () => {
|
||||
test("Today selects today, navigates the visible month, and keeps the picker open", () => {
|
||||
const now = new Date("2026-04-09T10:30:00Z");
|
||||
|
||||
const result = applyQuickDateShortcut({
|
||||
shortcut: "Today",
|
||||
value: "2026-06-15T14:30:00",
|
||||
allDay: false,
|
||||
now,
|
||||
});
|
||||
|
||||
expect(result.nextValue).toBe("2026-04-09T14:30:00");
|
||||
expect(result.nextMonth.toISOString()).toBe("2026-04-09T00:00:00.000Z");
|
||||
expect(result.keepOpen).toBe(true);
|
||||
});
|
||||
|
||||
test("Next week moves selection forward a week without dropping the existing time", () => {
|
||||
const now = new Date("2026-04-09T10:30:00Z");
|
||||
|
||||
const result = applyQuickDateShortcut({
|
||||
shortcut: "Next week",
|
||||
value: "2026-04-01T09:45:00",
|
||||
allDay: false,
|
||||
now,
|
||||
});
|
||||
|
||||
expect(result.nextValue).toBe("2026-04-16T09:45:00");
|
||||
expect(result.nextMonth.toISOString()).toBe("2026-04-16T00:00:00.000Z");
|
||||
});
|
||||
|
||||
test("Next month updates the selected all-day date and visible month in place", () => {
|
||||
const now = new Date("2026-04-09T10:30:00Z");
|
||||
|
||||
const result = applyQuickDateShortcut({
|
||||
shortcut: "Next month",
|
||||
value: "2026-04-01",
|
||||
allDay: true,
|
||||
now,
|
||||
});
|
||||
|
||||
expect(result.nextValue).toBe("2026-05-09");
|
||||
expect(result.nextMonth.toISOString()).toBe("2026-05-09T00:00:00.000Z");
|
||||
expect(result.keepOpen).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DateTimePicker visible month", () => {
|
||||
test("uses the selected value month when one is present", () => {
|
||||
const fallbackDate = new Date("2026-04-09T10:30:00Z");
|
||||
|
||||
const visibleMonth = getCalendarMonthForValue(
|
||||
"2026-07-18T08:15:00",
|
||||
fallbackDate,
|
||||
);
|
||||
|
||||
expect(visibleMonth.toISOString()).toBe("2026-07-18T08:15:00.000Z");
|
||||
});
|
||||
|
||||
test("falls back to the current month when no value is selected yet", () => {
|
||||
const fallbackDate = new Date("2026-04-09T10:30:00Z");
|
||||
|
||||
const visibleMonth = getCalendarMonthForValue("", fallbackDate);
|
||||
|
||||
expect(visibleMonth.toISOString()).toBe("2026-04-09T10:30:00.000Z");
|
||||
});
|
||||
});
|
||||
111
tests/location-autocomplete.test.ts
Normal file
111
tests/location-autocomplete.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import {
|
||||
getGoogleMapsLocationCapability,
|
||||
getGoogleMapsPlaceLabel,
|
||||
mapGooglePlacesSuggestions,
|
||||
} from "@/lib/google-maps";
|
||||
|
||||
const renderLocationAutocomplete = async (capability: {
|
||||
enabled: boolean;
|
||||
region: string;
|
||||
reason: "configured" | "missing_server_api_key" | "disabled";
|
||||
}) => {
|
||||
const { LocationAutocomplete } = await import("@/components/location-autocomplete");
|
||||
|
||||
return renderToStaticMarkup(
|
||||
createElement(LocationAutocomplete, {
|
||||
capability,
|
||||
id: "event-location",
|
||||
onChange: () => {},
|
||||
value: "",
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
describe("Google Maps location capability boundary", () => {
|
||||
test("disables Google Maps search when the server API key is missing", () => {
|
||||
expect(getGoogleMapsLocationCapability({ serverApiKey: "" })).toEqual({
|
||||
enabled: false,
|
||||
reason: "missing_server_api_key",
|
||||
region: "us",
|
||||
});
|
||||
});
|
||||
|
||||
test("enables Google Maps search when the server API key is present", () => {
|
||||
expect(
|
||||
getGoogleMapsLocationCapability({ serverApiKey: "maps-server-key", region: "gb" }),
|
||||
).toEqual({
|
||||
enabled: true,
|
||||
reason: "configured",
|
||||
region: "gb",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("LocationAutocomplete fallback mode", () => {
|
||||
test("renders a plain text location field when Google Maps configuration is unavailable", async () => {
|
||||
const markup = await renderLocationAutocomplete({
|
||||
enabled: false,
|
||||
reason: "missing_server_api_key",
|
||||
region: "us",
|
||||
});
|
||||
|
||||
expect(markup).toContain('data-location-mode="manual"');
|
||||
expect(markup).toContain('placeholder="Location"');
|
||||
expect(markup).not.toContain("Search Google Maps");
|
||||
});
|
||||
});
|
||||
|
||||
describe("LocationAutocomplete configured mode", () => {
|
||||
test("renders a server-backed Google Maps-assisted input path while keeping manual typing available", async () => {
|
||||
const markup = await renderLocationAutocomplete({
|
||||
enabled: true,
|
||||
reason: "configured",
|
||||
region: "us",
|
||||
});
|
||||
|
||||
expect(markup).toContain('data-location-mode="google-maps-server"');
|
||||
expect(markup).toContain('placeholder="Search Google Maps or type a location"');
|
||||
expect(markup).toContain("Search Google Maps or keep typing a custom location.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Google Maps place label selection", () => {
|
||||
test("prefers the chosen prediction label for the visible location value", () => {
|
||||
expect(
|
||||
getGoogleMapsPlaceLabel({
|
||||
displayName: "Google HQ",
|
||||
formattedAddress: "1600 Amphitheatre Parkway, Mountain View, CA",
|
||||
predictionText: "Googleplex",
|
||||
}),
|
||||
).toBe("Googleplex");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Google Places server response mapping", () => {
|
||||
test("maps server autocomplete predictions into lightweight suggestion records", () => {
|
||||
expect(
|
||||
mapGooglePlacesSuggestions({
|
||||
suggestions: [
|
||||
{
|
||||
placePrediction: {
|
||||
place: "places/abc123",
|
||||
structuredFormat: {
|
||||
secondaryText: { text: "Mountain View, CA" },
|
||||
},
|
||||
text: { text: "Googleplex" },
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
formattedAddress: "Mountain View, CA",
|
||||
placeId: "abc123",
|
||||
text: "Googleplex",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
38
tests/ui-shell-contract.test.ts
Normal file
38
tests/ui-shell-contract.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
APP_HEADER_SURFACE_CLASSES,
|
||||
APP_NAV_SURFACE_CLASSES,
|
||||
APP_SECTION_SURFACE_CLASSES,
|
||||
getConnectionBadgeClasses,
|
||||
} from "@/lib/ui-shell-contract";
|
||||
import { EVENT_CARD_SURFACE_CLASSES } from "@/components/event-card";
|
||||
|
||||
describe("app shell surfaces", () => {
|
||||
test("header, primary sections, and bottom navigation all use shared glass utilities", () => {
|
||||
expect(APP_HEADER_SURFACE_CLASSES).toMatch(/glass-surface/);
|
||||
expect(APP_SECTION_SURFACE_CLASSES).toMatch(/glass-panel/);
|
||||
expect(APP_NAV_SURFACE_CLASSES).toMatch(/glass-surface/);
|
||||
});
|
||||
|
||||
test("section surface keeps responsive padding for mobile and larger breakpoints", () => {
|
||||
expect(APP_SECTION_SURFACE_CLASSES).toContain("p-4");
|
||||
expect(APP_SECTION_SURFACE_CLASSES).toContain("sm:p-5");
|
||||
});
|
||||
});
|
||||
|
||||
describe("event cards", () => {
|
||||
test("event cards use the shared glass card treatment instead of a one-off surface", () => {
|
||||
expect(EVENT_CARD_SURFACE_CLASSES).toMatch(/glass-card/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("connection badge", () => {
|
||||
test("online-ready badge gets a success treatment while offline stays neutral", () => {
|
||||
const onlineClasses = getConnectionBadgeClasses(true);
|
||||
const offlineClasses = getConnectionBadgeClasses(false);
|
||||
|
||||
expect(onlineClasses).toMatch(/emerald/);
|
||||
expect(offlineClasses).not.toMatch(/emerald/);
|
||||
expect(offlineClasses).toContain("text-muted-foreground");
|
||||
});
|
||||
});
|
||||
109
tests/user-settings.test.ts
Normal file
109
tests/user-settings.test.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
USER_SETTINGS_STORAGE_KEY,
|
||||
getDefaultUserSettings,
|
||||
loadUserSettings,
|
||||
saveUserSettings,
|
||||
type UserSettings,
|
||||
} from "@/lib/user-settings";
|
||||
|
||||
const createStorage = (initialState?: Record<string, string>) => {
|
||||
const state = { ...initialState };
|
||||
|
||||
return {
|
||||
getItem(key: string) {
|
||||
return state[key] ?? null;
|
||||
},
|
||||
setItem(key: string, value: string) {
|
||||
state[key] = value;
|
||||
},
|
||||
read(key: string) {
|
||||
return state[key];
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const createFailingStorage = () => ({
|
||||
getItem() {
|
||||
throw new Error("storage read failed");
|
||||
},
|
||||
setItem() {
|
||||
throw new Error("storage write failed");
|
||||
},
|
||||
});
|
||||
|
||||
describe("user settings defaults", () => {
|
||||
test("returns typed defaults for future feature flags", () => {
|
||||
expect(getDefaultUserSettings()).toEqual({
|
||||
aiEnabled: true,
|
||||
skipAiReview: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("loads defaults when no persisted settings exist yet", () => {
|
||||
const storage = createStorage();
|
||||
|
||||
expect(loadUserSettings(storage)).toEqual(getDefaultUserSettings());
|
||||
});
|
||||
|
||||
test("loads the saved values from shared persisted settings storage", () => {
|
||||
const savedSettings: UserSettings = {
|
||||
aiEnabled: false,
|
||||
skipAiReview: true,
|
||||
};
|
||||
const storage = createStorage({
|
||||
[USER_SETTINGS_STORAGE_KEY]: JSON.stringify(savedSettings),
|
||||
});
|
||||
|
||||
expect(loadUserSettings(storage)).toEqual(savedSettings);
|
||||
});
|
||||
|
||||
test("keeps defaults for missing or malformed saved fields", () => {
|
||||
const storage = createStorage({
|
||||
[USER_SETTINGS_STORAGE_KEY]: JSON.stringify({ skipAiReview: true, aiEnabled: "nope" }),
|
||||
});
|
||||
|
||||
expect(loadUserSettings(storage)).toEqual({
|
||||
aiEnabled: true,
|
||||
skipAiReview: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("falls back to defaults when persisted settings are not valid JSON", () => {
|
||||
const storage = createStorage({
|
||||
[USER_SETTINGS_STORAGE_KEY]: "{not-json",
|
||||
});
|
||||
|
||||
expect(loadUserSettings(storage)).toEqual(getDefaultUserSettings());
|
||||
});
|
||||
|
||||
test("falls back to defaults when storage cannot be read", () => {
|
||||
expect(loadUserSettings(createFailingStorage())).toEqual(
|
||||
getDefaultUserSettings(),
|
||||
);
|
||||
});
|
||||
|
||||
test("saves a complete settings snapshot under the shared persistence key", () => {
|
||||
const storage = createStorage();
|
||||
const nextSettings: UserSettings = {
|
||||
aiEnabled: false,
|
||||
skipAiReview: true,
|
||||
};
|
||||
|
||||
expect(saveUserSettings(nextSettings, storage)).toEqual(nextSettings);
|
||||
expect(storage.read(USER_SETTINGS_STORAGE_KEY)).toBe(
|
||||
JSON.stringify(nextSettings),
|
||||
);
|
||||
});
|
||||
|
||||
test("returns the requested settings even when persistence fails", () => {
|
||||
const nextSettings: UserSettings = {
|
||||
aiEnabled: false,
|
||||
skipAiReview: true,
|
||||
};
|
||||
|
||||
expect(saveUserSettings(nextSettings, createFailingStorage())).toEqual(
|
||||
nextSettings,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user