<!--
	Library admin dialog. Tabs map 1-to-1 to PhotoPrism's own Library page:
	general settings, manual index, manual import, server error log.

	Each tab owns its own query/mutation pair via TanStack Query so the data
	loads on first open and the rest of the app can read the same caches.
-->
<script lang="ts">
	import { Dialog, Tabs } from 'bits-ui';
	import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
	import { toast } from 'svelte-sonner';
	import { AlertCircle, CheckCircle2, FolderOpen, Loader2, RefreshCw, Settings, X } from 'lucide-svelte';
	import { EmptyState, InlineLoader } from '$lib/components/feedback';
	import {
		cancelIndex,
		getConfig,
		getErrors,
		getSettings,
		getIndexSubpath,
		listFoldersUnderBase,
		saveSettings,
		setIndexSubpath,
		startIndex,
		type IndexBody,
		type PpFolder,
		type PpLogEntry,
		type PpSettings
	} from '$lib/services/photoprism';
	import type { PpClientConfig } from '$lib/types/photoprism';
	import {
		prefs,
		setIndexSubpathState,
		toOriginalsPath
	} from '$lib/stores/session.svelte';
	import FolderTree, { buildTree } from './FolderTree.svelte';

	interface Props {
		open: boolean;
		onClose: () => void;
	}
	let { open, onClose }: Props = $props();

	const qc = useQueryClient();

	let activeTab = $state<'library' | 'index' | 'logs' | 'about'>('library');

	// ── Library tab ───────────────────────────────────────────────────────
	// Pull settings only while the dialog is open so we don't keep them
	// warm in the background. Edits work on a local clone; Save POSTs it
	// back wholesale (PhotoPrism deep-merges server-side).
	const settingsQuery = createQuery<PpSettings>(() => ({
		queryKey: ['settings'],
		queryFn: getSettings,
		enabled: open
	}));

	/**
	 * Force the shape on every clone so each `bind:value={draft.index!.*}`
	 * etc. has a real object to write into. Older PhotoPrism versions
	 * return /settings without one or more of these sub-objects, and
	 * non-null assertions on a missing sub-object throw on the next tick
	 * when Svelte's bind getter reads through it.
	 *
	 * Same shape-coercion pattern used by GeneralSettingsDialog —
	 * keep them in sync if you add a new top-level group there.
	 */
	function normalize(s: PpSettings): PpSettings {
		return {
			...s,
			index: s.index ?? {},
			stack: s.stack ?? {},
			download: s.download ?? {}
		};
	}

	let draft = $state<PpSettings | null>(null);
	// Re-clone on every open so reopening shows the freshest server state.
	// Resetting on open (not close) avoids the race where bits-ui's exit
	// animation keeps the form mounted with `draft === null` and the
	// `bind:value={draft.download!.originals}` getter throws.
	$effect(() => {
		if (open && settingsQuery.data) {
			draft = normalize(structuredClone(settingsQuery.data));
		}
	});

	const saveMut = createMutation(() => ({
		mutationFn: (patch: PpSettings) => saveSettings(patch),
		onSuccess: (next) => {
			qc.setQueryData(['settings'], next);
			draft = normalize(structuredClone(next));
			toast.success('Settings saved');
		},
		onError: (err) =>
			toast.error(err instanceof Error ? err.message : 'Could not save settings')
	}));

	function resetDraft() {
		if (settingsQuery.data) draft = normalize(structuredClone(settingsQuery.data));
	}

	// ── Index folder (per-user, server-side) ──────────────────────────────
	// The originals-relative sub-folder, under the user's BasePath, that the
	// whole app re-roots to (Library tree) and the reindex scopes to. Picked
	// from the *full* BasePath tree (listFoldersUnderBase) so the user can
	// choose any sub-folder — including ones outside the current root. Stored
	// by the sidecar; mirrored into the `prefs` store so the sidebar reacts.
	const subpathFoldersQuery = createQuery<PpFolder[]>(() => ({
		queryKey: ['folders-under-base'],
		queryFn: listFoldersUnderBase,
		enabled: open && activeTab === 'library'
	}));
	const subpathTree = $derived(
		buildTree((subpathFoldersQuery.data ?? []).map((f) => f.Path))
	);

	// Hydrate the picker selection from the server pref when the dialog opens,
	// so it reflects the current choice instead of the in-memory store alone.
	const indexPrefQuery = createQuery<string>(() => ({
		queryKey: ['prefs'],
		queryFn: getIndexSubpath,
		enabled: open
	}));
	// Local selection: '' = whole folder. Seeded from the store, then from the
	// server pref once it loads.
	let pickedSubpath = $state<string>(prefs.indexSubpath);
	$effect(() => {
		if (open && indexPrefQuery.data !== undefined) {
			pickedSubpath = indexPrefQuery.data;
		}
	});

	const saveSubpathMut = createMutation(() => ({
		mutationFn: (sub: string) => setIndexSubpath(sub),
		onSuccess: (saved) => {
			setIndexSubpathState(saved);
			qc.setQueryData(['prefs'], saved);
			// Re-root the sidebar tree + grid: both are keyed on the effective
			// library base, which just changed.
			qc.invalidateQueries({ queryKey: ['folders'] });
			qc.invalidateQueries({ queryKey: ['photos'] });
			toast.success(saved === '' ? 'Indexing whole folder' : `Index folder: ${saved}`);
		},
		onError: (err) =>
			toast.error(err instanceof Error ? err.message : 'Could not save index folder')
	}));

	// ── Index tab ─────────────────────────────────────────────────────────
	// Default the reindex path to the effective library root (BasePath +
	// chosen index sub-path), so a manual run only rescans the user's working
	// subtree. PhotoPrism's /index expects originals-relative paths with a
	// leading slash; `'/'` means the whole library.
	let indexForm = $state<IndexBody>({
		path: '/' + toOriginalsPath('/'),
		rescan: false,
		cleanup: false
	});
	// SettingsDialog is mounted (open=false) before the index sub-path
	// hydrates, so re-seed the manual-run path to the effective library root
	// each time the dialog opens (and whenever the chosen root changes).
	$effect(() => {
		if (open) indexForm.path = '/' + toOriginalsPath('/');
	});
	const startIndexMut = createMutation(() => ({
		mutationFn: (b: IndexBody) => startIndex(b),
		onSuccess: (r) => toast.success(r.message || 'Indexing complete'),
		onError: (err) =>
			toast.error(err instanceof Error ? err.message : 'Index failed')
	}));
	const cancelIndexMut = createMutation(() => ({
		mutationFn: () => cancelIndex(),
		onSuccess: () => toast.success('Indexing canceled'),
		onError: (err) =>
			toast.error(err instanceof Error ? err.message : 'Cancel failed')
	}));

	// ── Logs tab ──────────────────────────────────────────────────────────
	// Poll while the Logs tab is showing; pause otherwise so the dialog
	// doesn't burn requests when the user is in another tab.
	const errorsQuery = createQuery<PpLogEntry[]>(() => ({
		queryKey: ['errors'],
		queryFn: () => getErrors({ limit: 200 }),
		enabled: open && activeTab === 'logs',
		refetchInterval: open && activeTab === 'logs' ? 5000 : false
	}));

	// ── About tab ─────────────────────────────────────────────────────────
	// Reuses the same query key as the LeftSidebar's `['photos', 'config']`
	// so the About tab never triggers an extra round-trip — config is
	// already warm by the time the user opens this dialog.
	const configQuery = createQuery<PpClientConfig>(() => ({
		queryKey: ['photos', 'config'],
		queryFn: getConfig,
		enabled: open && activeTab === 'about'
	}));

	// PhotoPrism's `flags` is a space-separated bag of feature toggles
	// ("experimental tensorflow places webdav share download import oidc").
	// Parse once so the chip grid can render in stable order.
	const flagSet = $derived.by<Set<string>>(() => {
		const raw = configQuery.data?.flags ?? '';
		return new Set(raw.split(/\s+/).filter(Boolean));
	});

	// Env-driven knobs that don't have a runtime PP API. Listed here so the
	// About tab can render a "you need to edit .env and restart" help
	// section instead of pretending these are mutable from the UI.
	interface EnvKnob {
		envVar: string;
		label: string;
		on: boolean;
	}
	const envKnobs = $derived.by<EnvKnob[]>(() => {
		const f = flagSet;
		const oidc = configQuery.data?.ext?.oidc?.enabled === true;
		return [
			{ envVar: 'OIDC_*', label: 'OIDC SSO', on: oidc },
			{ envVar: 'PP_AUTH_MODE=public', label: 'Public (no-auth) mode', on: f.has('public') },
			{ envVar: 'PHOTOPRISM_DISABLE_TF', label: 'TensorFlow / AI classifier', on: f.has('tensorflow') },
			{ envVar: 'PHOTOPRISM_DISABLE_PLACES', label: 'Places (geocoding)', on: f.has('places') },
			{ envVar: 'PHOTOPRISM_DISABLE_WEBDAV', label: 'WebDAV', on: f.has('webdav') }
		];
	});

	// Show the config block collapsed by default — most users only want the
	// version + counts; the env help is for the rare admin moment.
	let envHelpOpen = $state(false);

	// Library counts surfaced as a compact 2-column grid. Order matches
	// what users care about most often (photos, then derived buckets).
	const COUNT_ROWS: { key: keyof NonNullable<PpClientConfig['count']>; label: string }[] = [
		{ key: 'all', label: 'Photos' },
		{ key: 'videos', label: 'Videos' },
		{ key: 'live', label: 'Live photos' },
		{ key: 'favorites', label: 'Favorites' },
		{ key: 'review', label: 'In review' },
		{ key: 'archived', label: 'Archived' },
		{ key: 'hidden', label: 'Hidden' },
		{ key: 'people', label: 'People' },
		{ key: 'labels', label: 'Labels' },
		{ key: 'folders', label: 'Folders' },
		{ key: 'albums', label: 'Albums' }
	];
