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

@@ -29,7 +29,7 @@
setFocused,
setOrder
} from '$lib/stores/selection.svelte';
import { openPreview } from '$lib/stores/preview.svelte';
import { openPreview, preview } from '$lib/stores/preview.svelte';
import {
setRightSidebarWidth,
setThumbnailSize,
@@ -59,6 +59,7 @@
const next = parseUrlParams(page.url.searchParams);
if (next.section !== undefined) filters.section = next.section;
if (next.heapUid !== undefined) filters.heapUid = next.heapUid;
if (next.folderPath !== undefined) filters.folderPath = next.folderPath;
if (next.search !== undefined) filters.search = next.search;
});
@@ -90,13 +91,25 @@
switch (filters.section) {
case 'favorites':
return 'Favorites';
case 'review':
return 'Review';
case 'archive':
return 'Archive';
case 'hidden':
return 'Hidden';
case 'heap': {
const heap = (heapsQuery.data ?? []).find((h) => h.UID === filters.heapUid);
return heap ? `Heap · ${heap.Title}` : 'Heap';
}
default:
// 'all-photos' is the internal "no section filter" state —
// the visible context now comes from the folder filter
// (root by default). Show the folder path so the title
// reflects what's actually on screen; only the rare
// `folderPath === null` case (e.g. right after deleting a
// heap) still reads as "All photos".
if (filters.folderPath === '/') return 'Folder · /';
if (filters.folderPath) return `Folder · ${filters.folderPath}`;
return 'All photos';
}
}
@@ -133,8 +146,15 @@
* pages can repeat a photo when its file-row span straddles the offset
* boundary (a `merged=true` quirk); the Set keeps first occurrence and
* preserves order. Downstream (`setOrder`, `rows`, preview, click
* handlers) treat this as the single source of truth. */
const photos = $derived<PpPhoto[]>(dedupedPhotos(photosQuery.data?.pages));
* handlers) treat this as the single source of truth.
*
* When the user picks the root entry in the folder tree we filter
* to `Path === ''` here — PhotoPrism's `path:` operator can't
* express that match, so the query fetches the whole library and
* we strip subfolder rows post-hoc. */
const photos = $derived<PpPhoto[]>(
applyFolderScope(dedupedPhotos(photosQuery.data?.pages), filters)
);
function dedupedPhotos(pages: PpPhoto[][] | undefined): PpPhoto[] {
if (!pages) return [];
const seen = new Set<string>();
@@ -148,6 +168,20 @@
}
return out;
}
// Root-folder scope is the only client-side filter we apply, and only
// when the timeline is actually showing a folder view — never when the
// user is in a heap, has a free-form search, or is on a non-default
// section (favorites / archive / review / hidden). Those views are
// scoped server-side via the q-DSL and must not be re-filtered here,
// or labels / search will silently drop subfolder photos when the
// store hasn't fully hydrated from the URL yet.
function applyFolderScope(list: PpPhoto[], f: typeof filters): PpPhoto[] {
if (f.folderPath !== '/') return list;
if (f.section !== 'all-photos') return list;
if (f.heapUid) return list;
if (f.search) return list;
return list.filter((p) => !p.Path);
}
const pageCount = $derived(photosQuery.data?.pages.length ?? 0);
$effect(() => {
@@ -331,6 +365,25 @@
if (el) scrollTileIntoView(el);
}
// When the preview overlay closes, scroll the just-shown photo back
// into the timeline window. PreviewOverlay already keeps
// selection.focused in lockstep with preview.uid, so we just need to
// make sure that tile is mounted (forcedExpand) and visible — the
// blue ring renders itself once the inner button is in the DOM.
let wasPreviewOpen = $state(false);
$effect(() => {
const open = preview.uid !== null;
const closing = wasPreviewOpen && !open;
wasPreviewOpen = open;
if (!closing) return;
const uid = selection.focused;
if (!uid) return;
untrack(() => {
const i = photos.findIndex((p) => p.UID === uid);
if (i >= 0) void scrollToIndex(i);
});
});
// ── Visual rows for keyboard navigation ──────────────────────────────────
// The CSS Grid lays each photo into a cell with column count derived from
// `repeat(auto-fill, minmax(thumbnailSize, 1fr))`. Month headers span the
@@ -557,17 +610,26 @@
}
function onTileClick(e: MouseEvent, uid: string) {
// Modifier clicks (shift / cmd / ctrl) are handled by gridKeyNav's
// document-level click handler — let them bubble.
if (e.shiftKey || e.metaKey || e.ctrlKey) return;
if (selection.ids.size > 0) return;
// Establish the "starting photo" so a subsequent shift-click extends
// the range from this tile. Reset both focus and anchor — anchor on
// its own would stick to an older toggle/selectOnly tile and the
// shift-range would silently use the wrong starting point. Also
// clear the sticky-column intent so the next arrow press anchors
// off the clicked tile's actual column.
// Plain click: select this tile only. Replaces the previous
// "click opens preview" semantics — preview now lives on dblclick.
// Reset both focus and anchor so a subsequent shift-click extends
// the range from this tile, and clear sticky-column intent so
// arrow nav re-anchors off this tile's actual column.
selection.ids.clear();
selection.ids.add(uid);
setFocused(uid);
setAnchor(uid);
intendedCol = null;
}
function onTileDblclick(e: MouseEvent, uid: string) {
// Modifier-modified dblclicks shouldn't open the preview either —
// gridKeyNav already handled the underlying click.
if (e.shiftKey || e.metaKey || e.ctrlKey) return;
e.preventDefault();
openPreview(uid, photos.map((p) => p.UID));
}
@@ -657,6 +719,12 @@
</Toolbar>
<div class="flex min-h-0 flex-1">
<!--
Main column wraps the scrollable timeline and the action bar so
the bar's width matches the timeline only — the right aside is a
sibling at row level and stays full height when the bar appears.
-->
<div class="flex min-w-0 flex-1 flex-col overflow-hidden">
<main
bind:this={scrollRoot}
class="flex-1 overflow-y-auto outline-none focus:outline-none"
@@ -684,6 +752,13 @@
Archive is empty.
{:else if filters.section === 'favorites'}
No favorites yet. Heart a photo to add it here.
{:else if filters.section === 'review'}
Nothing left to review. Photos PhotoPrism's indexer wasn't sure about
land here — use Keep to accept them into the timeline or Archive to
set them aside.
{:else if filters.section === 'hidden'}
No hidden photos. PhotoPrism auto-hides files it can't index (broken
files, very low quality); they only ever show up here.
{:else if filters.section === 'heap'}
This heap has no photos yet. Select some photos and use the bulk bar's
" Add to heap" button.
@@ -742,6 +817,7 @@
data-tile
data-uid={photo.UID}
onclick={(e) => onTileClick(e, photo.UID)}
ondblclick={(e) => onTileDblclick(e, photo.UID)}
class:scale-90={sel}
class:ring-2={sel}
class:ring-blue-500={sel}
@@ -804,6 +880,8 @@
{/if}
</div>
</main>
<BulkActionBar />
</div>
{#if !view.rightSidebarCollapsed}
<aside
@@ -850,5 +928,3 @@
</aside>
{/if}
</div>
<BulkActionBar />