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:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user