web: unify empty + loading states behind EmptyState/InlineLoader

Replaces ad-hoc "Loading…" text and bare empty messages with two
shared feedback primitives that carry subtle lucide icons, consistent
muted-foreground/destructive tones, and a11y signaling (role=status,
aria-busy, role=alert on destructive empties). Loading copy gains
context ("Loading photos/folders/heaps/metadata…") and the right-
sidebar idle state moves from a "ⓘ" glyph to a MousePointerClick
icon. SkeletonGrid stays as the initial-grid loader.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-20 00:36:11 +02:00
parent d2a76fa58c
commit e364e4128f
14 changed files with 323 additions and 119 deletions

View File

@@ -30,6 +30,8 @@
import type { DuplicateGroup } from '$lib/services/adapters/duplicates';
import StackGroupCard from './StackGroupCard.svelte';
import CrossFolderGroupCard from './CrossFolderGroupCard.svelte';
import { EmptyState, InlineLoader } from '$lib/components/feedback';
import { AlertCircle, CheckCircle2, Copy } from 'lucide-svelte';
type Tab = 'stacks' | 'cross-folder';
@@ -75,20 +77,24 @@
{#if activeTab === 'stacks'}
<div role="tabpanel" aria-label="Stack duplicates" class="px-6 py-4 pb-6">
{#if pending}
<p class="text-sm text-muted-foreground">Loading stacks…</p>
<InlineLoader label="Loading stacks…" />
{:else if error}
<p class="text-sm text-destructive">
Could not load stacks: {error instanceof Error ? error.message : 'unknown error'}
</p>
<EmptyState
tone="destructive"
icon={AlertCircle}
title="Could not load stacks"
description={error instanceof Error ? error.message : 'unknown error'}
/>
{:else if groups.length === 0}
<div class="max-w-prose space-y-2 text-sm text-muted-foreground">
<p>No stacks.</p>
<p class="text-xs">
PhotoPrism stacks byte-identical (or EXIF-identical) files. If you don't have
any, this tab stays empty. Cross-folder copies PhotoPrism rejected at index
time live under the Cross-folder tab.
</p>
</div>
<EmptyState icon={Copy} title="No stacks">
{#snippet descriptionSnippet()}
<p>
PhotoPrism stacks byte-identical (or EXIF-identical) files. If you don't have
any, this tab stays empty. Cross-folder copies PhotoPrism rejected at index
time live under the Cross-folder tab.
</p>
{/snippet}
</EmptyState>
{:else}
<div class="space-y-3">
{#each groups as group, i (group.photo.UID)}
@@ -122,22 +128,26 @@
</header>
{#if crossQuery.isFetching && !crossQuery.data}
<p class="text-sm text-muted-foreground">Hashing files under originals…</p>
<InlineLoader label="Hashing files under originals…" />
{:else if crossQuery.isError}
<p class="text-sm text-destructive">
Scan failed: {crossQuery.error instanceof Error
<EmptyState
tone="destructive"
icon={AlertCircle}
title="Scan failed"
description={crossQuery.error instanceof Error
? crossQuery.error.message
: 'unknown error'}
</p>
/>
{:else if crossCount === 0}
<p class="text-sm text-muted-foreground">
No cross-folder duplicates found.
{#if crossQuery.data}
<span class="ml-1 text-[10px] text-muted-foreground/70">
(scanned in {crossQuery.data.scannedMs} ms)
</span>
{/if}
</p>
<EmptyState icon={CheckCircle2} title="No cross-folder duplicates found">
{#snippet descriptionSnippet()}
{#if crossQuery.data}
<p class="text-[10px] text-muted-foreground/70">
scanned in {crossQuery.data.scannedMs} ms
</p>
{/if}
{/snippet}
</EmptyState>
{:else}
<div class="space-y-3">
{#each crossQuery.data?.groups ?? [] as group, i (group.hash)}

View File

@@ -0,0 +1,84 @@
<!--
Shared empty / no-data placeholder. Doubles as an error display when
`tone="destructive"` (swaps colors and announces with role=alert).
Use `size="compact"` inside sidebars where vertical space is tight.
-->
<script lang="ts">
import type { Component, Snippet } from 'svelte';
interface Props {
icon?: Component<any> | any;
title: string;
description?: string;
descriptionSnippet?: Snippet;
align?: 'left' | 'center';
tone?: 'muted' | 'destructive';
size?: 'compact' | 'default';
children?: Snippet;
}
let {
icon: Icon,
title,
description,
descriptionSnippet,
align,
tone = 'muted',
size = 'default',
children
}: Props = $props();
const resolvedAlign = $derived(align ?? (size === 'compact' ? 'left' : 'center'));
const isDestructive = $derived(tone === 'destructive');
</script>
{#if size === 'compact'}
<div
class="flex gap-1.5 px-3 py-2 text-[11px] {resolvedAlign === 'center'
? 'items-center justify-center text-center'
: 'items-start'} {isDestructive ? 'text-destructive' : 'text-muted-foreground'}"
role={isDestructive ? 'alert' : undefined}
aria-live={isDestructive ? 'assertive' : undefined}
>
{#if Icon}
<Icon class="h-3 w-3 shrink-0 {resolvedAlign === 'left' ? 'mt-0.5' : ''}" aria-hidden="true" />
{/if}
<div class="min-w-0">
<span>{title}</span>
{#if descriptionSnippet}
<div class="mt-0.5 opacity-80">{@render descriptionSnippet()}</div>
{:else if description}
<div class="mt-0.5 opacity-80">{description}</div>
{/if}
{#if children}
<div class="mt-1.5">{@render children()}</div>
{/if}
</div>
</div>
{:else}
<div
class="flex flex-col gap-2 p-8 {resolvedAlign === 'center'
? 'items-center text-center'
: 'items-start text-left'}"
role={isDestructive ? 'alert' : undefined}
aria-live={isDestructive ? 'assertive' : undefined}
>
{#if Icon}
<Icon
class="h-5 w-5 {isDestructive ? 'text-destructive' : 'text-muted-foreground/70'}"
aria-hidden="true"
/>
{/if}
<p class="text-sm font-medium {isDestructive ? 'text-destructive' : ''}">{title}</p>
{#if descriptionSnippet}
<div class="max-w-prose space-y-2 text-xs text-muted-foreground">
{@render descriptionSnippet()}
</div>
{:else if description}
<p class="max-w-prose text-xs text-muted-foreground">{description}</p>
{/if}
{#if children}
<div class="mt-2">{@render children()}</div>
{/if}
</div>
{/if}

View File

@@ -0,0 +1,39 @@
<!--
Tiny "Loading…" indicator: spinner + label. Use this for in-flight queries
in sidebars, popovers, and right rails. For the initial photo-grid load,
use SkeletonGrid instead (layout-preserving).
-->
<script lang="ts">
import { Loader2 } from 'lucide-svelte';
interface Props {
label?: string;
size?: 'sm' | 'default';
align?: 'left' | 'center';
srOnly?: boolean;
polite?: boolean;
}
let {
label = 'Loading…',
size = 'default',
align = 'left',
srOnly = false,
polite = true
}: Props = $props();
const textSize = $derived(size === 'sm' ? 'text-[11px]' : 'text-xs');
const iconSize = $derived(size === 'sm' ? 'h-3 w-3' : 'h-3.5 w-3.5');
const padding = $derived(size === 'sm' ? 'px-3 py-2' : 'px-3 py-2');
const justify = $derived(align === 'center' ? 'justify-center' : 'justify-start');
</script>
<p
role="status"
aria-busy="true"
aria-live={polite ? 'polite' : 'off'}
class="flex items-center gap-1.5 {padding} {textSize} {justify} text-muted-foreground"
>
<Loader2 class="{iconSize} animate-spin" aria-hidden="true" />
<span class={srOnly ? 'sr-only' : ''}>{label}</span>
</p>

View File

@@ -0,0 +1,2 @@
export { default as EmptyState } from './EmptyState.svelte';
export { default as InlineLoader } from './InlineLoader.svelte';

View File

@@ -14,7 +14,8 @@
import { Dialog } from 'bits-ui';
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
import { toast } from 'svelte-sonner';
import { FolderInput, Loader2 } from 'lucide-svelte';
import { FolderInput, FolderOpen, Loader2 } from 'lucide-svelte';
import { EmptyState, InlineLoader } from '$lib/components/feedback';
import {
convertHeap,
listFolders,
@@ -153,11 +154,14 @@
</div>
<div class="max-h-[200px] overflow-y-auto">
{#if foldersQuery.isPending}
<p class="px-2 py-1 text-[11px] text-muted-foreground">Loading folders…</p>
<InlineLoader size="sm" label="Loading folders…" />
{:else if (foldersQuery.data ?? []).length === 0}
<p class="px-2 py-1 text-[11px] text-muted-foreground">
No folders. Create one from the sidebar first.
</p>
<EmptyState
size="compact"
icon={FolderOpen}
title="No folders"
description="Create one from the sidebar first."
/>
{:else}
<!-- Root row: lets the user drop the heap directly into
originals/ without picking a subfolder. The empty

View File

@@ -55,7 +55,9 @@
Copy,
Download,
FolderInput,
FolderOpen,
FolderPlus,
Layers,
LogOut,
Moon,
Pencil,
@@ -63,6 +65,7 @@
Sun,
Trash2
} from 'lucide-svelte';
import { EmptyState, InlineLoader } from '$lib/components/feedback';
const qc = useQueryClient();
@@ -757,9 +760,9 @@
</div>
</div>
{#if foldersQuery.isPending}
<p class="px-2 text-[11px] text-muted-foreground">Loading…</p>
<InlineLoader size="sm" label="Loading folders…" />
{:else if !hasSubfolders}
<p class="mt-1 px-2 text-[11px] text-muted-foreground">No subfolders.</p>
<EmptyState size="compact" icon={FolderOpen} title="No subfolders" />
{:else if rootExpanded}
<!--
depth=1 visually nests the top-level subfolders one indent
@@ -795,11 +798,11 @@
</div>
{#if heapsQuery.isPending}
<p class="px-2 text-[11px] text-muted-foreground">Loading…</p>
<InlineLoader size="sm" label="Loading heaps…" />
{:else if heapsQuery.isError}
<p class="px-2 text-[11px] text-destructive">Failed to load heaps</p>
<EmptyState size="compact" tone="destructive" title="Failed to load heaps" />
{:else if (heapsQuery.data ?? []).length === 0}
<p class="px-2 text-[11px] text-muted-foreground">No heaps yet.</p>
<EmptyState size="compact" icon={Layers} title="No heaps yet" />
{:else}
<ul>
{#each heapsQuery.data ?? [] as heap (heap.UID)}

View File

@@ -9,7 +9,8 @@
import { Dialog, Tabs } from 'bits-ui';
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
import { toast } from 'svelte-sonner';
import { Loader2, RefreshCw, Settings, X } from 'lucide-svelte';
import { AlertCircle, CheckCircle2, Loader2, RefreshCw, Settings, X } from 'lucide-svelte';
import { EmptyState, InlineLoader } from '$lib/components/feedback';
import {
cancelImport,
cancelIndex,
@@ -418,11 +419,16 @@
</button>
</div>
{#if errorsQuery.isPending}
<p class="px-1 text-muted-foreground">Loading…</p>
<InlineLoader size="sm" label="Loading error log…" />
{:else if errorsQuery.isError}
<p class="px-1 text-destructive">Could not load error log.</p>
<EmptyState
size="compact"
tone="destructive"
icon={AlertCircle}
title="Could not load error log"
/>
{:else if (errorsQuery.data ?? []).length === 0}
<p class="px-1 text-muted-foreground">No errors logged.</p>
<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]"

View File

@@ -15,6 +15,8 @@
import { setAnchor, setFocused } from '$lib/stores/selection.svelte';
import VideoPlayer from '$lib/components/preview/VideoPlayer.svelte';
import { isVideo, primaryFile, videoFile, type PpPhoto } from '$lib/types/photoprism';
import { EmptyState, InlineLoader } from '$lib/components/feedback';
import { AlertCircle, Image as ImageIcon } from 'lucide-svelte';
interface Props {
uid: string | null;
@@ -58,11 +60,11 @@
<div class="relative flex h-full w-full items-center justify-center bg-black/40 p-4">
{#if uid === null}
<p class="text-sm text-muted-foreground">Select a photo to preview.</p>
<EmptyState icon={ImageIcon} title="Select a photo to preview" />
{:else if photoQuery.isPending}
<p class="text-sm text-muted-foreground">Loading…</p>
<InlineLoader label="Loading photo…" align="center" />
{:else if photoQuery.isError}
<p class="text-sm text-destructive">Failed to load photo.</p>
<EmptyState tone="destructive" icon={AlertCircle} title="Failed to load photo" />
{:else if photoQuery.data}
{@const pf = primaryFile(photoQuery.data)}
{#if showChevrons && currentIndex > 0}

View File

@@ -16,6 +16,7 @@
import { thumbUrl } from '$lib/stores/session.svelte';
import { setFocused } from '$lib/stores/selection.svelte';
import type { PpPhoto } from '$lib/types/photoprism';
import { Loader2 } from 'lucide-svelte';
interface Props {
title: string;
@@ -62,7 +63,15 @@
</script>
{#if stripQuery.isPending}
<div class="text-[10px] text-muted-foreground/70">Loading {title.toLowerCase()}</div>
<div
class="flex items-center gap-1.5 text-[10px] text-muted-foreground/70"
role="status"
aria-busy="true"
aria-live="polite"
>
<Loader2 class="h-2.5 w-2.5 animate-spin" aria-hidden="true" />
<span>Loading {title.toLowerCase()}</span>
</div>
{:else if stripQuery.isError}
<!-- Errors shouldn't break the sidebar; just hide the strip. -->
{null}

View File

@@ -19,6 +19,8 @@
starLabel
} from '$lib/utils/tagGroups';
import type { PpPhoto } from '$lib/types/photoprism';
import { EmptyState, InlineLoader } from '$lib/components/feedback';
import { Hash, Tag } from 'lucide-svelte';
interface Props {
category: TagCategory;
@@ -214,13 +216,15 @@
{#if category === 'labels'}
{#if labelsQuery.isPending}
<p class="px-3 py-2 text-[11px] text-muted-foreground">Loading labels…</p>
<InlineLoader size="sm" label="Loading labels…" />
{:else if labelsQuery.isError}
<p class="px-3 py-2 text-[11px] text-destructive">Failed to load labels.</p>
<EmptyState size="compact" tone="destructive" title="Failed to load labels" />
{:else if filteredLabels.length === 0}
<p class="px-3 py-2 text-[11px] text-muted-foreground">
{filterText ? 'No labels match the filter.' : 'No labels yet.'}
</p>
<EmptyState
size="compact"
icon={Tag}
title={filterText ? 'No labels match the filter' : 'No labels yet'}
/>
{:else}
<div bind:this={scrollEl} class="min-h-0 flex-1 overflow-y-auto">
{#each visibleLabels as label (label.UID ?? label.Slug)}
@@ -276,17 +280,21 @@
{/if}
{:else if category === 'keywords'}
{#if keywordsQuery.isPending}
<p class="px-3 py-2 text-[11px] text-muted-foreground">
Loading keywords… (aggregates from photo details — first load may take a few seconds)
</p>
<InlineLoader
size="sm"
label="Loading keywords… (aggregates from photo details — first load may take a few seconds)"
/>
{:else if keywordsQuery.isError}
<p class="px-3 py-2 text-[11px] text-destructive">Failed to load keywords.</p>
<EmptyState size="compact" tone="destructive" title="Failed to load keywords" />
{:else if filteredKeywords.length === 0}
<p class="px-3 py-2 text-[11px] text-muted-foreground">
{filterText
? 'No keywords match the filter.'
: 'No user-set keywords yet. Add them from a photos right-sidebar metadata panel.'}
</p>
<EmptyState
size="compact"
icon={Hash}
title={filterText ? 'No keywords match the filter' : 'No user-set keywords yet'}
description={filterText
? undefined
: 'Add them from a photos right-sidebar metadata panel.'}
/>
{:else}
<div bind:this={scrollEl} class="min-h-0 flex-1 overflow-y-auto">
{#each visibleKeywords as kw (kw.keyword)}

View File

@@ -22,6 +22,8 @@
import { filters } from '$lib/stores/filters.svelte';
import { push as pushUndo } from '$lib/stores/undo.svelte';
import { isAuthenticated } from '$lib/stores/session.svelte';
import { EmptyState, InlineLoader } from '$lib/components/feedback';
import { Layers } from 'lucide-svelte';
const qc = useQueryClient();
let busy = $state(false);
@@ -288,9 +290,9 @@
class="absolute bottom-full right-0 mb-2 max-h-72 w-56 overflow-y-auto rounded-md border border-border bg-background p-1 text-xs shadow-lg"
>
{#if heapsQuery.isPending}
<p class="px-2 py-1 text-muted-foreground">Loading…</p>
<InlineLoader size="sm" label="Loading heaps…" />
{:else if (heapsQuery.data ?? []).length === 0}
<p class="px-2 py-1 text-muted-foreground">No heaps yet</p>
<EmptyState size="compact" icon={Layers} title="No heaps yet" />
{:else}
{#each heapsQuery.data ?? [] as heap, i (heap.UID)}
<button

View File

@@ -56,6 +56,16 @@
import RightSidebar from "$lib/components/sidebar/RightSidebar.svelte";
import SkeletonGrid from "$lib/components/timeline/SkeletonGrid.svelte";
import Toolbar from "$lib/components/layout/Toolbar.svelte";
import { EmptyState, InlineLoader } from "$lib/components/feedback";
import {
AlertCircle,
Archive,
EyeOff,
ImageOff,
Layers,
MousePointerClick,
Sparkles,
} from "lucide-svelte";
import { type PpPhoto } from "$lib/types/photoprism";
// ── URL ↔ filter store sync ──────────────────────────────────────────────
@@ -831,29 +841,42 @@
{#if photosQuery.isPending}
<SkeletonGrid />
{:else if photosQuery.isError}
<p class="text-sm text-destructive">
Failed to load photos: {photosQuery.error instanceof Error
<EmptyState
tone="destructive"
icon={AlertCircle}
title="Failed to load photos"
description={photosQuery.error instanceof Error
? photosQuery.error.message
: "unknown error"}
</p>
/>
{:else if photos.length === 0}
<p class="text-sm text-muted-foreground">
{#if filters.section === "archive"}
Archive is empty.
{:else if filters.section === "review"}
Nothing left to review. Photos PhotoPrism's indexer wasn't
sure about land here — use Keep to accept them into the
timeline or Archive to set them aside.
{:else if filters.section === "hidden"}
No hidden photos. PhotoPrism auto-hides files it can't index
(broken files, very low quality); they only ever show up here.
{:else if filters.section === "heap"}
This heap has no photos yet. Select some photos and use the
bulk bar's " Add to heap" button.
{:else}
No photos. Index a folder via PhotoPrism's reindex command.
{/if}
</p>
{#if filters.section === "archive"}
<EmptyState icon={Archive} title="Archive is empty" />
{:else if filters.section === "review"}
<EmptyState
icon={Sparkles}
title="Nothing left to review"
description="Photos PhotoPrism's indexer wasn't sure about land here — use Keep to accept them into the timeline or Archive to set them aside."
/>
{:else if filters.section === "hidden"}
<EmptyState
icon={EyeOff}
title="No hidden photos"
description="PhotoPrism auto-hides files it can't index (broken files, very low quality); they only ever show up here."
/>
{:else if filters.section === "heap"}
<EmptyState
icon={Layers}
title="This heap has no photos yet"
description={'Select some photos and use the bulk bars “+ Add to heap” button.'}
/>
{:else}
<EmptyState
icon={ImageOff}
title="No photos"
description="Index a folder via PhotoPrism's reindex command."
/>
{/if}
{:else}
<div
data-photo-grid
@@ -920,9 +943,12 @@
}}
></div>
{#if photosQuery.isFetchingNextPage}
<p class="py-3 text-center text-xs text-muted-foreground">
Loading more
</p>
<InlineLoader
size="sm"
align="center"
polite={false}
label="Loading more photos…"
/>
{/if}
{/if}
</div>
@@ -944,15 +970,16 @@
{:else if focusedPhotoQuery.data}
<RightSidebar photo={focusedPhotoQuery.data} />
{:else if focusedPhotoQuery.isFetching}
<p class="px-3 py-2 text-xs text-muted-foreground">Loading</p>
<InlineLoader size="sm" label="Loading metadata…" />
{:else}
<div class="space-y-2 p-4 text-center">
<div class="text-xl"></div>
<p class="text-xs text-muted-foreground">
Use arrow keys or <kbd class="rounded bg-muted px-1"></kbd>+click
on a thumbnail to view its metadata here.
</p>
</div>
<EmptyState icon={MousePointerClick} title="No photo selected">
{#snippet descriptionSnippet()}
<p>
Use arrow keys or <kbd class="rounded bg-muted px-1"></kbd
>+click on a thumbnail to view its metadata here.
</p>
{/snippet}
</EmptyState>
{/if}
</div>
<!-- Resize handle on the left edge; mirrors the layout's left aside

View File

@@ -51,6 +51,8 @@
import BulkMetadataSidebar from '$lib/components/sidebar/BulkMetadataSidebar.svelte';
import CauseGroupCard from '$lib/components/review/CauseGroupCard.svelte';
import DuplicatesView from '$lib/components/duplicates/DuplicatesView.svelte';
import { EmptyState, InlineLoader } from '$lib/components/feedback';
import { AlertCircle, Sparkles } from 'lucide-svelte';
type DupTab = 'stacks' | 'cross-folder';
type Tab = CauseKey | DupTab;
@@ -226,23 +228,27 @@
<div class="flex min-w-0 flex-1 flex-col">
<main class="min-h-0 flex-1 overflow-y-auto px-6 py-4 pb-6" use:gridKeyNav={{}}>
{#if reviewQuery.isPending}
<p class="text-sm text-muted-foreground">Loading review queue…</p>
<InlineLoader label="Loading review queue…" />
{:else if reviewQuery.error}
<p class="text-sm text-destructive">
Could not load review queue: {reviewQuery.error instanceof Error
<EmptyState
tone="destructive"
icon={AlertCircle}
title="Could not load review queue"
description={reviewQuery.error instanceof Error
? reviewQuery.error.message
: 'unknown error'}
</p>
/>
{:else if groups.length === 0}
<div class="max-w-prose space-y-2 text-sm text-muted-foreground">
<p>The review queue is empty.</p>
<p class="text-xs">
PhotoPrism's indexer flags photos with a low quality score for human
review. New arrivals with missing EXIF, low resolution, or unknown
cameras will land here. The Stacks and Cross-folder tabs above stay
available for duplicate cleanup.
</p>
</div>
<EmptyState icon={Sparkles} title="Nothing to review">
{#snippet descriptionSnippet()}
<p>
PhotoPrism's indexer flags photos with a low quality score for human
review. New arrivals with missing EXIF, low resolution, or unknown
cameras will land here. The Stacks and Cross-folder tabs above stay
available for duplicate cleanup.
</p>
{/snippet}
</EmptyState>
{:else if activeGroup}
{#key activeGroup.cause}
<CauseGroupCard group={activeGroup} />
@@ -263,7 +269,7 @@
{:else if focusedPhotoQuery.data}
<RightSidebar photo={focusedPhotoQuery.data} showRelated />
{:else if focusedPhotoQuery.isFetching}
<p class="px-3 py-2 text-xs text-muted-foreground">Loading…</p>
<InlineLoader size="sm" label="Loading metadata…" />
{/if}
</div>
<div

View File

@@ -32,6 +32,8 @@
import RightSidebar from '$lib/components/sidebar/RightSidebar.svelte';
import SkeletonGrid from '$lib/components/timeline/SkeletonGrid.svelte';
import Toolbar from '$lib/components/layout/Toolbar.svelte';
import { EmptyState, InlineLoader } from '$lib/components/feedback';
import { AlertCircle, ImageOff, MousePointerClick, Tag } from 'lucide-svelte';
import type { PpPhoto } from '$lib/types/photoprism';
// URL-driven category + value. `isTagCategory` rejects typos so a stray
@@ -188,12 +190,11 @@
{#if !selectedValue}
<main class="flex min-h-0 flex-1 items-center justify-center p-8">
<div class="max-w-sm space-y-2 text-center">
<p class="text-sm font-medium">Pick a {category ?? 'tag'} from the sidebar</p>
<p class="text-xs text-muted-foreground">
Click a row in the panel on the left to filter the photo grid by that tag.
</p>
</div>
<EmptyState
icon={Tag}
title={`Pick a ${category ?? 'tag'} from the sidebar`}
description="Click a row in the panel on the left to filter the photo grid by that tag."
/>
</main>
{:else}
<div class="flex min-h-0 flex-1">
@@ -205,9 +206,9 @@
{#if showSkeleton}
<SkeletonGrid />
{:else if showError}
<p class="text-sm text-destructive">Failed to load photos.</p>
<EmptyState tone="destructive" icon={AlertCircle} title="Failed to load photos" />
{:else if drillPhotos.length === 0}
<p class="text-sm text-muted-foreground">No photos under this tag.</p>
<EmptyState icon={ImageOff} title="No photos under this tag" />
{:else}
<PhotoGrid photos={drillPhotos} />
{/if}
@@ -225,15 +226,16 @@
{:else if focusedPhotoQuery.data}
<RightSidebar photo={focusedPhotoQuery.data} />
{:else if focusedPhotoQuery.isFetching}
<p class="px-3 py-2 text-xs text-muted-foreground">Loading…</p>
<InlineLoader size="sm" label="Loading metadata…" />
{:else}
<div class="space-y-2 p-4 text-center">
<div class="text-xl"></div>
<p class="text-xs text-muted-foreground">
Use arrow keys or <kbd class="rounded bg-muted px-1"></kbd>+click on a
thumbnail to view its metadata here.
</p>
</div>
<EmptyState icon={MousePointerClick} title="No photo selected">
{#snippet descriptionSnippet()}
<p>
Use arrow keys or <kbd class="rounded bg-muted px-1"></kbd>+click on a
thumbnail to view its metadata here.
</p>
{/snippet}
</EmptyState>
{/if}
</div>
<div