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 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)}
|
||||||
|
|||||||
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 { 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
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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]"
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 photo’s right-sidebar metadata panel.'}
|
title={filterText ? 'No keywords match the filter' : 'No user-set keywords yet'}
|
||||||
</p>
|
description={filterText
|
||||||
|
? undefined
|
||||||
|
: 'Add them from a photo’s 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)}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 bar’s “+ 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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user