ui: migrate to shadcn/ui primitives across dialogs, filters, and forms

Adopts shadcn/ui components (Dialog, Button, Input, Select, Popover,
Command, Checkbox, Switch, Toggle, Calendar, etc.) across the app,
replacing hand-rolled modals, dropdowns, and form controls. Adds a
reusable cmdk-backed MultiSelect for the Type, Tags, and Flag filters
so all multi-value filter popovers share one component and layout.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-15 09:07:20 +02:00
parent 8529771122
commit 7efac4354e
51 changed files with 3032 additions and 1660 deletions

20
frontend/components.json Normal file
View File

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

View File

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

View File

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

View File

@@ -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 (
<TooltipProvider delayDuration={300}>
<div className="flex flex-col h-screen bg-bg text-text">
<TopBar />
@@ -140,6 +142,7 @@ function MainApp() {
{viewMode === 'preview' && <PreviewView />}
</div>
</TooltipProvider>
)
}

View File

@@ -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 `<Toaster />`
* with palette-matched class overrides (see `@/components/ui/sonner`). */
export function ToastContainer() {
const [toasts, setToasts] = useState<Toast[]>([])
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: <CheckCircle className="h-3.5 w-3.5 text-pick" />,
error: <XCircle className="h-3.5 w-3.5 text-reject" />,
info: <Info className="h-3.5 w-3.5 text-primary" />,
warning: <AlertCircle className="h-3.5 w-3.5 text-star" />,
}
// 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 (
<div className="pointer-events-none fixed bottom-4 left-4 z-50 flex flex-col gap-1.5">
{toasts.map((toast) => (
<div
key={toast.id}
className={clsx(
'pointer-events-auto flex items-start gap-2 rounded-md border border-border border-l-2 bg-surface/80 px-2.5 py-1.5 text-xs shadow-md backdrop-blur-md transition-all duration-300',
'animate-slide-up',
accents[toast.type]
)}
style={{ minWidth: '220px', maxWidth: '320px' }}
>
<div className="mt-0.5 flex-shrink-0">{icons[toast.type]}</div>
<div className="min-w-0 flex-1">
<div className="truncate font-medium text-text">{toast.title}</div>
{toast.message && (
<div className="mt-0.5 truncate text-[11px] text-text-muted">
{toast.message}
</div>
)}
</div>
{toast.action && (
<button
onClick={() => {
toast.action!.onClick()
removeToast(toast.id)
}}
className="pointer-events-auto self-center rounded border border-border bg-surface px-1.5 py-0.5 text-[11px] font-medium text-text hover:bg-surface-2"
>
{toast.action.label}
</button>
)}
<button
onClick={() => removeToast(toast.id)}
className="pointer-events-auto rounded p-0.5 text-text-faint hover:bg-surface-offset hover:text-text"
>
<X className="h-3 w-3" />
</button>
</div>
))}
</div>
)
}
return <Toaster />
}

View File

