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 type { DuplicateGroup } from '$lib/services/adapters/duplicates';
import StackGroupCard from './StackGroupCard.svelte'; import StackGroupCard from './StackGroupCard.svelte';
import CrossFolderGroupCard from './CrossFolderGroupCard.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'; type Tab = 'stacks' | 'cross-folder';
@@ -75,20 +77,24 @@
{#if activeTab === 'stacks'} {#if activeTab === 'stacks'}
<div role="tabpanel" aria-label="Stack duplicates" class="px-6 py-4 pb-6"> <div role="tabpanel" aria-label="Stack duplicates" class="px-6 py-4 pb-6">
{#if pending} {#if pending}
<p class="text-sm text-muted-foreground">Loading stacks…</p> <InlineLoader label="Loading stacks…" />
{:else if error} {:else if error}
<p class="text-sm text-destructive"> <EmptyState
Could not load stacks: {error instanceof Error ? error.message : 'unknown error'} tone="destructive"
</p> icon={AlertCircle}
title="Could not load stacks"
description={error instanceof Error ? error.message : 'unknown error'}
/>
{:else if groups.length === 0} {:else if groups.length === 0}
<div class="max-w-prose space-y-2 text-sm text-muted-foreground"> <EmptyState icon={Copy} title="No stacks">
<p>No stacks.</p> {#snippet descriptionSnippet()}
<p class="text-xs"> <p>
PhotoPrism stacks byte-identical (or EXIF-identical) files. If you don't have 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 any, this tab stays empty. Cross-folder copies PhotoPrism rejected at index
time live under the Cross-folder tab. time live under the Cross-folder tab.
</p> </p>
</div> {/snippet}
</EmptyState>
{:else} {:else}
<div class="space-y-3"> <div class="space-y-3">
{#each groups as group, i (group.photo.UID)} {#each groups as group, i (group.photo.UID)}
@@ -122,22 +128,26 @@
</header> </header>
{#if crossQuery.isFetching && !crossQuery.data} {#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} {:else if crossQuery.isError}
<p class="text-sm text-destructive"> <EmptyState
Scan failed: {crossQuery.error instanceof Error tone="destructive"
icon={AlertCircle}
title="Scan failed"
description={crossQuery.error instanceof Error
? crossQuery.error.message ? crossQuery.error.message
: 'unknown error'} : 'unknown error'}
</p> />
{:else if crossCount === 0} {:else if crossCount === 0}
<p class="text-sm text-muted-foreground"> <EmptyState icon={CheckCircle2} title="No cross-folder duplicates found">
No cross-folder duplicates found. {#snippet descriptionSnippet()}
{#if crossQuery.data} {#if crossQuery.data}
<span class="ml-1 text-[10px] text-muted-foreground/70"> <p class="text-[10px] text-muted-foreground/70">
(scanned in {crossQuery.data.scannedMs} ms) scanned in {crossQuery.data.scannedMs} ms
</span> </p>
{/if} {/if}
</p> {/snippet}
</EmptyState>
{:else} {:else}
<div class="space-y-3"> <div class="space-y-3">
{#each crossQuery.data?.groups ?? [] as group, i (group.hash)} {#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 { Dialog } from 'bits-ui';
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'; import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
import { toast } from 'svelte-sonner'; 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 { import {
convertHeap, convertHeap,
listFolders, listFolders,
@@ -153,11 +154,14 @@
</div> </div>
<div class="max-h-[200px] overflow-y-auto"> <div class="max-h-[200px] overflow-y-auto">
{#if foldersQuery.isPending} {#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} {:else if (foldersQuery.data ?? []).length === 0}
<p class="px-2 py-1 text-[11px] text-muted-foreground"> <EmptyState
No folders. Create one from the sidebar first. size="compact"
</p> icon={FolderOpen}
title="No folders"
description="Create one from the sidebar first."
/>
{:else} {:else}
<!-- Root row: lets the user drop the heap directly into <!-- Root row: lets the user drop the heap directly into
originals/ without picking a subfolder. The empty originals/ without picking a subfolder. The empty

View File

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

View File

@@ -9,7 +9,8 @@
import { Dialog, Tabs } from 'bits-ui'; import { Dialog, Tabs } from 'bits-ui';
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'; import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
import { toast } from 'svelte-sonner'; 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 { import {
cancelImport, cancelImport,
cancelIndex, cancelIndex,
@@ -418,11 +419,16 @@
</button> </button>
</div> </div>
{#if errorsQuery.isPending} {#if errorsQuery.isPending}
<p class="px-1 text-muted-foreground">Loading…</p> <InlineLoader size="sm" label="Loading error log…" />
{:else if errorsQuery.isError} {: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} {: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} {:else}
<ul <ul
class="max-h-[55vh] space-y-1 overflow-y-auto rounded border border-border bg-background p-2 font-mono text-[11px]" 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 { setAnchor, setFocused } from '$lib/stores/selection.svelte';
import VideoPlayer from '$lib/components/preview/VideoPlayer.svelte'; import VideoPlayer from '$lib/components/preview/VideoPlayer.svelte';
import { isVideo, primaryFile, videoFile, type PpPhoto } from '$lib/types/photoprism'; 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 { interface Props {
uid: string | null; uid: string | null;
@@ -58,11 +60,11 @@
<div class="relative flex h-full w-full items-center justify-center bg-black/40 p-4"> <div class="relative flex h-full w-full items-center justify-center bg-black/40 p-4">
{#if uid === null} {#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} {:else if photoQuery.isPending}
<p class="text-sm text-muted-foreground">Loading…</p> <InlineLoader label="Loading photo…" align="center" />
{:else if photoQuery.isError} {: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} {:else if photoQuery.data}
{@const pf = primaryFile(photoQuery.data)} {@const pf = primaryFile(photoQuery.data)}
{#if showChevrons && currentIndex > 0} {#if showChevrons && currentIndex > 0}

View File

@@ -16,6 +16,7 @@
import { thumbUrl } from '$lib/stores/session.svelte'; import { thumbUrl } from '$lib/stores/session.svelte';
import { setFocused } from '$lib/stores/selection.svelte'; import { setFocused } from '$lib/stores/selection.svelte';
import type { PpPhoto } from '$lib/types/photoprism'; import type { PpPhoto } from '$lib/types/photoprism';
import { Loader2 } from 'lucide-svelte';
interface Props { interface Props {
title: string; title: string;
@@ -62,7 +63,15 @@
</script> </script>
{#if stripQuery.isPending} {#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} {:else if stripQuery.isError}
<!-- Errors shouldn't break the sidebar; just hide the strip. --> <!-- Errors shouldn't break the sidebar; just hide the strip. -->
{null} {null}

View File

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

View File

@@ -22,6 +22,8 @@
import { filters } from '$lib/stores/filters.svelte'; import { filters } from '$lib/stores/filters.svelte';
import { push as pushUndo } from '$lib/stores/undo.svelte'; import { push as pushUndo } from '$lib/stores/undo.svelte';
import { isAuthenticated } from '$lib/stores/session.svelte'; import { isAuthenticated } from '$lib/stores/session.svelte';
import { EmptyState, InlineLoader } from '$lib/components/feedback';
import { Layers } from 'lucide-svelte';
const qc = useQueryClient(); const qc = useQueryClient();
let busy = $state(false); 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" 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} {#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} {: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} {:else}
{#each heapsQuery.data ?? [] as heap, i (heap.UID)} {#each heapsQuery.data ?? [] as heap, i (heap.UID)}
<button <button

View File

@@ -56,6 +56,16 @@
import RightSidebar from "$lib/components/sidebar/RightSidebar.svelte"; import RightSidebar from "$lib/components/sidebar/RightSidebar.svelte";
import SkeletonGrid from "$lib/components/timeline/SkeletonGrid.svelte"; import SkeletonGrid from "$lib/components/timeline/SkeletonGrid.svelte";
import Toolbar from "$lib/components/layout/Toolbar.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"; import { type PpPhoto } from "$lib/types/photoprism";
// ── URL ↔ filter store sync ────────────────────────────────────────────── // ── URL ↔ filter store sync ──────────────────────────────────────────────
@@ -831,29 +841,42 @@
{#if photosQuery.isPending} {#if photosQuery.isPending}
<SkeletonGrid /> <SkeletonGrid />
{:else if photosQuery.isError} {:else if photosQuery.isError}
<p class="text-sm text-destructive"> <EmptyState
Failed to load photos: {photosQuery.error instanceof Error tone="destructive"
icon={AlertCircle}
title="Failed to load photos"
description={photosQuery.error instanceof Error
? photosQuery.error.message ? photosQuery.error.message
: "unknown error"} : "unknown error"}
</p> />
{:else if photos.length === 0} {:else if photos.length === 0}
<p class="text-sm text-muted-foreground"> {#if filters.section === "archive"}
{#if filters.section === "archive"} <EmptyState icon={Archive} title="Archive is empty" />
Archive is empty. {:else if filters.section === "review"}
{:else if filters.section === "review"} <EmptyState
Nothing left to review. Photos PhotoPrism's indexer wasn't icon={Sparkles}
sure about land here — use Keep to accept them into the title="Nothing left to review"
timeline or Archive to set them aside. 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"} />
No hidden photos. PhotoPrism auto-hides files it can't index {:else if filters.section === "hidden"}
(broken files, very low quality); they only ever show up here. <EmptyState
{:else if filters.section === "heap"} icon={EyeOff}
This heap has no photos yet. Select some photos and use the title="No hidden photos"
bulk bar's " Add to heap" button. description="PhotoPrism auto-hides files it can't index (broken files, very low quality); they only ever show up here."
{:else} />
No photos. Index a folder via PhotoPrism's reindex command. {:else if filters.section === "heap"}
{/if} <EmptyState
</p> 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} {:else}
<div <div
data-photo-grid data-photo-grid
@@ -920,9 +943,12 @@
}} }}
></div> ></div>
{#if photosQuery.isFetchingNextPage} {#if photosQuery.isFetchingNextPage}
<p class="py-3 text-center text-xs text-muted-foreground"> <InlineLoader
Loading more size="sm"
</p> align="center"
polite={false}
label="Loading more photos…"
/>
{/if} {/if}
{/if} {/if}
</div> </div>
@@ -944,15 +970,16 @@
{:else if focusedPhotoQuery.data} {:else if focusedPhotoQuery.data}
<RightSidebar photo={focusedPhotoQuery.data} /> <RightSidebar photo={focusedPhotoQuery.data} />
{:else if focusedPhotoQuery.isFetching} {:else if focusedPhotoQuery.isFetching}
<p class="px-3 py-2 text-xs text-muted-foreground">Loading</p> <InlineLoader size="sm" label="Loading metadata…" />
{:else} {:else}
<div class="space-y-2 p-4 text-center"> <EmptyState icon={MousePointerClick} title="No photo selected">
<div class="text-xl"></div> {#snippet descriptionSnippet()}
<p class="text-xs text-muted-foreground"> <p>
Use arrow keys or <kbd class="rounded bg-muted px-1"></kbd>+click Use arrow keys or <kbd class="rounded bg-muted px-1"></kbd
on a thumbnail to view its metadata here. >+click on a thumbnail to view its metadata here.
</p> </p>
</div> {/snippet}
</EmptyState>
{/if} {/if}
</div> </div>
<!-- Resize handle on the left edge; mirrors the layout's left aside <!-- 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 BulkMetadataSidebar from '$lib/components/sidebar/BulkMetadataSidebar.svelte';
import CauseGroupCard from '$lib/components/review/CauseGroupCard.svelte'; import CauseGroupCard from '$lib/components/review/CauseGroupCard.svelte';
import DuplicatesView from '$lib/components/duplicates/DuplicatesView.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 DupTab = 'stacks' | 'cross-folder';
type Tab = CauseKey | DupTab; type Tab = CauseKey | DupTab;
@@ -226,23 +228,27 @@
<div class="flex min-w-0 flex-1 flex-col"> <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={{}}> <main class="min-h-0 flex-1 overflow-y-auto px-6 py-4 pb-6" use:gridKeyNav={{}}>
{#if reviewQuery.isPending} {#if reviewQuery.isPending}
<p class="text-sm text-muted-foreground">Loading review queue…</p> <InlineLoader label="Loading review queue…" />
{:else if reviewQuery.error} {:else if reviewQuery.error}
<p class="text-sm text-destructive"> <EmptyState
Could not load review queue: {reviewQuery.error instanceof Error tone="destructive"
icon={AlertCircle}
title="Could not load review queue"
description={reviewQuery.error instanceof Error
? reviewQuery.error.message ? reviewQuery.error.message
: 'unknown error'} : 'unknown error'}
</p> />
{:else if groups.length === 0} {:else if groups.length === 0}
<div class="max-w-prose space-y-2 text-sm text-muted-foreground"> <EmptyState icon={Sparkles} title="Nothing to review">
<p>The review queue is empty.</p> {#snippet descriptionSnippet()}
<p class="text-xs"> <p>
PhotoPrism's indexer flags photos with a low quality score for human PhotoPrism's indexer flags photos with a low quality score for human
review. New arrivals with missing EXIF, low resolution, or unknown review. New arrivals with missing EXIF, low resolution, or unknown
cameras will land here. The Stacks and Cross-folder tabs above stay cameras will land here. The Stacks and Cross-folder tabs above stay
available for duplicate cleanup. available for duplicate cleanup.
</p> </p>
</div> {/snippet}
</EmptyState>
{:else if activeGroup} {:else if activeGroup}
{#key activeGroup.cause} {#key activeGroup.cause}
<CauseGroupCard group={activeGroup} /> <CauseGroupCard group={activeGroup} />
@@ -263,7 +269,7 @@
{:else if focusedPhotoQuery.data} {:else if focusedPhotoQuery.data}
<RightSidebar photo={focusedPhotoQuery.data} showRelated /> <RightSidebar photo={focusedPhotoQuery.data} showRelated />
{:else if focusedPhotoQuery.isFetching} {:else if focusedPhotoQuery.isFetching}
<p class="px-3 py-2 text-xs text-muted-foreground">Loading…</p> <InlineLoader size="sm" label="Loading metadata…" />
{/if} {/if}
</div> </div>
<div <div

View File

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