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

@@ -42,12 +42,40 @@ export const sourceFolders = {
return response.data
},
/** Rename the display label only — the on-disk path is controlled by
* the docker mount and cannot be changed from the UI. */
/** Rename a folder. SourceRoot ids only update the display label;
* Folder ids actually move the directory on disk and update every
* descendant photo's filepath. */
rename: async (folderId: string, name: string) => {
const response = await api.patch(`/folders/${folderId}`, { name })
return response.data
},
/** Create a new sub-folder under an existing Folder. parent_id MUST
* be a Folder row id (not a SourceRoot id). */
create: async (parentId: string, name: string) => {
const response = await api.post('/folders', {
name,
parent_id: parentId,
})
return response.data as { id: string; name: string; path: string; parent_id: string }
},
/** Delete a folder. mode=discard moves all photos under it to the
* discard pile (recoverable) and leaves the folder + on-disk dir
* alone. mode=permanent unlinks files, removes folder rows, and
* rmtrees the directory — irreversible. */
delete: async (folderId: string, mode: 'discard' | 'permanent') => {
const response = await api.delete(`/folders/${folderId}`, {
params: { mode },
})
return response.data as {
status: string
mode: string
discarded?: number
deleted_photos?: number
file_errors?: number
}
},
}
// Photos API