feat(web): sidebar root folder + folder counts, drill-in colors/tags/ratings, click semantics rework
Sidebar
- New `/` root-folder entry at the top of the Folders group. Active
when the timeline is scoped to root; the photo grid post-filters to
`Path === ''` because PhotoPrism's `path:` operator can't express an
exact-root match. Collapsible chevron, persisted to its own
localStorage key, and a kebab carrying just "New subfolder".
- Per-folder count badges. `/api/v1/photos?q=path:X&count=1000` per
folder in parallel via `listFolderCounts`; root count derived from
`config.count.all − Σ subfolder counts`.
- Folder tree starts at depth=1 under the root so nested rows indent
visually relative to `/`.
- Footer matches the Toolbar / action-bar h-9 height.
Timeline interaction
- Single click on a tile selects only that tile (clears others); the
preview now lives on dblclick. Modifier clicks still go through
`gridKeyNav`'s document handler (shift = range, cmd/ctrl = toggle).
- `x` (archive) now actually archives — PhotoPrism's photo PUT
silently drops the Archived field, so we route through
/batch/photos/{archive,restore} the same way the BulkActionBar
already did. Mirror for `u`.
- Preview close restores the timeline focus + scrolls the last-shown
photo into view via `forcedExpand`+`scrollTileIntoView` so it
actually mounts (selection ring would otherwise stay invisible when
the user navigated far in preview).
- `applyFolderScope` only narrows the timeline to root when the active
view is a folder view (no heap / search / non-default section), so
label clicks / heap views / favorites no longer drop subfolder
photos.
Action bar
- Inline `h-9` row at the bottom of the main column (not `fixed`),
matching the Toolbar's visual language. Right sidebar stays full
height — the bar only spans the timeline width.
- Approve action wired for the review pile.
Colors / Tags / Ratings drill-ins
- New shared `PhotoGrid` component owning tile rendering, selection
styling, single-click-selects + dblclick-previews, and `setOrder`
for arrow-key nav.
- Each route's drill-in `<main>` carries `use:gridKeyNav` and a
trailing `<BulkActionBar />` so shift/cmd/ctrl click, arrow keys,
and the keyboard shortcuts work the same as the timeline.
- Tags switches from `goto('/?q=label:…')` to an in-place drill-in
with a back button, mirroring `/colors`'s flow.
- Category cards + drill-in photo cards honour the global
`view.thumbnailSize` (XS–XL) so the timeline's size selector now
reaches into all four grids.
Settings
- General-settings dialog merges Appearance into UI and switches free
text inputs to selects for the PhotoPrism theme / language / start
page / map style (the value-from-server prepends if it's outside
the curated list so we never silently rewrite a custom value). Time
zone uses `<datalist>` with `Intl.supportedValuesOf('timeZone')`.
Sidecar
- Heap convert runs reindex synchronously per source path so the
client's invalidate-and-refetch sees the moved files.
Inbox
- New /inbox route stub for the upcoming import workflow.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,12 @@
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { batchEdit } from '$lib/services/batch';
|
||||
import { patchTargets } from '$lib/services/bulk';
|
||||
import { invalidatePhotos } from '$lib/services/bulk';
|
||||
import {
|
||||
addToHeap,
|
||||
approvePhoto,
|
||||
batchArchive,
|
||||
batchDelete,
|
||||
batchRestore,
|
||||
likePhoto,
|
||||
removeFromHeap,
|
||||
unlikePhoto,
|
||||
@@ -203,12 +207,77 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
||||
target = !(first?.Archived ?? false);
|
||||
}
|
||||
|
||||
await patchTargets(
|
||||
ids,
|
||||
{ Archived: target },
|
||||
target ? `Archived ${ids.length}` : `Restored ${ids.length}`,
|
||||
(p) => ({ Archived: p.Archived ?? false })
|
||||
);
|
||||
// PhotoPrism's photo PUT silently drops the Archived field — the
|
||||
// only working path is /api/v1/batch/photos/{archive,restore}. The
|
||||
// previous patchTargets call PUT'd `{Archived: true}` and got a 200
|
||||
// back, so the toast fired but nothing moved.
|
||||
try {
|
||||
if (target) await batchArchive(ids);
|
||||
else await batchRestore(ids);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Archive failed');
|
||||
return;
|
||||
}
|
||||
invalidatePhotos(ids);
|
||||
const label = target ? `Archived ${ids.length}` : `Restored ${ids.length}`;
|
||||
toast.success(label);
|
||||
pushUndo(label, async () => {
|
||||
if (target) await batchRestore(ids);
|
||||
else await batchArchive(ids);
|
||||
invalidatePhotos(ids);
|
||||
});
|
||||
}
|
||||
|
||||
/** Permanently delete cull targets — only callable from the archive
|
||||
* section (X is rerouted away from archive-toggle there). PhotoPrism
|
||||
* rejects deletion of un-archived photos with a 4xx, so the section
|
||||
* gate doubles as a safety guard against accidental deletes from the
|
||||
* main timeline. Confirm dialog is mandatory — no undo path exists. */
|
||||
async function deleteCullTargets() {
|
||||
const ids = cullTargets();
|
||||
if (ids.length === 0) {
|
||||
toast.message('Nothing to delete', {
|
||||
description: 'Click a photo or select some first'
|
||||
});
|
||||
return;
|
||||
}
|
||||
const msg =
|
||||
ids.length === 1
|
||||
? 'Permanently delete this photo? This cannot be undone.'
|
||||
: `Permanently delete ${ids.length} photos? This cannot be undone.`;
|
||||
if (!confirm(msg)) return;
|
||||
try {
|
||||
await batchDelete(ids);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Delete failed');
|
||||
return;
|
||||
}
|
||||
invalidatePhotos(ids);
|
||||
toast.success(`Deleted ${ids.length}`);
|
||||
}
|
||||
|
||||
/** Approve cull targets — clears them out of the review pile by
|
||||
* bumping each photo's quality score above PhotoPrism's review
|
||||
* threshold. The op is one-way (no /unapprove route), so we don't
|
||||
* push an undo entry: a re-keyed S would just be a no-op on
|
||||
* already-approved photos. */
|
||||
async function approveCullTargets() {
|
||||
const ids = cullTargets();
|
||||
if (ids.length === 0) {
|
||||
toast.message('Nothing to keep', {
|
||||
description: 'Click a photo or select some first'
|
||||
});
|
||||
return;
|
||||
}
|
||||
const { updated, errors } = await batchEdit(ids, (id) => approvePhoto(id));
|
||||
invalidatePhotos(ids);
|
||||
if (errors.length) {
|
||||
toast.error(`Kept ${updated.length}; ${errors.length} failed`, {
|
||||
description: errors[0].message
|
||||
});
|
||||
return;
|
||||
}
|
||||
toast.success(`Kept ${ids.length}`);
|
||||
}
|
||||
|
||||
/** Flip the Favorite (heart) flag on cull targets. Reads the first
|
||||
@@ -427,6 +496,13 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
||||
case 'X':
|
||||
if (meta || shift) return;
|
||||
e.preventDefault();
|
||||
// Archive section: X becomes permanent delete (Keep/Delete
|
||||
// is the binary flow there, mirroring Review's Keep/Archive).
|
||||
// Everywhere else X toggles archive on the cull targets.
|
||||
if (filters.section === 'archive') {
|
||||
void deleteCullTargets();
|
||||
return;
|
||||
}
|
||||
void toggleArchive('toggle');
|
||||
return;
|
||||
case 'u':
|
||||
@@ -444,9 +520,26 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
||||
case 's':
|
||||
case 'S':
|
||||
if (meta || shift) return;
|
||||
e.preventDefault();
|
||||
// Review section repurposes S as the Keep affordance —
|
||||
// matches the BulkActionBar button and keeps the binary
|
||||
// Keep/Archive flow on home-row keys (S/X). The heap chord
|
||||
// is meaningless here anyway (review photos can't sensibly
|
||||
// be filed before they're approved).
|
||||
if (filters.section === 'review') {
|
||||
void approveCullTargets();
|
||||
return;
|
||||
}
|
||||
// Archive section: S = Keep = restore back to the timeline
|
||||
// (inverse of Delete on X). Same rationale as review —
|
||||
// heap-filing an archived photo isn't a flow that fits the
|
||||
// section's intent.
|
||||
if (filters.section === 'archive') {
|
||||
void toggleArchive('restore');
|
||||
return;
|
||||
}
|
||||
// Arm the chord. A digit 1–9 within S_CHORD_MS picks heap N;
|
||||
// otherwise we fall back to the currently-viewed heap.
|
||||
e.preventDefault();
|
||||
clearSChord();
|
||||
sChordTimer = window.setTimeout(() => {
|
||||
sChordTimer = null;
|
||||
@@ -461,6 +554,9 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
||||
if (!tile) return;
|
||||
const uid = tile.dataset.uid;
|
||||
if (!uid) return;
|
||||
// Modifier clicks are the only paths this document-level handler
|
||||
// owns. Plain clicks bubble to the tile button's onclick, which
|
||||
// reduces selection to just that tile.
|
||||
if (e.shiftKey) {
|
||||
e.preventDefault();
|
||||
selectRange(uid);
|
||||
@@ -469,13 +565,6 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
||||
e.preventDefault();
|
||||
toggle(uid);
|
||||
setFocused(uid);
|
||||
} else if (selection.ids.size > 0) {
|
||||
// When a multi-selection is active, a plain click reduces it to
|
||||
// just this tile (matches mule-image's "selection mode" behaviour).
|
||||
e.preventDefault();
|
||||
selection.ids.clear();
|
||||
selection.ids.add(uid);
|
||||
setFocused(uid);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user