PhotoPrism's /api/v1/config.count returns library-wide aggregates to any authenticated session, with no per-user scoping. The timeline itself IS scoped (a guest sees zero photos), but the sidebar was rendering admin-side totals next to Review / Hidden / Archive / Tags / Map / root for non-admins — including a freshly-registered "test" user with role=guest and BasePath="". Until PhotoPrism gains per-user counters, the SPA now derives an `isAdminUser` flag and gates every count that's drawn from configQuery on it. Non-admin users see the labels without badges; counts re-appear automatically when promoted. Per-folder counts from the sidecar (which DO scope to BasePath) are unaffected.
835 lines
30 KiB
Svelte
835 lines
30 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,
|
||
createFolder,
|
||
createHeap,
|
||
deleteFolder,
|
||
deleteHeap,
|
||
duplicateHeap,
|
||
getAllMarks,
|
||
getConfig,
|
||
heapDownloadUrl,
|
||
listFolderCounts,
|
||
listFolders,
|
||
listGeo,
|
||
listHeaps,
|
||
logout,
|
||
renameFolder,
|
||
renameHeap,
|
||
scanCrossFolderDuplicates,
|
||
triggerDownload,
|
||
type AggregatedKeyword,
|
||
type CrossFolderScanResult,
|
||
type PhotoMarksMap,
|
||
type PpAlbum,
|
||
type PpClientConfig,
|
||
type PpFolder,
|
||
type PpGeoCollection
|
||
} from '$lib/services/photoprism';
|
||
import {
|
||
listDuplicateGroups,
|
||
type DuplicateGroup
|
||
} from '$lib/services/adapters/duplicates';
|
||
import {
|
||
filters,
|
||
setFolderPath,
|
||
setSection,
|
||
type Section
|
||
} 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 {
|
||
Copy,
|
||
Download,
|
||
FolderInput,
|
||
FolderPlus,
|
||
LogOut,
|
||
Moon,
|
||
Pencil,
|
||
Settings,
|
||
Sun,
|
||
Trash2
|
||
} from 'lucide-svelte';
|
||
|
||
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.
|
||
// Showing those numbers in a non-admin's sidebar is misleading
|
||
// (e.g. the `test` user with role=guest saw the admin's library
|
||
// totals next to Review / Hidden / Archive). Until PhotoPrism gains
|
||
// per-user count scoping, just suppress the global-derived badges
|
||
// for anyone who isn't the admin.
|
||
const isAdminUser = $derived(session.user?.Role === 'admin');
|
||
|
||
const marksQuery = createQuery<PhotoMarksMap>(() => ({
|
||
queryKey: ['marks'],
|
||
queryFn: getAllMarks,
|
||
enabled: isAuthenticated(),
|
||
staleTime: 60_000
|
||
}));
|
||
|
||
// 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
|
||
}));
|
||
|
||
// Geotagged-photo count for the Map sidebar badge. PhotoPrism's
|
||
// `count.places` is the number of distinct *locations* (cities/states),
|
||
// not the number of geotagged photos — so the sidebar would disagree
|
||
// with the "N geotagged" footer on /map. Sharing the `['geo']` cache
|
||
// keeps both numbers in lockstep and is free after /map's first visit.
|
||
const geoQuery = createQuery<PpGeoCollection>(() => ({
|
||
queryKey: ['geo'],
|
||
queryFn: () => listGeo(),
|
||
enabled: isAuthenticated(),
|
||
staleTime: 5 * 60_000
|
||
}));
|
||
|
||
// Keywords contribution to the Tags badge. Aggregation is heavy
|
||
// (1000-photo fan-out), so the sidebar observes the cache populated
|
||
// by /tags?tab=keywords rather than triggering its own fetch — same
|
||
// lazy pattern as the cross-folder duplicates count above.
|
||
const keywordsQuery = createQuery<AggregatedKeyword[]>(() => ({
|
||
queryKey: ['photos', 'keywords'],
|
||
queryFn: aggregateKeywords,
|
||
enabled: false,
|
||
staleTime: 5 * 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 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)
|
||
);
|
||
|
||
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);
|
||
|
||
// 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);
|
||
|
||
// 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.
|
||
// `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. Map uses the shared `['geo']`
|
||
// cache so its badge matches /map's "N geotagged" footer exactly —
|
||
// `count.places` would have shown distinct locations instead.
|
||
// 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: () => (isAdminUser ? geoQuery.data?.features?.length : undefined) },
|
||
// Tags hosts four tabs (Labels (auto) / Keywords / Ratings / Colors);
|
||
// the badge sums each tab's badge so the sidebar number is the
|
||
// total of what the inner tabs show. Keywords is lazy — it only
|
||
// contributes after /tags?tab=keywords has been visited once.
|
||
// Suppressed for non-admins because every contributing query is
|
||
// library-wide rather than per-user.
|
||
{
|
||
kind: 'route',
|
||
href: '/tags',
|
||
label: 'Tags',
|
||
getCount: () => {
|
||
if (!isAdminUser) return undefined;
|
||
const labels = configQuery.data?.count?.labels;
|
||
if (labels === undefined) return undefined;
|
||
const keywords = keywordsQuery.data?.length ?? 0;
|
||
return labels + keywords + ratingsCount + colorsCount;
|
||
}
|
||
}
|
||
];
|
||
|
||
const manageViews: ViewItem[] = [
|
||
{
|
||
kind: 'route',
|
||
href: '/review',
|
||
label: 'Review',
|
||
getCount: () => {
|
||
if (!isAdminUser) return undefined;
|
||
const review = configQuery.data?.count?.review;
|
||
if (review === undefined) return undefined;
|
||
return review + (stacksQuery.data?.length ?? 0) + (crossFolderQuery.data?.groups.length ?? 0);
|
||
}
|
||
},
|
||
{ kind: 'section', id: 'hidden', label: 'Hidden', getCount: () => (isAdminUser ? configQuery.data?.count?.hidden : undefined) },
|
||
{ kind: 'section', id: 'archive', label: 'Archive', getCount: () => (isAdminUser ? configQuery.data?.count?.archived : undefined) }
|
||
];
|
||
|
||
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-[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">
|
||
<!--
|
||
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-3 overflow-y-auto p-3"
|
||
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-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}
|
||
<!--
|
||
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 text-left"
|
||
class:px-1={hasSubfolders}
|
||
onclick={() => pickFolder('/')}
|
||
title={userBasePath() === '' ? 'Your library' : `Your library (${userBasePath()})`}
|
||
>
|
||
<span class="truncate">{rootLabel}</span>
|
||
{#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}
|
||
</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}
|
||
<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}
|
||
</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">
|
||
Views
|
||
</span>
|
||
</div>
|
||
{#each views as v (v.kind === 'section' ? `s:${v.id}` : `r:${v.href}`)}
|
||
{@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>
|
||
|
||
<div>
|
||
<div class="group/header flex items-center px-3 pb-1">
|
||
<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}
|
||
<p class="px-2 text-[11px] text-muted-foreground">Loading…</p>
|
||
{:else if heapsQuery.isError}
|
||
<p class="px-2 text-[11px] text-destructive">Failed to load heaps</p>
|
||
{:else if (heapsQuery.data ?? []).length === 0}
|
||
<p class="px-2 text-[11px] text-muted-foreground">No heaps yet.</p>
|
||
{: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-[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 min-w-0 flex-1 items-center gap-2 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="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>
|
||
|
||
</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>
|
||
<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)}
|
||
/>
|