</script>

<Dialog.Root
	{open}
	onOpenChange={(o) => {
		if (!o) onClose();
	}}
>
	<Dialog.Portal>
		<Dialog.Overlay
			class="fixed inset-0 z-40 bg-background/80 backdrop-blur-sm data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0"
		/>
		<Dialog.Content
			class="fixed left-1/2 top-1/2 z-50 grid w-full max-w-[640px] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-lg border border-border bg-card p-5 text-card-foreground shadow-lg outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95"
		>
			<div class="flex items-start gap-2">
				<Settings class="mt-0.5 h-4 w-4 text-muted-foreground" />
				<div class="flex-1">
					<Dialog.Title class="text-sm font-semibold leading-tight">
						Library settings
					</Dialog.Title>
					<Dialog.Description class="mt-1 text-xs text-muted-foreground">
						Drive the library, indexer, importer and server log.
					</Dialog.Description>
				</div>
				<Dialog.Close
					class="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
					aria-label="Close"
				>
					<X class="h-3.5 w-3.5" />
				</Dialog.Close>
			</div>

			<Tabs.Root bind:value={activeTab}>
				<Tabs.List
					class="mb-3 flex gap-1 border-b border-border"
				>
					{#each ['library', 'index', 'logs', 'about'] as const as t (t)}
						<Tabs.Trigger
							value={t}
							class="-mb-px border-b-2 border-transparent px-3 py-1.5 text-[12px] capitalize text-muted-foreground hover:text-foreground data-[state=active]:border-primary data-[state=active]:text-foreground"
						>
							{t}
						</Tabs.Trigger>
					{/each}
				</Tabs.List>

				<!-- Library — general settings -->
				<Tabs.Content value="library" class="space-y-4 outline-none">
					<!-- Index folder — the per-user sub-folder the Library tree
					     re-roots to and the reindex scopes to. Picked from the
					     full BasePath tree so any sub-folder is reachable. -->
					<section class="space-y-2 text-[12px]">
						<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
							Index folder
						</h3>
						<p class="text-muted-foreground">
							Pick the sub-folder PhotoPrism should treat as your library
							root. The folder tree re-roots here and the reindex only scans
							this subtree. Leave on “Whole folder” to use everything.
						</p>
						<div class="rounded-md border border-border bg-background p-2">
							<div class="max-h-[180px] overflow-y-auto">
								{#if subpathFoldersQuery.isPending}
									<InlineLoader size="sm" label="Loading folders…" />
								{:else if subpathFoldersQuery.isError}
									<EmptyState
										size="compact"
										tone="destructive"
										icon={FolderOpen}
										title="Could not load folders"
									/>
								{:else}
									<!-- Whole-folder reset: '' is the "no sub-path" sentinel. -->
									<button
										type="button"
										class="flex w-full items-center rounded px-2 py-1 text-left text-[12px] hover:bg-accent"
										class:bg-primary={pickedSubpath === ''}
										class:text-primary-foreground={pickedSubpath === ''}
										class:hover:bg-primary={pickedSubpath === ''}
										onclick={() => (pickedSubpath = '')}
									>
										Whole folder
									</button>
									{#if (subpathFoldersQuery.data ?? []).length > 0}
										<FolderTree
											nodes={subpathTree}
											onPick={(p) => (pickedSubpath = p)}
											selectedPath={pickedSubpath}
											readonly
										/>
									{/if}
								{/if}
							</div>
						</div>
						<div class="flex items-center justify-between gap-2">
							<span class="truncate text-[11px] text-muted-foreground">
								Current: {prefs.indexSubpath === '' ? 'Whole folder' : prefs.indexSubpath}
							</span>
							<button
								type="button"
								class="flex items-center gap-1.5 rounded bg-primary px-3 py-1 text-[12px] text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
								onclick={() => saveSubpathMut.mutate(pickedSubpath)}
								disabled={saveSubpathMut.isPending || pickedSubpath === prefs.indexSubpath}
							>
								{#if saveSubpathMut.isPending}
									<Loader2 class="h-3 w-3 animate-spin" />
								{/if}
								Set index folder
							</button>
						</div>
					</section>

					<div class="h-px bg-border"></div>

					{#if settingsQuery.isPending}
						<p class="px-1 text-[12px] text-muted-foreground">Loading settings…</p>
					{:else if settingsQuery.isError}
						<p class="px-1 text-[12px] text-destructive">
							Could not load settings.
						</p>
					{:else if draft}
						<div class="max-h-[55vh] space-y-4 overflow-y-auto pr-1 text-[12px]">
							<section class="space-y-1.5">
								<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
									Indexer defaults
								</h3>
								<label class="flex items-center gap-2">
									<input
										type="checkbox"
										bind:checked={draft.index!.convert}
									/>
									Convert RAW / HEIC on index
								</label>
								<label class="flex items-center gap-2">
									<input
										type="checkbox"
										bind:checked={draft.index!.rescan}
									/>
									Rescan known files
								</label>
								<label class="flex items-center gap-2">
									<input
										type="checkbox"
										bind:checked={draft.index!.skipArchived}
									/>
									Skip archived photos
								</label>
							</section>

							<section class="space-y-1.5">
								<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
									Stacks
								</h3>
								<label class="flex items-center gap-2">
									<input type="checkbox" bind:checked={draft.stack!.uuid} />
									Stack files sharing a UUID
								</label>
								<label class="flex items-center gap-2">
									<input type="checkbox" bind:checked={draft.stack!.meta} />
									Stack files with matching metadata
								</label>
								<label class="flex items-center gap-2">
									<input type="checkbox" bind:checked={draft.stack!.name} />
									Stack files with matching names
								</label>
							</section>

							<section class="space-y-1.5">
								<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
									Downloads
								</h3>
								<label class="flex items-center gap-2">
									<input
										type="checkbox"
										bind:checked={draft.download!.originals}
									/>
									Originals
								</label>
								<label class="flex items-center gap-2">
									<input
										type="checkbox"
										bind:checked={draft.download!.mediaRaw}
									/>
									Include RAW
								</label>
								<label class="flex items-center gap-2">
									<input
										type="checkbox"
										bind:checked={draft.download!.mediaSidecar}
									/>
									Include sidecar files
								</label>
								<label class="flex items-center gap-2">
									<input
										type="checkbox"
										bind:checked={draft.download!.disabled}
									/>
									Disable downloads entirely
								</label>
								{#if draft.download?.crc32 !== undefined}
									<label class="flex items-center gap-2">
										<input
											type="checkbox"
											bind:checked={draft.download!.crc32}
										/>
										Include CRC32 checksum
									</label>
								{/if}
								{#if draft.download?.sha1 !== undefined}
									<label class="flex items-center gap-2">
										<input
											type="checkbox"
											bind:checked={draft.download!.sha1}
										/>
										Include SHA1 checksum
									</label>
								{/if}
							</section>

							<!-- Indexer advanced — only renders the fields PP actually
							     reported. Older PP versions return a smaller `index`
							     block and we don't want to fabricate UI for missing keys. -->
							{#if draft.index?.skipMeta !== undefined || draft.index?.skipRaw !== undefined || draft.index?.skipHidden !== undefined}
								<section class="space-y-1.5">
									<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
										Indexer advanced
									</h3>
									{#if draft.index?.skipMeta !== undefined}
										<label class="flex items-center gap-2">
											<input
												type="checkbox"
												bind:checked={draft.index!.skipMeta}
											/>
											Skip metadata-only changes
										</label>
									{/if}
									{#if draft.index?.skipRaw !== undefined}
										<label class="flex items-center gap-2">
											<input
												type="checkbox"
												bind:checked={draft.index!.skipRaw}
											/>
											Skip RAW files
										</label>
									{/if}
									{#if draft.index?.skipHidden !== undefined}
										<label class="flex items-center gap-2">
											<input
												type="checkbox"
												bind:checked={draft.index!.skipHidden}
											/>
											Skip hidden files
										</label>
									{/if}
								</section>
							{/if}

						</div>

						<div class="mt-4 flex items-center justify-end gap-2">
							<button
								type="button"
								class="rounded border border-border px-3 py-1 text-[12px] hover:bg-accent"
								onclick={resetDraft}
								disabled={saveMut.isPending}
							>
								Revert
							</button>
							<button
								type="button"
								class="flex items-center gap-1.5 rounded bg-primary px-3 py-1 text-[12px] text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
								onclick={() => draft && saveMut.mutate(draft)}
								disabled={saveMut.isPending}
							>
								{#if saveMut.isPending}
									<Loader2 class="h-3 w-3 animate-spin" />
								{/if}
								Save
							</button>
						</div>
					{/if}
				</Tabs.Content>

				<!-- Index — manual indexer run -->
				<Tabs.Content value="index" class="space-y-3 text-[12px] outline-none">
					<p class="text-muted-foreground">
						Rebuilds the search index by walking the originals folder. Safe to
						run while users are connected.
					</p>
					<label class="flex flex-col gap-1">
						<span class="text-muted-foreground">Path</span>
						<input
							type="text"
							bind:value={indexForm.path}
							placeholder="/"
							class="rounded border border-input bg-background px-2 py-1 focus:outline-none focus:ring-2 focus:ring-ring"
						/>
					</label>
					<label class="flex items-center gap-2">
						<input type="checkbox" bind:checked={indexForm.rescan} />
						Rescan files already in the index
					</label>
					<label class="flex items-center gap-2">
						<input type="checkbox" bind:checked={indexForm.cleanup} />
						Clean up missing files
					</label>
					<div class="flex items-center justify-end gap-2 pt-1">
						<button
							type="button"
							class="rounded border border-border px-3 py-1 hover:bg-accent disabled:opacity-50"
							onclick={() => cancelIndexMut.mutate()}
							disabled={cancelIndexMut.isPending || startIndexMut.isPending}
						>
							Cancel current
						</button>
						<button
							type="button"
							class="flex items-center gap-1.5 rounded bg-primary px-3 py-1 text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
							onclick={() => startIndexMut.mutate(indexForm)}
							disabled={startIndexMut.isPending}
						>
							{#if startIndexMut.isPending}
								<Loader2 class="h-3 w-3 animate-spin" />
							{/if}
							Start indexing
						</button>
					</div>
				</Tabs.Content>

				<!-- About — version, library counts, env-driven config help -->
				<Tabs.Content value="about" class="space-y-4 text-[12px] outline-none">
					{#if configQuery.isPending}
						<InlineLoader size="sm" label="Loading server info…" />
					{:else if configQuery.isError || !configQuery.data}
						<EmptyState
							size="compact"
							tone="destructive"
							icon={AlertCircle}
							title="Could not load server info"
						/>
					{:else}
						{@const cfg = configQuery.data}
						<section class="space-y-1.5">
							<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
								Server
							</h3>
							<div class="grid grid-cols-2 gap-x-4 gap-y-1 rounded border border-border bg-muted/30 p-2">
								<span class="text-muted-foreground">PhotoPrism</span>
								<span class="text-right tabular-nums">{cfg.edition} {cfg.version}</span>
								<span class="text-muted-foreground">Site</span>
								<span class="truncate text-right" title={cfg.siteUrl}>
									{cfg.siteUrl || '—'}
								</span>
								<span class="text-muted-foreground">Auth mode</span>
								<span class="text-right">{cfg.mode}</span>
							</div>
						</section>

						<section class="space-y-1.5">
							<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
								Features
							</h3>
							<div class="flex flex-wrap gap-1.5">
								{#each envKnobs as knob (knob.envVar)}
									<span
										class="inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-[11px] {knob.on
											? 'border-green-500/30 bg-green-500/10 text-green-700 dark:text-green-300'
											: 'border-border bg-secondary text-muted-foreground'}"
										title={knob.envVar}
									>
										<span
											class="h-1.5 w-1.5 rounded-full {knob.on
												? 'bg-green-500'
												: 'bg-muted-foreground/40'}"
										></span>
										{knob.label}
									</span>
								{/each}
							</div>
						</section>

						{#if cfg.count}
							<section class="space-y-1.5">
								<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
									Library
								</h3>
								<div class="grid grid-cols-2 gap-x-4 gap-y-1 rounded border border-border bg-muted/30 p-2">
									{#each COUNT_ROWS as row (row.key)}
										{@const v = cfg.count?.[row.key]}
										{#if v !== undefined}
											<span class="text-muted-foreground">{row.label}</span>
											<span class="text-right tabular-nums">{v}</span>
										{/if}
									{/each}
								</div>
							</section>
						{/if}

						<!-- Env-driven config: there is no PhotoPrism API for these.
						     The panel surfaces what's on/off and reminds the admin
						     where to flip the switch — .env + restart. -->
						<section class="space-y-2">
							<button
								type="button"
								class="flex w-full items-center justify-between rounded border border-border bg-muted/30 px-2 py-1.5 text-left hover:bg-accent"
								onclick={() => (envHelpOpen = !envHelpOpen)}
								aria-expanded={envHelpOpen}
							>
								<span class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
									Server configuration (env-driven)
								</span>
								<span class="text-[10px] text-muted-foreground">
									{envHelpOpen ? '▾' : '▸'}
								</span>
							</button>
							{#if envHelpOpen}
								<div class="space-y-2 rounded border border-border bg-muted/20 p-2 text-[11px]">
									<p class="text-muted-foreground">
										These knobs aren't exposed through the API. Edit
										<code class="rounded bg-background px-1">.env</code>
										on the host and restart PhotoPrism:
									</p>
									<pre
										class="overflow-x-auto rounded bg-background p-2 font-mono text-[11px] leading-snug"
									>docker compose up -d photoprism
# or, with podman-compose:
podman-compose --env-file .env -f docker-compose.yml -f docker-compose.podman.yml up -d photoprism</pre>
									<ul class="space-y-0.5">
										{#each envKnobs as knob (knob.envVar)}
											<li>
												<code class="rounded bg-background px-1">{knob.envVar}</code>
												—
												<span class:text-green-600={knob.on}
													class:text-muted-foreground={!knob.on}>
													{knob.on ? 'enabled' : 'disabled'}
												</span>
											</li>
										{/each}
									</ul>
									{#if cfg.ext?.oidc?.enabled}
										<p class="text-muted-foreground">
											OIDC provider:
											<span class="text-foreground">
												{cfg.ext.oidc.provider ?? '—'}
											</span>
										</p>
									{/if}
								</div>
							{/if}
						</section>
					{/if}
				</Tabs.Content>

				<!-- Logs — recent server errors -->
				<Tabs.Content value="logs" class="space-y-2 text-[12px] outline-none">
					<div class="flex items-center justify-between">
						<p class="text-muted-foreground">
							Most recent server errors and warnings. Auto-refreshes every
							5 seconds.
						</p>
						<button
							type="button"
							class="flex items-center gap-1 rounded border border-border px-2 py-1 text-[11px] hover:bg-accent"
							onclick={() => qc.invalidateQueries({ queryKey: ['errors'] })}
							disabled={errorsQuery.isFetching}
						>
							<RefreshCw
								class="h-3 w-3 {errorsQuery.isFetching ? 'animate-spin' : ''}"
							/>
							Refresh
						</button>
					</div>
					{#if errorsQuery.isPending}
						<InlineLoader size="sm" label="Loading error log…" />
					{:else if errorsQuery.isError}
						<EmptyState
							size="compact"
							tone="destructive"
							icon={AlertCircle}
							title="Could not load error log"
						/>
					{:else if (errorsQuery.data ?? []).length === 0}
						<EmptyState size="compact" icon={CheckCircle2} title="No errors logged" />
					{:else}
						<ul
							class="max-h-[55vh] space-y-1 overflow-y-auto rounded border border-border bg-background p-2 font-mono text-[11px]"
						>
							{#each errorsQuery.data ?? [] as entry, i (i)}
								<li class="leading-snug">
									<span class="text-muted-foreground">{entry.Time}</span>
									<span
										class:text-destructive={entry.Level === 'error'}
										class:text-yellow-500={entry.Level === 'warn' ||
											entry.Level === 'warning'}
									>
										[{entry.Level}]
									</span>
									{entry.Message}
								</li>
							{/each}
						</ul>
					{/if}
				</Tabs.Content>
			</Tabs.Root>
		</Dialog.Content>
	</Dialog.Portal>
</Dialog.Root>
