diff --git a/sidecar/handlers_heap.go b/sidecar/handlers_heap.go index 1547f3c..653b4dd 100644 --- a/sidecar/handlers_heap.go +++ b/sidecar/handlers_heap.go @@ -9,6 +9,7 @@ import ( "net/url" "os" "path/filepath" + "strings" "github.com/gin-gonic/gin" ) @@ -84,10 +85,20 @@ func handleHeapConvert(cfg *Config, pp *ppClient) gin.HandlerFunc { // Resolve destination. resolveUnderRoot ensures the target lives // inside ORIGINALS_ROOT and that its parent is a real directory. - targetAbs, err := resolveUnderRoot(cfg.OriginalsRoot, body.TargetFolder, true) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid targetFolder"}) - return + // Empty / "/" / "." are valid here — they mean "drop these into + // originals/ itself" (the modal's "Root" option). resolveUnderRoot + // rejects those for safety, so handle the root case explicitly. + var targetAbs string + trimmed := strings.Trim(body.TargetFolder, "/") + if trimmed == "" || trimmed == "." { + targetAbs = cfg.OriginalsRoot + } else { + abs, err := resolveUnderRoot(cfg.OriginalsRoot, body.TargetFolder, true) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid targetFolder"}) + return + } + targetAbs = abs } destAbs := targetAbs if subfolder != "" { @@ -123,20 +134,32 @@ func handleHeapConvert(cfg *Config, pp *ppClient) gin.HandlerFunc { moved, copied := 0, 0 for _, photo := range photos { + // Pick the file to physically move. PhotoPrism's "primary" file + // for a HEIC photo is the generated `.HEIC.jpg` preview that + // lives in storage/sidecar (Root=="sidecar"), not in originals + // — moving that path would fail "file missing on disk" every + // time. Prefer the primary that lives in originals (Root=="/") + // and fall back to the first originals-rooted file. PhotoPrism + // regenerates sidecars on reindex, so they don't need to follow. var file ppFile found := false for _, f := range photo.Files { - if f.Primary { + if f.Root == "/" && f.Primary { file, found = f, true break } } if !found { - if len(photo.Files) == 0 { - errs = append(errs, heapErr{UID: photo.UID, Reason: "no primary file"}) - continue + for _, f := range photo.Files { + if f.Root == "/" { + file, found = f, true + break + } } - file = photo.Files[0] + } + if !found { + errs = append(errs, heapErr{UID: photo.UID, Reason: "no originals-rooted file"}) + continue } srcRel := file.Name srcAbs := filepath.Join(cfg.OriginalsRoot, srcRel) @@ -185,9 +208,13 @@ func handleHeapConvert(cfg *Config, pp *ppClient) gin.HandlerFunc { } // Reindex the destination + every source parent so PhotoPrism's - // DB catches up. We do this in the background — the user gets - // their counts immediately; PhotoPrism's timeline updates as the - // reindex lands. + // DB catches up. We block on these so the response only goes out + // after the index reflects the move — callers (the frontend's + // invalidateQueries refetch in particular) need the next /photos + // fetch to return the moved files, otherwise the folder view + // looks unchanged. PhotoPrism's index endpoint serialises calls + // internally; running them sequentially matches that contract + // without surprising the server. destRel, _ := filepath.Rel(cfg.OriginalsRoot, destAbs) paths := map[string]struct{}{destRel: {}} for p := range sourceParents { @@ -202,7 +229,7 @@ func handleHeapConvert(cfg *Config, pp *ppClient) gin.HandlerFunc { if p != "" && p != "." { reindex = "/" + p } - go fireReindex(cfg, pp, token, reindex) + fireReindex(cfg, pp, token, reindex) } heapDeleted := false diff --git a/web/src/lib/actions/gridKeyNav.ts b/web/src/lib/actions/gridKeyNav.ts index 80529eb..95fc936 100644 --- a/web/src/lib/actions/gridKeyNav.ts +++ b/web/src/lib/actions/gridKeyNav.ts @@ -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); } } diff --git a/web/src/lib/actions/nearBottom.ts b/web/src/lib/actions/nearBottom.ts index 9a859d1..b9c2fd7 100644 --- a/web/src/lib/actions/nearBottom.ts +++ b/web/src/lib/actions/nearBottom.ts @@ -27,19 +27,24 @@ export interface NearBottomParams { export function nearBottom(node: HTMLElement, params: NearBottomParams) { let current: NearBottomParams = params; let io: IntersectionObserver | null = null; + // IntersectionObserver only emits on state changes. With a 4-viewport + // preload zone, the sentinel typically stays continuously intersecting + // across a whole fetchNextPage cycle: enabled flips false (fetching), + // the IO callback runs but no-ops, enabled flips back true — and no new + // event is emitted because the intersection state never changed. We'd + // stall mid-pagination. Remember the last reported intersection so the + // next `enabled` rising edge can re-fire manually. + let lastIntersecting = false; function buildObserver(p: NearBottomParams) { io?.disconnect(); const preload = p.preloadPx ?? Math.max(800, window.innerHeight * 4); io = new IntersectionObserver( (entries) => { - if (!current.enabled) return; for (const e of entries) { - if (e.isIntersecting) { - current.onHit(); - return; - } + lastIntersecting = e.isIntersecting; } + if (lastIntersecting && current.enabled) current.onHit(); }, { root: p.root ?? null, @@ -57,11 +62,15 @@ export function nearBottom(node: HTMLElement, params: NearBottomParams) { update(next: NearBottomParams) { const rootChanged = next.root !== current.root; const preloadChanged = next.preloadPx !== current.preloadPx; + const enabledRose = !current.enabled && !!next.enabled; current = next; - // `enabled` and `onHit` are read live inside the callback, - // so they don't require rebuilding the observer. Root and - // preloadPx are baked in at construction. - if (rootChanged || preloadChanged) buildObserver(current); + if (rootChanged || preloadChanged) { + buildObserver(current); + return; + } + // `enabled` rising while the sentinel is still in the preload + // zone — no IO event coming, so fire manually. + if (enabledRose && lastIntersecting) current.onHit(); }, destroy() { io?.disconnect(); diff --git a/web/src/lib/components/layout/FolderTree.svelte b/web/src/lib/components/layout/FolderTree.svelte index 3f1ff1d..3d087e4 100644 --- a/web/src/lib/components/layout/FolderTree.svelte +++ b/web/src/lib/components/layout/FolderTree.svelte @@ -60,6 +60,11 @@ * `filters.folderPath` matches (the sidebar nav case); the picker * passes its own selection so the dialog has independent state. */ selectedPath?: string | null; + /** Optional per-path photo count. When provided, each row renders a + * compact badge with the count — matching the heaps section's + * "{n} photos" affordance. Undefined keeps the badge off entirely + * (the picker dialog doesn't need it). */ + counts?: Record; } let { nodes, @@ -69,7 +74,8 @@ onDelete, onCreateChild, readonly = false, - selectedPath + selectedPath, + counts }: Props = $props(); // Auto-expanded folders, persisted to localStorage so the tree state @@ -114,7 +120,7 @@ with the px-2 of Views/Heaps rows; +12px per nested level. -->