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

@@ -1,5 +1,4 @@
<script lang="ts">
import { tick } from 'svelte';
import { createQuery } from '@tanstack/svelte-query';
import { getPhoto } from '$lib/services/photoprism';
import {
@@ -19,25 +18,14 @@
enabled: Boolean(preview.uid)
}));
// Track the last visible uid so we can return focus to the matching
// timeline tile when the overlay closes — lets the user keep moving
// with arrow keys without re-clicking.
let lastShown: string | null = null;
// Mirror the currently-shown photo into selection.focused. The
// timeline's tile uses `selection.focused === photo.UID` to draw the
// blue ring, so this keeps the selection in lockstep with whatever
// the user is paging through in preview. The host page (+page.svelte)
// owns the matching scroll-into-view on close so the tile actually
// mounts (it can be windowed out if the user navigated far).
$effect(() => {
if (preview.uid !== null) {
lastShown = preview.uid;
setFocused(preview.uid);
} else if (lastShown) {
const target = lastShown;
lastShown = null;
// Wait for the overlay to unmount before grabbing focus, otherwise
// the browser swallows it as the modal element is removed.
void tick().then(() => {
const tile = document.querySelector<HTMLElement>(`[data-uid="${target}"]`);
tile?.focus({ preventScroll: false });
tile?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
});
}
if (preview.uid !== null) setFocused(preview.uid);
});
// Keyboard handling lives at the document level so it works regardless