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>
1005 lines
36 KiB
Svelte
1005 lines
36 KiB
Svelte
<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}
|