feat: folder CRUD with discard-or-delete dialog

The left sidebar can now create, rename, and delete folders. Each
operation is mirrored to disk through the backend.

Backend (folders router):
- POST /folders { name, parent_id } — create a sub-folder under an
  existing Folder row, mkdir on disk, insert the row, return it. Names
  are validated (no separators, no traversal).
- PATCH /folders/{id} extended — still does the display-only rename for
  SourceRoot ids, but for Folder ids it now actually moves the directory
  on disk and rewrites every descendant Folder.path + Photo.filepath
  that lived under the old prefix in a single transaction. Refuses to
  rename the source-root mount itself.
- DELETE /folders/{id}?mode=discard|permanent —
    discard: set is_discarded on every photo whose filepath lives under
             this folder. The folder, descendants, and on-disk dir are
             left intact. Recoverable from the discard pile.
    permanent: unlink each file, remove rows, rmtree the directory.
- Refuses to delete the source-root mount in either mode.

Frontend:
- New DeleteFolderDialog: two-card mode picker (Move to discard pile /
  Permanently delete) with destructive accent on the latter. Esc and
  backdrop click cancel.
- LeftSidebar: hover-revealed kebab menu on every folder row with
  New sub-folder, Rename, and Delete folder…  Inline create input
  appears below the parent row when "New sub-folder" is picked.
  All mutations invalidate ['folders'], ['photos'], and the library
  stats query so the sidebar counts stay live.
- api.ts: sourceFolders.create + sourceFolders.delete wrappers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-08 20:51:23 +02:00
parent bd904aca36
commit f05ae77ef0
4 changed files with 699 additions and 20 deletions

View File

