Files
mule-image/web/src/lib/components/layout/LeftSidebar.svelte
dtoro 981328faff web(review): expandable Review group in sidebar, tabs become subitems
Mirrors the Tags affordance: chevron-only toggle, no /review landing
entry, navigation only via subitems (cause buckets + Stacks +
Cross-folder linked as /review?tab=<id>). Cause list reuses the
review-groups query so empty buckets stay hidden. The /review toolbar
drops the pill row and shows the active tab as a breadcrumb segment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:42:43 +02:00

1005 lines
36 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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';
import { mode, toggleMode } from 'mode-watcher';
import { toast } from 'svelte-sonner';
import {
aggregateKeywords,
countPhotos,
createFolder,
createHeap,
deleteFolder,
deleteHeap,
duplicateHeap,
getConfig,
heapDownloadUrl,
listFolderCounts,
listFolders,
listHeaps,
logout,
renameFolder,
renameHeap,
scanCrossFolderDuplicates,
triggerDownload,
type CrossFolderScanResult,
type PpAlbum,
type PpClientConfig,
type PpFolder
} from '$lib/services/photoprism';
import {
listDuplicateGroups,
type DuplicateGroup
} from '$lib/services/adapters/duplicates';
import {
listReviewGroups,
type CauseKey,
type ReviewGroup
} from '$lib/services/adapters/review';
import {
filters,
setFolderPath,
setSection,
TAG_CATEGORIES,
type Section,
type TagCategory
} from '$lib/stores/filters.svelte';
import { isAuthenticated, session, userBasePath } from '$lib/stores/session.svelte';
import FolderTree, { buildTree } from './FolderTree.svelte';
import GeneralSettingsDialog from './GeneralSettingsDialog.svelte';
import HeapConvertDialog from './HeapConvertDialog.svelte';
import KebabMenu, { Item, Separator } from './KebabMenu.svelte';
import SettingsDialog from './SettingsDialog.svelte';
import UsersDialog from './UsersDialog.svelte';
import {
Copy,
Download,
FolderInput,
FolderOpen,
FolderPlus,
Layers,
LogOut,
Moon,
Pencil,
Settings,
Sun,
Trash2,
Users
} from 'lucide-svelte';
import { EmptyState, InlineLoader } from '$lib/components/feedback';
const qc = useQueryClient();
const heapsQuery = createQuery<PpAlbum[]>(() => ({
queryKey: ['heaps'],
queryFn: listHeaps,
enabled: isAuthenticated()
}));
const foldersQuery = createQuery<PpFolder[]>(() => ({
queryKey: ['folders'],
queryFn: listFolders,
enabled: isAuthenticated()
}));
// View counts come from PhotoPrism's `/config` response, which carries a
// precomputed counter for every common bucket (all/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 (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()
}));
// PhotoPrism's /api/v1/config.count returns library-wide aggregates
// to any authenticated session regardless of role — the timeline
// itself IS scoped per-user, but the precomputed counters aren't.
// `isAdminUser` controls the cheap path: an admin without a
// BasePath gets the precomputed totals from /config directly. Every
// other case (non-admin, or admin scoped to a subfolder) goes
// through `countPhotos()` which appends a `path:<base>*` filter so
// the badge matches what the user can actually see.
const isAdminUser = $derived(session.user?.Role === 'admin');
const wantScoped = $derived(!isAdminUser || userBasePath() !== '');
// Builds a DSL clause that mirrors PhotoPrism's ACL scoping. An
// admin with `BasePath === ""` gets a no-op clause and the global
// query; everyone else gets a `path:` clause anchored to their
// BasePath so unrelated folders never contribute to the badge.
// Non-admins with no BasePath have nothing they can see, so we
// short-circuit to a query that returns zero (`uid:none`).
function scoped(filter: string): string {
const bp = userBasePath();
if (isAdminUser && bp === '') return filter;
if (!isAdminUser && bp === '') return 'uid:none';
return `${filter} path:"${bp}*"`.trim();
}
function scopedCountQuery(key: string, filter: string) {
return createQuery<number>(() => ({
queryKey: ['photos', 'scoped-count', key, userBasePath(), isAdminUser],
queryFn: () => countPhotos(scoped(filter)),
enabled: isAuthenticated() && wantScoped,
staleTime: 60_000
}));
}
// One query per badge. Admins with no BasePath skip these
// (enabled:false via `wantScoped`) and the configQuery numbers are
// used directly — same chrome as before that fix, no extra
// round-trips. Review has no aggregate badge (it's a pure toggle in
// the sidebar now, like Tags), so it doesn't appear here.
const hiddenCountQuery = scopedCountQuery('hidden', 'hidden:true');
const archivedCountQuery = scopedCountQuery('archived', 'archived:true');
function bucketCount(
key: 'hidden' | 'archived',
query: { data: number | undefined; isPending: boolean }
): number | undefined {
if (wantScoped) {
if (query.isPending) return undefined;
return query.data;
}
// Admin + no BasePath: use the precomputed PhotoPrism counters
// (no extra round-trip).
const c = configQuery.data?.count;
if (!c) return undefined;
return c[key];
}
// Duplicates counts for the sidebar badge. Stacks is a cheap
// PhotoPrism query so we always fetch it; cross-folder is an
// O(disk) scan, so the sidebar only *observes* its cache
// (enabled:false) and the duplicates page itself is what populates
// it on first visit. Both share queryKeys with the /duplicates
// view so cache is reused.
const stacksQuery = createQuery<DuplicateGroup[]>(() => ({
queryKey: ['duplicates'],
queryFn: listDuplicateGroups,
enabled: isAuthenticated(),
staleTime: 60_000
}));
const crossFolderQuery = createQuery<CrossFolderScanResult>(() => ({
queryKey: ['duplicates-cross-folder'],
queryFn: scanCrossFolderDuplicates,
enabled: false,
staleTime: 5 * 60_000
}));
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 the sidecar /folders/counts
// endpoint resolves them in one round-trip (see listFolderCounts).
// 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.
//
// `countsReady` gates the query until just after the sidebar's first
// paint. Even though the sidecar response is small, the per-folder
// fan-out it does to PhotoPrism still takes a few hundred ms cold;
// blocking it on idle means the folder list paints immediately and
// the count badges fade in instead of holding back the whole tree.
const folderPaths = $derived((foldersQuery.data ?? []).map((f) => f.Path));
let countsReady = $state(false);
if (browser) {
const kick = () => (countsReady = true);
// requestIdleCallback isn't in Safari yet; fall back to a short
// timeout so the deferral is still bounded.
const ric = (window as Window & { requestIdleCallback?: (cb: () => void) => number })
.requestIdleCallback;
if (typeof ric === 'function') ric(kick);
else setTimeout(kick, 200);
}
const folderCountsQuery = createQuery<Record<string, number>>(() => ({
queryKey: ['photos', 'folder-counts', [...folderPaths].sort()],
queryFn: () => listFolderCounts(folderPaths),
enabled: isAuthenticated() && folderPaths.length > 0 && countsReady,
staleTime: 60_000
}));
const folderCounts = $derived(folderCountsQuery.data ?? {});
// Root entry shows "the user's library" — for admins without a
// BasePath that's still the whole library, served cheaply from
// /api/v1/config's `count.all`. For any user with a non-empty
// BasePath the precomputed total is wrong (it's library-wide), so we
// ask the sidecar for a recursive count rooted at the user's
// BasePath — listFolderCounts maps `""` through toOriginalsPath, which
// resolves to the BasePath itself, and the sidecar fan-out recurses.
const scopedRootCountQuery = createQuery<Record<string, number>>(() => ({
queryKey: ['photos', 'root-count', userBasePath()],
queryFn: () => listFolderCounts(['']),
enabled: isAuthenticated() && userBasePath() !== '',
staleTime: 60_000
}));
const rootCount = $derived(
userBasePath() === ''
? isAdminUser
? (configQuery.data?.count?.all ?? 0)
: 0
: (scopedRootCountQuery.data?.[''] ?? 0)
);
// Hidden / Archive nav entries use these derived values rather than
// peeking at configQuery directly so the scoped path is invisible to
// the manageViews[] declarations.
const hiddenBadge = $derived(bucketCount('hidden', hiddenCountQuery));
const archivedBadge = $derived(bucketCount('archived', archivedCountQuery));
const createMut = createMutation(() => ({
mutationFn: (title: string) => createHeap(title),
onSuccess: (h) => {
qc.invalidateQueries({ queryKey: ['heaps'] });
toast.success(`Heap created: ${h.Title}`);
navigateTo('heap', h.UID);
},
onError: (err) =>
toast.error(err instanceof Error ? err.message : 'Could not create heap')
}));
const renameMut = createMutation(() => ({
mutationFn: (args: { uid: string; title: string }) => renameHeap(args.uid, args.title),
onSuccess: () => qc.invalidateQueries({ queryKey: ['heaps'] })
}));
const deleteMut = createMutation(() => ({
mutationFn: (uid: string) => deleteHeap(uid),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['heaps'] });
toast.success('Heap deleted');
if (filters.section === 'heap') navigateTo('all-photos');
}
}));
const duplicateMut = createMutation(() => ({
mutationFn: (uid: string) => duplicateHeap(uid),
onSuccess: (copy) => {
qc.invalidateQueries({ queryKey: ['heaps'] });
toast.success(`Duplicated → ${copy.Title}`);
navigateTo('heap', copy.UID);
},
onError: (err) =>
toast.error(err instanceof Error ? err.message : 'Could not duplicate heap')
}));
// Heap currently being converted (move/copy to folder). Setting this
// mounts <HeapConvertDialog>; the dialog clears it on close.
let convertingHeap = $state<PpAlbum | null>(null);
// Library/admin settings dialog visibility.
let settingsOpen = $state(false);
// App-wide preferences dialog (theme etc.). Distinct from the library
// admin dialog above — opened from the bottom-of-sidebar footer.
let generalSettingsOpen = $state(false);
// Admin-only user management dialog. Footer icon is gated on
// `isAdminUser` so non-admins never see the entry point.
let usersOpen = $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');
}
// Tags-submenu collapse state. Same dedicated-key pattern as `rootExpanded`
// above (keeping it out of `view.metadataSections`, which is reserved for
// the right-sidebar metadata panel). Defaults to collapsed so the sidebar
// doesn't grow on first paint.
const TAGS_OPEN_KEY = 'mule_tags_expanded';
let tagsExpanded = $state(loadTagsExpanded());
function loadTagsExpanded(): boolean {
if (!browser) return false;
const raw = localStorage.getItem(TAGS_OPEN_KEY);
return raw === '1';
}
function toggleTags() {
tagsExpanded = !tagsExpanded;
if (browser) localStorage.setItem(TAGS_OPEN_KEY, tagsExpanded ? '1' : '0');
}
// Review-submenu collapse state. Mirrors `tagsExpanded` so the Review
// row in Manage can expose the same set of tabs the /review page shows
// (cause groups + duplicates panels). Defaults to collapsed.
const REVIEW_OPEN_KEY = 'mule_review_expanded';
let reviewExpanded = $state(loadReviewExpanded());
function loadReviewExpanded(): boolean {
if (!browser) return false;
return localStorage.getItem(REVIEW_OPEN_KEY) === '1';
}
function toggleReview() {
reviewExpanded = !reviewExpanded;
if (browser) localStorage.setItem(REVIEW_OPEN_KEY, reviewExpanded ? '1' : '0');
}
// Cause-tab list is dynamic (only buckets with hits show up on /review),
// so the sidebar mirrors that by reusing the same query. Gated on
// `reviewExpanded` to avoid paying the /photos round-trip for users who
// never expand the section; the queryKey is shared with the /review page
// so visiting that route warms the cache for free.
const reviewGroupsQuery = createQuery<ReviewGroup[]>(() => ({
queryKey: ['review-groups'],
queryFn: listReviewGroups,
enabled: isAuthenticated() && reviewExpanded,
staleTime: 30_000
}));
type ReviewTabId = CauseKey | 'stacks' | 'cross-folder';
// Stacks + Cross-folder are always present on the /review tab strip
// regardless of count (cross-folder's scan is lazy from its own panel),
// so they tail every cause-tab list the sidebar renders.
const reviewTabs = $derived<{ id: ReviewTabId; label: string }[]>([
...(reviewGroupsQuery.data ?? []).map((g) => ({
id: g.cause as ReviewTabId,
label: g.meta.title
})),
{ id: 'stacks', label: 'Stacks' },
{ id: 'cross-folder', label: 'Cross-folder' }
]);
const reviewActive = $derived(page.url.pathname === '/review');
function isReviewTabActive(id: ReviewTabId): boolean {
if (!reviewActive) return false;
return page.url.searchParams.get('tab') === id;
}
const TAG_CATEGORY_LABELS: Record<TagCategory, string> = {
labels: 'Labels',
keywords: 'Keywords',
people: 'People',
colors: 'Colors',
ratings: 'Ratings'
};
function isTagCategoryActive(cat: TagCategory): boolean {
return page.url.pathname.startsWith(`/tags/${cat}`);
}
// Hover-prefetch for the expensive keywords aggregation. Same idea as
// the cross-folder duplicates pattern: the LeftSidebar's badge query is
// `enabled: false`, but we eagerly populate the cache on intent so the
// click into /tags/keywords lands on warm data.
function prefetchKeywords(): void {
void qc.prefetchQuery({
queryKey: ['photos', 'keywords'],
queryFn: aggregateKeywords,
staleTime: 5 * 60_000
});
}
const rootActive = $derived(filters.folderPath === '/');
const hasSubfolders = $derived((foldersQuery.data ?? []).length > 0);
// Root-folder label. Every account (admins included) gets a
// BasePath named after them on disk, so surface that identity
// here instead of an opaque "/".
const rootLabel = $derived(
session.user?.DisplayName?.trim() || session.user?.Name || '/'
);
async function onSignOut() {
await logout();
await goto('/login', { replaceState: true });
}
async function navigateTo(section: Section, heapUid: string | null = null) {
setSection(section, heapUid);
setFolderPath(null);
const params = new URLSearchParams();
if (section !== 'all-photos') params.set('section', section);
if (heapUid) params.set('heap', heapUid);
const qs = params.toString();
await goto(`/${qs ? '?' + qs : ''}`, { keepFocus: true, noScroll: true });
}
const createFolderMut = createMutation(() => ({
mutationFn: (relPath: string) => createFolder(relPath),
onSuccess: (r) => {
qc.invalidateQueries({ queryKey: ['folders'] });
toast.success(`Folder created: ${r.path}`);
},
onError: (err) =>
toast.error(err instanceof Error ? err.message : 'Could not create folder')
}));
const renameFolderMut = createMutation(() => ({
mutationFn: (args: { rel: string; newName: string }) =>
renameFolder(args.rel, args.newName),
onSuccess: (r) => {
qc.invalidateQueries({ queryKey: ['folders'] });
qc.invalidateQueries({ queryKey: ['photos'] });
// If the active folder filter was on this folder, follow the rename.
if (filters.folderPath === r.oldPath) {
setFolderPath(r.newPath);
const params = new URLSearchParams({ folder: r.newPath });
void goto(`/?${params.toString()}`, { keepFocus: true, noScroll: true });
}
toast.success(`Renamed: ${r.oldPath}${r.newPath}`);
},
onError: (err) =>
toast.error(err instanceof Error ? err.message : 'Rename failed')
}));
const deleteFolderMut = createMutation(() => ({
mutationFn: (rel: string) => deleteFolder(rel),
onSuccess: (r) => {
qc.invalidateQueries({ queryKey: ['folders'] });
if (filters.folderPath && filters.folderPath.startsWith(r.path)) {
setFolderPath(null);
void goto('/', { keepFocus: true, noScroll: true });
}
toast.success(`Folder deleted: ${r.path}`);
},
onError: (err) =>
toast.error(err instanceof Error ? err.message : 'Delete failed')
}));
function onCreateFolder(parent: string | null = null) {
const name = prompt(parent ? `New subfolder under "${parent}"` : 'New folder name')?.trim();
if (!name) return;
const rel = parent ? `${parent}/${name}` : name;
createFolderMut.mutate(rel);
}
function onRenameFolder(rel: string) {
const segs = rel.split('/');
const cur = segs[segs.length - 1];
const next = prompt(`Rename folder "${rel}"`, cur)?.trim();
if (!next || next === cur) return;
renameFolderMut.mutate({ rel, newName: next });
}
function onDeleteFolder(rel: string) {
if (!confirm(`Delete folder "${rel}"? Must be empty.`)) return;
deleteFolderMut.mutate(rel);
}
async function pickFolder(folderPath: string) {
// Folder selection works on top of the All Photos section; clearing
// the heap/section context mirrors mule-image's "drill into folder"
// behaviour. The URL sync $effect on the timeline picks this up.
setSection('all-photos');
setFolderPath(folderPath);
const params = new URLSearchParams();
params.set('folder', folderPath);
await goto(`/?${params.toString()}`, { keepFocus: true, noScroll: true });
}
function onCreateHeap() {
const title = prompt('Heap name')?.trim();
if (title) createMut.mutate(title);
}
function onRenameHeap(h: PpAlbum) {
const title = prompt('Rename heap', h.Title)?.trim();
if (title && title !== h.Title) renameMut.mutate({ uid: h.UID, title });
}
function onDeleteHeap(h: PpAlbum) {
if (confirm(`Delete heap "${h.Title}"? Photos stay in the library.`)) {
deleteMut.mutate(h.UID);
}
}
// Sync section into URL when filters change (so back/forward works).
function isActive(section: Section, heapUid: string | null = null): boolean {
if (page.url.pathname !== '/') return false;
if (filters.section !== section) return false;
if (section === 'heap' && filters.heapUid !== heapUid) return false;
return true;
}
// 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.
// Map and Tags intentionally render without a count badge; the count
// columns inside the TagsBrowserSidebar are the canonical surface for
// per-tag totals. Review rolls in the duplicates tabs hosted under
// /review — stacks always contributes; cross-folder only contributes
// once its tab has been opened (the scan is lazy, not eager from the
// sidebar).
type ViewItem =
| { 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: 'route', href: '/map', label: 'Map', getCount: () => undefined }
// Tags is rendered as a bespoke expandable block below the
// `views` loop — it has sub-categories (Labels/Keywords/Colors/
// Ratings) and a chevron, neither of which fits the flat
// section/route ViewItem shape.
];
// Review is rendered separately below as a pure expandable toggle
// (mirroring Tags — no /review landing entry from the sidebar,
// navigation only via subitems). This list carries the flat Manage
// entries that follow it.
const manageViews: ViewItem[] = [
{ kind: 'section', id: 'hidden', label: 'Hidden', getCount: () => hiddenBadge },
{ kind: 'section', id: 'archive', label: 'Archive', getCount: () => archivedBadge }
];
function isRouteActive(href: string): boolean {
return page.url.pathname === href;
}
</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-[22px] w-full items-center rounded pl-6 pr-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-[24px] 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-[22px] items-center rounded pl-6 pr-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-[24px] 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">
<!--
Soft fade at the bottom of the scrolling nav so users with hidden
scrollbars (default on macOS) get a visual cue that there's more
content below the fold — common when the Heaps list grows long.
No JS / scroll listener; the trade-off is the last ~16px is always
slightly faded even at scroll-bottom.
-->
<nav
class="flex-1 space-y-2 overflow-y-auto p-2"
style="mask-image: linear-gradient(to bottom, black calc(100% - 16px), transparent); -webkit-mask-image: linear-gradient(to bottom, black calc(100% - 16px), transparent);"
>
<!-- 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-2 pb-0.5">
<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-[22px] 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: 4px;"
>
{#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>
{:else}
<!-- Spacer keeps chevronless rows aligned with their chevroned
peers, so labels share a common left edge across the sidebar. -->
<span class="inline-block h-[18px] w-4" aria-hidden="true"></span>
{/if}
<!--
Count badge lives INSIDE the button so the entire row (label
+ badge) is one hit target — the badge is the most visually
prominent element on the row and was previously a dead zone.
-->
<button
type="button"
class="flex min-w-0 flex-1 items-center pl-1 text-left"
onclick={() => pickFolder('/')}
title={userBasePath() === '' ? 'Your library' : `Your library (${userBasePath()})`}
>
<span class="truncate">{rootLabel}</span>
{#if configQuery.data}
<span
class="ml-auto flex h-4 min-w-[24px] flex-shrink-0 items-center justify-center 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}
</button>
<!-- 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}
<InlineLoader size="sm" label="Loading folders…" />
{:else if !hasSubfolders}
<EmptyState size="compact" icon={FolderOpen} title="No subfolders" />
{: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}
</div>
<div>
<div class="group/header flex items-center px-2 pb-0.5">
<span class="flex-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Heaps
</span>
<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={onCreateHeap}
title="New heap"
aria-label="New heap"
>
</button>
</div>
{#if heapsQuery.isPending}
<InlineLoader size="sm" label="Loading heaps…" />
{:else if heapsQuery.isError}
<EmptyState size="compact" tone="destructive" title="Failed to load heaps" />
{:else if (heapsQuery.data ?? []).length === 0}
<EmptyState size="compact" icon={Layers} title="No heaps yet" />
{:else}
<ul>
{#each heapsQuery.data ?? [] as heap (heap.UID)}
{@const active = isActive('heap', heap.UID)}
<!--
Count badge lives INSIDE the button (along with the
title) so clicking the badge navigates to the heap —
previously the badge was a dead zone. Kebab stays a
sibling and swaps in on hover (or while the menu is
open), pushing the button slightly left.
-->
<li
class="group flex h-[22px] 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 min-w-0 flex-1 items-center pl-6 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-[24px] 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="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"
onSelect={() => onRenameHeap(heap)}
>
<Pencil class="h-3.5 w-3.5 text-muted-foreground" />
Rename
</Item>
<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={() => duplicateMut.mutate(heap.UID)}
>
<Copy class="h-3.5 w-3.5 text-muted-foreground" />
Duplicate
</Item>
<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={() => triggerDownload(heapDownloadUrl(heap.UID))}
>
<Download class="h-3.5 w-3.5 text-muted-foreground" />
Download as zip
</Item>
<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={() => (convertingHeap = heap)}
>
<FolderInput class="h-3.5 w-3.5 text-muted-foreground" />
Move to folder…
</Item>
<Separator class="my-1 h-px bg-border" />
<Item
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-[12px] text-destructive outline-none hover:bg-destructive/10 focus:bg-destructive/10"
onSelect={() => onDeleteHeap(heap)}
>
<Trash2 class="h-3.5 w-3.5" />
Delete heap…
</Item>
</KebabMenu>
</div>
</li>
{/each}
</ul>
{/if}
</div>
<!-- Views — everyday browse entries (section + route mixed) under
a single uppercase eyebrow. Compact rows, no icons. -->
<div>
<div class="px-2 pb-0.5">
<span class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Views
</span>
</div>
{#each views as v (v.kind === 'section' ? `s:${v.id}` : `r:${v.href}`)}
{@render viewRow(v)}
{/each}
<!--
Tags expandable. Whole row is a toggle (chevron + label); there is
no landing page at /tags — selecting a sub-category is the only way
into a real view. Counts intentionally live in the TagsBrowserSidebar
(secondary sidebar) so this row stays a pure navigator.
-->
<button
type="button"
class="group flex h-[22px] w-full items-center rounded pr-2 text-left text-[12px] leading-tight hover:bg-accent"
style="padding-left: 4px;"
onclick={toggleTags}
title={tagsExpanded ? 'Collapse tags' : 'Expand tags'}
aria-expanded={tagsExpanded}
>
<span
class="flex h-[18px] w-4 items-center justify-center text-[10px] text-muted-foreground"
>
{tagsExpanded ? '▾' : '▸'}
</span>
<span class="flex min-w-0 flex-1 items-center pl-1">
<span class="truncate">Tags</span>
</span>
</button>
{#if tagsExpanded}
{#each TAG_CATEGORIES as cat (cat)}
{@const active = isTagCategoryActive(cat)}
<a
href={`/tags/${cat}`}
class="flex h-[22px] 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}
style="padding-left: 36px;"
onmouseenter={cat === 'keywords' ? prefetchKeywords : undefined}
onfocus={cat === 'keywords' ? prefetchKeywords : undefined}
>
<span class="truncate">{TAG_CATEGORY_LABELS[cat]}</span>
</a>
{/each}
{/if}
</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-2 pb-0.5">
<span class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Manage
</span>
</div>
<!--
Review expandable. Mirrors the Tags affordance — pure toggle
with no landing page; the only way into a tab is to expand and
pick a subitem. Cause buckets are dynamic (only buckets with
hits show up); Stacks/Cross-folder are always present.
-->
<button
type="button"
class="group flex h-[22px] w-full items-center rounded pr-2 text-left text-[12px] leading-tight hover:bg-accent"
style="padding-left: 4px;"
onclick={toggleReview}
title={reviewExpanded ? 'Collapse review' : 'Expand review'}
aria-expanded={reviewExpanded}
>
<span
class="flex h-[18px] w-4 items-center justify-center text-[10px] text-muted-foreground"
>
{reviewExpanded ? '▾' : '▸'}
</span>
<span class="flex min-w-0 flex-1 items-center pl-1">
<span class="truncate">Review</span>
</span>
</button>
{#if reviewExpanded}
{#each reviewTabs as t (t.id)}
{@const active = isReviewTabActive(t.id)}
<a
href={`/review?tab=${t.id}`}
class="flex h-[22px] 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}
style="padding-left: 36px;"
>
<span class="truncate">{t.label}</span>
</a>
{/each}
{/if}
{#each manageViews as v (v.kind === 'section' ? `s:${v.id}` : `r:${v.href}`)}
{@render viewRow(v)}
{/each}
</div>
</nav>
<!--
Footer — fixed to the bottom of the sidebar. Holds the per-user
affordances (display name, quick theme toggle, general preferences,
sign-out) that used to live in the top toolbar.
-->
<footer
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"
title={session.user?.DisplayName ?? session.user?.Name ?? ''}
>
{session.user?.DisplayName ?? session.user?.Name ?? 'Signed in'}
</span>
<button
type="button"
class="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
onclick={toggleMode}
title="Toggle theme"
aria-label="Toggle theme"
>
{#if mode.current === 'dark'}
<Sun class="h-3.5 w-3.5" />
{:else}
<Moon class="h-3.5 w-3.5" />
{/if}
</button>
<button
type="button"
class="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
onclick={() => (generalSettingsOpen = true)}
title="General settings"
aria-label="General settings"
>
<Settings class="h-3.5 w-3.5" />
</button>
{#if isAdminUser}
<button
type="button"
class="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
onclick={() => (usersOpen = true)}
title="Users"
aria-label="Manage users"
>
<Users class="h-3.5 w-3.5" />
</button>
{/if}
<button
type="button"
class="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
onclick={onSignOut}
title="Sign out"
aria-label="Sign out"
>
<LogOut class="h-3.5 w-3.5" />
</button>
</footer>
</div>
<HeapConvertDialog heap={convertingHeap} onClose={() => (convertingHeap = null)} />
<SettingsDialog open={settingsOpen} onClose={() => (settingsOpen = false)} />
<GeneralSettingsDialog
open={generalSettingsOpen}
onClose={() => (generalSettingsOpen = false)}
/>
{#if isAdminUser}
<UsersDialog open={usersOpen} onClose={() => (usersOpen = false)} />
{/if}