@@ -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<AdminUser[]>([])
const [loading, setLoading] = useState(true)
const [showCreate, setShowCreate] = useState(false)
const [editingUser, setEditingUser] = useState<AdminUser | null>(null)
const [deactivatingUser, setDeactivatingUser] = useState<AdminUser | null>(null)
const [error, setError] = useState<string | null>(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 <div className="p-4 text-sm text-text-muted">Loading users&hellip;</div>
}
@@ -32,19 +64,16 @@ export function UserManagement() {
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-text">Users</h3>
<button
onClick={() => setShowCreate(true)}
className="flex items-center gap-1 rounded bg-accent px-2 py-1 text-xs text-white hover:bg-accent/80"
>
<Plus className="h-3 w-3" />
<Button size="sm" onClick={() => setShowCreate(true)}>
<Plus className="mr-1 h-3 w-3" />
Add User
</button>
</Button>
</div>
{error && (
<div className="rounded bg-red-900/30 px-3 py-2 text-xs text-red-300">
{error}
</div>
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<table className="w-full text-xs">
@@ -87,29 +116,25 @@ export function UserManagement() {
</td>
<td className="py-1.5">
<div className="flex gap-1">
<button
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setEditingUser(u)}
className="rounded p-1 text-text-muted hover:bg-bg hover:text-text"
title="Edit user"
>
<Pencil className="h-3 w-3" />
</button>
</Button>
{u.is_active && (
<button
onClick={async () => {
if (!confirm(`Deactivate user "${u.username}"? Their photos will be preserved.`)) return
try {
await admin.deleteUser(u.id)
fetchUsers()
} catch (err: any) {
setError(err.response?.data?.detail ?? 'Failed to deactivate user.')
}
}}
className="rounded p-1 text-text-muted hover:bg-bg hover:text-red-400"
<Button
variant="ghost"
size="icon"
className="h-6 w-6 hover:text-red-400"
onClick={() => setDeactivatingUser(u)}
title="Deactivate user"
>
<UserX className="h-3 w-3" />
</button>
</Button>
)}
</div>
</td>
@@ -118,26 +143,33 @@ export function UserManagement() {
</tbody>
</table>
{showCreate && (
<CreateUserModal
onClose={() => setShowCreate(false)}
onCreated={() => {
setShowCreate(false)
fetchUsers()
}}
/>
)}
<CreateUserModal
open={showCreate}
onClose={() => setShowCreate(false)}
onCreated={() => {
setShowCreate(false)
fetchUsers()
}}
/>
{editingUser && (
<EditUserModal
user={editingUser}
onClose={() => setEditingUser(null)}
onSaved={() => {
setEditingUser(null)
fetchUsers()
}}
/>
)}
<EditUserModal
user={editingUser}
onClose={() => setEditingUser(null)}
onSaved={() => {
setEditingUser(null)
fetchUsers()
}}
/>
<ConfirmDialog
isOpen={!!deactivatingUser}
title={`Deactivate "${deactivatingUser?.username}"?`}
message="Their photos will be preserved."
confirmLabel="Deactivate"
destructive
onConfirm={handleDeactivate}
onClose={() => setDeactivatingUser(null)}
/>
</div>
)
}
@@ -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<string | null>(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 (
<ModalOverlay onClose={onClose} title="Add User">
{error && (
<div className="rounded bg-red-900/30 px-3 py-2 text-xs text-red-300">
{error}
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Add User</DialogTitle>
</DialogHeader>
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="space-y-3">
<div className="space-y-1">
<Label htmlFor="new-username">Username</Label>
<Input
id="new-username"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoFocus
/>
</div>
<div className="space-y-1">
<Label htmlFor="new-password">Password</Label>
<Input
id="new-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
<div className="space-y-1">
<Label>Role</Label>
<Select
value={role}
onValueChange={(v) => setRole(v as 'user' | 'admin')}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">User</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
<div className="space-y-3">
<Field label="Username">
<input
value={username}
onChange={(e) => 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
/>
</Field>
<Field label="Password">
<input
type="password"
value={password}
onChange={(e) => 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"
/>
</Field>
<Field label="Role">
<select
value={role}
onChange={(e) => setRole(e.target.value as 'user' | 'admin')}
className="rounded border border-border bg-bg px-2 py-1 text-xs text-text outline-none focus:border-accent"
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</Field>
</div>
<div className="mt-4 flex justify-end gap-2">
<button
onClick={onClose}
className="rounded border border-border px-3 py-1 text-xs text-text hover:bg-bg"
>
Cancel
</button>
<button
onClick={handleSubmit}
disabled={loading}
className="rounded bg-accent px-3 py-1 text-xs text-white hover:bg-accent/80 disabled:opacity-50"
>
{loading ? 'Creating\u2026' : 'Create'}
</button>
</div>
</ModalOverlay>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleSubmit} disabled={loading}>
{loading ? 'Creating\u2026' : 'Create'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -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<string | null>(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 (
<ModalOverlay onClose={onClose} title={`Edit: ${user.username}`}>
{error && (
<div className="rounded bg-red-900/30 px-3 py-2 text-xs text-red-300">
{error}
<Dialog open={!!user} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Edit: {user?.username}</DialogTitle>
</DialogHeader>
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="space-y-3">
<div className="space-y-1">
<Label>Role</Label>
<Select
value={role}
onValueChange={(v) => setRole(v as 'user' | 'admin')}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">User</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label htmlFor="edit-password">
New Password (leave blank to keep current)
</Label>
<Input
id="edit-password"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Unchanged"
/>
</div>
</div>
)}
<div className="space-y-3">
<Field label="Role">
<select
value={role}
onChange={(e) => setRole(e.target.value as 'user' | 'admin')}
className="rounded border border-border bg-bg px-2 py-1 text-xs text-text outline-none focus:border-accent"
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</Field>
<Field label="New Password (leave blank to keep current)">
<input
type="password"
value={newPassword}
onChange={(e) => 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"
/>
</Field>
</div>
<div className="mt-4 flex justify-end gap-2">
<button
onClick={onClose}
className="rounded border border-border px-3 py-1 text-xs text-text hover:bg-bg"
>
Cancel
</button>
<button
onClick={handleSubmit}
disabled={loading}
className="rounded bg-accent px-3 py-1 text-xs text-white hover:bg-accent/80 disabled:opacity-50"
>
{loading ? 'Saving\u2026' : 'Save'}
</button>
</div>
</ModalOverlay>
)
}
// ── Shared helpers ─────────────────────────────────────────────────────
function ModalOverlay({
onClose: _onClose,
title,
children,
}: {
onClose: () => void
title: string
children: React.ReactNode
}) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={_onClose}>
<div className="w-full max-w-sm rounded-lg border border-border bg-surface p-5 shadow-xl" onClick={(e) => e.stopPropagation()}>
<h4 className="mb-3 text-sm font-semibold text-text">{title}</h4>
{children}
</div>
</div>
)
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="space-y-1">
<label className="block text-[11px] text-text-muted">{label}</label>
{children}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleSubmit} disabled={loading}>
{loading ? 'Saving\u2026' : 'Save'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -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() {
</h1>
{error && (
<div className="rounded bg-red-900/30 px-3 py-2 text-sm text-red-300">
{error}
</div>
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="space-y-1">
<label htmlFor="login-user" className="block text-sm text-text-muted">
Username
</label>
<input
<div className="space-y-1.5">
<Label htmlFor="login-user">Username</Label>
<Input
id="login-user"
type="text"
value={username}
onChange={(e) => 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"
/>
</div>
<div className="space-y-1">
<label htmlFor="login-pass" className="block text-sm text-text-muted">
Password
</label>
<input
<div className="space-y-1.5">
<Label htmlFor="login-pass">Password</Label>
<Input
id="login-pass"
type="password"
value={password}
onChange={(e) => 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"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full rounded bg-accent px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent/80 disabled:opacity-50"
>
<Button type="submit" disabled={loading} className="w-full">
{loading ? 'Signing in\u2026' : 'Sign In'}
</button>
</Button>
</form>
</div>
)

View File

@@ -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() {
</div>
{error && (
<div className="rounded bg-red-900/30 px-3 py-2 text-sm text-red-300">
{error}
</div>
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="space-y-1">
<label htmlFor="setup-user" className="block text-sm text-text-muted">
Username
</label>
<input
<div className="space-y-1.5">
<Label htmlFor="setup-user">Username</Label>
<Input
id="setup-user"
type="text"
value={username}
onChange={(e) => 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"
/>
</div>
<div className="space-y-1">
<label htmlFor="setup-pass" className="block text-sm text-text-muted">
Password
</label>
<input
<div className="space-y-1.5">
<Label htmlFor="setup-pass">Password</Label>
<Input
id="setup-pass"
type="password"
value={password}
onChange={(e) => 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"
/>
</div>
<div className="space-y-1">
<label htmlFor="setup-confirm" className="block text-sm text-text-muted">
Confirm Password
</label>
<input
<div className="space-y-1.5">
<Label htmlFor="setup-confirm">Confirm Password</Label>
<Input
id="setup-confirm"
type="password"
value={confirmPassword}
onChange={(e) => 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"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full rounded bg-accent px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent/80 disabled:opacity-50"
>
<Button type="submit" disabled={loading} className="w-full">
{loading ? 'Creating account\u2026' : 'Create Admin Account'}
</button>
</Button>
</form>
</div>
)

View File

@@ -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 (
<div className="flex h-full flex-col overflow-hidden">
<div className="flex items-center gap-3 border-b border-border px-4 py-3">
<button
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-text-muted"
onClick={exitDetail}
className="rounded p-1 text-text-muted transition-colors hover:bg-surface-2 hover:text-text"
title="Back to colors"
>
<ArrowLeft className="h-4 w-4" />
</button>
</Button>
<div className="flex items-center gap-2">
<span className={`inline-block h-3 w-3 rounded-full ${selectedGroup.className}`} />
<h2 className="text-sm font-semibold text-text">{selectedGroup.label}</h2>

View File

@@ -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 (
<div className="fixed inset-0 z-50">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
<div className="relative z-10 w-96 rounded-lg border border-border bg-surface p-5 shadow-2xl">
<h2 className="mb-2 text-base font-semibold text-text">{title}</h2>
<div className="mb-4 text-sm text-text-muted">{message}</div>
<div className="flex justify-end gap-2">
<button
onClick={onClose}
className="rounded border border-border px-3 py-1.5 text-sm text-text hover:bg-surface-2"
>
{cancelLabel}
</button>
<button
onClick={onConfirm}
className={clsx(
'rounded px-3 py-1.5 text-sm font-medium text-white',
destructive
? 'bg-reject hover:bg-reject/80'
: 'bg-primary hover:bg-primary/80'
)}
>
{confirmLabel}
</button>
</div>
</div>
</div>
</div>
<Dialog open={isOpen} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription asChild>
<div>{message}</div>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
{cancelLabel}
</Button>
<Button
variant={destructive ? 'destructive' : 'default'}
onClick={onConfirm}
>
{confirmLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -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<Mode>('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 (
<div className="fixed inset-0 z-50">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
<div className="relative z-10 w-[420px] rounded-lg border border-border bg-surface p-5 shadow-2xl">
<h2 className="mb-1 text-base font-semibold text-text">
Delete folder "{folderName}"?
</h2>
<p className="mb-4 text-sm text-text-muted">
<Dialog open={isOpen} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-[420px]">
<DialogHeader>
<DialogTitle>Delete folder "{folderName}"?</DialogTitle>
<DialogDescription>
What should happen to {photoBlurb}?
</p>
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<ModeCard
icon={<Archive className="h-4 w-4" />}
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')}
/>
<ModeCard
icon={<Trash2 className="h-4 w-4" />}
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')}
/>
</div>
<RadioGroup
value={mode}
onValueChange={(v) => setMode(v as Mode)}
className="gap-2"
>
<ModeCard
id="folder-delete-discard"
value="discard"
icon={<Archive className="h-4 w-4" />}
title="Move photos to discard pile"
description="Photos can be restored later from Discarded. The folder and files stay on disk."
selected={mode === 'discard'}
/>
<ModeCard
id="folder-delete-permanent"
value="permanent"
icon={<Trash2 className="h-4 w-4" />}
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
/>
</RadioGroup>
<div className="mt-5 flex justify-end gap-2">
<button
onClick={onClose}
className="rounded border border-border px-3 py-1.5 text-sm text-text hover:bg-surface-2"
>
Cancel
</button>
<button
onClick={() => onConfirm(mode)}
className={clsx(
'rounded px-3 py-1.5 text-sm font-medium text-white',
mode === 'permanent'
? 'bg-reject hover:bg-reject/80'
: 'bg-primary hover:bg-primary/80'
)}
>
{mode === 'permanent' ? 'Delete forever' : 'Move to discard pile'}
</button>
</div>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button
variant={mode === 'permanent' ? 'destructive' : 'default'}
onClick={() => onConfirm(mode)}
>
{mode === 'permanent' ? 'Delete forever' : 'Move to discard pile'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
/** 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 (
<button
onClick={onClick}
<Label
htmlFor={id}
className={clsx(
'flex w-full gap-3 rounded-lg border p-3 text-left transition-colors',
'flex w-full cursor-pointer gap-3 rounded-lg border p-3 text-left transition-colors',
selected
? destructive
? 'border-reject/60 bg-reject/10'
@@ -140,10 +133,15 @@ function ModeCard({
: 'border-border bg-surface-2 hover:bg-surface-offset'
)}
>
<RadioGroupItem id={id} value={value} className="mt-0.5" />
<div
className={clsx(
'mt-0.5 flex-shrink-0',
selected ? (destructive ? 'text-reject' : 'text-primary') : 'text-text-muted'
selected
? destructive
? 'text-reject'
: 'text-primary'
: 'text-text-muted'
)}
>
{icon}
@@ -152,13 +150,17 @@ function ModeCard({
<div
className={clsx(
'text-sm font-medium',
selected ? (destructive ? 'text-reject' : 'text-primary') : 'text-text'
selected
? destructive
? 'text-reject'
: 'text-primary'
: 'text-text'
)}
>
{title}
</div>
<div className="mt-0.5 text-xs text-text-muted">{description}</div>
</div>
</button>
</Label>
)
}

View File

@@ -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 (
<div className="flex h-full flex-col">
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as SettingsTab)}
className="flex h-full flex-col"
>
{/* Tab bar */}
<div className="flex items-center gap-1 border-b border-border bg-surface px-4 py-1.5">
{visibleTabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={clsx(
'rounded px-3 py-1 text-xs font-medium transition-colors',
activeTab === tab.id
? 'bg-primary/20 text-primary'
: 'text-text-muted hover:bg-surface-2 hover:text-text',
)}
>
{tab.label}
</button>
))}
<div className="flex items-center border-b border-border bg-surface px-4 py-1.5">
<TabsList className="h-7 bg-transparent">
{visibleTabs.map((tab) => (
<TabsTrigger
key={tab.id}
value={tab.id}
className="h-6 px-3 text-xs data-[state=active]:bg-primary/20 data-[state=active]:text-primary"
>
{tab.label}
</TabsTrigger>
))}
</TabsList>
</div>
{/* Tab content */}
@@ -867,7 +871,7 @@ export function SettingsPage() {
</div>
</div>
</div>
</Tabs>
)
}
@@ -1211,36 +1215,24 @@ function AiFeaturesTab({ busy, runAction }: AiFeaturesTabProps) {
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<button
role="switch"
aria-checked={state.effective}
onClick={() => applyFlag(meta.id, !state.effective)}
<Switch
checked={state.effective}
onCheckedChange={(v) => applyFlag(meta.id, v)}
disabled={isBusy || (dimmed && !isMaster)}
className={clsx(
'relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors',
state.effective ? 'bg-primary' : 'bg-surface-2 border border-border',
(isBusy || (dimmed && !isMaster)) && 'cursor-not-allowed opacity-50',
)}
title={state.effective ? 'Click to disable' : 'Click to enable'}
>
<span
aria-hidden="true"
className={clsx(
'inline-block h-5 w-5 transform rounded-full bg-white shadow transition-transform',
state.effective ? 'translate-x-[22px]' : 'translate-x-0.5',
)}
/>
</button>
/>
{state.overridden && (
<button
<Button
variant="outline"
size="icon"
onClick={() => applyFlag(meta.id, null)}
disabled={isBusy}
className="rounded border border-border p-1 text-text-muted hover:bg-surface-2 hover:text-text disabled:opacity-50"
className="h-6 w-6"
title="Reset to YAML default"
aria-label="Reset override"
>
<RotateCcw className="h-3 w-3" />
</button>
</Button>
)}
</div>
</div>

View File

@@ -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() {
<div className="flex items-center gap-2">
{selected > 0 && (
<>
<button
<Button
variant="secondary"
size="sm"
onClick={() => restoreMutation.mutate(selectedPhotos)}
disabled={restoreMutation.isPending}
className="flex items-center gap-1.5 rounded bg-surface-2 px-3 py-1 text-text hover:bg-surface-offset disabled:opacity-50"
title="Restore selected (U)"
>
<RotateCcw className="h-3.5 w-3.5" />
<RotateCcw className="mr-1.5 h-3.5 w-3.5" />
Restore {selected}
</button>
<button
</Button>
<Button
size="sm"
onClick={() => setDeleteSelectedOpen(true)}
disabled={deleteSelectedMutation.isPending}
className="flex items-center gap-1.5 rounded bg-reject/20 px-3 py-1 text-reject hover:bg-reject/30 disabled:opacity-50"
className="bg-reject/20 text-reject hover:bg-reject/30"
title="Permanently delete selected"
>
<Trash2 className="h-3.5 w-3.5" />
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
Delete {selected}
</button>
</Button>
</>
)}
<button
<Button
size="sm"
onClick={() => setConfirmOpen(true)}
disabled={total === 0 || emptyMutation.isPending}
className="flex items-center gap-1.5 rounded bg-reject/20 px-3 py-1 text-reject hover:bg-reject/30 disabled:opacity-50"
className="bg-reject/20 text-reject hover:bg-reject/30"
title="Permanently delete all discarded photos and files"
>
<Trash2 className="h-3.5 w-3.5" />
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
Empty discard pile
</button>
</Button>
</div>
</div>

View File

@@ -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({
)}
</span>
</div>
<button
<Button
variant="outline"
size="sm"
onClick={() => {
const discardIds = group.members
.filter((m) => m.id !== best.id)
@@ -278,16 +281,12 @@ function DuplicateGroupSection({
onKeepBest(discardIds)
}}
disabled={isPending}
className={clsx(
'flex items-center gap-1.5 rounded border border-border px-2 py-1 text-xs font-medium transition-colors',
'hover:border-reject/50 hover:bg-reject/10 hover:text-reject',
isPending && 'cursor-not-allowed opacity-50'
)}
className="hover:border-reject/50 hover:bg-reject/10 hover:text-reject"
title="Keep the highest-resolution copy and discard the rest"
>
<Trash2 className="h-3 w-3" />
<Trash2 className="mr-1.5 h-3 w-3" />
Keep best, discard {discardCount}
</button>
</Button>
</header>
<div

View File

@@ -5,8 +5,6 @@ import {
ArrowDown,
ArrowUp,
Search,
AlertTriangle,
Check,
PanelLeftOpen,
PanelLeftClose,
PanelRightOpen,
@@ -20,9 +18,20 @@ import {
type SortField,
} from '../../store/filterStore'
import { useTagsQuery } from '../../hooks/useTagsQuery'
import type { Tag } from '../../services/api'
import { usePhotosQuery } from '../../hooks/usePhotosQuery'
import { FilterPill } from './FilterPill'
import { COLOR_LABEL_OPTIONS } from '../../constants/colorLabels'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { MultiSelect } from '@/components/ui/multi-select'
import { Calendar } from '@/components/ui/calendar'
const SEARCH_DEBOUNCE_MS = 300
@@ -88,7 +97,6 @@ export function FilterBar({
const setColorLabel = useFilterStore((s) => 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)
}}
>
<div className="space-y-2">
<div>
<label className="mb-1 block text-[11px] text-text-muted">From</label>
<input
type="date"
value={dateFrom ?? ''}
onChange={(e) => setDateFrom(e.target.value || null)}
className="w-full rounded border border-border bg-bg px-2 py-1 text-xs text-text"
/>
</div>
<div>
<label className="mb-1 block text-[11px] text-text-muted">To</label>
<input
type="date"
value={dateTo ?? ''}
onChange={(e) => setDateTo(e.target.value || null)}
className="w-full rounded border border-border bg-bg px-2 py-1 text-xs text-text"
/>
</div>
</div>
<DateRangePicker
from={dateFrom}
to={dateTo}
onFromChange={setDateFrom}
onToChange={setDateTo}
/>
</FilterPill>
{/* Type */}
@@ -215,25 +209,23 @@ export function FilterBar({
isActive={typeActive}
onClear={() => mediaTypes.forEach((t) => toggleMediaType(t))}
>
<div className="flex flex-wrap gap-1">
{MEDIA_TYPES.map(({ value, label }) => {
const active = mediaTypes.includes(value)
return (
<button
key={value}
onClick={() => toggleMediaType(value)}
className={clsx(
'rounded px-2 py-1 text-xs transition-colors',
active
? 'bg-primary text-white'
: 'bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
)}
>
{label}
</button>
)
})}
</div>
<MultiSelect
searchable={false}
pinSelected={false}
options={MEDIA_TYPES.map(({ value, label }) => ({
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)
}
}}
/>
</FilterPill>
{/* Rating */}
@@ -316,69 +308,45 @@ export function FilterBar({
setFlag('any')
setNeedsReview(false)
}}
>
<div className="flex flex-col gap-1">
<button
onClick={() => {
setFlag('any')
setNeedsReview(false)
>
<MultiSelect
searchable={false}
pinSelected={false}
options={[
{ value: 'discarded', label: 'Discarded' },
{ value: 'date_warning', label: 'Date issues' },
{ value: 'needs_review', label: 'Needs review' },
]}
values={
needsReview
? ['needs_review']
: flag !== 'any'
? [flag]
: []
}
onChange={(next) => {
// Flag state is mutually exclusive in the store; treat
// the just-added value as the new single selection, or
// clear everything when the user unchecks the current.
const added = next.find(
(v) =>
v !==
(needsReview ? 'needs_review' : flag !== 'any' ? flag : '')
)
if (!added) {
setFlag('any')
setNeedsReview(false)
return
}
if (added === 'needs_review') {
setFlag('any')
setNeedsReview(true)
} else {
setFlag(added as 'discarded' | 'date_warning')
setNeedsReview(false)
}
}}
className={clsx(
'rounded px-2 py-1 text-left text-xs transition-colors',
flag === 'any' && !needsReview
? 'bg-primary text-white'
: 'bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
)}
>
Any
</button>
<button
onClick={() => {
setFlag('discarded')
setNeedsReview(false)
}}
className={clsx(
'rounded px-2 py-1 text-left text-xs transition-colors',
flag === 'discarded'
? 'bg-primary text-white'
: 'bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
)}
>
Discarded
</button>
<button
onClick={() => {
setFlag('date_warning')
setNeedsReview(false)
}}
className={clsx(
'flex items-center gap-1.5 rounded px-2 py-1 text-left text-xs transition-colors',
flag === 'date_warning'
? 'bg-primary text-white'
: 'bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
)}
title="Photos whose folder/filename suggests a different date than the stored taken_at"
>
<AlertTriangle className="h-3 w-3" />
Date issues
</button>
<button
onClick={() => {
setFlag('any')
setNeedsReview(true)
}}
className={clsx(
'flex items-center gap-1.5 rounded px-2 py-1 text-left text-xs transition-colors',
needsReview
? 'bg-primary text-white'
: 'bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
)}
title="Show only photos classified as non-photographs (screenshots, documents, memes)"
>
<AlertTriangle className="h-3 w-3" />
Needs review
</button>
</div>
/>
</FilterPill>
)}
@@ -389,12 +357,25 @@ export function FilterBar({
value={tagValue}
isActive={tagActive}
onClear={() => setTagIds([])}
>
<TagFilterPopover
allTags={allTags}
selectedIds={tagIds}
onToggle={toggleTagId}
onClear={() => setTagIds([])}
>
<MultiSelect
className="w-60"
searchPlaceholder="Search tags…"
emptyMessage="No tags match"
options={allTags
.slice()
.sort((a, b) => {
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}
/>
</FilterPill>
)}
@@ -403,20 +384,26 @@ export function FilterBar({
always a value. */}
<FilterPill label="Sort" value={sortValue} isActive>
<div className="space-y-2">
<select
<Select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as SortField)}
className="w-full rounded border border-border bg-bg px-2 py-1 text-xs text-text focus:border-primary focus:outline-none"
onValueChange={(v) => setSortBy(v as SortField)}
>
{SORT_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<button
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{SORT_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="secondary"
size="sm"
onClick={toggleSortOrder}
className="flex w-full items-center justify-center gap-1 rounded bg-surface-2 px-2 py-1 text-xs text-text-muted hover:bg-surface-offset hover:text-text"
className="w-full"
>
{sortOrder === 'desc' ? (
<>
@@ -429,7 +416,7 @@ export function FilterBar({
Ascending
</>
)}
</button>
</Button>
</div>
</FilterPill>
@@ -452,7 +439,7 @@ export function FilterBar({
* so the global "/" focus shortcut still finds it. */}
<div className="relative w-56 flex-shrink-0">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-text-muted" />
<input
<Input
id="topbar-search"
type="text"
value={searchQuery}
@@ -465,7 +452,7 @@ export function FilterBar({
}
}}
placeholder="Search photos…"
className="h-7 w-full rounded-full border border-border bg-surface-2 pl-8 pr-7 text-xs text-text placeholder-text-muted focus:border-primary focus:outline-none"
className="h-7 w-full rounded-full bg-surface-2 pl-8 pr-7 text-xs"
/>
{searchQuery && (
<button
@@ -499,144 +486,111 @@ export function FilterBar({
)
}
interface TagFilterPopoverProps {
allTags: Tag[]
selectedIds: string[]
onToggle: (id: string) => void
onClear: () => void
}
/** Date range picker used inside the Date FilterPill. A single
* shadcn/react-day-picker Calendar in `range` mode — first click picks
* the start, second click picks the end. Writes both bounds to the
* store as yyyy-mm-dd ISO strings.
*
* Days without matching photos are disabled + visually dimmed
* ("booked days" pattern from shadcn docs), so the user can see at a
* glance which dates are worth clicking. The booked set is derived
* from the currently-cached photos — reflects every other active
* filter, which is the intended UX: "which days have 5★ photos of
* screenshots" etc. The caption uses the dropdown layout so the user
* can jump across months/years without clicking the arrows. */
function DateRangePicker({
from,
to,
onFromChange,
onToChange,
}: {
from: string | null
to: string | null
onFromChange: (v: string | null) => void
onToChange: (v: string | null) => void
}) {
// Unique yyyy-mm-dd keys of every cached photo's taken_at. Derived
// from the shared photos cache so opening the calendar doesn't fire
// another network request.
const { data: photos = [] } = usePhotosQuery()
const bookedSet = useMemo(() => {
const s = new Set<string>()
for (const p of photos) {
if (!p.taken_at) continue
const d = new Date(p.taken_at)
if (Number.isNaN(d.getTime())) continue
s.add(formatIsoDate(d))
}
return s
}, [photos])
function TagFilterPopover({
allTags,
selectedIds,
onToggle,
onClear,
}: TagFilterPopoverProps) {
const [query, setQuery] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
const currentYear = new Date().getFullYear()
const earliestYear = useMemo(() => {
let min = currentYear
for (const k of bookedSet) {
const y = parseInt(k.slice(0, 4), 10)
if (!Number.isNaN(y) && y < min) min = y
}
return min
}, [bookedSet, currentYear])
useEffect(() => {
inputRef.current?.focus()
}, [])
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds])
// Selected tags pinned at top, remaining sorted by photo_count desc
// then name. Filtered by query (case-insensitive substring).
const orderedTags = useMemo(() => {
const q = query.trim().toLowerCase()
const match = (t: Tag) => !q || t.name.toLowerCase().includes(q)
const selected = allTags.filter((t) => selectedSet.has(t.id) && match(t))
const unselected = allTags
.filter((t) => !selectedSet.has(t.id) && match(t))
.sort((a, b) => {
if (b.photo_count !== a.photo_count) return b.photo_count - a.photo_count
return a.name.localeCompare(b.name)
})
return { selected, unselected }
}, [allTags, selectedSet, query])
const totalVisible = orderedTags.selected.length + orderedTags.unselected.length
const selected = {
from: from ? parseIsoDate(from) : undefined,
to: to ? parseIsoDate(to) : undefined,
}
const handleSelect = (range: { from?: Date; to?: Date } | undefined) => {
onFromChange(range?.from ? formatIsoDate(range.from) : null)
onToChange(range?.to ? formatIsoDate(range.to) : null)
}
const label =
from && to
? from === to
? from
: `${from}${to}`
: from
? `From ${from}`
: to
? `Until ${to}`
: 'Click to pick a start date, then an end date'
return (
<div className="w-64">
<div className="relative mb-2">
<Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-text-muted" />
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Escape' && query) {
e.stopPropagation()
setQuery('')
}
}}
placeholder="Search tags…"
className="h-7 w-full rounded border border-border bg-bg pl-7 pr-6 text-xs text-text placeholder-text-muted focus:border-primary focus:outline-none"
/>
{query && (
<button
onClick={() => setQuery('')}
className="absolute right-1 top-1/2 -translate-y-1/2 rounded p-0.5 text-text-muted hover:bg-surface-2 hover:text-text"
aria-label="Clear search"
>
<X className="h-3 w-3" />
</button>
)}
</div>
<div className="mb-1 flex items-center justify-between px-0.5 text-[11px] text-text-muted">
<span>
{selectedIds.length > 0
? `${selectedIds.length} selected`
: `${totalVisible} tag${totalVisible === 1 ? '' : 's'}`}
</span>
{selectedIds.length > 0 && (
<button
onClick={onClear}
className="underline-offset-2 hover:text-text hover:underline"
>
Clear
</button>
)}
</div>
<div className="max-h-64 overflow-y-auto rounded border border-border bg-bg">
{totalVisible === 0 ? (
<div className="px-2 py-3 text-center text-xs text-text-muted">
No tags match
</div>
) : (
<>
{orderedTags.selected.map((tag) => (
<TagRow key={tag.id} tag={tag} selected onToggle={onToggle} />
))}
{orderedTags.selected.length > 0 && orderedTags.unselected.length > 0 && (
<div className="my-0.5 border-t border-border" />
)}
{orderedTags.unselected.map((tag) => (
<TagRow key={tag.id} tag={tag} selected={false} onToggle={onToggle} />
))}
</>
)}
</div>
<div className="space-y-2">
<div className="text-[11px] text-text-muted">{label}</div>
<Calendar
mode="range"
selected={selected}
onSelect={handleSelect}
numberOfMonths={1}
defaultMonth={selected.from ?? selected.to ?? new Date()}
captionLayout="dropdown"
fromYear={earliestYear}
toYear={currentYear + 1}
modifiers={{
booked: (d) => 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"
/>
</div>
)
}
function TagRow({
tag,
selected,
onToggle,
}: {
tag: Tag
selected: boolean
onToggle: (id: string) => void
}) {
return (
<button
onClick={() => onToggle(tag.id)}
className={clsx(
'flex w-full items-center gap-2 px-2 py-1.5 text-left text-xs transition-colors',
selected
? 'bg-primary/15 text-text hover:bg-primary/25'
: 'text-text-muted hover:bg-surface-2 hover:text-text'
)}
>
<span
className={clsx(
'flex h-3.5 w-3.5 flex-shrink-0 items-center justify-center rounded border',
selected ? 'border-primary bg-primary text-white' : 'border-border'
)}
>
{selected && <Check className="h-2.5 w-2.5" strokeWidth={3} />}
</span>
<span className="min-w-0 flex-1 truncate">{tag.name}</span>
<span className="flex-shrink-0 tabular-nums text-[10px] text-text-muted">
{tag.photo_count}
</span>
</button>
)
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}`
}

View File

@@ -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<HTMLButtonElement>(null)
const popoverRef = useRef<HTMLDivElement>(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 (
<>
<button
ref={buttonRef}
onClick={() => setOpen((v) => !v)}
// Hover to see the active value as a tooltip — keeps the pill at
// a constant width regardless of state. The popover is the
// canonical place to read/edit the filter value.
title={isActive && value ? `${label}: ${value}` : label}
className={clsx(
// Fixed height + py-0 so neither the X clear icon nor the
// chevron can stretch the pill vertically when the active
// state swaps them in.
'flex h-7 items-center gap-1 rounded-full border px-2.5 py-0 text-xs transition-colors',
isActive
? 'border-primary/40 bg-primary/15 text-primary'
: 'border-border bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
)}
>
<span className={clsx(isActive && 'font-medium')}>{label}</span>
{isActive && onClear ? (
<span
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation()
onClear()
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
<Popover defaultOpen={defaultOpen}>
<PopoverTrigger asChild>
<button
title={isActive && value ? `${label}: ${value}` : label}
className={clsx(
// Fixed height + py-0 so neither the X clear icon nor the
// chevron can stretch the pill vertically when the active
// state swaps them in.
'flex h-7 items-center gap-1 rounded-full border px-2.5 py-0 text-xs transition-colors',
isActive
? 'border-primary/40 bg-primary/15 text-primary'
: 'border-border bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
)}
>
<span className={clsx(isActive && 'font-medium')}>{label}</span>
{isActive && onClear ? (
<span
role="button"
tabIndex={0}
onClick={(e) => {
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}`}
>
<X className="h-3 w-3" />
</span>
) : (
<span className="inline-flex h-3.5 w-4 items-center justify-center">
<ChevronDown className="h-3 w-3 opacity-60" />
</span>
)}
</button>
{open &&
createPortal(
<div
ref={popoverRef}
style={{
position: 'fixed',
top: popoverPos?.top ?? -9999,
left: popoverPos?.left ?? -9999,
visibility: popoverPos ? 'visible' : 'hidden',
}}
className="z-50 min-w-[220px] rounded-lg border border-border bg-surface p-3 shadow-xl"
>
{children}
</div>,
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}`}
>
<X className="h-3 w-3" />
</span>
) : (
<span className="inline-flex h-3.5 w-4 items-center justify-center">
<ChevronDown className="h-3 w-3 opacity-60" />
</span>
)}
</button>
</PopoverTrigger>
<PopoverContent
align="start"
className={clsx('w-auto', contentClassName ?? 'p-2')}
>
{children}
</PopoverContent>
</Popover>
)
}

View File

@@ -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<FlatFolder[]>(() => 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 (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
<Dialog open={!!heap} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Convert "{heap?.name}" to folder</DialogTitle>
</DialogHeader>
<div className="relative z-10 w-full max-w-md rounded-lg border border-border bg-surface p-6 shadow-xl">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-text">
Convert "{heap.name}" to folder
</h2>
<button
onClick={onClose}
disabled={convertMutation.isPending}
className="rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="space-y-4">
{/* Target picker */}
<div className="space-y-1.5">
<Label>Target folder</Label>
{folders.length === 0 ? (
<div className="rounded border border-border bg-bg px-3 py-2 text-xs text-text-muted">
No folders available
</div>
) : (
<Select value={targetId} onValueChange={setTargetId}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{folders.map((f) => (
<SelectItem key={f.id} value={f.id}>
{'\u00A0\u00A0'.repeat(f.depth) + f.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{targetFolder && (
<p className="flex items-center gap-1 text-xs text-text-faint">
<Folder className="h-3 w-3" />
{targetFolder.path}
</p>
)}
</div>
{/* Target picker */}
<div className="mb-4">
<label className="mb-1 block text-xs text-text-muted">Target folder</label>
{folders.length === 0 ? (
<div className="rounded border border-border bg-bg px-3 py-2 text-xs text-text-muted">
No folders available
</div>
) : (
<select
value={targetId}
onChange={(e) => setTargetId(e.target.value)}
className="w-full rounded border border-border bg-bg px-2 py-1.5 text-sm text-text focus:border-primary focus:outline-none"
>
{folders.map((f) => (
<option key={f.id} value={f.id}>
{/* Two non-breaking spaces per depth so nested
* subfolders read as a tree in the native dropdown. */}
{'\u00A0\u00A0'.repeat(f.depth) + f.name}
</option>
))}
</select>
)}
{targetFolder && (
<p className="mt-1 flex items-center gap-1 text-xs text-text-faint">
<Folder className="h-3 w-3" />
{targetFolder.path}
{/* Subfolder name */}
<div className="space-y-1.5">
<Label htmlFor="heap-convert-subfolder">Subfolder name</Label>
<Input
id="heap-convert-subfolder"
type="text"
value={subfolderName}
onChange={(e) => setSubfolderName(e.target.value)}
placeholder="(none — use parent directly)"
/>
<p className="text-xs text-text-faint">
{subfolderName.trim() && targetFolder
? `Will create ${targetFolder.path}/${subfolderName.trim()} if missing.`
: 'Photos go directly into the parent folder.'}
</p>
</div>
{/* Mode */}
<div className="space-y-1.5">
<Label>Mode</Label>
<RadioGroup
value={mode}
onValueChange={(v) => setMode(v as 'move' | 'copy')}
className="grid grid-cols-2 gap-2"
>
<Label
htmlFor="heap-convert-move"
className="flex cursor-pointer items-center gap-2 rounded-md border border-border bg-surface-2 px-3 py-1.5 text-sm text-text hover:bg-surface-offset has-[[data-state=checked]]:border-primary has-[[data-state=checked]]:bg-primary/15"
>
<RadioGroupItem id="heap-convert-move" value="move" />
Move
</Label>
<Label
htmlFor="heap-convert-copy"
className="flex cursor-pointer items-center gap-2 rounded-md border border-border bg-surface-2 px-3 py-1.5 text-sm text-text hover:bg-surface-offset has-[[data-state=checked]]:border-primary has-[[data-state=checked]]:bg-primary/15"
>
<RadioGroupItem id="heap-convert-copy" value="copy" />
Copy
</Label>
</RadioGroup>
<p className="text-xs text-text-faint">
{mode === 'move'
? 'Files are moved on disk; original photos update their folder.'
: 'Files are copied on disk; new photo records are created.'}
</p>
</div>
{/* Delete heap */}
<div className="flex items-center gap-2">
<Checkbox
id="delete-heap"
checked={deleteHeap}
onCheckedChange={(v) => setDeleteHeap(v === true)}
/>
<Label htmlFor="delete-heap" className="text-sm text-text">
Delete heap after conversion
</Label>
</div>
{convertMutation.isError && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
{(convertMutation.error as any)?.message || 'Conversion failed'}
</AlertDescription>
</Alert>
)}
</div>
{/* Subfolder name */}
<div className="mb-4">
<label className="mb-1 block text-xs text-text-muted">
Subfolder name
</label>
<input
type="text"
value={subfolderName}
onChange={(e) => 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"
/>
<p className="mt-1 text-xs text-text-faint">
{subfolderName.trim() && targetFolder
? `Will create ${targetFolder.path}/${subfolderName.trim()} if missing.`
: 'Photos go directly into the parent folder.'}
</p>
</div>
{/* Mode toggle */}
<div className="mb-4">
<label className="mb-1 block text-xs text-text-muted">Mode</label>
<div className="flex gap-2">
<button
onClick={() => setMode('move')}
className={clsx(
'flex-1 rounded px-3 py-1.5 text-sm transition-colors',
mode === 'move'
? 'bg-primary text-white'
: 'bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
)}
>
Move
</button>
<button
onClick={() => setMode('copy')}
className={clsx(
'flex-1 rounded px-3 py-1.5 text-sm transition-colors',
mode === 'copy'
? 'bg-primary text-white'
: 'bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
)}
>
Copy
</button>
</div>
<p className="mt-1 text-xs text-text-faint">
{mode === 'move'
? 'Files are moved on disk; original photos update their folder.'
: 'Files are copied on disk; new photo records are created.'}
</p>
</div>
{/* Delete heap toggle */}
<div className="mb-4 flex items-center gap-2">
<input
id="delete-heap"
type="checkbox"
checked={deleteHeap}
onChange={(e) => 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"
/>
<label htmlFor="delete-heap" className="text-sm text-text">
Delete heap after conversion
</label>
</div>
{convertMutation.isError && (
<div className="mb-3 flex items-center gap-2 rounded bg-reject/10 p-3 text-sm text-reject">
<AlertCircle className="h-4 w-4 flex-shrink-0" />
<span>{(convertMutation.error as any)?.message || 'Conversion failed'}</span>
</div>
)}
<div className="flex justify-end gap-2">
<button
<DialogFooter>
<Button
variant="outline"
onClick={onClose}
disabled={convertMutation.isPending}
className="rounded bg-surface-2 px-4 py-2 text-sm text-text hover:bg-surface-offset disabled:opacity-50"
>
Cancel
</button>
<button
</Button>
<Button
onClick={() => convertMutation.mutate()}
disabled={!targetId || convertMutation.isPending}
className="rounded bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary/90 disabled:opacity-50"
>
{convertMutation.isPending ? 'Converting…' : 'Convert'}
</button>
</div>
</div>
</div>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -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<string | null>(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<string | null>(null)
const menuRef = useRef<HTMLDivElement>(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' }}
>
<input
<Input
autoFocus
type="text"
value={newName}
@@ -230,15 +221,16 @@ export function HeapsPanel() {
}
}}
placeholder="Heap name"
className="flex-1 rounded border border-border bg-bg px-2 py-0.5 text-[11px] text-text focus:border-primary focus:outline-none"
className="h-6 flex-1 bg-bg px-2 text-[11px]"
/>
<button
<Button
onClick={handleCreate}
disabled={!newName.trim() || createMutation.isPending}
className="rounded bg-primary px-2 py-0.5 text-[11px] text-white hover:bg-primary/80 disabled:opacity-50"
size="sm"
className="h-6 px-2 text-[11px]"
>
Add
</button>
</Button>
</div>
)}
@@ -323,7 +315,7 @@ export function HeapsPanel() {
/>
{isRenaming ? (
<input
<Input
autoFocus
type="text"
value={renameDraft}
@@ -337,7 +329,7 @@ export function HeapsPanel() {
setRenamingId(null)
}
}}
className="flex-1 rounded border border-border bg-bg px-1 py-0 text-[13px] text-text focus:border-primary focus:outline-none"
className="h-6 flex-1 bg-bg px-1 text-[13px]"
/>
) : (
<>
@@ -389,93 +381,81 @@ export function HeapsPanel() {
{/* Kebab menu — collects rename / duplicate / convert /
* delete so the row stays compact. */}
<div
className={clsx(
'relative flex-shrink-0',
isMenuOpen ? 'block' : 'hidden group-hover:block'
)}
<DropdownMenu
open={isMenuOpen}
onOpenChange={(o) => setOpenMenuId(o ? heap.id : null)}
>
<button
onClick={(e) => {
e.stopPropagation()
setOpenMenuId(isMenuOpen ? null : heap.id)
}}
className="rounded p-0.5 text-text-muted hover:bg-surface-offset hover:text-text"
title="More actions"
aria-label="More heap actions"
aria-haspopup="menu"
aria-expanded={isMenuOpen}
<div
className={clsx(
'relative flex-shrink-0',
isMenuOpen ? 'block' : 'hidden group-hover:block'
)}
>
<MoreHorizontal className="h-3.5 w-3.5" />
</button>
{isMenuOpen && (
<div
ref={menuRef}
role="menu"
className="absolute right-0 top-full z-30 mt-1 min-w-[160px] overflow-hidden rounded-lg border border-border bg-surface py-1 text-sm shadow-xl"
onClick={(e) => e.stopPropagation()}
<DropdownMenuTrigger asChild>
<button
onClick={(e) => e.stopPropagation()}
className="rounded p-0.5 text-text-muted hover:bg-surface-offset hover:text-text"
title="More actions"
aria-label="More heap actions"
>
<MoreHorizontal className="h-3.5 w-3.5" />
</button>
</DropdownMenuTrigger>
</div>
<DropdownMenuContent
align="end"
className="min-w-[160px]"
onClick={(e) => e.stopPropagation()}
>
<DropdownMenuItem
onClick={() => {
setRenamingId(heap.id)
setRenameDraft(heap.name)
}}
>
<MenuItem
icon={<Pencil className="h-3.5 w-3.5" />}
label="Rename"
onClick={() => {
setOpenMenuId(null)
setRenamingId(heap.id)
setRenameDraft(heap.name)
}}
/>
<MenuItem
icon={<Copy className="h-3.5 w-3.5" />}
label="Duplicate"
onClick={() => {
setOpenMenuId(null)
duplicateMutation.mutate(heap.id)
}}
/>
<MenuItem
icon={<FolderOutput className="h-3.5 w-3.5" />}
label="Move to folder…"
onClick={() => {
setOpenMenuId(null)
setConvertingHeap(heap)
}}
/>
<MenuItem
icon={<Users className="h-3.5 w-3.5" />}
label="Share…"
onClick={() => {
setOpenMenuId(null)
setSharingHeap(heap)
}}
/>
<MenuItem
icon={<DownloadIcon className="h-3.5 w-3.5" />}
label="Download as zip"
onClick={() => {
setOpenMenuId(null)
downloads.trigger(downloads.heapUrl(heap.id))
}}
/>
<div className="my-1 h-px bg-border" />
<MenuItem
icon={<Trash2 className="h-3.5 w-3.5" />}
label="Delete"
destructive
onClick={() => {
setOpenMenuId(null)
if (
confirm(
`Delete heap "${heap.name}"? Photos are not affected.`
)
) {
deleteMutation.mutate(heap.id)
}
}}
/>
</div>
)}
</div>
<Pencil className="h-3.5 w-3.5 text-text-muted" />
Rename
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => duplicateMutation.mutate(heap.id)}
>
<Copy className="h-3.5 w-3.5 text-text-muted" />
Duplicate
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setConvertingHeap(heap)}>
<FolderOutput className="h-3.5 w-3.5 text-text-muted" />
Move to folder
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setSharingHeap(heap)}>
<Users className="h-3.5 w-3.5 text-text-muted" />
Share
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
downloads.trigger(downloads.heapUrl(heap.id))
}
>
<DownloadIcon className="h-3.5 w-3.5 text-text-muted" />
Download as zip
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
if (
confirm(
`Delete heap "${heap.name}"? Photos are not affected.`
)
) {
deleteMutation.mutate(heap.id)
}
}}
className="text-reject focus:bg-reject/10 focus:text-reject"
>
<Trash2 className="h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
})}
@@ -543,30 +523,3 @@ export function HeapsPanel() {
)
}
function MenuItem({
icon,
label,
onClick,
destructive = false,
}: {
icon: React.ReactNode
label: string
onClick: () => void
destructive?: boolean
}) {
return (
<button
role="menuitem"
onClick={onClick}
className={clsx(
'flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs transition-colors',
destructive
? 'text-reject hover:bg-reject/10'
: 'text-text hover:bg-surface-2'
)}
>
<span className="text-text-muted">{icon}</span>
{label}
</button>
)
}

View File

@@ -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<string | null>(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<string | null>(null)
const menuRef = useRef<HTMLDivElement>(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 ? (
<input
<Input
autoFocus
type="text"
value={renameDraft}
@@ -621,7 +614,7 @@ export function LeftSidebar() {
setRenamingId(null)
}
}}
className="flex-1 rounded border border-border bg-bg px-1 py-0 text-[13px] text-text focus:border-primary focus:outline-none"
className="h-6 flex-1 bg-bg px-1 text-[13px]"
/>
) : (
<span
@@ -678,114 +671,108 @@ export function LeftSidebar() {
const folderId = item.id.slice('folder-'.length)
const isMenuOpen = openMenuId === item.id
return (
<div
className={clsx(
'relative flex-shrink-0',
isMenuOpen ? 'block' : 'hidden group-hover:block'
)}
<DropdownMenu
open={isMenuOpen}
onOpenChange={(o) => setOpenMenuId(o ? item.id : null)}
>
<button
onClick={(e) => {
e.stopPropagation()
setOpenMenuId(isMenuOpen ? null : item.id)
}}
className="rounded p-0.5 text-text-muted hover:bg-surface-offset hover:text-text"
title="More actions"
aria-label="More folder actions"
aria-haspopup="menu"
aria-expanded={isMenuOpen}
<div
className={clsx(
'relative flex-shrink-0',
isMenuOpen ? 'block' : 'hidden group-hover:block'
)}
>
<MoreHorizontal className="h-3.5 w-3.5" />
</button>
{isMenuOpen && (
<div
ref={menuRef}
role="menu"
onClick={(e) => 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"
<DropdownMenuTrigger asChild>
<button
onClick={(e) => e.stopPropagation()}
className="rounded p-0.5 text-text-muted hover:bg-surface-offset hover:text-text"
title="More actions"
aria-label="More folder actions"
>
<MoreHorizontal className="h-3.5 w-3.5" />
</button>
</DropdownMenuTrigger>
</div>
<DropdownMenuContent
align="end"
className="min-w-[180px]"
onClick={(e) => e.stopPropagation()}
>
<DropdownMenuItem
onClick={() =>
setUploadTarget({ open: true, folderId })
}
>
<FolderMenuItem
icon={<UploadIcon className="h-3.5 w-3.5" />}
label="Upload here…"
onClick={() => {
setOpenMenuId(null)
setUploadTarget({ open: true, folderId })
}}
/>
<FolderMenuItem
icon={<FolderPlus className="h-3.5 w-3.5" />}
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)
}
}}
/>
<FolderMenuItem
icon={<Pencil className="h-3.5 w-3.5" />}
label="Rename"
onClick={() => {
setOpenMenuId(null)
setRenamingId(item.id)
setRenameDraft(item.label)
}}
/>
<FolderMenuItem
icon={
item.isHidden ? (
<Eye className="h-3.5 w-3.5" />
) : (
<EyeOff className="h-3.5 w-3.5" />
)
<UploadIcon className="h-3.5 w-3.5 text-text-muted" />
Upload here
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
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,
})
}}
/>
<FolderMenuItem
icon={<Users className="h-3.5 w-3.5" />}
label="Share…"
onClick={() => {
setOpenMenuId(null)
setSharingFolder({ id: folderId, name: item.label })
}}
/>
<FolderMenuItem
icon={<DownloadIcon className="h-3.5 w-3.5" />}
label="Download as zip"
onClick={() => {
setOpenMenuId(null)
downloads.trigger(downloads.folderUrl(folderId))
}}
/>
<div className="my-1 h-px bg-border" />
<FolderMenuItem
icon={<Trash2 className="h-3.5 w-3.5" />}
label="Delete folder…"
destructive
onClick={() => {
setOpenMenuId(null)
setDeletingFolder({
id: folderId,
name: item.label,
photoCount: item.count,
})
}}
/>
</div>
)}
</div>
}}
>
<FolderPlus className="h-3.5 w-3.5 text-text-muted" />
New sub-folder
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setRenamingId(item.id)
setRenameDraft(item.label)
}}
>
<Pencil className="h-3.5 w-3.5 text-text-muted" />
Rename
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
toggleHiddenMutation.mutate({
id: folderId,
hidden: !item.isHidden,
})
}
>
{item.isHidden ? (
<Eye className="h-3.5 w-3.5 text-text-muted" />
) : (
<EyeOff className="h-3.5 w-3.5 text-text-muted" />
)}
{item.isHidden ? 'Show in views' : 'Hide from views'}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
setSharingFolder({ id: folderId, name: item.label })
}
>
<Users className="h-3.5 w-3.5 text-text-muted" />
Share
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
downloads.trigger(downloads.folderUrl(folderId))
}
>
<DownloadIcon className="h-3.5 w-3.5 text-text-muted" />
Download as zip
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() =>
setDeletingFolder({
id: folderId,
name: item.label,
photoCount: item.count,
})
}
className="text-reject focus:bg-reject/10 focus:text-reject"
>
<Trash2 className="h-3.5 w-3.5" />
Delete folder
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
})()}
@@ -984,30 +971,3 @@ export function LeftSidebar() {
)
}
function FolderMenuItem({
icon,
label,
onClick,
destructive = false,
}: {
icon: React.ReactNode
label: string
onClick: () => void
destructive?: boolean
}) {
return (
<button
role="menuitem"
onClick={onClick}
className={clsx(
'flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs transition-colors',
destructive
? 'text-reject hover:bg-reject/10'
: 'text-text hover:bg-surface-2'
)}
>
<span className="text-text-muted">{icon}</span>
{label}
</button>
)
}

View File

@@ -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() {
</h2>
<div className="flex items-center gap-0.5">
{selectedPhotos.length > 0 && (
<button
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-text-muted"
onClick={clearSelection}
className="rounded p-0.5 text-text-muted hover:bg-surface-2 hover:text-text"
title="Clear selection (Esc)"
aria-label="Clear selection"
>
<X className="h-3.5 w-3.5" />
</button>
</Button>
)}
</div>
</div>
@@ -305,7 +310,7 @@ export function RightSidebar() {
{/* Bulk rating */}
<div>
<label className="mb-1 block text-xs text-text-muted">Rating</label>
<Label className="mb-1 block">Rating</Label>
<div className="flex gap-1">
{[1, 2, 3, 4, 5].map((value) => (
<button
@@ -333,7 +338,7 @@ export function RightSidebar() {
{/* Bulk color */}
<div>
<label className="mb-1 block text-xs text-text-muted">Color label</label>
<Label className="mb-1 block">Color label</Label>
<div className="flex items-center gap-1.5">
{COLOR_LABEL_OPTIONS.map(({ value, className }) => (
<button
@@ -362,18 +367,18 @@ export function RightSidebar() {
{/* Bulk flag */}
<div>
<label className="mb-1 block text-xs text-text-muted">Flag</label>
<Label className="mb-1 block">Flag</Label>
<div className="flex gap-2">
<button
<Button
size="sm"
onClick={() => {
if (!activeHeap) return
heapMutation.mutate({ ids: selectedPhotos, remove: allMembers })
}}
disabled={!activeHeap || heapMutation.isPending}
className={clsx(
'flex items-center gap-1 rounded px-2 py-1 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-50',
allMembers
? 'bg-pick/20 text-pick'
? 'bg-pick/20 text-pick hover:bg-pick/30'
: 'bg-surface-2 text-text-muted hover:bg-surface-offset'
)}
title={
@@ -384,16 +389,18 @@ export function RightSidebar() {
: 'Set an active heap first'
}
>
<ShoppingBasket className="h-3 w-3" />
<ShoppingBasket className="mr-1 h-3 w-3" />
{allMembers ? 'Selected' : 'Select'}
</button>
<button
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => bulkDiscardMutation.mutate(selectedPhotos)}
className="flex items-center gap-1 rounded bg-surface-2 px-2 py-1 text-sm text-text-muted transition-colors hover:bg-surface-offset"
className="text-text-muted"
>
<Trash2 className="h-3 w-3" />
<Trash2 className="mr-1 h-3 w-3" />
Discard
</button>
</Button>
</div>
</div>
@@ -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. */}
<div>
<label className="mb-1 block text-xs text-text-muted">Tags</label>
<Label className="mb-1 block">Tags</Label>
<BulkTagsEditor
allTags={allTags}
tagInput={tagInput}
@@ -435,7 +442,7 @@ export function RightSidebar() {
* clock (1970 epoch) and for legacy libraries where the folder
* structure is the only trustworthy date signal. */}
<div>
<label className="mb-1 block text-xs text-text-muted">Date Taken</label>
<Label className="mb-1 block">Date Taken</Label>
<BulkTakenAtEditor
disabled={
bulkTakenAtMutation.isPending || bulkTakenAtMapMutation.isPending
@@ -518,33 +525,36 @@ function BulkTakenAtEditor({
<div className="space-y-2">
{/* Apply-one row */}
<div className="flex items-center gap-1.5">
<input
<Input
type="datetime-local"
value={uniformDraft}
onChange={(e) => 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"
/>
<button
<Button
size="sm"
onClick={handleApplyUniform}
disabled={disabled || !uniformDraft}
className="rounded bg-primary/20 px-2 py-1 text-xs text-primary hover:bg-primary/30 disabled:cursor-not-allowed disabled:opacity-40"
className="bg-primary/20 text-primary hover:bg-primary/30"
title={`Apply this date to all ${selectedCount} selected`}
>
Apply
</button>
</Button>
</div>
{/* Guess-from-path preview */}
{preview === null ? (
<button
<Button
variant="outline"
size="sm"
onClick={handleGuess}
disabled={disabled}
className="flex w-full items-center justify-center gap-1 rounded border border-dashed border-primary/50 px-2 py-1 text-xs text-primary hover:bg-primary/10 disabled:opacity-50"
className="w-full border-dashed border-primary/50 bg-transparent text-primary hover:bg-primary/10"
title="Scan each photo's folder + filename for a date pattern"
>
Guess from folder paths
</button>
</Button>
) : (
<div className="rounded border border-border bg-bg p-2 text-[11px]">
<div className="mb-1.5 text-text-muted">
@@ -570,20 +580,23 @@ function BulkTakenAtEditor({
</ul>
)}
<div className="flex gap-1.5">
<button
<Button
size="sm"
onClick={handleApplyPreview}
disabled={disabled || preview.hits.length === 0}
className="flex-1 rounded bg-primary/20 px-2 py-1 text-xs text-primary hover:bg-primary/30 disabled:cursor-not-allowed disabled:opacity-40"
className="flex-1 bg-primary/20 text-primary hover:bg-primary/30"
>
Apply {preview.hits.length}
</button>
<button
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => setPreview(null)}
disabled={disabled}
className="rounded bg-surface-2 px-2 py-1 text-xs text-text-muted hover:bg-surface-offset disabled:opacity-50"
className="text-text-muted"
>
Cancel
</button>
</Button>
</div>
</div>
)}
@@ -640,7 +653,7 @@ function BulkTagsEditor({
return (
<div className="space-y-2">
<input
<Input
type="text"
value={tagInput}
onChange={(e) => 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 && (
<button
<Button
variant="outline"
size="sm"
onClick={handleSubmit}
disabled={disabled}
className="flex w-full items-center justify-center gap-1 rounded border border-dashed border-primary/50 px-2 py-1 text-xs text-primary hover:bg-primary/10 disabled:opacity-50"
className="w-full border-dashed border-primary/50 bg-transparent text-primary hover:bg-primary/10"
>
<Plus className="h-3 w-3" />
<Plus className="mr-1 h-3 w-3" />
Create "{trimmed}" and apply
</button>
</Button>
)}
{filtered.length > 0 ? (

View File

@@ -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"
>
<div>No photo to display</div>
<button
onClick={closePreview}
className="mt-4 rounded border border-border px-3 py-1 text-sm hover:bg-surface"
>
<Button variant="outline" size="sm" className="mt-4" onClick={closePreview}>
Close
</button>
</Button>
</div>
)
}
@@ -227,26 +226,28 @@ export function PreviewView() {
{/* Top-right action buttons */}
<div className="absolute right-3 top-3 z-10 flex items-center gap-2">
<button
<Button
size="icon"
onClick={() => setInfoPanelOpen((v) => !v)}
className={
'flex h-9 w-9 items-center justify-center rounded-full bg-black/60 text-white transition hover:bg-black/80 ' +
(infoPanelOpen ? 'ring-2 ring-primary' : '')
}
className={cn(
'h-9 w-9 rounded-full bg-black/60 text-white hover:bg-black/80',
infoPanelOpen && 'ring-2 ring-primary'
)}
title="Toggle info panel (I)"
aria-label="Toggle info panel"
aria-pressed={infoPanelOpen}
>
<Info className="h-5 w-5" />
</button>
<button
</Button>
<Button
size="icon"
onClick={closePreview}
className="flex h-9 w-9 items-center justify-center rounded-full bg-black/60 text-white transition hover:bg-black/80"
className="h-9 w-9 rounded-full bg-black/60 text-white hover:bg-black/80"
title="Close (Esc)"
aria-label="Close preview"
>
<X className="h-5 w-5" />
</button>
</Button>
</div>
<PreviewImage photo={currentPhoto} />

View File

@@ -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 (
<div className="flex h-full flex-col overflow-hidden">
<div className="flex items-center gap-3 border-b border-border px-4 py-3">
<button
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-text-muted"
onClick={exitDetail}
className="rounded p-1 text-text-muted transition-colors hover:bg-surface-2 hover:text-text"
title="Back to ratings"
>
<ArrowLeft className="h-4 w-4" />
</button>
</Button>
<h2 className="text-sm font-semibold text-amber-400">{selectedGroup.label}</h2>
</div>
<div className="flex-1 overflow-hidden">

View File

@@ -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<string | null>(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 (
<div className="fixed inset-0 z-50">
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
<div className="relative z-10 w-[420px] rounded-lg border border-border bg-surface p-5 shadow-2xl">
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<Users className="h-4 w-4 text-text-muted" />
<h2 className="text-base font-semibold text-text">
Share {type === 'heap' ? 'heap' : 'folder'}
</h2>
</div>
<button onClick={onClose} className="text-text-muted hover:text-text">
<X className="h-4 w-4" />
</button>
</div>
<Dialog open={isOpen} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-[420px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Users className="h-4 w-4 text-text-muted" />
Share {type === 'heap' ? 'heap' : 'folder'}
</DialogTitle>
</DialogHeader>
<div className="mb-3 text-sm text-text-muted">
Sharing <span className="font-medium text-text">{targetName}</span>
</div>
{/* Existing shares */}
{shares.length > 0 && (
<div className="mb-4 space-y-1.5">
{shares.map((share) => (
<div
key={share.id}
className="flex items-center justify-between rounded border border-border bg-surface-2 px-3 py-1.5 text-sm"
>
<div className="flex items-center gap-2">
<span className="font-medium text-text">{share.shared_with_username}</span>
<span className="rounded bg-surface px-1.5 py-0.5 text-[10px] font-medium uppercase text-text-muted">
{share.permission}
</span>
</div>
<button
onClick={() => revokeMutation.mutate(share.id)}
className="text-text-muted hover:text-reject"
title="Revoke access"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
)}
{isLoading && <div className="mb-4 text-xs text-text-muted">Loading shares...</div>}
{/* Add new share */}
<form
onSubmit={(e) => {
e.preventDefault()
if (username.trim()) addMutation.mutate()
}}
className="space-y-3"
>
<div className="flex gap-2">
<input
type="text"
value={username}
onChange={(e) => {
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
/>
<select
value={permission}
onChange={(e) => setPermission(e.target.value as 'read' | 'write')}
className="rounded border border-border bg-surface-2 px-2 py-1.5 text-sm text-text"
>
<option value="read">Read</option>
<option value="write">Read + Write</option>
</select>
</div>
{error && <div className="text-xs text-reject">{error}</div>}
<div className="flex justify-end">
<button
type="submit"
disabled={!username.trim() || addMutation.isPending}
className={clsx(
'rounded px-3 py-1.5 text-sm font-medium text-white',
'bg-primary hover:bg-primary/80 disabled:opacity-50'
)}
>
{addMutation.isPending ? 'Sharing...' : 'Share'}
</button>
</div>
</form>
<div className="text-sm text-text-muted">
Sharing <span className="font-medium text-text">{targetName}</span>
</div>
</div>
</div>
{/* Existing shares */}
{shares.length > 0 && (
<div className="space-y-1.5">
{shares.map((share) => (
<div
key={share.id}
className="flex items-center justify-between rounded border border-border bg-surface-2 px-3 py-1.5 text-sm"
>
<div className="flex items-center gap-2">
<span className="font-medium text-text">
{share.shared_with_username}
</span>
<span className="rounded bg-surface px-1.5 py-0.5 text-[10px] font-medium uppercase text-text-muted">
{share.permission}
</span>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => revokeMutation.mutate(share.id)}
className="h-6 w-6 text-text-muted hover:text-reject"
title="Revoke access"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
))}
</div>
)}
{isLoading && (
<div className="text-xs text-text-muted">Loading shares...</div>
)}
<form
onSubmit={(e) => {
e.preventDefault()
if (username.trim()) addMutation.mutate()
}}
className="space-y-3"
>
<div className="flex gap-2">
<Input
type="text"
value={username}
onChange={(e) => {
setUsername(e.target.value)
setError(null)
}}
placeholder="Username"
className="flex-1"
autoFocus
/>
<Select
value={permission}
onValueChange={(v) => setPermission(v as 'read' | 'write')}
>
<SelectTrigger className="w-auto">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="read">Read</SelectItem>
<SelectItem value="write">Read + Write</SelectItem>
</SelectContent>
</Select>
</div>
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="flex justify-end">
<Button
type="submit"
disabled={!username.trim() || addMutation.isPending}
>
{addMutation.isPending ? 'Sharing...' : 'Share'}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
)
}

View File

@@ -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
<div className="space-y-2.5 border-b border-border p-3">
<div>
<label className="mb-1 block text-xs text-text-muted">Filename</label>
<input
<Input
type="text"
value={filenameDraft}
onChange={(e) => setFilenameDraft(e.target.value)}
@@ -405,7 +412,7 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
<div>
<label className="mb-1 block text-xs text-text-muted">Title</label>
<input
<Input
type="text"
value={titleDraft}
onChange={(e) => setTitleDraft(e.target.value)}
@@ -425,7 +432,7 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
<div>
<label className="mb-1 block text-xs text-text-muted">Notes</label>
<textarea
<Textarea
value={notesDraft}
onChange={(e) => setNotesDraft(e.target.value)}
onBlur={commitNotes}
@@ -680,20 +687,23 @@ function Section({
children: React.ReactNode
}) {
return (
<div className="border-b border-border">
<button
onClick={onToggle}
className="flex w-full items-center justify-between px-3 py-1.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-text-muted hover:bg-surface-2 hover:text-text"
>
<Collapsible
open={expanded}
onOpenChange={onToggle}
className="border-b border-border"
>
<CollapsibleTrigger className="flex w-full items-center justify-between px-3 py-1.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-text-muted hover:bg-surface-2 hover:text-text">
<span>{title}</span>
{expanded ? (
<ChevronDown className="h-3 w-3" />
) : (
<ChevronRight className="h-3 w-3" />
)}
</button>
{expanded && <div className="px-3 pb-2.5">{children}</div>}
</div>
</CollapsibleTrigger>
<CollapsibleContent className="px-3 pb-2.5">
{children}
</CollapsibleContent>
</Collapsible>
)
}
@@ -776,7 +786,7 @@ function TagsEditor({
)}
<div className="relative">
<input
<Input
type="text"
value={tagInput}
onChange={(e) => onTagInputChange(e.target.value)}
@@ -789,7 +799,7 @@ function TagsEditor({
}
}}
placeholder="Add tag…"
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"
className="h-7 bg-bg text-xs"
/>
{suggestions.length > 0 && (
<div className="mt-1 rounded border border-border bg-bg shadow-md">

View File

@@ -1,6 +1,7 @@
import { useState, useMemo, useCallback } from 'react'
import { Tag as TagIcon, ArrowLeft, Loader2 } from 'lucide-react'
import clsx from 'clsx'
import { Button } from '@/components/ui/button'
import { useTagsQuery } from '../../hooks/useTagsQuery'
import { photos as photosApi, type Tag } from '../../services/api'
import { useFilterStore } from '../../store/filterStore'
@@ -49,13 +50,15 @@ export function TagsView() {
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="flex items-center gap-3 border-b border-border px-4 py-3">
<button
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-text-muted"
onClick={exitDetail}
className="rounded p-1 text-text-muted transition-colors hover:bg-surface-2 hover:text-text"
title="Back to tags"
>
<ArrowLeft className="h-4 w-4" />
</button>
</Button>
<h2 className="text-sm font-semibold text-text">{selectedTag.name}</h2>
</div>
<div className="flex-1 overflow-hidden">

View File

@@ -0,0 +1,59 @@
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const alertVariants = cva(
'relative w-full rounded-md border border-border bg-surface p-3 text-sm text-text [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-3 [&>svg]:top-3 [&>svg]:text-text',
{
variants: {
variant: {
default: '',
destructive:
'border-reject/40 bg-reject/10 text-reject [&>svg]:text-reject',
},
},
defaultVariants: {
variant: 'default',
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = 'Alert'
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn('mb-1 font-medium leading-none tracking-tight', className)}
{...props}
/>
))
AlertTitle.displayName = 'AlertTitle'
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('text-xs [&_p]:leading-relaxed', className)}
{...props}
/>
))
AlertDescription.displayName = 'AlertDescription'
export { Alert, AlertTitle, AlertDescription }

View File

@@ -0,0 +1,56 @@
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
// Palette-rewritten shadcn Button. Variants map to the existing
// desert-dusk tokens; add more variants here if a new visual emerges.
const buttonVariants = cva(
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default: 'bg-primary text-bg hover:bg-primary/90',
destructive: 'bg-reject text-text hover:bg-reject/90',
outline:
'border border-border bg-surface hover:bg-surface-2 text-text',
secondary: 'bg-surface-2 text-text hover:bg-surface-offset',
ghost: 'text-text hover:bg-surface-2',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-8 px-3',
sm: 'h-7 rounded-md px-2 text-xs',
lg: 'h-9 rounded-md px-4',
icon: 'h-8 w-8',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button'
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = 'Button'
export { Button, buttonVariants }

View File

@@ -0,0 +1,84 @@
import * as React from 'react'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { DayPicker } from 'react-day-picker'
import { cn } from '@/lib/utils'
import { buttonVariants } from './button'
export type CalendarProps = React.ComponentProps<typeof DayPicker>
function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn('p-3', className)}
classNames={{
months: 'flex flex-col sm:flex-row gap-4',
month: 'flex flex-col gap-3',
// Row layout: prev-nav · dropdowns (Month / Year) · next-nav.
// Works for both button and dropdown caption layouts.
caption: 'flex items-center justify-between px-1 pt-1',
caption_label: 'text-sm font-medium text-text',
// Dropdown caption — react-day-picker v8 renders each dropdown
// as an invisible <select> layered over a visible caption_label
// span. We position the <select> absolutely + transparent so
// the label is what's actually drawn, while clicks still open
// the native picker. The top-level CaptionLabel ("January 2026"
// in full) sits inside a .vhidden wrapper — absolutely clipped
// so it stays screen-reader only.
caption_dropdowns: 'flex items-center justify-center gap-2',
dropdown_month: 'relative inline-flex items-center rounded-md px-1 text-sm font-medium text-text hover:bg-surface-2',
dropdown_year: 'relative inline-flex items-center rounded-md px-1 text-sm font-medium text-text hover:bg-surface-2',
dropdown:
'absolute inset-0 z-10 cursor-pointer appearance-none bg-transparent opacity-0',
dropdown_icon: 'ml-1 h-3 w-3 opacity-60',
vhidden:
'!absolute !-m-px !h-px !w-px !overflow-hidden !whitespace-nowrap !border-0 !p-0 ![clip:rect(0,0,0,0)]',
nav: 'flex items-center gap-1',
nav_button: cn(
buttonVariants({ variant: 'ghost' }),
'h-6 w-6 p-0 text-text-muted hover:text-text'
),
nav_button_previous: '',
nav_button_next: '',
table: 'w-full border-collapse',
head_row: 'flex',
head_cell:
'text-text-muted rounded-md w-7 font-normal text-[0.7rem]',
row: 'flex w-full mt-1',
cell: cn(
'relative p-0 text-center text-sm focus-within:relative focus-within:z-20',
'[&:has([aria-selected])]:bg-surface-2 [&:has([aria-selected].day-outside)]:bg-surface-2/50'
),
day: cn(
buttonVariants({ variant: 'ghost' }),
'h-7 w-7 p-0 font-normal aria-selected:opacity-100'
),
day_range_end: 'day-range-end',
day_selected:
'bg-primary text-bg hover:bg-primary hover:text-bg focus:bg-primary focus:text-bg',
day_today: 'text-star',
day_outside:
'day-outside text-text-faint aria-selected:bg-surface-2/50 aria-selected:text-text-faint',
day_disabled: 'text-text-faint opacity-50',
day_range_middle:
'aria-selected:bg-surface-2 aria-selected:text-text',
day_hidden: 'invisible',
...classNames,
}}
components={{
IconLeft: () => <ChevronLeft className="h-4 w-4" />,
IconRight: () => <ChevronRight className="h-4 w-4" />,
}}
{...props}
/>
)
}
Calendar.displayName = 'Calendar'
export { Calendar }

View File

@@ -0,0 +1,28 @@
import * as React from 'react'
import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
import { Check } from 'lucide-react'
import { cn } from '@/lib/utils'
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
'peer h-4 w-4 shrink-0 rounded-sm border border-border bg-surface-offset focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-bg',
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn('flex items-center justify-center text-current')}
>
<Check className="h-3 w-3" strokeWidth={3} />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }

View File

@@ -0,0 +1,7 @@
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible'
const Collapsible = CollapsiblePrimitive.Root
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

View File

@@ -0,0 +1,145 @@
import * as React from 'react'
import { type DialogProps } from '@radix-ui/react-dialog'
import { Command as CommandPrimitive } from 'cmdk'
import { Search } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Dialog, DialogContent } from './dialog'
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
'flex h-full w-full flex-col overflow-hidden rounded-md bg-surface text-text',
className
)}
{...props}
/>
))
Command.displayName = CommandPrimitive.displayName
type CommandDialogProps = DialogProps
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-text-muted [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b border-border px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 text-text-muted" />
<CommandPrimitive.Input
ref={ref}
className={cn(
'flex h-9 w-full rounded-md bg-transparent py-2 text-sm text-text outline-none placeholder:text-text-faint disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
/>
</div>
))
CommandInput.displayName = CommandPrimitive.Input.displayName
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn('max-h-[300px] overflow-y-auto overflow-x-hidden', className)}
{...props}
/>
))
CommandList.displayName = CommandPrimitive.List.displayName
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm text-text-muted"
{...props}
/>
))
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
'overflow-hidden p-1 text-text [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-text-muted',
className
)}
{...props}
/>
))
CommandGroup.displayName = CommandPrimitive.Group.displayName
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn('-mx-1 h-px bg-border', className)}
{...props}
/>
))
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm text-text outline-none data-[selected=true]:bg-surface-2 data-[selected=true]:text-text data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50',
className
)}
{...props}
/>
))
CommandItem.displayName = CommandPrimitive.Item.displayName
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn('ml-auto text-xs tracking-widest text-text-muted', className)}
{...props}
/>
)
}
CommandShortcut.displayName = 'CommandShortcut'
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

View File

@@ -0,0 +1,108 @@
import * as React from 'react'
import * as DialogPrimitive from '@radix-ui/react-dialog'
import { X } from 'lucide-react'
import { cn } from '@/lib/utils'
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-bg/70 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-1/2 top-1/2 z-50 w-[92vw] max-w-lg -translate-x-1/2 -translate-y-1/2 rounded-lg border border-border bg-surface p-5 text-text shadow-xl duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-3 top-3 rounded-sm p-0.5 text-text-muted opacity-70 transition-opacity hover:bg-surface-2 hover:opacity-100 focus:outline-none focus:ring-1 focus:ring-primary disabled:pointer-events-none">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn('flex flex-col gap-1 text-left', className)}
{...props}
/>
)
DialogHeader.displayName = 'DialogHeader'
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn('mt-4 flex flex-row justify-end gap-2', className)}
{...props}
/>
)
DialogFooter.displayName = 'DialogFooter'
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn('text-sm font-semibold leading-none text-text', className)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn('text-xs text-text-muted', className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@@ -0,0 +1,192 @@
import * as React from 'react'
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
import { Check, ChevronRight, Circle } from 'lucide-react'
import { cn } from '@/lib/utils'
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
'flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none text-text focus:bg-surface-2 data-[state=open]:bg-surface-2',
inset && 'pl-8',
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border border-border bg-surface p-1 text-text shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border border-border bg-surface p-1 text-text shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm text-text outline-none transition-colors focus:bg-surface-2 data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
inset && 'pl-8',
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm text-text outline-none transition-colors focus:bg-surface-2 data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm text-text outline-none transition-colors focus:bg-surface-2 data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
'px-2 py-1.5 text-xs font-semibold text-text-muted',
inset && 'pl-8',
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-border', className)}
{...props}
/>
))
DropdownMenuSeparator.displayName =
DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => (
<span
className={cn('ml-auto text-xs tracking-widest text-text-muted', className)}
{...props}
/>
)
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut'
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

View File

@@ -0,0 +1,23 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
const Input = React.forwardRef<
HTMLInputElement,
React.InputHTMLAttributes<HTMLInputElement>
>(({ className, type, ...props }, ref) => {
return (
<input
type={type}
ref={ref}
className={cn(
'flex h-8 w-full rounded-md border border-border bg-surface-offset px-2.5 text-sm text-text placeholder:text-text-faint focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary disabled:cursor-not-allowed disabled:opacity-50 file:border-0 file:bg-transparent file:text-sm file:font-medium',
className
)}
{...props}
/>
)
})
Input.displayName = 'Input'
export { Input }

View File

@@ -0,0 +1,21 @@
import * as React from 'react'
import * as LabelPrimitive from '@radix-ui/react-label'
import { cn } from '@/lib/utils'
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(
'text-xs font-medium leading-none text-text-muted peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
className
)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@@ -0,0 +1,141 @@
import * as React from 'react'
import { Check } from 'lucide-react'
import { cn } from '@/lib/utils'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from './command'
export interface MultiSelectOption {
value: string
label: string
/** Optional right-hand meta (count, badge, etc.). */
meta?: React.ReactNode
/** Optional keywords added to the cmdk search index. */
keywords?: string[]
}
interface MultiSelectProps {
options: MultiSelectOption[]
values: string[]
onChange: (next: string[]) => void
/** Show the search input. Defaults to true when options.length > 5. */
searchable?: boolean
searchPlaceholder?: string
emptyMessage?: string
/** When true, selected items pin to the top with a separator. */
pinSelected?: boolean
className?: string
}
/**
* Multi-select list built on cmdk/Command. Meant to live inside an existing
* popover/pill/dialog container — it does NOT render its own trigger.
* Clicking a row toggles it; selected rows show a checkmark.
*/
export function MultiSelect({
options,
values,
onChange,
searchable,
searchPlaceholder = 'Search…',
emptyMessage = 'No results.',
pinSelected = true,
className,
}: MultiSelectProps) {
const showSearch = searchable ?? options.length > 5
const selectedSet = React.useMemo(() => new Set(values), [values])
const { selected, unselected } = React.useMemo(() => {
if (!pinSelected) {
return { selected: [] as MultiSelectOption[], unselected: options }
}
const sel: MultiSelectOption[] = []
const unsel: MultiSelectOption[] = []
for (const o of options) {
;(selectedSet.has(o.value) ? sel : unsel).push(o)
}
return { selected: sel, unselected: unsel }
}, [options, selectedSet, pinSelected])
const toggle = (v: string) => {
const next = selectedSet.has(v)
? values.filter((x) => x !== v)
: [...values, v]
onChange(next)
}
return (
<Command className={cn('bg-transparent', className)}>
{showSearch && (
<CommandInput placeholder={searchPlaceholder} className="h-8 text-xs" />
)}
<CommandList className="max-h-64">
<CommandEmpty>{emptyMessage}</CommandEmpty>
{pinSelected && selected.length > 0 && (
<>
<CommandGroup className="p-0">
{selected.map((opt) => (
<Row key={opt.value} option={opt} selected onToggle={toggle} />
))}
</CommandGroup>
{unselected.length > 0 && <CommandSeparator />}
</>
)}
{unselected.length > 0 && (
<CommandGroup className="p-0">
{unselected.map((opt) => (
<Row
key={opt.value}
option={opt}
selected={selectedSet.has(opt.value)}
onToggle={toggle}
/>
))}
</CommandGroup>
)}
</CommandList>
</Command>
)
}
function Row({
option,
selected,
onToggle,
}: {
option: MultiSelectOption
selected: boolean
onToggle: (v: string) => void
}) {
return (
<CommandItem
value={option.value}
keywords={[option.label, ...(option.keywords ?? [])]}
onSelect={() => onToggle(option.value)}
className={cn(
'cursor-pointer gap-1.5 px-2 py-1 text-xs',
selected && 'bg-primary/10 data-[selected=true]:bg-primary/20'
)}
>
<span className="min-w-0 flex-1 truncate">{option.label}</span>
{option.meta != null && (
<span className="shrink-0 text-[10px] tabular-nums text-text-muted">
{option.meta}
</span>
)}
<Check
className={cn(
'h-3 w-3 shrink-0 text-primary',
selected ? 'opacity-100' : 'opacity-0'
)}
/>
</CommandItem>
)
}

View File

@@ -0,0 +1,29 @@
import * as React from 'react'
import * as PopoverPrimitive from '@radix-ui/react-popover'
import { cn } from '@/lib/utils'
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverAnchor = PopoverPrimitive.Anchor
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
'z-50 w-72 rounded-md border border-border bg-surface p-3 text-text shadow-lg outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

View File

@@ -0,0 +1,42 @@
import * as React from 'react'
import * as RadioGroupPrimitive from '@radix-ui/react-radio-group'
import { Circle } from 'lucide-react'
import { cn } from '@/lib/utils'
const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Root
className={cn('grid gap-2', className)}
{...props}
ref={ref}
/>
)
})
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
'aspect-square h-4 w-4 rounded-full border border-border text-primary focus:outline-none focus-visible:ring-1 focus-visible:ring-primary disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<Circle className="h-2 w-2 fill-current text-primary" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
})
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
export { RadioGroup, RadioGroupItem }

View File

@@ -0,0 +1,155 @@
import * as React from 'react'
import * as SelectPrimitive from '@radix-ui/react-select'
import { Check, ChevronDown, ChevronUp } from 'lucide-react'
import { cn } from '@/lib/utils'
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
'flex h-8 w-full items-center justify-between rounded-md border border-border bg-surface-offset px-2.5 text-sm text-text placeholder:text-text-faint focus:outline-none focus:ring-1 focus:ring-primary disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 text-text-muted" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
'flex cursor-default items-center justify-center py-1 text-text-muted',
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
'flex cursor-default items-center justify-center py-1 text-text-muted',
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = 'popper', ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-border bg-surface text-text shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]'
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn('py-1.5 pl-8 pr-2 text-xs font-semibold text-text-muted', className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm text-text outline-none focus:bg-surface-2 data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-border', className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}

View File

@@ -0,0 +1,29 @@
import * as React from 'react'
import * as SeparatorPrimitive from '@radix-ui/react-separator'
import { cn } from '@/lib/utils'
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = 'horizontal', decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
'shrink-0 bg-border',
orientation === 'horizontal' ? 'h-px w-full' : 'h-full w-px',
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }

View File

@@ -0,0 +1,33 @@
import { Toaster as Sonner } from 'sonner'
type ToasterProps = React.ComponentProps<typeof Sonner>
// Palette-mapped sonner toaster. Mount once near the App root; the
// `toast.*` API comes from `@/components/ToastContainer` which wraps
// sonner's `toast` with our legacy (title, message?, action?) shape so
// existing call sites don't need edits.
const Toaster = ({ ...props }: ToasterProps) => (
<Sonner
position="bottom-left"
theme="dark"
toastOptions={{
classNames: {
toast:
'group toast bg-surface/90 border border-border backdrop-blur-md text-text shadow-lg',
title: 'text-text text-sm font-semibold',
description: 'text-text-muted text-xs',
actionButton:
'bg-primary text-bg hover:bg-primary/90 rounded-md px-2 py-1 text-xs font-medium',
cancelButton:
'bg-surface-2 text-text-muted hover:bg-surface-offset rounded-md px-2 py-1 text-xs',
success: 'border-l-2 border-l-pick',
error: 'border-l-2 border-l-reject',
info: 'border-l-2 border-l-primary',
warning: 'border-l-2 border-l-star',
},
}}
{...props}
/>
)
export { Toaster }

View File

@@ -0,0 +1,27 @@
import * as React from 'react'
import * as SwitchPrimitives from '@radix-ui/react-switch'
import { cn } from '@/lib/utils'
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-bg disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-surface-offset',
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
'pointer-events-none block h-4 w-4 rounded-full bg-text shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0'
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

View File

@@ -0,0 +1,53 @@
import * as React from 'react'
import * as TabsPrimitive from '@radix-ui/react-tabs'
import { cn } from '@/lib/utils'
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
'inline-flex h-9 items-center justify-center gap-1 rounded-md bg-surface-2 p-1 text-text-muted',
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1 text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-surface data-[state=active]:text-text data-[state=active]:shadow-sm',
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
'mt-3 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary',
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@@ -0,0 +1,22 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.TextareaHTMLAttributes<HTMLTextAreaElement>
>(({ className, ...props }, ref) => {
return (
<textarea
ref={ref}
className={cn(
'flex min-h-[60px] w-full resize-y rounded-md border border-border bg-surface-offset px-2.5 py-1.5 text-sm text-text placeholder:text-text-faint focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
/>
)
})
Textarea.displayName = 'Textarea'
export { Textarea }

View File

@@ -0,0 +1,56 @@
import * as React from 'react'
import * as ToggleGroupPrimitive from '@radix-ui/react-toggle-group'
import { type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
import { toggleVariants } from './toggle'
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants>
>({
size: 'default',
variant: 'default',
})
const ToggleGroup = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, children, ...props }, ref) => (
<ToggleGroupPrimitive.Root
ref={ref}
className={cn('flex items-center gap-1', className)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
))
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName
const ToggleGroupItem = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>
>(({ className, children, variant, size, ...props }, ref) => {
const context = React.useContext(ToggleGroupContext)
return (
<ToggleGroupPrimitive.Item
ref={ref}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
className
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
)
})
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName
export { ToggleGroup, ToggleGroupItem }

View File

@@ -0,0 +1,42 @@
import * as React from 'react'
import * as TogglePrimitive from '@radix-ui/react-toggle'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const toggleVariants = cva(
'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors hover:bg-surface-2 hover:text-text focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-surface-offset data-[state=on]:text-text',
{
variants: {
variant: {
default: 'bg-transparent text-text-muted',
outline:
'border border-border bg-transparent text-text-muted hover:bg-surface-2',
},
size: {
default: 'h-8 px-2',
sm: 'h-7 px-2 text-xs',
lg: 'h-9 px-2.5',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
)
const Toggle = React.forwardRef<
React.ElementRef<typeof TogglePrimitive.Root>,
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, ...props }, ref) => (
<TogglePrimitive.Root
ref={ref}
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
))
Toggle.displayName = TogglePrimitive.Root.displayName
export { Toggle, toggleVariants }

View File

@@ -0,0 +1,28 @@
import * as React from 'react'
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
import { cn } from '@/lib/utils'
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 overflow-hidden rounded-md border border-border bg-surface-2 px-2 py-1 text-xs text-text shadow-md data-[state=delayed-open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=delayed-open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=delayed-open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
</TooltipPrimitive.Portal>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@@ -17,6 +17,12 @@ import { useHeapsQuery, HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
import { FOLDER_TREE_QUERY_KEY } from '../../hooks/useFolderTreeQuery'
import { LIBRARY_STATS_QUERY_KEY } from '../../hooks/useLibraryStatsQuery'
import { toast } from '../ToastContainer'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
interface UploadModalProps {
isOpen: boolean
@@ -123,15 +129,8 @@ export function UploadModal({ isOpen, onClose, initialFolderId }: UploadModalPro
}
}, [isOpen, initialFolderId, folderTree, destFolderId])
// Esc closes (unless mid-upload — don't orphan in-flight requests).
useEffect(() => {
if (!isOpen) return
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape' && !isUploading) onClose()
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [isOpen, isUploading, onClose])
// Esc / overlay-click dismissal lives on the Dialog primitive below.
// We only need to guard against closing while an upload is in flight.
// Reset transient state on open so a previous session's queue doesn't
// bleed into a fresh one.
@@ -324,31 +323,20 @@ export function UploadModal({ isOpen, onClose, initialFolderId }: UploadModalPro
)
const overallPct = totalBytes > 0 ? Math.round((uploadedBytes / totalBytes) * 100) : 0
if (!isOpen) return null
return (
<div className="fixed inset-0 z-50">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={!isUploading ? onClose : undefined}
/>
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
<div className="relative z-10 flex max-h-[85vh] w-[760px] flex-col rounded-lg border border-border bg-surface shadow-2xl">
{/* Header */}
<div className="flex items-center justify-between border-b border-border px-5 py-3">
<div className="flex items-center gap-2">
<UploadIcon className="h-4 w-4 text-text-muted" />
<h2 className="text-base font-semibold text-text">Upload photos</h2>
</div>
<button
onClick={onClose}
disabled={isUploading}
className="rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text disabled:opacity-40"
aria-label="Close"
>
<X className="h-4 w-4" />
</button>
</div>
<Dialog
open={isOpen}
onOpenChange={(o) => {
if (!o && !isUploading) onClose()
}}
>
<DialogContent className="flex max-h-[85vh] w-[760px] max-w-[760px] flex-col p-0">
<DialogHeader className="border-b border-border px-5 py-3">
<DialogTitle className="flex items-center gap-2">
<UploadIcon className="h-4 w-4 text-text-muted" />
Upload photos
</DialogTitle>
</DialogHeader>
{/* Body */}
<div className="flex min-h-0 flex-1 gap-4 overflow-hidden p-5">
@@ -535,9 +523,8 @@ export function UploadModal({ isOpen, onClose, initialFolderId }: UploadModalPro
{isUploading ? 'Uploading…' : `Upload ${queue.length || ''}`.trim()}
</button>
</div>
</div>
</div>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,9 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
/** Tailwind-aware className combiner. Used by every shadcn ui/*.tsx
* component so repeated Tailwind utilities collapse to the last-wins
* value instead of fighting each other in the class attribute. */
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs))
}

View File

@@ -213,7 +213,11 @@ export function filtersToParams(f: FilterState): Record<string, string | number>
const params: Record<string, string | number> = {}
if (f.q.trim()) params.q = f.q.trim()
if (f.dateFrom) params.date_from = f.dateFrom
if (f.dateTo) params.date_to = f.dateTo
// Make the upper bound inclusive end-of-day: the backend compares
// `taken_at <= date_to` as a full datetime, so a bare "2024-01-15"
// would exclude every photo taken after midnight that day — a
// single-day pick (from==to) would then match nothing.
if (f.dateTo) params.date_to = `${f.dateTo}T23:59:59`
if (f.mediaTypes.length > 0) params.media_type = f.mediaTypes.join(',')
if (f.ratingMin > 0) params.rating_min = f.ratingMin
if (f.ratingMax > 0) params.rating_max = f.ratingMax

View File

@@ -33,6 +33,8 @@ export default {
'fade-in': 'fadeIn 0.2s ease-in-out',
'slide-up': 'slideUp 0.3s ease-out',
'shimmer': 'shimmer 2s infinite linear',
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out',
},
keyframes: {
fadeIn: {
@@ -47,8 +49,17 @@ export default {
'0%': { backgroundPosition: '-200% 0' },
'100%': { backgroundPosition: '200% 0' },
},
// Used by shadcn Accordion / Collapsible.
'accordion-down': {
from: { height: '0' },
to: { height: 'var(--radix-accordion-content-height)' },
},
'accordion-up': {
from: { height: 'var(--radix-accordion-content-height)' },
to: { height: '0' },
},
},
},
},
plugins: [],
plugins: [require('tailwindcss-animate')],
}