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,4 +1,5 @@
<script lang="ts">
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
@@ -10,14 +11,21 @@
deleteFolder,
deleteHeap,
duplicateHeap,
getAllMarks,
getConfig,
getImportInfo,
heapDownloadUrl,
listFolderCounts,
listFolders,
listHeaps,
logout,
renameFolder,
renameHeap,
triggerDownload,
type ImportInfo,
type PhotoMarksMap,
type PpAlbum,
type PpClientConfig,
type PpFolder
} from '$lib/services/photoprism';
import {
@@ -36,6 +44,7 @@
Copy,
Download,
FolderInput,
FolderPlus,
LogOut,
Moon,
Pencil,
@@ -58,10 +67,90 @@
enabled: isAuthenticated()
}));
// Import staging area (PhotoPrism's `/import` root). Polled at a leisurely
// 60s — the inbox only changes when files are uploaded or the indexer
// runs, neither of which happens often enough to justify a tighter cadence.
const importQuery = createQuery<ImportInfo>(() => ({
queryKey: ['import'],
queryFn: getImportInfo,
enabled: isAuthenticated(),
staleTime: 60_000
}));
// View counts come from PhotoPrism's `/config` response, which carries a
// precomputed counter for every common bucket (all/favorites/archived/
// labels/places/…) updated incrementally on every mutation. Cheap to
// refetch, and gives us a stable total — `/photos` only returns
// per-page row counts via `X-Count`, never a total.
//
// The key sits under the `['photos', …]` prefix so it inherits the
// existing `invalidateQueries({ queryKey: ['photos'] })` calls scattered
// across mutations (favorite, archive, restore, delete, heap add) — the
// counter map refreshes whenever the photo list does. Marks-derived
// counts (ratings/colors) react through the shared `['marks']` cache.
const configQuery = createQuery<PpClientConfig>(() => ({
queryKey: ['photos', 'config'],
queryFn: getConfig,
enabled: isAuthenticated()
}));
const marksQuery = createQuery<PhotoMarksMap>(() => ({
queryKey: ['marks'],
queryFn: getAllMarks,
enabled: isAuthenticated(),
staleTime: 60_000
}));
const ratingsCount = $derived(countRatings(marksQuery.data));
const colorsCount = $derived(countColors(marksQuery.data));
function countRatings(marks: PhotoMarksMap | undefined): number {
if (!marks) return 0;
let n = 0;
for (const m of Object.values(marks)) {
if ((m.rating ?? 0) > 0) n++;
}
return n;
}
function countColors(marks: PhotoMarksMap | undefined): number {
if (!marks) return 0;
let n = 0;
for (const m of Object.values(marks)) {
if (m.color) n++;
}
return n;
}
const folderTree = $derived(
buildTree((foldersQuery.data ?? []).map((f) => f.Path))
);
// Per-folder photo counts. PhotoPrism's /folders/originals reports
// FileCount: 0 for every folder, so we hit /photos?q=path:X per folder
// in parallel. Key the query off the folder-path list so it refetches
// when folders are added/renamed/deleted, and share the ['photos', …]
// prefix so it invalidates alongside the other photo caches whenever a
// mutation lands.
const folderPaths = $derived((foldersQuery.data ?? []).map((f) => f.Path));
const folderCountsQuery = createQuery<Record<string, number>>(() => ({
queryKey: ['photos', 'folder-counts', [...folderPaths].sort()],
queryFn: () => listFolderCounts(folderPaths),
enabled: isAuthenticated() && folderPaths.length > 0,
staleTime: 60_000
}));
const folderCounts = $derived(folderCountsQuery.data ?? {});
// Root count = total photos minus the sum of every subfolder count.
// `config.count.all` is PhotoPrism's authoritative library total
// (kept in sync server-side); subtracting non-root photos gives an
// exact root-only count without a separate API trip.
const rootCount = $derived.by(() => {
const total = configQuery.data?.count?.all ?? 0;
const sub = Object.values(folderCounts).reduce((a, b) => a + b, 0);
return Math.max(0, total - sub);
});
const createMut = createMutation(() => ({
mutationFn: (title: string) => createHeap(title),
onSuccess: (h) => {
@@ -109,6 +198,24 @@
// admin dialog above — opened from the bottom-of-sidebar footer.
let generalSettingsOpen = $state(false);
// Root-folder collapse state. Persisted to its own localStorage key so
// it doesn't collide with FolderTree's per-subfolder openSet. Defaults
// to open so first-time users see the full tree.
const ROOT_OPEN_KEY = 'mule_root_expanded';
let rootExpanded = $state(loadRootExpanded());
function loadRootExpanded(): boolean {
if (!browser) return true;
const raw = localStorage.getItem(ROOT_OPEN_KEY);
return raw === null ? true : raw === '1';
}
function toggleRoot() {
rootExpanded = !rootExpanded;
if (browser) localStorage.setItem(ROOT_OPEN_KEY, rootExpanded ? '1' : '0');
}
const rootActive = $derived(filters.folderPath === '/');
const hasSubfolders = $derived((foldersQuery.data ?? []).length > 0);
async function onSignOut() {
await logout();
await goto('/login', { replaceState: true });
@@ -221,24 +328,40 @@
return true;
}
// Single Views group — section-driven entries and route-driven entries
// mixed in display order. `kind` discriminates which click handler runs
// (sections go through `navigateTo` to seed filter state; routes are
// plain links). Archive intentionally sits at the bottom to keep it out
// of the way of the everyday-browse rows.
// Two groups: Views (everyday browse) and Manage (curation flows that
// decide a photo's fate — review, dedup, unhide, delete). `kind`
// discriminates which click handler runs (sections go through
// `navigateTo` to seed filter state; routes are plain links).
//
// `getCount` is a getter (not a snapshot) so the badge reads the latest
// derived value on every render — the arrays themselves are constant.
// `count.all` already excludes archived/review/hidden (PhotoPrism's
// "everything visible in the main timeline" tally), so it matches what
// the All photos view actually renders. `places` is the count of
// geocoded locations — semantically what the Map view groups by.
// Duplicates has no precomputed counter; we omit its badge.
type ViewItem =
| { kind: 'section'; id: Section; label: string }
| { kind: 'route'; href: string; label: string };
| { kind: 'section'; id: Section; label: string; getCount: () => number | undefined }
| { kind: 'route'; href: string; label: string; getCount: () => number | undefined };
// "All photos" is not in this list: the root-folder row at the top
// of the sidebar is the canonical entry into the library, so a
// separate "everything regardless of folder" destination would just
// duplicate it for users whose photos live under the root.
const views: ViewItem[] = [
{ kind: 'section', id: 'all-photos', label: 'All photos' },
{ kind: 'section', id: 'favorites', label: 'Favorites' },
{ kind: 'route', href: '/duplicates', label: 'Duplicates' },
{ kind: 'route', href: '/map', label: 'Map' },
{ kind: 'route', href: '/ratings', label: 'Ratings' },
{ kind: 'route', href: '/colors', label: 'Colors' },
{ kind: 'route', href: '/tags', label: 'Tags' },
{ kind: 'section', id: 'archive', label: 'Archive' }
{ kind: 'route', href: '/inbox', label: 'Inbox', getCount: () => importQuery.data?.files },
{ kind: 'section', id: 'favorites', label: 'Favorites', getCount: () => configQuery.data?.count?.favorites },
{ kind: 'route', href: '/map', label: 'Map', getCount: () => configQuery.data?.count?.places },
{ kind: 'route', href: '/ratings', label: 'Ratings', getCount: () => ratingsCount },
{ kind: 'route', href: '/colors', label: 'Colors', getCount: () => colorsCount },
{ kind: 'route', href: '/tags', label: 'Tags', getCount: () => configQuery.data?.count?.labels }
];
const manageViews: ViewItem[] = [
{ kind: 'section', id: 'review', label: 'Review', getCount: () => configQuery.data?.count?.review },
{ kind: 'route', href: '/duplicates', label: 'Duplicates', getCount: () => undefined },
{ kind: 'section', id: 'hidden', label: 'Hidden', getCount: () => configQuery.data?.count?.hidden },
{ kind: 'section', id: 'archive', label: 'Archive', getCount: () => configQuery.data?.count?.archived }
];
function isRouteActive(href: string): boolean {
@@ -246,10 +369,176 @@
}
</script>
{#snippet viewRow(v: ViewItem)}
{@const active = v.kind === 'section' ? isActive(v.id) : isRouteActive(v.href)}
{@const count = v.getCount()}
{#if v.kind === 'section'}
<button
class="flex h-[24px] w-full items-center rounded px-2 text-left text-[12px] leading-tight hover:bg-accent"
class:bg-primary={active}
class:text-primary-foreground={active}
class:hover:bg-primary={active}
onclick={() => navigateTo(v.id)}
>
<span class="truncate">{v.label}</span>
{#if count !== undefined}
<span
class="ml-auto flex h-4 min-w-[20px] flex-shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {active
? 'bg-primary-foreground/15 text-primary-foreground'
: 'bg-secondary text-muted-foreground'}"
>
{count}
</span>
{/if}
</button>
{:else}
<a
href={v.href}
class="flex h-[24px] items-center rounded px-2 text-[12px] leading-tight hover:bg-accent"
class:bg-primary={active}
class:text-primary-foreground={active}
class:hover:bg-primary={active}
>
<span class="truncate">{v.label}</span>
{#if count !== undefined}
<span
class="ml-auto flex h-4 min-w-[20px] flex-shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {active
? 'bg-primary-foreground/15 text-primary-foreground'
: 'bg-secondary text-muted-foreground'}"
>
{count}
</span>
{/if}
</a>
{/if}
{/snippet}
<div class="flex h-full flex-col">
<nav class="flex-1 space-y-3 overflow-y-auto p-3">
<!-- Views — section-driven entries + route-driven entries under a
single uppercase eyebrow. Compact rows, no icons. -->
<!-- Folders — top of the sidebar because the root folder is the
default landing view (see filters store init), making it the
primary navigation surface. Root-folder row + subfolder tree;
hover-revealed actions on the header for library settings and
new-top-level-folder. -->
<div>
<div class="group/header flex items-center gap-0.5 px-3 pb-1">
<span class="flex-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Library
</span>
<button
class="rounded p-0.5 text-muted-foreground opacity-0 hover:bg-accent hover:text-foreground group-hover/header:opacity-100"
onclick={() => (settingsOpen = true)}
title="Library settings"
aria-label="Library settings"
>
<Settings class="h-3 w-3" />
</button>
<button
class="rounded p-0.5 text-xs text-muted-foreground opacity-0 hover:bg-accent hover:text-foreground group-hover/header:opacity-100"
onclick={() => onCreateFolder(null)}
title="New top-level folder"
aria-label="New top-level folder"
>
</button>
</div>
<!--
Root-folder entry. Mirrors a subfolder row's hover/active state
via the `/` sentinel; clicking the label filters the timeline to
photos whose Path is empty (handled by applyFolderScope in
+page.svelte). The chevron collapses/expands the subfolder tree
below — same affordance as nested folder rows.
-->
<div
class="group flex h-[24px] items-center rounded pr-2 text-[12px] leading-tight hover:bg-accent"
class:bg-primary={rootActive}
class:text-primary-foreground={rootActive}
class:hover:bg-primary={rootActive}
style="padding-left: 8px;"
>
{#if hasSubfolders}
<button
type="button"
class="flex h-[18px] w-4 items-center justify-center text-[10px]"
class:text-muted-foreground={!rootActive}
onclick={toggleRoot}
title={rootExpanded ? 'Collapse' : 'Expand'}
aria-label={rootExpanded ? 'Collapse root' : 'Expand root'}
>
{rootExpanded ? '▾' : '▸'}
</button>
{/if}
<button
type="button"
class="flex min-w-0 flex-1 items-center truncate text-left"
class:px-1={hasSubfolders}
onclick={() => pickFolder('/')}
title="Photos directly under originals/"
>
<span class="truncate">/</span>
</button>
{#if configQuery.data}
<span
class="ml-auto shrink-0 rounded px-1 text-[10px] tabular-nums {rootActive
? 'bg-primary-foreground/15 text-primary-foreground'
: 'bg-secondary text-muted-foreground'}"
>
{rootCount >= 1000 ? '1000+' : rootCount}
</span>
{/if}
<!-- Root-row kebab. Only "New subfolder" applies — root itself
can't be renamed or deleted, so those entries are omitted
entirely rather than greyed out. Hidden until row hover (or
menu open) so the count holds the right edge by default. -->
<div class="ml-1 hidden group-hover:block has-[[data-state=open]]:block">
<KebabMenu label="Root folder actions">
<Item
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-[12px] outline-none hover:bg-accent focus:bg-accent"
onSelect={() => onCreateFolder(null)}
>
<FolderPlus class="h-3.5 w-3.5 text-muted-foreground" />
New subfolder
</Item>
</KebabMenu>
</div>
</div>
{#if foldersQuery.isPending}
<p class="px-2 text-[11px] text-muted-foreground">Loading…</p>
{:else if !hasSubfolders}
<p class="mt-1 px-2 text-[11px] text-muted-foreground">No subfolders.</p>
{:else if rootExpanded}
<!--
depth=1 visually nests the top-level subfolders one indent
step under the root row above. Labels at depth=1 line up
12px right of the root label, matching the same per-level
step used for deeper folders.
-->
<FolderTree
nodes={folderTree}
depth={1}
onPick={pickFolder}
onRename={onRenameFolder}
onDelete={onDeleteFolder}
onCreateChild={(parent) => onCreateFolder(parent)}
counts={folderCounts}
/>
{/if}
{#if filters.folderPath && filters.folderPath !== '/'}
<button
class="mt-1 flex h-[22px] w-full items-center rounded px-2 text-[11px] leading-tight text-muted-foreground hover:bg-accent hover:text-foreground"
onclick={() => {
setFolderPath(null);
void goto('/', { keepFocus: true, noScroll: true });
}}
title="Clear folder filter"
>
<span class="truncate">{filters.folderPath}</span>
</button>
{/if}
</div>
<!-- Views — everyday browse entries (section + route mixed) under
a single uppercase eyebrow. Compact rows, no icons. -->
<div>
<div class="px-3 pb-1">
<span class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
@@ -257,27 +546,21 @@
</span>
</div>
{#each views as v (v.kind === 'section' ? `s:${v.id}` : `r:${v.href}`)}
{#if v.kind === 'section'}
<button
class="flex h-[24px] w-full items-center rounded px-2 text-left text-[12px] leading-tight hover:bg-accent"
class:bg-primary={isActive(v.id)}
class:text-primary-foreground={isActive(v.id)}
class:hover:bg-primary={isActive(v.id)}
onclick={() => navigateTo(v.id)}
>
<span class="truncate">{v.label}</span>
</button>
{:else}
<a
href={v.href}
class="flex h-[24px] items-center rounded px-2 text-[12px] leading-tight hover:bg-accent"
class:bg-primary={isRouteActive(v.href)}
class:text-primary-foreground={isRouteActive(v.href)}
class:hover:bg-primary={isRouteActive(v.href)}
>
<span class="truncate">{v.label}</span>
</a>
{/if}
{@render viewRow(v)}
{/each}
</div>
<!-- Manage — curation flows that decide a photo's fate. Same
row shape as Views; grouped separately so the binary-decision
destinations (Review/Archive) don't crowd the browse list. -->
<div>
<div class="px-3 pb-1">
<span class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Manage
</span>
</div>
{#each manageViews as v (v.kind === 'section' ? `s:${v.id}` : `r:${v.href}`)}
{@render viewRow(v)}
{/each}
</div>
@@ -306,28 +589,36 @@
<ul>
{#each heapsQuery.data ?? [] as heap (heap.UID)}
{@const active = isActive('heap', heap.UID)}
<!--
Count + kebab share the right edge: count is the
resting state, kebab swaps in on hover (or while the
menu is open). Moving the count out of the inner
button is what lets it reach the row's right edge
the way Views rows do — and the inner button still
owns the navigate-on-click area.
-->
<li
class="group flex h-[24px] items-center rounded text-[12px] leading-tight hover:bg-accent"
class="group flex h-[24px] items-center rounded pr-2 text-[12px] leading-tight hover:bg-accent"
class:bg-primary={active}
class:text-primary-foreground={active}
class:hover:bg-primary={active}
>
<button
class="flex flex-1 items-center gap-2 px-2 text-left"
class="flex min-w-0 flex-1 items-center gap-2 truncate px-2 text-left"
onclick={() => navigateTo('heap', heap.UID)}
ondblclick={() => onRenameHeap(heap)}
title={`${heap.Title} (${heap.PhotoCount ?? 0})`}
>
<span class="truncate">{heap.Title}</span>
<span
class="ml-auto flex h-4 min-w-[20px] flex-shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {active
? 'bg-primary-foreground/15 text-primary-foreground'
: 'bg-secondary text-muted-foreground'}"
>
{heap.PhotoCount ?? 0}
</span>
</button>
<div class="mr-1">
<span
class="ml-auto flex h-4 min-w-[20px] flex-shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {active
? 'bg-primary-foreground/15 text-primary-foreground'
: 'bg-secondary text-muted-foreground'}"
>
{heap.PhotoCount ?? 0}
</span>
<div class="ml-1 hidden group-hover:block has-[[data-state=open]]:block">
<KebabMenu label="Heap actions">
<Item
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-[12px] outline-none hover:bg-accent focus:bg-accent"
@@ -373,54 +664,6 @@
{/if}
</div>
<div>
<div class="group/header flex items-center gap-0.5 px-3 pb-1">
<span class="flex-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Folders
</span>
<button
class="rounded p-0.5 text-muted-foreground opacity-0 hover:bg-accent hover:text-foreground group-hover/header:opacity-100"
onclick={() => (settingsOpen = true)}
title="Library settings"
aria-label="Library settings"
>
<Settings class="h-3 w-3" />
</button>
<button
class="rounded p-0.5 text-xs text-muted-foreground opacity-0 hover:bg-accent hover:text-foreground group-hover/header:opacity-100"
onclick={() => onCreateFolder(null)}
title="New top-level folder"
aria-label="New top-level folder"
>
</button>
</div>
{#if foldersQuery.isPending}
<p class="px-2 text-[11px] text-muted-foreground">Loading…</p>
{:else if (foldersQuery.data ?? []).length === 0}
<p class="px-2 text-[11px] text-muted-foreground">No subfolders.</p>
{:else}
<FolderTree
nodes={folderTree}
onPick={pickFolder}
onRename={onRenameFolder}
onDelete={onDeleteFolder}
onCreateChild={(parent) => onCreateFolder(parent)}
/>
{/if}
{#if filters.folderPath}
<button
class="mt-1 flex h-[22px] w-full items-center rounded px-2 text-[11px] leading-tight text-muted-foreground hover:bg-accent hover:text-foreground"
onclick={() => {
setFolderPath(null);
void goto('/', { keepFocus: true, noScroll: true });
}}
title="Clear folder filter"
>
<span class="truncate">{filters.folderPath}</span>
</button>
{/if}
</div>
</nav>
<!--
@@ -429,7 +672,7 @@
sign-out) that used to live in the top toolbar.
-->
<footer
class="flex shrink-0 items-center gap-1 border-t border-border bg-card/50 px-3 py-2"
class="flex h-9 shrink-0 items-center gap-1 border-t border-border bg-card/50 px-3"
>
<span
class="min-w-0 flex-1 truncate text-[12px] text-foreground"