<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 {
		createFolder,
		createHeap,
		deleteFolder,
		deleteHeap,
		duplicateHeap,
		getAllMarks,
		getConfig,
		getImportInfo,
		heapDownloadUrl,
		listFolderCounts,
		listFolders,
		listHeaps,
		logout,
		renameFolder,
		renameHeap,
		triggerDownload,
		type ImportInfo,
		type PhotoMarksMap,
		type PpAlbum,
		type PpClientConfig,
		type PpFolder
	} from '$lib/services/photoprism';
	import {
		filters,
		setFolderPath,
		setSection,
		type Section
	} from '$lib/stores/filters.svelte';
	import { isAuthenticated, session } 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()
	}));

	// Import staging area (PhotoPrism's `/import` root). Polled at a leisurely
	// 60s — the inbox only changes when files are uploaded or the indexer
	// runs, neither of which happens often enough to justify a tighter cadence.
	const importQuery = createQuery<ImportInfo>(() => ({
		queryKey: ['import'],
		queryFn: getImportInfo,
		enabled: isAuthenticated(),
		staleTime: 60_000
	}));

	// View counts come from PhotoPrism's `/config` response, which carries a
	// precomputed counter for every common bucket (all/favorites/archived/
	// labels/places/…) updated incrementally on every mutation. Cheap to
	// refetch, and gives us a stable total — `/photos` only returns
	// per-page row counts via `X-Count`, never a total.
	//
	// The key sits under the `['photos', …]` prefix so it inherits the
	// existing `invalidateQueries({ queryKey: ['photos'] })` calls scattered
	// across mutations (favorite, archive, restore, delete, heap add) — the
	// counter map refreshes whenever the photo list does. Marks-derived
	// counts (ratings/colors) react through the shared `['marks']` cache.
	const configQuery = createQuery<PpClientConfig>(() => ({
		queryKey: ['photos', 'config'],
		queryFn: getConfig,
		enabled: isAuthenticated()
	}));

	const marksQuery = createQuery<PhotoMarksMap>(() => ({
		queryKey: ['marks'],
		queryFn: getAllMarks,
		enabled: isAuthenticated(),
		staleTime: 60_000
	}));

	const ratingsCount = $derived(countRatings(marksQuery.data));
	const colorsCount = $derived(countColors(marksQuery.data));

	function countRatings(marks: PhotoMarksMap | undefined): number {
		if (!marks) return 0;
		let n = 0;
		for (const m of Object.values(marks)) {
			if ((m.rating ?? 0) > 0) n++;
		}
		return n;
	}

	function countColors(marks: PhotoMarksMap | undefined): number {
		if (!marks) return 0;
		let n = 0;
		for (const m of Object.values(marks)) {
			if (m.color) n++;
		}
		return n;
	}

	const folderTree = $derived(
		buildTree((foldersQuery.data ?? []).map((f) => f.Path))
	);

	// Per-folder photo counts. PhotoPrism's /folders/originals reports
	// FileCount: 0 for every folder, so 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 count = total photos minus the sum of every subfolder count.
	// `config.count.all` is PhotoPrism's authoritative library total
	// (kept in sync server-side); subtracting non-root photos gives an
	// exact root-only count without a separate API trip.
	const rootCount = $derived.by(() => {
		const total = configQuery.data?.count?.all ?? 0;
		const sub = Object.values(folderCounts).reduce((a, b) => a + b, 0);
		return Math.max(0, total - sub);
	});

	const createMut = createMutation(() => ({
		mutationFn: (title: string) => createHeap(title),
		onSuccess: (h) => {
			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);

	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. `places` is the count of
	// geocoded locations — semantically what the Map view groups by.
	// Duplicates has no precomputed counter; we omit its badge.
	type ViewItem =
		| { kind: 'section'; id: Section; label: string; 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: '/inbox', label: 'Inbox', getCount: () => importQuery.data?.files },
		{ kind: 'section', id: 'favorites', label: 'Favorites', getCount: () => configQuery.data?.count?.favorites },
		{ kind: 'route', href: '/map', label: 'Map', getCount: () => configQuery.data?.count?.places },
		// Tags hosts four tabs (Labels (auto) / Keywords / Ratings / Colors);
		// the badge shows total labels + keywords + ratings + colors so the
		// number reflects the combined "things you can filter by" surface.
		{
			kind: 'route',
			href: '/tags',
			label: 'Tags',
			getCount: () => {
				const labels = configQuery.data?.count?.labels;
				if (labels === undefined) return undefined;
				return labels + ratingsCount + colorsCount;
			}
		}
	];

	const manageViews: ViewItem[] = [
		{ kind: 'section', id: 'review', label: 'Review', getCount: () => configQuery.data?.count?.review },
		{ kind: 'route', href: '/duplicates', label: 'Duplicates', getCount: () => undefined },
		{ kind: 'section', id: 'hidden', label: 'Hidden', getCount: () => configQuery.data?.count?.hidden },
		{ kind: 'section', id: 'archive', label: 'Archive', getCount: () => configQuery.data?.count?.archived }
	];

	function isRouteActive(href: string): boolean {
		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="Photos directly under originals/"
			>
				<span class="truncate">/</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}
		{#if filters.folderPath && filters.folderPath !== '/'}
			<button
				class="mt-1 flex h-[22px] w-full items-center rounded px-2 text-[11px] leading-tight text-muted-foreground hover:bg-accent hover:text-foreground"
				onclick={() => {
					setFolderPath(null);
					void goto('/', { keepFocus: true, noScroll: true });
				}}
				title="Clear folder filter"
			>
				<span class="truncate">✕ {filters.folderPath}</span>
			</button>
		{/if}
	</div>

	<!-- Views — everyday browse entries (section + route mixed) under
	     a single uppercase eyebrow. Compact rows, no icons. -->
	<div>
		<div class="px-3 pb-1">
			<span class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
				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)}
/>
