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