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

@@ -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<string, number>;
}
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.
-->
<div
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}
@@ -137,7 +143,7 @@
<span class="inline-block h-[18px] w-4" aria-hidden="true"></span>
{/if}
<button
class="flex flex-1 items-center truncate text-left"
class="flex min-w-0 flex-1 items-center truncate text-left"
class:px-1={hasChildren || depth > 0}
onclick={() => onPick(node.path)}
ondblclick={readonly ? undefined : () => onRename?.(node.path)}
@@ -145,11 +151,23 @@
>
<span class="truncate">{node.name}</span>
</button>
{#if counts && counts[node.path] !== undefined}
{@const n = counts[node.path]}
<span
class="ml-auto shrink-0 rounded px-1 text-[10px] tabular-nums {active
? 'bg-primary-foreground/15 text-primary-foreground'
: 'bg-secondary text-muted-foreground'}"
>
{n >= 1000 ? '1000+' : n}
</span>
{/if}
{#if !readonly}
<!-- Hover-revealed kebab. Reserves zero width when idle so the
row stays compact; expands on hover and stays visible while
the menu is open. Suppressed in readonly mode (picker). -->
<div class="mr-1">
<!-- Hover-revealed kebab. `display: none` until row hover
(or while the menu is open via has-[[data-state=open]])
so the count holds the row's right edge by default
and the kebab pushes it left when it appears.
Suppressed in readonly mode (picker). -->
<div class="ml-1 hidden group-hover:block has-[[data-state=open]]:block">
<KebabMenu label="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"
@@ -187,6 +205,7 @@
{onCreateChild}
{readonly}
{selectedPath}
{counts}
/>
{/if}
</li>

View File

@@ -1,12 +1,23 @@
<!--
General app preferences. Distinct from the PhotoPrism-library admin dialog:
this one owns settings that affect *this* SvelteKit shell (theme), not the
server. Opened from the bottom of the left sidebar.
General app preferences. The UI tab owns the SvelteKit shell's
light/dark/system theme (mode-watcher) plus the per-user UI knobs
PhotoPrism's /settings exposes. Search and Maps follow the same
pattern — server prefs round-trip via /api/v1/settings.
The Library admin dialog and this one share the ['settings'] cache,
so saves from either invalidate the other.
-->
<script lang="ts">
import { Dialog } from 'bits-ui';
import { Dialog, Tabs } from 'bits-ui';
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
import { mode, setMode } from 'mode-watcher';
import { Monitor, Moon, Settings as SettingsIcon, Sun, X } from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import { Loader2, Monitor, Moon, Settings as SettingsIcon, Sun, X } from 'lucide-svelte';
import {
getSettings,
saveSettings,
type PpSettings
} from '$lib/services/photoprism';
interface Props {
open: boolean;
@@ -14,11 +25,98 @@
}
let { open, onClose }: Props = $props();
const qc = useQueryClient();
let activeTab = $state<'ui' | 'search' | 'maps'>('ui');
const themeOptions = [
{ value: 'light', label: 'Light', Icon: Sun },
{ value: 'dark', label: 'Dark', Icon: Moon },
{ value: 'system', label: 'System', Icon: Monitor }
] as const;
// PhotoPrism palette names from its built-in themes. Any value
// outside this list is preserved verbatim (see `withCurrent`).
const ppThemes = [
'default',
'abyss',
'gemstone',
'grayscale',
'lavender',
'legacy',
'neon',
'onyx',
'raspberry',
'shadow',
'yellowstone'
];
// IETF subtags PhotoPrism ships translations for. Extend without
// fear — `withCurrent` keeps unknown values visible.
const ppLanguages = [
'en', 'de', 'es', 'fr', 'it', 'pt', 'nl', 'pl', 'cs', 'sk',
'sv', 'no', 'da', 'fi', 'hu', 'ro', 'bg', 'el', 'ru', 'uk',
'tr', 'ar', 'he', 'hi', 'vi', 'th', 'ja', 'ko', 'zh'
];
const ppStartPages = [
'default',
'browse',
'albums',
'favorites',
'calendar',
'moments',
'people',
'places',
'labels',
'states',
'library'
];
const ppMapStyles = ['default', 'streets', 'hybrid', 'topographique', 'offline'];
// Returns `opts` with `current` prepended if it's set and not
// already in the list — so e.g. an experimental theme name in the
// server response shows up selected and editable instead of
// silently being overwritten by the dropdown's default.
function withCurrent(opts: string[], current?: string): string[] {
if (!current) return opts;
return opts.includes(current) ? opts : [current, ...opts];
}
const settingsQuery = createQuery<PpSettings>(() => ({
queryKey: ['settings'],
queryFn: getSettings,
enabled: open
}));
let draft = $state<PpSettings | null>(null);
$effect(() => {
if (settingsQuery.data && draft === null) {
draft = structuredClone(settingsQuery.data);
}
});
$effect(() => {
if (!open) draft = null;
});
const saveMut = createMutation(() => ({
mutationFn: (patch: PpSettings) => saveSettings(patch),
onSuccess: (next) => {
qc.setQueryData(['settings'], next);
draft = structuredClone(next);
toast.success('Settings saved');
},
onError: (err) =>
toast.error(err instanceof Error ? err.message : 'Could not save settings')
}));
function resetDraft() {
if (settingsQuery.data) draft = structuredClone(settingsQuery.data);
}
const selectClass =
'rounded border border-input bg-background px-2 py-1 focus:outline-none focus:ring-2 focus:ring-ring';
</script>
<Dialog.Root
@@ -32,7 +130,7 @@
class="fixed inset-0 z-40 bg-background/80 backdrop-blur-sm data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0"
/>
<Dialog.Content
class="fixed left-1/2 top-1/2 z-50 grid w-full max-w-[440px] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-lg border border-border bg-card p-5 text-card-foreground shadow-lg outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95"
class="fixed left-1/2 top-1/2 z-50 grid w-full max-w-[560px] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-lg border border-border bg-card p-5 text-card-foreground shadow-lg outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95"
>
<div class="flex items-start gap-2">
<SettingsIcon class="mt-0.5 h-4 w-4 text-muted-foreground" />
@@ -41,8 +139,8 @@
General settings
</Dialog.Title>
<Dialog.Description class="mt-1 text-xs text-muted-foreground">
Preferences for this app. Library-side settings live under
Folders → ⚙.
Preferences for this app and your PhotoPrism account.
Library admin lives under Folders → ⚙.
</Dialog.Description>
</div>
<Dialog.Close
@@ -53,31 +151,219 @@
</Dialog.Close>
</div>
<section class="space-y-2 text-[12px]">
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Appearance
</h3>
<div
class="flex items-center overflow-hidden rounded-md border border-border"
role="group"
aria-label="Theme"
>
{#each themeOptions as opt (opt.value)}
{@const active = mode.current === opt.value}
<button
type="button"
class="flex flex-1 items-center justify-center gap-1.5 px-3 py-1.5 hover:bg-accent"
class:bg-primary={active}
class:text-primary-foreground={active}
class:hover:bg-primary={active}
onclick={() => setMode(opt.value)}
<Tabs.Root bind:value={activeTab}>
<Tabs.List class="mb-3 flex gap-1 border-b border-border">
{#each ['ui', 'search', 'maps'] as const as t (t)}
<Tabs.Trigger
value={t}
class="-mb-px border-b-2 border-transparent px-3 py-1.5 text-[12px] capitalize text-muted-foreground hover:text-foreground data-[state=active]:border-primary data-[state=active]:text-foreground"
>
<opt.Icon class="h-3.5 w-3.5" />
{opt.label}
</button>
{t}
</Tabs.Trigger>
{/each}
</Tabs.List>
<!-- UI — local app theme (mode-watcher) on top, then the
PhotoPrism per-user UI knobs that go to /settings. -->
<Tabs.Content value="ui" class="space-y-4 text-[12px] outline-none">
<section class="space-y-2">
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
App theme
</h3>
<div
class="flex items-center overflow-hidden rounded-md border border-border"
role="group"
aria-label="Theme"
>
{#each themeOptions as opt (opt.value)}
{@const active = mode.current === opt.value}
<button
type="button"
class="flex flex-1 items-center justify-center gap-1.5 px-3 py-1.5 hover:bg-accent"
class:bg-primary={active}
class:text-primary-foreground={active}
class:hover:bg-primary={active}
onclick={() => setMode(opt.value)}
>
<opt.Icon class="h-3.5 w-3.5" />
{opt.label}
</button>
{/each}
</div>
<p class="text-[11px] text-muted-foreground">
Light/dark for this app. Persists locally; no Save needed.
</p>
</section>
{#if settingsQuery.isPending}
<p class="px-1 text-muted-foreground">Loading PhotoPrism settings…</p>
{:else if settingsQuery.isError}
<p class="px-1 text-destructive">Could not load PhotoPrism settings.</p>
{:else if draft}
<section class="space-y-3">
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
PhotoPrism UI
</h3>
<label class="flex flex-col gap-1">
<span class="text-muted-foreground">Theme</span>
<select bind:value={draft.ui!.theme} class={selectClass}>
{#each withCurrent(ppThemes, draft.ui!.theme) as v (v)}
<option value={v}>{v}</option>
{/each}
</select>
</label>
<label class="flex flex-col gap-1">
<span class="text-muted-foreground">Language</span>
<select bind:value={draft.ui!.language} class={selectClass}>
{#each withCurrent(ppLanguages, draft.ui!.language) as v (v)}
<option value={v}>{v}</option>
{/each}
</select>
</label>
<label class="flex flex-col gap-1">
<span class="text-muted-foreground">Time zone</span>
<!-- IANA tz list is ~400 entries, browser support varies; use
a datalist so we get autocomplete without spamming a
gigantic <select>. "Local" is PhotoPrism's special
"follow system" sentinel. -->
<input
type="text"
list="general-tz-list"
placeholder="Local"
bind:value={draft.ui!.timeZone}
class={selectClass}
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-muted-foreground">Start page</span>
<select bind:value={draft.ui!.startPage} class={selectClass}>
{#each withCurrent(ppStartPages, draft.ui!.startPage) as v (v)}
<option value={v}>{v}</option>
{/each}
</select>
</label>
<label class="flex items-center gap-2">
<input type="checkbox" bind:checked={draft.ui!.scrollbar} />
Always show scrollbars
</label>
<label class="flex items-center gap-2">
<input type="checkbox" bind:checked={draft.ui!.zoom} />
Allow image zoom
</label>
</section>
{/if}
</Tabs.Content>
{#if settingsQuery.isPending && activeTab !== 'ui'}
<Tabs.Content value={activeTab} class="outline-none">
<p class="px-1 text-[12px] text-muted-foreground">Loading settings…</p>
</Tabs.Content>
{:else if settingsQuery.isError && activeTab !== 'ui'}
<Tabs.Content value={activeTab} class="outline-none">
<p class="px-1 text-[12px] text-destructive">
Could not load settings.
</p>
</Tabs.Content>
{:else if draft}
<Tabs.Content value="search" class="space-y-3 text-[12px] outline-none">
<label class="flex items-center gap-2">
<input type="checkbox" bind:checked={draft.search!.listView} />
Default to list view
</label>
<label class="flex items-center gap-2">
<input type="checkbox" bind:checked={draft.search!.showTitles} />
Show titles
</label>
<label class="flex items-center gap-2">
<input type="checkbox" bind:checked={draft.search!.showCaptions} />
Show captions
</label>
<label class="flex flex-col gap-1">
<span class="text-muted-foreground">
Batch size (-1 = server default)
</span>
<input
type="number"
bind:value={draft.search!.batchSize}
class={selectClass}
/>
</label>
</Tabs.Content>
<Tabs.Content value="maps" class="space-y-3 text-[12px] outline-none">
<label class="flex flex-col gap-1">
<span class="text-muted-foreground">Style</span>
<select bind:value={draft.maps!.style} class={selectClass}>
{#each withCurrent(ppMapStyles, draft.maps!.style) as v (v)}
<option value={v}>{v}</option>
{/each}
</select>
</label>
<label class="flex flex-col gap-1">
<span class="text-muted-foreground">
Animation duration (ms, 0 = off)
</span>
<input
type="number"
bind:value={draft.maps!.animate}
class={selectClass}
/>
</label>
</Tabs.Content>
{/if}
</Tabs.Root>
<!-- Datalist for time-zone autocomplete. Falls back to the
"Local" sentinel when the browser can't enumerate the
IANA list (older Safari, etc.). -->
<datalist id="general-tz-list">
<option value="Local"></option>
{#each tzOptions() as tz (tz)}<option value={tz}></option>{/each}
</datalist>
<!-- Save/Revert apply to draft (the PhotoPrism /settings round
trip). The App theme group above persists itself, so we
only show the action row when there's something to save. -->
{#if draft}
<div class="flex items-center justify-end gap-2 border-t border-border pt-3">
<button
type="button"
class="rounded border border-border px-3 py-1 text-[12px] hover:bg-accent"
onclick={resetDraft}
disabled={saveMut.isPending}
>
Revert
</button>
<button
type="button"
class="flex items-center gap-1.5 rounded bg-primary px-3 py-1 text-[12px] text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
onclick={() => draft && saveMut.mutate(draft)}
disabled={saveMut.isPending}
>
{#if saveMut.isPending}
<Loader2 class="h-3 w-3 animate-spin" />
{/if}
Save
</button>
</div>
</section>
{/if}
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
<script lang="ts" module>
// `Intl.supportedValuesOf` is a 2022+ API; older browsers (Safari
// 15.3 and below) return undefined here. The component handles that
// by simply showing only the "Local" sentinel in the datalist.
export function tzOptions(): string[] {
const fn = (Intl as unknown as {
supportedValuesOf?: (k: string) => string[];
}).supportedValuesOf;
if (typeof fn !== 'function') return [];
try {
return fn('timeZone');
} catch {
return [];
}
}
</script>

View File

@@ -92,7 +92,9 @@
}));
function submit() {
if (!heap || !pickedPath) return;
// pickedPath === '' is the root selection; falsy check would
// wrongly block it. Distinguish `null` (nothing picked) from `''`.
if (!heap || pickedPath === null) return;
convertMut.mutate({
uid: heap.UID,
body: {
@@ -154,6 +156,20 @@
No folders. Create one from the sidebar first.
</p>
{:else}
<!-- Root row: lets the user drop the heap directly into
originals/ without picking a subfolder. The empty
string is the sidecar's "root" sentinel — matches
resolveUnderRoot's special case in handlers_heap. -->
<button
type="button"
class="flex w-full items-center rounded px-2 py-1 text-left text-[12px] hover:bg-accent"
class:bg-primary={pickedPath === ''}
class:text-primary-foreground={pickedPath === ''}
class:hover:bg-primary={pickedPath === ''}
onclick={() => (pickedPath = '')}
>
/
</button>
<FolderTree
nodes={folderTree}
onPick={(p) => (pickedPath = p)}
@@ -213,7 +229,7 @@
type="button"
class="flex items-center gap-1.5 rounded bg-primary px-3 py-1 text-[12px] text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
onclick={submit}
disabled={!pickedPath || convertMut.isPending}
disabled={pickedPath === null || convertMut.isPending}
>
{#if convertMut.isPending}
<Loader2 class="h-3 w-3 animate-spin" />

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"