diff --git a/frontend/components.json b/frontend/components.json new file mode 100644 index 0000000..8d53b9c --- /dev/null +++ b/frontend/components.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "src/index.css", + "baseColor": "zinc", + "cssVariables": false, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "hooks": "@/hooks", + "lib": "@/lib" + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index aea0f16..9153318 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,34 +11,44 @@ "@radix-ui/react-accordion": "^1.1.2", "@radix-ui/react-alert-dialog": "^1.0.5", "@radix-ui/react-checkbox": "^1.0.4", + "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-context-menu": "^2.1.5", "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-label": "^2.0.2", "@radix-ui/react-popover": "^1.0.7", + "@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-scroll-area": "^1.0.5", "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-separator": "^1.0.3", "@radix-ui/react-slider": "^1.1.2", + "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-switch": "^1.0.3", "@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-toast": "^1.1.5", + "@radix-ui/react-toggle": "^1.1.10", + "@radix-ui/react-toggle-group": "^1.1.11", "@radix-ui/react-tooltip": "^1.0.7", "@tanstack/react-query": "^5.17.0", "@tanstack/react-virtual": "^3.0.1", "axios": "^1.6.5", + "class-variance-authority": "^0.7.1", "clsx": "^2.1.0", + "cmdk": "^1.1.1", "date-fns": "^3.2.0", "framer-motion": "^10.18.0", "leaflet": "^1.9.4", "lucide-react": "^0.303.0", "react": "^18.2.0", + "react-day-picker": "^8.10.1", "react-dom": "^18.2.0", "react-hotkeys-hook": "^4.4.3", "react-intersection-observer": "^9.5.3", "react-leaflet": "^4.2.1", "react-leaflet-cluster": "^2.1.0", + "sonner": "^2.0.7", "tailwind-merge": "^2.2.0", + "tailwindcss-animate": "^1.0.7", "zustand": "^4.4.7" }, "devDependencies": { @@ -63,7 +73,6 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -973,7 +982,6 @@ "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -995,7 +1003,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -1005,14 +1012,12 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -1023,7 +1028,6 @@ "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.stat": "2.0.5", @@ -1037,7 +1041,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, "license": "MIT", "engines": { "node": ">= 8" @@ -1047,7 +1050,6 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.scandir": "2.1.5", @@ -1128,6 +1130,24 @@ } } }, + "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "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" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-arrow": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", @@ -1237,6 +1257,24 @@ } } }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "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" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-compose-refs": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", @@ -1331,6 +1369,24 @@ } } }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "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" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-direction": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", @@ -1506,24 +1562,6 @@ } } }, - "node_modules/@radix-ui/react-label/node_modules/@radix-ui/react-slot": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", - "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", - "license": "MIT", - "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" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-menu": { "version": "2.1.16", "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz", @@ -1564,6 +1602,24 @@ } } }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "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" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-popover": { "version": "1.1.15", "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz", @@ -1601,6 +1657,24 @@ } } }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "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" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-popper": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", @@ -1704,6 +1778,56 @@ } } }, + "node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "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" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-radio-group": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz", + "integrity": "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==", + "license": "MIT", + "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-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "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" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-roving-focus": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", @@ -1809,6 +1933,24 @@ } } }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "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" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-separator": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.8.tgz", @@ -1855,24 +1997,6 @@ } } }, - "node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-slot": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", - "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", - "license": "MIT", - "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" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-slider": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.6.tgz", @@ -1907,9 +2031,9 @@ } }, "node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", + "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" @@ -2017,6 +2141,60 @@ } } }, + "node_modules/@radix-ui/react-toggle": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz", + "integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@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" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle-group": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.11.tgz", + "integrity": "sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-toggle": "1.1.10", + "@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" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-tooltip": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", @@ -2051,6 +2229,24 @@ } } }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "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" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-callback-ref": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", @@ -3073,14 +3269,12 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", - "dev": true, "license": "MIT" }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, "license": "ISC", "dependencies": { "normalize-path": "^3.0.0", @@ -3094,7 +3288,6 @@ "version": "5.0.2", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", - "dev": true, "license": "MIT" }, "node_modules/argparse": { @@ -3204,7 +3397,6 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -3227,7 +3419,6 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, "license": "MIT", "dependencies": { "fill-range": "^7.1.1" @@ -3297,7 +3488,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", - "dev": true, "license": "MIT", "engines": { "node": ">= 6" @@ -3345,7 +3535,6 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, "license": "MIT", "dependencies": { "anymatch": "~3.1.2", @@ -3370,7 +3559,6 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.1" @@ -3379,6 +3567,18 @@ "node": ">= 6" } }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -3388,6 +3588,22 @@ "node": ">=6" } }, + "node_modules/cmdk": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz", + "integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "^1.1.1", + "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-id": "^1.1.0", + "@radix-ui/react-primitive": "^2.0.2" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "react-dom": "^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -3424,7 +3640,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", - "dev": true, "license": "MIT", "engines": { "node": ">= 6" @@ -3463,7 +3678,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "dev": true, "license": "MIT", "bin": { "cssesc": "bin/cssesc" @@ -3533,7 +3747,6 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", - "dev": true, "license": "Apache-2.0" }, "node_modules/dir-glob": { @@ -3553,7 +3766,6 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", - "dev": true, "license": "MIT" }, "node_modules/doctrine": { @@ -3906,7 +4118,6 @@ "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.stat": "^2.0.2", @@ -3923,7 +4134,6 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.1" @@ -3950,7 +4160,6 @@ "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", - "dev": true, "license": "ISC", "dependencies": { "reusify": "^1.0.4" @@ -3973,7 +4182,6 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" @@ -4106,7 +4314,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -4208,7 +4415,6 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.3" @@ -4406,7 +4612,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, "license": "MIT", "dependencies": { "binary-extensions": "^2.0.0" @@ -4419,7 +4624,6 @@ "version": "2.16.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", - "dev": true, "license": "MIT", "dependencies": { "hasown": "^2.0.2" @@ -4435,7 +4639,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -4445,7 +4648,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -4458,7 +4660,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.12.0" @@ -4485,7 +4686,6 @@ "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", - "dev": true, "license": "MIT", "bin": { "jiti": "bin/jiti.js" @@ -4600,7 +4800,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", - "dev": true, "license": "MIT", "engines": { "node": ">=14" @@ -4613,7 +4812,6 @@ "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true, "license": "MIT" }, "node_modules/locate-path": { @@ -4683,7 +4881,6 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 8" @@ -4693,7 +4890,6 @@ "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, "license": "MIT", "dependencies": { "braces": "^3.0.3", @@ -4751,7 +4947,6 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", - "dev": true, "license": "MIT", "dependencies": { "any-promise": "^1.0.0", @@ -4763,7 +4958,6 @@ "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, "funding": [ { "type": "github", @@ -4796,7 +4990,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -4806,7 +4999,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -4816,7 +5008,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 6" @@ -4929,7 +5120,6 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true, "license": "MIT" }, "node_modules/path-type": { @@ -4946,14 +5136,12 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, "license": "ISC" }, "node_modules/picomatch": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -4966,7 +5154,6 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -4976,7 +5163,6 @@ "version": "4.0.7", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", - "dev": true, "license": "MIT", "engines": { "node": ">= 6" @@ -4986,7 +5172,6 @@ "version": "8.5.8", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", - "dev": true, "funding": [ { "type": "opencollective", @@ -5015,7 +5200,6 @@ "version": "15.1.0", "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", - "dev": true, "license": "MIT", "dependencies": { "postcss-value-parser": "^4.0.0", @@ -5033,7 +5217,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", - "dev": true, "funding": [ { "type": "opencollective", @@ -5059,7 +5242,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", - "dev": true, "funding": [ { "type": "opencollective", @@ -5102,7 +5284,6 @@ "version": "6.2.0", "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", - "dev": true, "funding": [ { "type": "opencollective", @@ -5128,7 +5309,6 @@ "version": "6.1.2", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", - "dev": true, "license": "MIT", "dependencies": { "cssesc": "^3.0.0", @@ -5142,7 +5322,6 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", - "dev": true, "license": "MIT" }, "node_modules/prelude-ls": { @@ -5178,7 +5357,6 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, "funding": [ { "type": "github", @@ -5207,6 +5385,20 @@ "node": ">=0.10.0" } }, + "node_modules/react-day-picker": { + "version": "8.10.1", + "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-8.10.1.tgz", + "integrity": "sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA==", + "license": "MIT", + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/gpbl" + }, + "peerDependencies": { + "date-fns": "^2.28.0 || ^3.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/react-dom": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", @@ -5357,7 +5549,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", - "dev": true, "license": "MIT", "dependencies": { "pify": "^2.3.0" @@ -5367,7 +5558,6 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, "license": "MIT", "dependencies": { "picomatch": "^2.2.1" @@ -5380,7 +5570,6 @@ "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", - "dev": true, "license": "MIT", "dependencies": { "is-core-module": "^2.16.1", @@ -5411,7 +5600,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, "license": "MIT", "engines": { "iojs": ">=1.0.0", @@ -5484,7 +5672,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, "funding": [ { "type": "github", @@ -5559,11 +5746,20 @@ "node": ">=8" } }, + "node_modules/sonner": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", + "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -5599,7 +5795,6 @@ "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", @@ -5635,7 +5830,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -5658,7 +5852,6 @@ "version": "3.4.19", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", - "dev": true, "license": "MIT", "dependencies": { "@alloc/quick-lru": "^5.2.0", @@ -5692,6 +5885,15 @@ "node": ">=14.0.0" } }, + "node_modules/tailwindcss-animate": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz", + "integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==", + "license": "MIT", + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders" + } + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -5703,7 +5905,6 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", - "dev": true, "license": "MIT", "dependencies": { "any-promise": "^1.0.0" @@ -5713,7 +5914,6 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", - "dev": true, "license": "MIT", "dependencies": { "thenify": ">= 3.1.0 < 4" @@ -5726,7 +5926,6 @@ "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -5743,7 +5942,6 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -5761,7 +5959,6 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -5774,7 +5971,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, "license": "MIT", "dependencies": { "is-number": "^7.0.0" @@ -5800,7 +5996,6 @@ "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", - "dev": true, "license": "Apache-2.0" }, "node_modules/tslib": { @@ -5946,7 +6141,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, "license": "MIT" }, "node_modules/vite": { diff --git a/frontend/package.json b/frontend/package.json index 00a0795..763f45b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,34 +13,44 @@ "@radix-ui/react-accordion": "^1.1.2", "@radix-ui/react-alert-dialog": "^1.0.5", "@radix-ui/react-checkbox": "^1.0.4", + "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-context-menu": "^2.1.5", "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-label": "^2.0.2", "@radix-ui/react-popover": "^1.0.7", + "@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-scroll-area": "^1.0.5", "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-separator": "^1.0.3", "@radix-ui/react-slider": "^1.1.2", + "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-switch": "^1.0.3", "@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-toast": "^1.1.5", + "@radix-ui/react-toggle": "^1.1.10", + "@radix-ui/react-toggle-group": "^1.1.11", "@radix-ui/react-tooltip": "^1.0.7", "@tanstack/react-query": "^5.17.0", "@tanstack/react-virtual": "^3.0.1", "axios": "^1.6.5", + "class-variance-authority": "^0.7.1", "clsx": "^2.1.0", + "cmdk": "^1.1.1", "date-fns": "^3.2.0", "framer-motion": "^10.18.0", "leaflet": "^1.9.4", "lucide-react": "^0.303.0", "react": "^18.2.0", + "react-day-picker": "^8.10.1", "react-dom": "^18.2.0", "react-hotkeys-hook": "^4.4.3", "react-intersection-observer": "^9.5.3", "react-leaflet": "^4.2.1", "react-leaflet-cluster": "^2.1.0", + "sonner": "^2.0.7", "tailwind-merge": "^2.2.0", + "tailwindcss-animate": "^1.0.7", "zustand": "^4.4.7" }, "devDependencies": { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3c236bd..832a8fc 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -24,6 +24,7 @@ import { usePhotosQuery } from './hooks/usePhotosQuery' import { AuthProvider, useAuth } from './contexts/AuthContext' import { LoginPage } from './components/auth/LoginPage' import { SetupPage } from './components/auth/SetupPage' +import { TooltipProvider } from '@/components/ui/tooltip' function MainApp() { const [leftSidebarOpen, setLeftSidebarOpen] = useState(true) @@ -71,6 +72,7 @@ function MainApp() { const showRightSidebar = rightSidebarOpen && !isSettings return ( +
@@ -140,6 +142,7 @@ function MainApp() { {viewMode === 'preview' && }
+
) } diff --git a/frontend/src/components/ToastContainer.tsx b/frontend/src/components/ToastContainer.tsx index db8cd08..66ce31f 100644 --- a/frontend/src/components/ToastContainer.tsx +++ b/frontend/src/components/ToastContainer.tsx @@ -1,132 +1,34 @@ -import { useEffect, useState } from 'react' -import { CheckCircle, XCircle, Info, AlertCircle, X } from 'lucide-react' -import clsx from 'clsx' +import { toast as sonnerToast } from 'sonner' +import { Toaster } from '@/components/ui/sonner' export interface ToastAction { label: string onClick: () => void } -export interface Toast { - id: string - type: 'success' | 'error' | 'info' | 'warning' - title: string - message?: string - duration?: number - action?: ToastAction -} - -// Global toast state (in production, use Zustand or Context) -let toastListeners: ((toasts: Toast[]) => void)[] = [] -let toastList: Toast[] = [] +/** Shim that preserves the legacy `(title, message?, action?)` call + * shape used throughout the codebase while delegating to sonner for + * actual rendering. Call sites don't need to change. Actions auto- + * extend the toast duration to 8s so users have time to hit Undo. */ +const build = (message?: string, action?: ToastAction, duration = 5000) => ({ + description: message, + action: action && { label: action.label, onClick: action.onClick }, + duration: action ? Math.max(duration, 8000) : duration, +}) export const toast = { success: (title: string, message?: string, action?: ToastAction) => - addToast('success', title, message, 5000, action), + sonnerToast.success(title, build(message, action)), error: (title: string, message?: string, action?: ToastAction) => - addToast('error', title, message, 5000, action), + sonnerToast.error(title, build(message, action)), info: (title: string, message?: string, action?: ToastAction) => - addToast('info', title, message, 5000, action), + sonnerToast.info(title, build(message, action)), warning: (title: string, message?: string, action?: ToastAction) => - addToast('warning', title, message, 5000, action), -} - -function addToast( - type: Toast['type'], - title: string, - message?: string, - duration = 5000, - action?: ToastAction -) { - const id = Date.now().toString() + Math.random().toString(36).slice(2, 6) - const newToast: Toast = { id, type, title, message, duration, action } - toastList = [...toastList, newToast] - toastListeners.forEach(listener => listener(toastList)) - - // Auto-remove after duration. Toasts with an action get a longer window - // so the user has time to actually click Undo. - const removeAfter = action ? Math.max(duration, 8000) : duration - setTimeout(() => { - removeToast(id) - }, removeAfter) -} - -function removeToast(id: string) { - toastList = toastList.filter(t => t.id !== id) - toastListeners.forEach(listener => listener(toastList)) + sonnerToast.warning(title, build(message, action)), } +/** Mounted once near the App root. Delegates to sonner's `` + * with palette-matched class overrides (see `@/components/ui/sonner`). */ export function ToastContainer() { - const [toasts, setToasts] = useState([]) - - useEffect(() => { - const listener = (newToasts: Toast[]) => setToasts(newToasts) - toastListeners.push(listener) - return () => { - toastListeners = toastListeners.filter(l => l !== listener) - } - }, []) - - // Subdued icons — smaller and muted so the toast reads as a - // background notification rather than a modal. The colored tint - // comes from the border-left accent, not a filled background. - const icons = { - success: , - error: , - info: , - warning: , - } - - // Single thin left accent bar per type instead of a full-border + - // tinted fill. Keeps the toast visually quiet — the user can still - // glance it but it doesn't compete with the rest of the UI. - const accents = { - success: 'border-l-pick', - error: 'border-l-reject', - info: 'border-l-primary', - warning: 'border-l-star', - } - - return ( -
- {toasts.map((toast) => ( -
-
{icons[toast.type]}
-
-
{toast.title}
- {toast.message && ( -
- {toast.message} -
- )} -
- {toast.action && ( - - )} - -
- ))} -
- ) -} \ No newline at end of file + return +} diff --git a/frontend/src/components/admin/UserManagement.tsx b/frontend/src/components/admin/UserManagement.tsx index 57ab1d3..dbb4df6 100644 --- a/frontend/src/components/admin/UserManagement.tsx +++ b/frontend/src/components/admin/UserManagement.tsx @@ -1,12 +1,32 @@ import { useState, useEffect, useCallback } from 'react' import { Plus, Pencil, UserX, Shield, User as UserIcon } from 'lucide-react' import { admin, type AdminUser } from '../../services/api' +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { Alert, AlertDescription } from '@/components/ui/alert' +import { ConfirmDialog } from '../dialogs/ConfirmDialog' export function UserManagement() { const [users, setUsers] = useState([]) const [loading, setLoading] = useState(true) const [showCreate, setShowCreate] = useState(false) const [editingUser, setEditingUser] = useState(null) + const [deactivatingUser, setDeactivatingUser] = useState(null) const [error, setError] = useState(null) const fetchUsers = useCallback(async () => { @@ -24,6 +44,18 @@ export function UserManagement() { fetchUsers() }, [fetchUsers]) + const handleDeactivate = async () => { + if (!deactivatingUser) return + try { + await admin.deleteUser(deactivatingUser.id) + setDeactivatingUser(null) + fetchUsers() + } catch (err: any) { + setError(err.response?.data?.detail ?? 'Failed to deactivate user.') + setDeactivatingUser(null) + } + } + if (loading) { return
Loading users…
} @@ -32,19 +64,16 @@ export function UserManagement() {

Users

- +
{error && ( -
- {error} -
+ + {error} + )} @@ -87,29 +116,25 @@ export function UserManagement() { @@ -118,26 +143,33 @@ export function UserManagement() {
- + {u.is_active && ( - + )}
- {showCreate && ( - setShowCreate(false)} - onCreated={() => { - setShowCreate(false) - fetchUsers() - }} - /> - )} + setShowCreate(false)} + onCreated={() => { + setShowCreate(false) + fetchUsers() + }} + /> - {editingUser && ( - setEditingUser(null)} - onSaved={() => { - setEditingUser(null) - fetchUsers() - }} - /> - )} + setEditingUser(null)} + onSaved={() => { + setEditingUser(null) + fetchUsers() + }} + /> + + setDeactivatingUser(null)} + />
) } @@ -145,9 +177,11 @@ export function UserManagement() { // ── Create User Modal ────────────────────────────────────────────────── function CreateUserModal({ + open, onClose, onCreated, }: { + open: boolean onClose: () => void onCreated: () => void }) { @@ -157,6 +191,16 @@ function CreateUserModal({ const [error, setError] = useState(null) const [loading, setLoading] = useState(false) + useEffect(() => { + if (open) { + setUsername('') + setPassword('') + setRole('user') + setError(null) + setLoading(false) + } + }, [open]) + const handleSubmit = async () => { setError(null) setLoading(true) @@ -171,56 +215,61 @@ function CreateUserModal({ } return ( - - {error && ( -
- {error} + !o && onClose()}> + + + Add User + + {error && ( + + {error} + + )} +
+
+ + setUsername(e.target.value)} + autoFocus + /> +
+
+ + setPassword(e.target.value)} + /> +
+
+ + +
- )} -
- - setUsername(e.target.value)} - className="w-full rounded border border-border bg-bg px-2 py-1 text-xs text-text outline-none focus:border-accent" - autoFocus - /> - - - setPassword(e.target.value)} - className="w-full rounded border border-border bg-bg px-2 py-1 text-xs text-text outline-none focus:border-accent" - /> - - - - -
-
- - -
- + + + + +
+
) } @@ -231,16 +280,26 @@ function EditUserModal({ onClose, onSaved, }: { - user: AdminUser + user: AdminUser | null onClose: () => void onSaved: () => void }) { - const [role, setRole] = useState(user.role) + const [role, setRole] = useState<'user' | 'admin'>('user') const [newPassword, setNewPassword] = useState('') const [error, setError] = useState(null) const [loading, setLoading] = useState(false) + useEffect(() => { + if (user) { + setRole(user.role as 'user' | 'admin') + setNewPassword('') + setError(null) + setLoading(false) + } + }, [user]) + const handleSubmit = async () => { + if (!user) return setError(null) setLoading(true) try { @@ -259,78 +318,54 @@ function EditUserModal({ } return ( - - {error && ( -
- {error} + !o && onClose()}> + + + Edit: {user?.username} + + {error && ( + + {error} + + )} +
+
+ + +
+
+ + setNewPassword(e.target.value)} + placeholder="Unchanged" + /> +
- )} -
- - - - - setNewPassword(e.target.value)} - className="w-full rounded border border-border bg-bg px-2 py-1 text-xs text-text outline-none focus:border-accent" - placeholder="Unchanged" - /> - -
-
- - -
- - ) -} - -// ── Shared helpers ───────────────────────────────────────────────────── - -function ModalOverlay({ - onClose: _onClose, - title, - children, -}: { - onClose: () => void - title: string - children: React.ReactNode -}) { - return ( -
-
e.stopPropagation()}> -

{title}

- {children} -
-
- ) -} - -function Field({ label, children }: { label: string; children: React.ReactNode }) { - return ( -
- - {children} -
+ + + + +
+
) } diff --git a/frontend/src/components/auth/LoginPage.tsx b/frontend/src/components/auth/LoginPage.tsx index ae8dbe2..4e73cf2 100644 --- a/frontend/src/components/auth/LoginPage.tsx +++ b/frontend/src/components/auth/LoginPage.tsx @@ -1,5 +1,9 @@ import { useState, type FormEvent } from 'react' import { useAuth } from '../../contexts/AuthContext' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Alert, AlertDescription } from '@/components/ui/alert' export function LoginPage() { const { login } = useAuth() @@ -34,47 +38,37 @@ export function LoginPage() { {error && ( -
- {error} -
+ + {error} + )} -
- - + + setUsername(e.target.value)} required autoFocus - className="w-full rounded border border-border bg-bg px-3 py-2 text-sm text-text outline-none focus:border-accent" />
-
- - + + setPassword(e.target.value)} required - className="w-full rounded border border-border bg-bg px-3 py-2 text-sm text-text outline-none focus:border-accent" />
- +
) diff --git a/frontend/src/components/auth/SetupPage.tsx b/frontend/src/components/auth/SetupPage.tsx index a02ca79..a52c71f 100644 --- a/frontend/src/components/auth/SetupPage.tsx +++ b/frontend/src/components/auth/SetupPage.tsx @@ -1,6 +1,10 @@ import { useState, type FormEvent } from 'react' import { useAuth } from '../../contexts/AuthContext' import api from '../../services/api' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Alert, AlertDescription } from '@/components/ui/alert' export function SetupPage() { const { onSetupComplete } = useAuth() @@ -58,61 +62,48 @@ export function SetupPage() {
{error && ( -
- {error} -
+ + {error} + )} -
- - + + setUsername(e.target.value)} required autoFocus - className="w-full rounded border border-border bg-bg px-3 py-2 text-sm text-text outline-none focus:border-accent" />
-
- - + + setPassword(e.target.value)} required - className="w-full rounded border border-border bg-bg px-3 py-2 text-sm text-text outline-none focus:border-accent" />
-
- - + + setConfirmPassword(e.target.value)} required - className="w-full rounded border border-border bg-bg px-3 py-2 text-sm text-text outline-none focus:border-accent" />
- + ) diff --git a/frontend/src/components/colors/ColorsView.tsx b/frontend/src/components/colors/ColorsView.tsx index f20c831..014493c 100644 --- a/frontend/src/components/colors/ColorsView.tsx +++ b/frontend/src/components/colors/ColorsView.tsx @@ -1,6 +1,7 @@ import { useState, useMemo, useCallback } from 'react' import { Palette, ArrowLeft, Loader2 } from 'lucide-react' import clsx from 'clsx' +import { Button } from '@/components/ui/button' import { photos as photosApi } from '../../services/api' import { useFilterStore } from '../../store/filterStore' import { usePhotosQuery } from '../../hooks/usePhotosQuery' @@ -90,13 +91,15 @@ export function ColorsView() { return (
- +

{selectedGroup.label}

diff --git a/frontend/src/components/dialogs/ConfirmDialog.tsx b/frontend/src/components/dialogs/ConfirmDialog.tsx index c400652..e0a6648 100644 --- a/frontend/src/components/dialogs/ConfirmDialog.tsx +++ b/frontend/src/components/dialogs/ConfirmDialog.tsx @@ -1,5 +1,12 @@ -import { useEffect } from 'react' -import clsx from 'clsx' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' interface ConfirmDialogProps { isOpen: boolean @@ -14,8 +21,9 @@ interface ConfirmDialogProps { } /** - * Tiny modal-confirmation dialog. Mirrors the AddSourceFolderDialog overlay - * pattern (custom fixed inset-0 backdrop, no shadcn Dialog dep). Esc closes. + * Modal confirmation dialog. Built on the shadcn Dialog primitive: + * Radix handles focus trap, portal, overlay-click dismissal, Esc, and + * animations. We only supply title, body, and the two action buttons. */ export function ConfirmDialog({ isOpen, @@ -27,49 +35,27 @@ export function ConfirmDialog({ onConfirm, onClose, }: ConfirmDialogProps) { - // Esc to close. - useEffect(() => { - if (!isOpen) return - const handler = (e: KeyboardEvent) => { - if (e.key === 'Escape') onClose() - } - window.addEventListener('keydown', handler) - return () => window.removeEventListener('keydown', handler) - }, [isOpen, onClose]) - - if (!isOpen) return null - return ( -
-
-
-
-

{title}

-
{message}
-
- - -
-
-
-
+ !o && onClose()}> + + + {title} + +
{message}
+
+
+ + + + +
+
) } diff --git a/frontend/src/components/dialogs/DeleteFolderDialog.tsx b/frontend/src/components/dialogs/DeleteFolderDialog.tsx index 7573a03..62e953e 100644 --- a/frontend/src/components/dialogs/DeleteFolderDialog.tsx +++ b/frontend/src/components/dialogs/DeleteFolderDialog.tsx @@ -2,6 +2,18 @@ import { useEffect, useState } from 'react' import clsx from 'clsx' import { Trash2, Archive } from 'lucide-react' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group' +import { Label } from '@/components/ui/label' + interface DeleteFolderDialogProps { isOpen: boolean folderName: string @@ -13,14 +25,12 @@ interface DeleteFolderDialogProps { onConfirm: (mode: 'discard' | 'permanent') => void } +type Mode = 'discard' | 'permanent' + /** - * Two-mode folder delete dialog: - * - * - Move to discard pile (default, soft, recoverable) - * - Permanently delete (destructive, irreversible) - * - * The user picks a mode via the radio cards then clicks Delete. Esc / - * backdrop click cancels. + * Two-mode folder delete dialog built on the shadcn Dialog + RadioGroup + * primitives (instead of the old custom fixed-inset backdrop). Esc + * closes, overlay click closes, focus is trapped by Radix. */ export function DeleteFolderDialog({ isOpen, @@ -29,110 +39,93 @@ export function DeleteFolderDialog({ onClose, onConfirm, }: DeleteFolderDialogProps) { - const [mode, setMode] = useState<'discard' | 'permanent'>('discard') + const [mode, setMode] = useState('discard') // Reset mode when re-opening so the safe option is always the default. useEffect(() => { if (isOpen) setMode('discard') }, [isOpen]) - // Esc to close. - useEffect(() => { - if (!isOpen) return - const handler = (e: KeyboardEvent) => { - if (e.key === 'Escape') onClose() - } - window.addEventListener('keydown', handler) - return () => window.removeEventListener('keydown', handler) - }, [isOpen, onClose]) - - if (!isOpen) return null - const photoBlurb = photoCount === undefined ? 'photos in this folder' : photoCount === 0 - ? 'this empty folder' - : `${photoCount} photo${photoCount === 1 ? '' : 's'} in this folder` + ? 'this empty folder' + : `${photoCount} photo${photoCount === 1 ? '' : 's'} in this folder` return ( -
-
-
-
-

