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:
2026-05-17 21:01:41 +02:00
parent 5153aeebec
commit d5e4f23c0f
18 changed files with 1577 additions and 332 deletions

View File

@@ -3,6 +3,7 @@
import { toast } from 'svelte-sonner';
import {
addToHeap,
approvePhoto,
batchArchive,
batchDelete,
batchRestore,
@@ -43,6 +44,16 @@
selection.ids.size > 0 ? selection.ids.size : selection.focused ? 1 : 0
);
const isBulk = $derived(selection.ids.size > 0);
// Review section uses a two-button decision flow (Keep / Archive) —
// every other action is hidden so the choice can't be confused with
// favoriting / heap-adding / restoring. The S keybinding is rerouted
// to approve from gridKeyNav for the same reason.
const isReview = $derived(filters.section === 'review');
// Archive section is the parallel two-button flow: Keep (restore back
// to the timeline) or Delete (permanent, no undo). X is repurposed
// from "archive" to "delete" since the photo is already archived;
// gridKeyNav mirrors the rerouting.
const isArchive = $derived(filters.section === 'archive');
function clearAll() {
clearSelection();
@@ -59,6 +70,24 @@
}
}
async function onApprove() {
const ids = snapshotIds();
if (ids.length === 0) return;
await withBusy(async () => {
// PhotoPrism's approve is one-way (Quality jumps to 3+); there's
// no /unapprove route. We fan out per-photo because there's no
// batch endpoint either. Errors are tallied rather than aborting
// the loop so a single bad UID doesn't block the rest.
const { updated, errors } = await batchEdit(ids, (id) => approvePhoto(id));
if (errors.length) {
toast.error(`Kept ${updated.length}; ${errors.length} failed`);
} else {
toast.success(`Kept ${ids.length}`);
}
clearSelection();
});
}
async function onArchive() {
const ids = snapshotIds();
if (ids.length === 0) return;
@@ -160,28 +189,90 @@
</script>
{#if targetCount > 0}
<!--
Inline row at the bottom of the main content column (NOT fixed) so
the sidebars stay reachable. Matches the Toolbar's h-9 / px-3 /
bg-background/80 backdrop-blur visual so it reads as the timeline's
own footer.
-->
<div
class="fixed inset-x-0 bottom-0 z-20 border-t border-border bg-background/95 px-6 py-3 shadow-lg backdrop-blur"
class="flex min-h-9 shrink-0 items-center gap-2 border-t border-border bg-background px-3 py-1"
>
<div class="mx-auto flex max-w-7xl items-center gap-3">
<span class="text-sm font-medium text-foreground">
{#if isBulk}
{targetCount} selected
{:else}
Focused photo
{/if}
</span>
<span class="shrink-0 text-[11px] font-medium text-foreground">
{#if isBulk}
{targetCount} selected
{:else}
Focused photo
{/if}
</span>
<div class="ml-auto flex flex-wrap items-center gap-2">
<!--
`overflow-x-auto` would clip the heap-picker dropdown — CSS
forces `overflow-y: auto` whenever `overflow-x` is non-visible,
so the dropdown's `bottom-full` placement is clipped to zero
pixels above the 36px bar (it renders but is invisible). We
use `flex-wrap` instead so very narrow viewports get a second
row rather than a horizontal scroll, and the dropdown stays
free to escape upward.
-->
<div class="ml-auto flex min-w-0 flex-wrap items-center justify-end gap-1">
{#if isReview}
<!-- Review pile = binary decision. Keep approves (Quality →
3+, lands in the main timeline); Archive batches into
the archive section. Everything else (heap, favorite,
restore) is hidden so the choice reads as decisive. -->
<button
class="inline-flex items-center gap-1 rounded border border-primary/40 bg-primary/10 px-2 py-0.5 text-[11px] text-primary hover:bg-primary/20 disabled:opacity-50"
disabled={busy}
onclick={onApprove}
title="Keep — accept into timeline"
>
✓ Keep
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">S</kbd>
</button>
<button
class="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[11px] hover:bg-accent disabled:opacity-50"
disabled={busy}
onclick={onArchive}
title="Archive"
>
Archive
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">X</kbd>
</button>
{:else if isArchive}
<!-- Archive section = mirror of review: Keep restores back
to the timeline; Delete is permanent and can't be
undone. X is repurposed from archive→delete since the
photo is already archived; the destructive styling
reinforces the irreversibility. -->
<button
class="inline-flex items-center gap-1 rounded border border-primary/40 bg-primary/10 px-2 py-0.5 text-[11px] text-primary hover:bg-primary/20 disabled:opacity-50"
disabled={busy}
onclick={onRestore}
title="Keep — restore to timeline"
>
✓ Keep
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">S</kbd>
</button>
<button
class="inline-flex items-center gap-1 rounded border border-destructive/40 bg-destructive/5 px-2 py-0.5 text-[11px] text-destructive hover:bg-destructive/10 disabled:opacity-50"
disabled={busy}
onclick={onDelete}
title="Permanently delete (no undo)"
>
Delete
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">X</kbd>
</button>
{:else}
<div class="relative">
<button
class="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-accent disabled:opacity-50"
class="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[11px] hover:bg-accent disabled:opacity-50"
disabled={busy}
onclick={() => (heapPickerOpen = !heapPickerOpen)}
title="Add to heap (S then 19 picks a heap)"
>
Add to heap
<kbd class="rounded bg-muted px-1 py-0.5 text-[9px] font-medium text-muted-foreground"
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground"
>S&nbsp;N</kbd
>
</button>
@@ -220,70 +311,50 @@
{/if}
</div>
<button
class="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-accent disabled:opacity-50"
class="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[11px] hover:bg-accent disabled:opacity-50"
disabled={busy}
onclick={onFavorite}
title="Favorite"
>
♥ Favorite
<kbd class="rounded bg-muted px-1 py-0.5 text-[9px] font-medium text-muted-foreground"
>F</kbd
>
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">F</kbd>
</button>
<button
class="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-accent disabled:opacity-50"
class="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[11px] hover:bg-accent disabled:opacity-50"
disabled={busy}
onclick={onArchive}
title="Archive"
>
Archive
<kbd class="rounded bg-muted px-1 py-0.5 text-[9px] font-medium text-muted-foreground"
>X</kbd
>
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">X</kbd>
</button>
<button
class="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-accent disabled:opacity-50"
class="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[11px] hover:bg-accent disabled:opacity-50"
disabled={busy}
onclick={onRestore}
title="Restore"
>
Restore
<kbd class="rounded bg-muted px-1 py-0.5 text-[9px] font-medium text-muted-foreground"
>U</kbd
>
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">U</kbd>
</button>
{#if filters.section === 'archive'}
<button
class="inline-flex items-center gap-1.5 rounded-md border border-destructive/40 px-3 py-1.5 text-xs text-destructive hover:bg-destructive/10 disabled:opacity-50"
disabled={busy}
onclick={onDelete}
title="Permanently delete (no undo)"
>
Delete
</button>
{/if}
<button
class="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-accent disabled:opacity-50"
disabled={busy || undoStack.entries.length === 0}
onclick={onUndo}
title="Undo last action"
>
Undo
<kbd class="rounded bg-muted px-1 py-0.5 text-[9px] font-medium text-muted-foreground"
>⌘Z</kbd
>
</button>
<button
class="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-accent"
onclick={clearAll}
title={isBulk ? 'Clear selection' : 'Clear focus'}
>
{isBulk ? 'Clear' : 'Dismiss'}
<kbd class="rounded bg-muted px-1 py-0.5 text-[9px] font-medium text-muted-foreground"
>Esc</kbd
>
</button>
</div>
{/if}
<button
class="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[11px] hover:bg-accent disabled:opacity-50"
disabled={busy || undoStack.entries.length === 0}
onclick={onUndo}
title="Undo last action"
>
Undo
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">⌘Z</kbd>
</button>
<button
class="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[11px] hover:bg-accent"
onclick={clearAll}
title={isBulk ? 'Clear selection' : 'Clear focus'}
>
{isBulk ? 'Clear' : 'Dismiss'}
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">Esc</kbd>
</button>
</div>
</div>
{/if}