@@ -1,8 +1,9 @@
import { useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import {
ChevronRight,
ChevronDown,
Folder,
FolderPlus,
Image,
Star,
Trash2,
@@ -11,6 +12,8 @@ import {
Copy,
Tag as TagIcon,
Layers2,
MoreHorizontal,
Pencil,
} from 'lucide-react'
import clsx from 'clsx'
import { sourceFolders, library, photos as photosApi, type FolderTreeNode } from '../../services/api'
@@ -27,6 +30,7 @@ import {
} from '../../hooks/useLibraryStatsQuery'
import { registerUndoable } from '../../store/undoStore'
import type { Photo } from '../../types/photo'
import { DeleteFolderDialog } from '../dialogs/DeleteFolderDialog'
interface TreeItem {
id: string
@@ -52,6 +56,40 @@ export function LeftSidebar() {
const { data: stats } = useLibraryStatsQuery()
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.
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).
const [creatingUnder, setCreatingUnder] = useState<string | null>(null)
const [createDraft, setCreateDraft] = useState('')
// Folder being deleted, drives the DeleteFolderDialog mounted below.
const [deletingFolder, setDeletingFolder] = useState<{
id: string
name: string
photoCount?: number
} | null>(null)
// Bulk discard mutation for the drag-onto-Discarded interaction.
const discardDropMutation = useMutation({
mutationFn: (photoIds: string[]) => photosApi.bulkDiscard(photoIds),
@@ -217,11 +255,55 @@ export function LeftSidebar() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['folders'] })
queryClient.invalidateQueries({ queryKey: ['folders', 'tree'] })
queryClient.invalidateQueries({ queryKey: ['photos'] })
},
onError: (e: any) =>
toast.error('Rename failed', e?.response?.data?.detail || e.message || 'Unknown error'),
})
const createFolderMutation = useMutation({
mutationFn: ({ parentId, name }: { parentId: string; name: string }) =>
sourceFolders.create(parentId, name),
onSuccess: (data) => {
toast.success('Folder created', data.name)
queryClient.invalidateQueries({ queryKey: ['folders'] })
queryClient.invalidateQueries({ queryKey: ['folders', 'tree'] })
setCreatingUnder(null)
setCreateDraft('')
},
onError: (e: any) =>
toast.error('Create failed', e?.response?.data?.detail || e.message || 'Unknown error'),
})
const deleteFolderMutation = useMutation({
mutationFn: ({ id, mode }: { id: string; mode: 'discard' | 'permanent' }) =>
sourceFolders.delete(id, mode),
onSuccess: (data) => {
if (data.mode === 'discard') {
toast.success(
'Folder photos discarded',
`${data.discarded ?? 0} moved to discard pile`
)
} else {
toast.success(
'Folder deleted',
`${data.deleted_photos ?? 0} photos removed from disk`
)
}
queryClient.invalidateQueries({ queryKey: ['folders'] })
queryClient.invalidateQueries({ queryKey: ['folders', 'tree'] })
queryClient.invalidateQueries({ queryKey: ['photos'] })
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
// If we were viewing the deleted folder, snap back to all-photos.
if (deletingFolder && currentSection === `folder-${deletingFolder.id}`) {
navigateToSection('all-photos', {})
}
setDeletingFolder(null)
},
onError: (e: any) =>
toast.error('Delete failed', e?.response?.data?.detail || e.message || 'Unknown error'),
})
// Mutation for scanning all folders
const scanLibraryMutation = useMutation({
mutationFn: library.scan,
@@ -455,8 +537,127 @@ export function LeftSidebar() {
<span className="h-5 min-w-[24px] flex-shrink-0" aria-hidden="true" />
)}
{/* Folder kebab menu — only on folder rows. Hidden until hover
* (or when its menu is open) so the count column stays aligned
* in the resting state. */}
{item.id.startsWith('folder-') &&
(() => {
const folderId = item.id.slice('folder-'.length)
const isMenuOpen = openMenuId === item.id
return (
<div className="relative flex-shrink-0">
<button
onClick={(e) => {
e.stopPropagation()
setOpenMenuId(isMenuOpen ? null : item.id)
}}
className={clsx(
'rounded p-0.5 text-text-muted hover:bg-surface-offset hover:text-text',
isMenuOpen ? 'visible' : 'invisible group-hover:visible'
)}
title="More actions"
aria-label="More folder actions"
aria-haspopup="menu"
aria-expanded={isMenuOpen}
>
<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"
>
<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)
}}
/>
<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>
)
})()}
</div>
{/* Inline "create new sub-folder" input. Renders just below the
* parent row when its create state is active. */}
{item.id.startsWith('folder-') &&
creatingUnder === item.id.slice('folder-'.length) && (
<div
className="flex items-center gap-1 px-2 py-1"
style={{ paddingLeft: `${8 + (depth + 1) * 16 + 4}px` }}
>
<FolderPlus className="h-3 w-3 flex-shrink-0 text-text-muted" />
<input
autoFocus
type="text"
value={createDraft}
placeholder="New folder name"
onChange={(e) => setCreateDraft(e.target.value)}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === 'Enter') {
const name = createDraft.trim()
if (name) {
createFolderMutation.mutate({
parentId: item.id.slice('folder-'.length),
name,
})
}
} else if (e.key === 'Escape') {
setCreatingUnder(null)
setCreateDraft('')
}
}}
onBlur={() => {
// Don't auto-commit on blur — empty/escaped renames
// close the input but don't fire the request.
if (!createFolderMutation.isPending) {
setCreatingUnder(null)
setCreateDraft('')
}
}}
className="flex-1 rounded border border-border bg-bg px-1 py-0 text-[13px] text-text focus:border-primary focus:outline-none"
/>
</div>
)}
{/* Render Children */}
{hasChildren && isExpanded && (
<div>
@@ -488,6 +689,46 @@ export function LeftSidebar() {
</button>
</div>
)}
<DeleteFolderDialog
isOpen={!!deletingFolder}
folderName={deletingFolder?.name ?? ''}
photoCount={deletingFolder?.photoCount}
onClose={() => setDeletingFolder(null)}
onConfirm={(mode) => {
if (deletingFolder) {
deleteFolderMutation.mutate({ id: deletingFolder.id, mode })
}
}}
/>
</div>
)
}
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>
)
}