- Delete folder "{folderName}"? -

-

+

!o && onClose()}> + + + Delete folder "{folderName}"? + What should happen to {photoBlurb}? -

+
+
-
- } - title="Move photos to discard pile" - description="Photos can be restored later from Discarded. The folder and files stay on disk." - selected={mode === 'discard'} - onClick={() => setMode('discard')} - /> - } - title="Permanently delete folder and photos" - description="Removes the folder, every photo inside it, and the directory from disk. This cannot be undone." - selected={mode === 'permanent'} - destructive - onClick={() => setMode('permanent')} - /> -
+ setMode(v as Mode)} + className="gap-2" + > + } + title="Move photos to discard pile" + description="Photos can be restored later from Discarded. The folder and files stay on disk." + selected={mode === 'discard'} + /> + } + title="Permanently delete folder and photos" + description="Removes the folder, every photo inside it, and the directory from disk. This cannot be undone." + selected={mode === 'permanent'} + destructive + /> + -
- - -
-
-
-
+ + + + + + ) } +/** Radio-group item rendered as a full-width descriptive card. */ function ModeCard({ + id, + value, icon, title, description, selected, destructive = false, - onClick, }: { + id: string + value: string icon: React.ReactNode title: string description: string selected: boolean destructive?: boolean - onClick: () => void }) { return ( - + ) } diff --git a/frontend/src/components/dialogs/SettingsDialog.tsx b/frontend/src/components/dialogs/SettingsDialog.tsx index 30cc1eb..266115d 100644 --- a/frontend/src/components/dialogs/SettingsDialog.tsx +++ b/frontend/src/components/dialogs/SettingsDialog.tsx @@ -32,6 +32,9 @@ import { import { toast } from '../ToastContainer' import { useAuth } from '../../contexts/AuthContext' import { UserManagement } from '../admin/UserManagement' +import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { Switch } from '@/components/ui/switch' +import { Button } from '@/components/ui/button' // React Query keys for the settings panels. Kept here (not in a shared // hook module) since they're internal to this dialog and used by the @@ -212,23 +215,24 @@ export function SettingsPage() { const visibleTabs = TABS.filter((t) => !t.adminOnly || isAdmin) return ( -
+ setActiveTab(v as SettingsTab)} + className="flex h-full flex-col" + > {/* Tab bar */} -
- {visibleTabs.map((tab) => ( - - ))} +
+ + {visibleTabs.map((tab) => ( + + {tab.label} + + ))} +
{/* Tab content */} @@ -867,7 +871,7 @@ export function SettingsPage() {
-
+ ) } @@ -1211,36 +1215,24 @@ function AiFeaturesTab({ busy, runAction }: AiFeaturesTabProps) {

- + /> {state.overridden && ( - + )}
diff --git a/frontend/src/components/discard/DiscardActionBar.tsx b/frontend/src/components/discard/DiscardActionBar.tsx index 92db19f..2a679a5 100644 --- a/frontend/src/components/discard/DiscardActionBar.tsx +++ b/frontend/src/components/discard/DiscardActionBar.tsx @@ -7,6 +7,7 @@ import { usePhotosQuery, stripPhotosFromCache } from '../../hooks/usePhotosQuery import { discard as discardApi, photos as photosApi } from '../../services/api' import { toast } from '../ToastContainer' import { ConfirmDialog } from '../dialogs/ConfirmDialog' +import { Button } from '@/components/ui/button' import { registerUndoable } from '../../store/undoStore' import { LIBRARY_STATS_QUERY_KEY } from '../../hooks/useLibraryStatsQuery' @@ -120,35 +121,38 @@ export function DiscardActionBar() {
{selected > 0 && ( <> - - + )} - +
diff --git a/frontend/src/components/duplicates/DuplicatesView.tsx b/frontend/src/components/duplicates/DuplicatesView.tsx index ff5ad6d..dd08c45 100644 --- a/frontend/src/components/duplicates/DuplicatesView.tsx +++ b/frontend/src/components/duplicates/DuplicatesView.tsx @@ -2,6 +2,7 @@ import { useMemo, useState, useEffect, useCallback } from 'react' import { Copy, Layers, Sparkles, Trash2, Loader2, Info, Crown } from 'lucide-react' import clsx from 'clsx' import { useMutation, useQueryClient } from '@tanstack/react-query' +import { Button } from '@/components/ui/button' import { useDuplicateGroupsQuery, DUPLICATE_GROUPS_QUERY_KEY, @@ -269,7 +270,9 @@ function DuplicateGroupSection({ )}
- +
s.setColorLabel) const setFlag = useFilterStore((s) => s.setFlag) const setTagIds = useFilterStore((s) => s.setTagIds) - const toggleTagId = useFilterStore((s) => s.toggleTagId) const setSortBy = useFilterStore((s) => s.setSortBy) const toggleSortOrder = useFilterStore((s) => s.toggleSortOrder) const clearAll = useFilterStore((s) => s.clearAll) @@ -186,26 +194,12 @@ export function FilterBar({ setDateTo(null) }} > -
-
- - setDateFrom(e.target.value || null)} - className="w-full rounded border border-border bg-bg px-2 py-1 text-xs text-text" - /> -
-
- - setDateTo(e.target.value || null)} - className="w-full rounded border border-border bg-bg px-2 py-1 text-xs text-text" - /> -
-
+ {/* Type */} @@ -215,25 +209,23 @@ export function FilterBar({ isActive={typeActive} onClear={() => mediaTypes.forEach((t) => toggleMediaType(t))} > -
- {MEDIA_TYPES.map(({ value, label }) => { - const active = mediaTypes.includes(value) - return ( - - ) - })} -
+ ({ + value, + label, + }))} + values={mediaTypes} + onChange={(next) => { + // Diff against current selection — store uses per-item toggles. + for (const t of MEDIA_TYPES.map((m) => m.value)) { + const wasOn = mediaTypes.includes(t) + const nowOn = next.includes(t as MediaType) + if (wasOn !== nowOn) toggleMediaType(t) + } + }} + /> {/* Rating */} @@ -316,69 +308,45 @@ export function FilterBar({ setFlag('any') setNeedsReview(false) }} - > -
- - - - -
+ /> )} @@ -389,12 +357,25 @@ export function FilterBar({ value={tagValue} isActive={tagActive} onClear={() => setTagIds([])} - > - setTagIds([])} + > + { + if (b.photo_count !== a.photo_count) + return b.photo_count - a.photo_count + return a.name.localeCompare(b.name) + }) + .map((t) => ({ + value: t.id, + label: t.name, + meta: t.photo_count, + }))} + values={tagIds} + onChange={setTagIds} /> )} @@ -403,20 +384,26 @@ export function FilterBar({ always a value. */}
- - +
@@ -452,7 +439,7 @@ export function FilterBar({ * so the global "/" focus shortcut still finds it. */}
- {searchQuery && ( - )} -
- -
- - {selectedIds.length > 0 - ? `${selectedIds.length} selected` - : `${totalVisible} tag${totalVisible === 1 ? '' : 's'}`} - - {selectedIds.length > 0 && ( - - )} -
- -
- {totalVisible === 0 ? ( -
- No tags match -
- ) : ( - <> - {orderedTags.selected.map((tag) => ( - - ))} - {orderedTags.selected.length > 0 && orderedTags.unselected.length > 0 && ( -
- )} - {orderedTags.unselected.map((tag) => ( - - ))} - - )} -
+
+
{label}
+ bookedSet.has(formatIsoDate(d)), + }} + modifiersClassNames={{ + // Days that DO have matching photos get a small primary dot + // below the number; unmatched days stay visible and + // clickable so the user can still pick any bound they want. + booked: + 'relative font-semibold text-text after:absolute after:bottom-0.5 after:left-1/2 after:h-1 after:w-1 after:-translate-x-1/2 after:rounded-full after:bg-primary after:content-[""]', + }} + className="rounded-md border border-border bg-bg p-2" + />
) } -function TagRow({ - tag, - selected, - onToggle, -}: { - tag: Tag - selected: boolean - onToggle: (id: string) => void -}) { - return ( - - ) +function parseIsoDate(s: string): Date { + // yyyy-mm-dd → Date with local components so a user's "2024-01-15" + // never rolls back to 2024-01-14 in a pacific timezone. + const [y, m, d] = s.split('-').map(Number) + return new Date(y, (m ?? 1) - 1, d ?? 1) +} + +function formatIsoDate(d: Date): string { + const y = d.getFullYear() + const m = String(d.getMonth() + 1).padStart(2, '0') + const day = String(d.getDate()).padStart(2, '0') + return `${y}-${m}-${day}` } diff --git a/frontend/src/components/filter/FilterPill.tsx b/frontend/src/components/filter/FilterPill.tsx index 7748450..f29a206 100644 --- a/frontend/src/components/filter/FilterPill.tsx +++ b/frontend/src/components/filter/FilterPill.tsx @@ -1,7 +1,10 @@ -import { useEffect, useLayoutEffect, useRef, useState } from 'react' -import { createPortal } from 'react-dom' import { ChevronDown, X } from 'lucide-react' import clsx from 'clsx' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover' interface FilterPillProps { /** Category label, always shown ("Date", "Type", etc.). */ @@ -19,13 +22,18 @@ interface FilterPillProps { children: React.ReactNode /** Force the popover open programmatically (rare). */ defaultOpen?: boolean + /** Override the PopoverContent className — use when the child has its + * own padding (e.g. MultiSelect rows) and the default `p-3` doubles + * up. */ + contentClassName?: string } /** - * A toolbar pill that hosts a filter category. Click the pill to open a + * Toolbar pill that hosts a filter category. Click the pill to open a * small popover with the actual control; the popover closes on outside - * click or Escape. Active filters tint the pill primary and show their - * current value inline. + * click or Escape. Built on Radix Popover so positioning, focus + * management, portal rendering, and dismissal come for free — the + * pill only owns the trigger styling and the "clear" affordance. */ export function FilterPill({ label, @@ -34,126 +42,58 @@ export function FilterPill({ onClear, children, defaultOpen = false, + contentClassName, }: FilterPillProps) { - const [open, setOpen] = useState(defaultOpen) - const buttonRef = useRef(null) - const popoverRef = useRef(null) - const [popoverPos, setPopoverPos] = useState<{ top: number; left: number } | null>(null) - - // Compute the popover's screen position from the trigger button. Done - // imperatively (not via CSS absolute) so the popover can live in a portal - // and escape the FilterBar's overflow-x-auto clipping. Re-computed on - // open, scroll, and resize. - useLayoutEffect(() => { - if (!open) return - const update = () => { - const btn = buttonRef.current - if (!btn) return - const rect = btn.getBoundingClientRect() - // Default left-align under the trigger; clamp to viewport so the - // last pill on the right doesn't overflow. - const popWidth = popoverRef.current?.offsetWidth ?? 240 - const margin = 8 - let left = rect.left - if (left + popWidth + margin > window.innerWidth) { - left = Math.max(margin, window.innerWidth - popWidth - margin) - } - setPopoverPos({ top: rect.bottom + 4, left }) - } - update() - window.addEventListener('resize', update) - window.addEventListener('scroll', update, true) - return () => { - window.removeEventListener('resize', update) - window.removeEventListener('scroll', update, true) - } - }, [open]) - - // Close on outside click + Escape. Outside means neither the trigger - // button nor the (portaled) popover. - useEffect(() => { - if (!open) return - const onDocMouseDown = (e: MouseEvent) => { - const target = e.target as Node - if (buttonRef.current?.contains(target)) return - if (popoverRef.current?.contains(target)) return - setOpen(false) - } - const onKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') setOpen(false) - } - document.addEventListener('mousedown', onDocMouseDown) - document.addEventListener('keydown', onKey) - return () => { - document.removeEventListener('mousedown', onDocMouseDown) - document.removeEventListener('keydown', onKey) - } - }, [open]) - return ( - <> - - - {open && - createPortal( -
- {children} -
, - document.body - )} - + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + e.stopPropagation() + onClear() + } + }} + className="ml-0.5 inline-flex h-3.5 w-4 cursor-pointer items-center justify-center rounded-full hover:bg-primary/30" + title={`Clear ${label}`} + aria-label={`Clear ${label}`} + > + + + ) : ( + + + + )} + + + + {children} + + ) } diff --git a/frontend/src/components/heaps/HeapConvertDialog.tsx b/frontend/src/components/heaps/HeapConvertDialog.tsx index 96021fb..2343201 100644 --- a/frontend/src/components/heaps/HeapConvertDialog.tsx +++ b/frontend/src/components/heaps/HeapConvertDialog.tsx @@ -1,7 +1,6 @@ import { useState, useEffect, useMemo } from 'react' import { useMutation, useQueryClient } from '@tanstack/react-query' -import { X, Folder, AlertCircle } from 'lucide-react' -import clsx from 'clsx' +import { Folder, AlertCircle } from 'lucide-react' import { heaps as heapsApi, type Heap, @@ -10,6 +9,26 @@ import { import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery' import { useFolderTreeQuery } from '../../hooks/useFolderTreeQuery' import { toast } from '../ToastContainer' +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group' +import { Checkbox } from '@/components/ui/checkbox' +import { Alert, AlertDescription } from '@/components/ui/alert' interface FlatFolder { id: string @@ -37,11 +56,6 @@ interface HeapConvertDialogProps { onClose: () => void } -/** - * Modal that converts a heap into a folder. The user picks a target folder - * (any source root, today — sub-folder picking is a follow-up), chooses - * move vs copy semantics, and optionally has the heap deleted on success. - */ export function HeapConvertDialog({ heap, onClose }: HeapConvertDialogProps) { const queryClient = useQueryClient() const [targetId, setTargetId] = useState('') @@ -49,19 +63,15 @@ export function HeapConvertDialog({ heap, onClose }: HeapConvertDialogProps) { const [deleteHeap, setDeleteHeap] = useState(false) const [subfolderName, setSubfolderName] = useState('') - // Use the recursive folder tree, not the flat source-root list, so the - // user can pick a sub-folder at any depth as the target. const { data: tree = [] } = useFolderTreeQuery() const folders = useMemo(() => flattenTree(tree), [tree]) - // Default to the first folder when the dialog opens or folders load. useEffect(() => { if (!targetId && folders.length > 0) { setTargetId(folders[0].id) } }, [folders, targetId]) - // Reset state on close, prefill subfolder name when opened. useEffect(() => { if (heap) { setSubfolderName(heap.name) @@ -79,8 +89,6 @@ export function HeapConvertDialog({ heap, onClose }: HeapConvertDialogProps) { target_id: targetId, mode, delete_heap: deleteHeap, - // Empty subfolder = drop directly into the parent. Trim and only - // send if the user kept it populated. subfolder_name: subfolderName.trim() || null, }), onSuccess: (data) => { @@ -99,149 +107,130 @@ export function HeapConvertDialog({ heap, onClose }: HeapConvertDialogProps) { toast.error('Convert failed', e?.response?.data?.detail || e.message), }) - if (!heap) return null - const targetFolder = folders.find((f) => f.id === targetId) return ( -
-
+ !o && onClose()}> + + + Convert "{heap?.name}" to folder + -
-
-

- Convert "{heap.name}" to folder -

- -
+
+ {/* Target picker */} +
+ + {folders.length === 0 ? ( +
+ No folders available +
+ ) : ( + + )} + {targetFolder && ( +

+ + {targetFolder.path} +

+ )} +
- {/* Target picker */} -
- - {folders.length === 0 ? ( -
- No folders available -
- ) : ( - - )} - {targetFolder && ( -

- - {targetFolder.path} + {/* Subfolder name */} +

+ + setSubfolderName(e.target.value)} + placeholder="(none — use parent directly)" + /> +

+ {subfolderName.trim() && targetFolder + ? `Will create ${targetFolder.path}/${subfolderName.trim()} if missing.` + : 'Photos go directly into the parent folder.'}

+
+ + {/* Mode */} +
+ + setMode(v as 'move' | 'copy')} + className="grid grid-cols-2 gap-2" + > + + + +

+ {mode === 'move' + ? 'Files are moved on disk; original photos update their folder.' + : 'Files are copied on disk; new photo records are created.'} +

+
+ + {/* Delete heap */} +
+ setDeleteHeap(v === true)} + /> + +
+ + {convertMutation.isError && ( + + + + {(convertMutation.error as any)?.message || 'Conversion failed'} + + )}
- {/* Subfolder name */} -
- - setSubfolderName(e.target.value)} - placeholder="(none — use parent directly)" - className="w-full rounded border border-border bg-bg px-2 py-1.5 text-sm text-text placeholder-text-faint focus:border-primary focus:outline-none" - /> -

- {subfolderName.trim() && targetFolder - ? `Will create ${targetFolder.path}/${subfolderName.trim()} if missing.` - : 'Photos go directly into the parent folder.'} -

-
- - {/* Mode toggle */} -
- -
- - -
-

- {mode === 'move' - ? 'Files are moved on disk; original photos update their folder.' - : 'Files are copied on disk; new photo records are created.'} -

-
- - {/* Delete heap toggle */} -
- setDeleteHeap(e.target.checked)} - className="h-4 w-4 rounded border-border bg-bg text-primary focus:ring-2 focus:ring-primary focus:ring-offset-0" - /> - -
- - {convertMutation.isError && ( -
- - {(convertMutation.error as any)?.message || 'Conversion failed'} -
- )} - -
- - -
-
-
+ + +
+
) } diff --git a/frontend/src/components/heaps/HeapsPanel.tsx b/frontend/src/components/heaps/HeapsPanel.tsx index ccb53c2..12beb47 100644 --- a/frontend/src/components/heaps/HeapsPanel.tsx +++ b/frontend/src/components/heaps/HeapsPanel.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react' +import { useState } from 'react' import { ShoppingBasket, Plus, @@ -23,6 +23,15 @@ import { toast } from '../ToastContainer' import { PHOTO_DRAG_MIME } from '../timeline/PhotoThumbnail' import { HeapConvertDialog } from './HeapConvertDialog' import { ShareDialog } from '../sharing/ShareDialog' +import { Input } from '@/components/ui/input' +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' /** * Heaps panel for the left sidebar. Renders the list of heaps with the @@ -53,28 +62,10 @@ export function HeapsPanel() { // the draft name. Mirrors the folder rename pattern in LeftSidebar. const [renamingId, setRenamingId] = useState(null) const [renameDraft, setRenameDraft] = useState('') - // Which heap's burger menu is currently open. null when no menu is open. - // The popover closes on outside click and Escape via the effect below. + // Which heap's burger menu is currently open. Drives the trigger's + // hover-visible state via DropdownMenu's open prop — Radix handles + // outside-click + Escape dismissal internally. const [openMenuId, setOpenMenuId] = useState(null) - const menuRef = useRef(null) - - useEffect(() => { - if (!openMenuId) return - const onDown = (e: MouseEvent) => { - if (menuRef.current && !menuRef.current.contains(e.target as Node)) { - setOpenMenuId(null) - } - } - const onKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') setOpenMenuId(null) - } - document.addEventListener('mousedown', onDown) - document.addEventListener('keydown', onKey) - return () => { - document.removeEventListener('mousedown', onDown) - document.removeEventListener('keydown', onKey) - } - }, [openMenuId]) const invalidate = () => { queryClient.invalidateQueries({ queryKey: HEAPS_QUERY_KEY }) @@ -217,7 +208,7 @@ export function HeapsPanel() { className="flex items-center gap-1 px-2 py-0.5" style={{ paddingLeft: '20px' }} > - - +
)} @@ -323,7 +315,7 @@ export function HeapsPanel() { /> {isRenaming ? ( - ) : ( <> @@ -389,93 +381,81 @@ export function HeapsPanel() { {/* Kebab menu — collects rename / duplicate / convert / * delete so the row stays compact. */} -
setOpenMenuId(o ? heap.id : null)} > - - - {isMenuOpen && ( -
e.stopPropagation()} + + + +
+ e.stopPropagation()} + > + { + setRenamingId(heap.id) + setRenameDraft(heap.name) + }} > - } - label="Rename" - onClick={() => { - setOpenMenuId(null) - setRenamingId(heap.id) - setRenameDraft(heap.name) - }} - /> - } - label="Duplicate" - onClick={() => { - setOpenMenuId(null) - duplicateMutation.mutate(heap.id) - }} - /> - } - label="Move to folder…" - onClick={() => { - setOpenMenuId(null) - setConvertingHeap(heap) - }} - /> - } - label="Share…" - onClick={() => { - setOpenMenuId(null) - setSharingHeap(heap) - }} - /> - } - label="Download as zip" - onClick={() => { - setOpenMenuId(null) - downloads.trigger(downloads.heapUrl(heap.id)) - }} - /> -
- } - label="Delete" - destructive - onClick={() => { - setOpenMenuId(null) - if ( - confirm( - `Delete heap "${heap.name}"? Photos are not affected.` - ) - ) { - deleteMutation.mutate(heap.id) - } - }} - /> -
- )} -
+ + Rename + + duplicateMutation.mutate(heap.id)} + > + + Duplicate + + setConvertingHeap(heap)}> + + Move to folder… + + setSharingHeap(heap)}> + + Share… + + + downloads.trigger(downloads.heapUrl(heap.id)) + } + > + + Download as zip + + + { + if ( + confirm( + `Delete heap "${heap.name}"? Photos are not affected.` + ) + ) { + deleteMutation.mutate(heap.id) + } + }} + className="text-reject focus:bg-reject/10 focus:text-reject" + > + + Delete + + +
) })} @@ -543,30 +523,3 @@ export function HeapsPanel() { ) } -function MenuItem({ - icon, - label, - onClick, - destructive = false, -}: { - icon: React.ReactNode - label: string - onClick: () => void - destructive?: boolean -}) { - return ( - - ) -} diff --git a/frontend/src/components/layout/LeftSidebar.tsx b/frontend/src/components/layout/LeftSidebar.tsx index a4a6061..1dc9167 100644 --- a/frontend/src/components/layout/LeftSidebar.tsx +++ b/frontend/src/components/layout/LeftSidebar.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react' +import { useState } from 'react' import { ChevronRight, ChevronDown, @@ -51,6 +51,14 @@ import { useSharedFoldersQuery } from '../../hooks/useSharingQueries' import { useAuth } from '../../contexts/AuthContext' import { useFeaturesQuery } from '../../hooks/useFeaturesQuery' import { useScanActivity } from '../../hooks/useScanActivity' +import { Input } from '@/components/ui/input' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' interface TreeItem { id: string @@ -88,26 +96,11 @@ export function LeftSidebar() { const [dropTargetId, setDropTargetId] = useState(null) // Per-folder kebab menu open state. Stores the tree-item id ("folder-..." - // or "folders" for the section header). Outside-click + Escape close. + // or "folders" for the section header). Radix DropdownMenu handles + // outside-click + Escape dismissal for us — we only track which row's + // menu is open so the trigger stays visible while it's open (the + // trigger is hover-hidden by default on non-hovered rows). const [openMenuId, setOpenMenuId] = useState(null) - const menuRef = useRef(null) - useEffect(() => { - if (!openMenuId) return - const onDown = (e: MouseEvent) => { - if (menuRef.current && !menuRef.current.contains(e.target as Node)) { - setOpenMenuId(null) - } - } - const onKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') setOpenMenuId(null) - } - document.addEventListener('mousedown', onDown) - document.addEventListener('keydown', onKey) - return () => { - document.removeEventListener('mousedown', onDown) - document.removeEventListener('keydown', onKey) - } - }, [openMenuId]) // "Create new folder under {parent}" inline state. parentId is the // Folder.id (no "folder-" prefix). @@ -600,7 +593,7 @@ export function LeftSidebar() { {/* Label (or inline rename input for folder rows) */} {renamingId === item.id ? ( - ) : ( setOpenMenuId(o ? item.id : null)} > - - - {isMenuOpen && ( -
e.stopPropagation()} - className="absolute right-0 top-full z-30 mt-1 min-w-[180px] overflow-hidden rounded-lg border border-border bg-surface py-1 text-sm shadow-xl" + + + +
+ e.stopPropagation()} + > + + setUploadTarget({ open: true, folderId }) + } > - } - label="Upload here…" - onClick={() => { - setOpenMenuId(null) - setUploadTarget({ open: true, folderId }) - }} - /> - } - label="New sub-folder" - onClick={() => { - setOpenMenuId(null) - setCreatingUnder(folderId) - setCreateDraft('') - // Make sure the parent is expanded so the new - // input is visible. - if (!expandedItems.has(item.id)) { - toggleExpanded(item.id) - } - }} - /> - } - label="Rename" - onClick={() => { - setOpenMenuId(null) - setRenamingId(item.id) - setRenameDraft(item.label) - }} - /> - - ) : ( - - ) + + Upload here… + + { + setCreatingUnder(folderId) + setCreateDraft('') + if (!expandedItems.has(item.id)) { + toggleExpanded(item.id) } - label={item.isHidden ? 'Show in views' : 'Hide from views'} - onClick={() => { - setOpenMenuId(null) - toggleHiddenMutation.mutate({ - id: folderId, - hidden: !item.isHidden, - }) - }} - /> - } - label="Share…" - onClick={() => { - setOpenMenuId(null) - setSharingFolder({ id: folderId, name: item.label }) - }} - /> - } - label="Download as zip" - onClick={() => { - setOpenMenuId(null) - downloads.trigger(downloads.folderUrl(folderId)) - }} - /> -
- } - label="Delete folder…" - destructive - onClick={() => { - setOpenMenuId(null) - setDeletingFolder({ - id: folderId, - name: item.label, - photoCount: item.count, - }) - }} - /> -
- )} -
+ }} + > + + New sub-folder + + { + setRenamingId(item.id) + setRenameDraft(item.label) + }} + > + + Rename + + + toggleHiddenMutation.mutate({ + id: folderId, + hidden: !item.isHidden, + }) + } + > + {item.isHidden ? ( + + ) : ( + + )} + {item.isHidden ? 'Show in views' : 'Hide from views'} + + + setSharingFolder({ id: folderId, name: item.label }) + } + > + + Share… + + + downloads.trigger(downloads.folderUrl(folderId)) + } + > + + Download as zip + + + + setDeletingFolder({ + id: folderId, + name: item.label, + photoCount: item.count, + }) + } + className="text-reject focus:bg-reject/10 focus:text-reject" + > + + Delete folder… + + + ) })()} @@ -984,30 +971,3 @@ export function LeftSidebar() { ) } -function FolderMenuItem({ - icon, - label, - onClick, - destructive = false, -}: { - icon: React.ReactNode - label: string - onClick: () => void - destructive?: boolean -}) { - return ( - - ) -} \ No newline at end of file diff --git a/frontend/src/components/layout/RightSidebar.tsx b/frontend/src/components/layout/RightSidebar.tsx index d677771..4f95b12 100644 --- a/frontend/src/components/layout/RightSidebar.tsx +++ b/frontend/src/components/layout/RightSidebar.tsx @@ -22,6 +22,9 @@ import { stripPhotosFromCache } from '../../hooks/usePhotosQuery' import { toast } from '../ToastContainer' import { PhotoInfoPanel } from '../sidebar/PhotoInfoPanel' import { COLOR_LABEL_OPTIONS } from '../../constants/colorLabels' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' /** * Right-hand details panel. @@ -208,14 +211,16 @@ export function RightSidebar() {
{selectedPhotos.length > 0 && ( - + )}
@@ -305,7 +310,7 @@ export function RightSidebar() { {/* Bulk rating */}
- +
{[1, 2, 3, 4, 5].map((value) => ( - +
@@ -402,7 +409,7 @@ export function RightSidebar() { * input adds an existing tag if it matches a name, or creates * a new tag and applies it. */}
- + - + {/* Apply-one row */}
- setUniformDraft(e.target.value)} disabled={disabled} - className="flex-1 rounded border border-border bg-bg px-2 py-1 text-xs text-text focus:border-primary focus:outline-none disabled:opacity-50" + className="h-7 flex-1 text-xs" /> - +
{/* Guess-from-path preview */} {preview === null ? ( - + ) : (
@@ -570,20 +580,23 @@ function BulkTakenAtEditor({ )}
- - +
)} @@ -640,7 +653,7 @@ function BulkTagsEditor({ return (
- onTagInputChange(e.target.value)} @@ -654,18 +667,20 @@ function BulkTagsEditor({ }} placeholder="Filter or create…" disabled={disabled} - className="w-full rounded border border-border bg-bg px-2 py-1 text-xs text-text placeholder-text-faint focus:border-primary focus:outline-none disabled:opacity-50" + className="h-7 text-xs" /> {trimmed && !exactMatch && ( - + )} {filtered.length > 0 ? ( diff --git a/frontend/src/components/preview/PreviewView.tsx b/frontend/src/components/preview/PreviewView.tsx index 7a2cf9c..0b595f5 100644 --- a/frontend/src/components/preview/PreviewView.tsx +++ b/frontend/src/components/preview/PreviewView.tsx @@ -11,6 +11,8 @@ import { PreviewFilmstrip } from './PreviewFilmstrip' import { getPreviewImageSrc, isVideo } from './previewSrc' import { PhotoInfoPanel } from '../sidebar/PhotoInfoPanel' import { KeyboardHints } from '../KeyboardHints' +import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' export function PreviewView() { const activePhotoId = usePhotoStore((s) => s.activePhotoId) @@ -195,12 +197,9 @@ export function PreviewView() { className="fixed inset-0 z-[1000] flex flex-col items-center justify-center bg-black text-text-muted outline-none" >
No photo to display
- +
) } @@ -227,26 +226,28 @@ export function PreviewView() { {/* Top-right action buttons */}
- - +
diff --git a/frontend/src/components/rated/RatedView.tsx b/frontend/src/components/rated/RatedView.tsx index 09d4561..d890aa9 100644 --- a/frontend/src/components/rated/RatedView.tsx +++ b/frontend/src/components/rated/RatedView.tsx @@ -1,6 +1,7 @@ import { useState, useMemo, useCallback } from 'react' import { Star, ArrowLeft, Loader2 } from 'lucide-react' import clsx from 'clsx' +import { Button } from '@/components/ui/button' import { photos as photosApi } from '../../services/api' import { useFilterStore } from '../../store/filterStore' import { usePhotosQuery } from '../../hooks/usePhotosQuery' @@ -80,13 +81,15 @@ export function RatedView() { return (
- +

{selectedGroup.label}

diff --git a/frontend/src/components/sharing/ShareDialog.tsx b/frontend/src/components/sharing/ShareDialog.tsx index b706ab8..47a4695 100644 --- a/frontend/src/components/sharing/ShareDialog.tsx +++ b/frontend/src/components/sharing/ShareDialog.tsx @@ -1,9 +1,27 @@ import { useEffect, useState } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { Users, Trash2, X } from 'lucide-react' -import clsx from 'clsx' +import { Users, Trash2 } from 'lucide-react' import { sharing, type ShareInfo } from '../../services/api' -import { SHARED_HEAPS_KEY, SHARED_FOLDERS_KEY } from '../../hooks/useSharingQueries' +import { + SHARED_HEAPS_KEY, + SHARED_FOLDERS_KEY, +} from '../../hooks/useSharingQueries' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { Alert, AlertDescription } from '@/components/ui/alert' interface ShareDialogProps { isOpen: boolean @@ -13,7 +31,13 @@ interface ShareDialogProps { onClose: () => void } -export function ShareDialog({ isOpen, type, targetId, targetName, onClose }: ShareDialogProps) { +export function ShareDialog({ + isOpen, + type, + targetId, + targetName, + onClose, +}: ShareDialogProps) { const [username, setUsername] = useState('') const [permission, setPermission] = useState<'read' | 'write'>('read') const [error, setError] = useState(null) @@ -40,7 +64,9 @@ export function ShareDialog({ isOpen, type, targetId, targetName, onClose }: Sha setPermission('read') setError(null) queryClient.invalidateQueries({ queryKey: sharesQueryKey }) - queryClient.invalidateQueries({ queryKey: type === 'heap' ? SHARED_HEAPS_KEY : SHARED_FOLDERS_KEY }) + queryClient.invalidateQueries({ + queryKey: type === 'heap' ? SHARED_HEAPS_KEY : SHARED_FOLDERS_KEY, + }) }, onError: (err: any) => { setError(err?.response?.data?.detail || 'Failed to share') @@ -54,19 +80,12 @@ export function ShareDialog({ isOpen, type, targetId, targetName, onClose }: Sha : sharing.revokeFolderShare(targetId, shareId), onSuccess: () => { queryClient.invalidateQueries({ queryKey: sharesQueryKey }) - queryClient.invalidateQueries({ queryKey: type === 'heap' ? SHARED_HEAPS_KEY : SHARED_FOLDERS_KEY }) + queryClient.invalidateQueries({ + queryKey: type === 'heap' ? SHARED_HEAPS_KEY : SHARED_FOLDERS_KEY, + }) }, }) - useEffect(() => { - if (!isOpen) return - const handler = (e: KeyboardEvent) => { - if (e.key === 'Escape') onClose() - } - window.addEventListener('keydown', handler) - return () => window.removeEventListener('keydown', handler) - }, [isOpen, onClose]) - useEffect(() => { if (isOpen) { setUsername('') @@ -75,103 +94,102 @@ export function ShareDialog({ isOpen, type, targetId, targetName, onClose }: Sha } }, [isOpen]) - if (!isOpen) return null - return ( -
-
-
-
-
-
- -

- Share {type === 'heap' ? 'heap' : 'folder'} -

-
- -
+ !o && onClose()}> + + + + + Share {type === 'heap' ? 'heap' : 'folder'} + + -
- Sharing {targetName} -
- - {/* Existing shares */} - {shares.length > 0 && ( -
- {shares.map((share) => ( -
-
- {share.shared_with_username} - - {share.permission} - -
- -
- ))} -
- )} - {isLoading &&
Loading shares...
} - - {/* Add new share */} -
{ - e.preventDefault() - if (username.trim()) addMutation.mutate() - }} - className="space-y-3" - > -
- { - setUsername(e.target.value) - setError(null) - }} - placeholder="Username" - className="flex-1 rounded border border-border bg-surface-2 px-3 py-1.5 text-sm text-text placeholder:text-text-muted focus:border-primary focus:outline-none" - autoFocus - /> - -
- - {error &&
{error}
} - -
- -
-
+
+ Sharing {targetName}
-
-
+ + {/* Existing shares */} + {shares.length > 0 && ( +
+ {shares.map((share) => ( +
+
+ + {share.shared_with_username} + + + {share.permission} + +
+ +
+ ))} +
+ )} + {isLoading && ( +
Loading shares...
+ )} + +
{ + e.preventDefault() + if (username.trim()) addMutation.mutate() + }} + className="space-y-3" + > +
+ { + setUsername(e.target.value) + setError(null) + }} + placeholder="Username" + className="flex-1" + autoFocus + /> + +
+ + {error && ( + + {error} + + )} + +
+ +
+
+ + ) } diff --git a/frontend/src/components/sidebar/PhotoInfoPanel.tsx b/frontend/src/components/sidebar/PhotoInfoPanel.tsx index 55dec0d..f9f1d0f 100644 --- a/frontend/src/components/sidebar/PhotoInfoPanel.tsx +++ b/frontend/src/components/sidebar/PhotoInfoPanel.tsx @@ -29,6 +29,13 @@ import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery' import { LIBRARY_STATS_QUERY_KEY } from '../../hooks/useLibraryStatsQuery' import { useTagsQuery, TAGS_QUERY_KEY } from '../../hooks/useTagsQuery' import { toast } from '../ToastContainer' +import { Input } from '@/components/ui/input' +import { Textarea } from '@/components/ui/textarea' +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from '@/components/ui/collapsible' import { COLOR_LABEL_OPTIONS, type ColorLabel, @@ -386,7 +393,7 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
- setFilenameDraft(e.target.value)} @@ -405,7 +412,7 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
- setTitleDraft(e.target.value)} @@ -425,7 +432,7 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
-