Compare commits

...

6 Commits

45 changed files with 5873 additions and 330 deletions

View File

@@ -1,3 +0,0 @@
POSTGRES_PASSWORD=
POSTGRES_USER=
POSTGRES_DB=

291
bun.lock
View File

@@ -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=="],
}
}

View File

@@ -1,21 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {
"@diceui": "https://diceui.com/r/{name}.json",
"@coss": "https://coss.com/ui/r/{name}.json"
}
}

View File

@@ -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",

View File

@@ -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(),
});

View File

@@ -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(),
});

View 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),
});
}

View 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 &amp; 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>
);
}

View File

@@ -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;
}
}

View File

@@ -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,134 +425,153 @@ 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">
<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">
Create with AI
</p>
<h2 className="text-lg font-semibold tracking-tight">
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.
</p>
</div>
</div>
<AIToolbar
isAuthenticated={!!session?.user}
isPending={isPending}
aiPrompt={aiPrompt}
setAiPrompt={setAiPrompt}
aiLoading={aiLoading}
imagePreviews={imagePreviews}
onImagesSelect={handleImagesSelect}
onImageRemove={handleImageRemove}
onAiCreate={handleAiCreate}
onAiTemplateSelect={runAiCreate}
onAiSummarize={handleAiSummarize}
onSummaryDismiss={() => setSummary(null)}
summary={summary}
summaryUpdated={summaryUpdated}
events={events}
{activeView === "settings" ? (
<SettingsPanel
adminAiEnabled={adminAiEnabled}
className={APP_SECTION_SURFACE_CLASSES}
hasLoadedSettings={hasLoadedSettings}
onSettingsChange={updateSettings}
settings={settings}
/>
</section>
) : (
<>
<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">
Create with AI
</p>
<h2 className="text-lg font-semibold tracking-tight">
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.
</p>
</div>
</div>
<section className="rounded-[1.5rem] border border-border/70 bg-card/95 p-4 shadow-sm sm:p-5">
<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">
Events
</p>
<h2 className="text-lg font-semibold tracking-tight">
Your local calendar timeline
</h2>
</div>
<div className="rounded-full bg-muted px-3 py-1 text-xs font-medium text-muted-foreground">
{events.length} item{events.length === 1 ? "" : "s"}
</div>
</div>
<AIToolbar
adminAiEnabled={adminAiEnabled}
aiEnabled={settings.aiEnabled}
isAuthenticated={!!session?.user}
isPending={isPending}
aiPrompt={aiPrompt}
setAiPrompt={setAiPrompt}
aiLoading={aiLoading}
imagePreviews={imagePreviews}
onImagesSelect={handleImagesSelect}
onImageRemove={handleImageRemove}
onAiCreate={handleAiCreate}
onAiTemplateSelect={runAiCreate}
onAiSummarize={handleAiSummarize}
onSummaryDismiss={() => setSummary(null)}
summary={summary}
summaryUpdated={summaryUpdated}
events={events}
/>
</section>
<div className="mb-4 rounded-2xl border border-border/70 bg-muted/35 p-3">
<div className="flex flex-wrap items-center gap-2">
<IcsFilePicker
onFileSelect={handleImport}
variant="outline"
size="sm"
className="h-9 rounded-xl gap-1.5 text-xs"
>
Import
</IcsFilePicker>
<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">
Events
</p>
<h2 className="text-lg font-semibold tracking-tight">
Your local calendar timeline
</h2>
</div>
<div className="rounded-full bg-muted px-3 py-1 text-xs font-medium text-muted-foreground">
{events.length} item{events.length === 1 ? "" : "s"}
</div>
</div>
{events.length > 0 && (
<>
<Button
type="button"
<div className={APP_ACTION_BAR_CLASSES}>
<div className="flex flex-wrap items-center gap-2">
<IcsFilePicker
onFileSelect={handleImport}
variant="outline"
size="sm"
onClick={handleExport}
className="h-9 rounded-xl gap-1.5 text-xs"
>
Export
</Button>
Import
</IcsFilePicker>
{events.length > 0 && (
<>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleExport}
className="h-9 rounded-xl gap-1.5 text-xs"
>
Export
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleClearAll}
className="h-9 rounded-xl gap-1.5 text-xs text-muted-foreground hover:text-destructive"
>
Clear
</Button>
</>
)}
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleClearAll}
className="h-9 rounded-xl gap-1.5 text-xs text-muted-foreground hover:text-destructive"
onClick={() => {
resetForm();
setDialogSource("manual");
setDialogOpen(true);
}}
className="ml-auto h-9 rounded-xl gap-1.5 text-xs"
>
Clear
Manual event
</Button>
</>
)}
</div>
</div>
<Button
type="button"
size="sm"
onClick={() => {
resetForm();
setDialogSource("manual");
setDialogOpen(true);
}}
className="ml-auto h-9 rounded-xl gap-1.5 text-xs"
>
Manual event
</Button>
</div>
</div>
<EventsList
events={events}
onEdit={handleEdit}
onDelete={handleDelete}
/>
</section>
<EventsList
events={events}
onEdit={handleEdit}
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

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -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">

View File

@@ -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,45 +252,65 @@ 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 && (
<Controller
name="end"
control={control}
render={() => (
<div className="flex gap-1 pl-0.5">
{DURATIONS.map(({ label, minutes }) => (
<Button
key={label}
type="button"
variant="ghost"
size="sm"
disabled={!start}
onClick={() => handleApplyDuration(minutes, allDay, start)}
className="px-2 py-1 text-xs text-muted-foreground"
>
{label}
</Button>
))}
</div>
)}
/>
<Controller
name="end"
control={control}
render={() => (
<div className="flex gap-1 pl-0.5">
{DURATIONS.map(({ label, minutes }) => (
<Button
key={label}
type="button"
variant="ghost"
size="sm"
disabled={!start}
onClick={() =>
handleApplyDuration(minutes, allDay, start)
}
className="px-2 py-1 text-xs text-muted-foreground"
>
{label}
</Button>
))}
</div>
)}
/>
)}
<Controller
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>

View 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>
);
};

View File

@@ -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]) => (
<SelectItem key={optionValue} value={optionValue}>
{label}
</SelectItem>
))}
{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>

View File

@@ -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>

View 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>
);
}

View 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>
);
}

View 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,
};

View 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
View 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,
};

View 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}
/>
);
}

View 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}
/>
);
}

File diff suppressed because it is too large Load Diff

View 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
View 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 };

View 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
View 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
View 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";
};

View 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
View 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 };

View File

@@ -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);

View File

@@ -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
View 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;
};

View File

@@ -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",

View 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
View 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,
};
};

View 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
View 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");
});
});

View File

@@ -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 ──────────────────────────────────────

View 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");
});
});

View 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",
},
]);
});
});

View 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
View 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,
);
});
});