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:
@@ -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)}
|
||||
|
||||
84
web/src/lib/components/feedback/EmptyState.svelte
Normal file
84
web/src/lib/components/feedback/EmptyState.svelte
Normal 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}
|
||||
39
web/src/lib/components/feedback/InlineLoader.svelte
Normal file
39
web/src/lib/components/feedback/InlineLoader.svelte
Normal 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>
|
||||
2
web/src/lib/components/feedback/index.ts
Normal file
2
web/src/lib/components/feedback/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as EmptyState } from './EmptyState.svelte';
|
||||
export { default as InlineLoader } from './InlineLoader.svelte';
|
||||
@@ -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
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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]"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 photo’s 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 photo’s right-sidebar metadata panel.'}
|
||||
/>
|
||||
{:else}
|
||||
<div bind:this={scrollEl} class="min-h-0 flex-1 overflow-y-auto">
|
||||
{#each visibleKeywords as kw (kw.keyword)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 bar’s “+ 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user