From e364e4128ff93b89ad99bf7e33972f3a9ed31b7e Mon Sep 17 00:00:00 2001 From: dtoro Date: Wed, 20 May 2026 00:36:11 +0200 Subject: [PATCH] web: unify empty + loading states behind EmptyState/InlineLoader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../duplicates/DuplicatesView.svelte | 58 +++++++----- .../lib/components/feedback/EmptyState.svelte | 84 +++++++++++++++++ .../components/feedback/InlineLoader.svelte | 39 ++++++++ web/src/lib/components/feedback/index.ts | 2 + .../layout/HeapConvertDialog.svelte | 14 +-- .../lib/components/layout/LeftSidebar.svelte | 13 +-- .../components/layout/SettingsDialog.svelte | 14 ++- .../lib/components/preview/PreviewPane.svelte | 8 +- .../components/sidebar/RelatedStrip.svelte | 11 ++- .../sidebar/TagsBrowserSidebar.svelte | 36 +++++--- .../components/timeline/BulkActionBar.svelte | 6 +- web/src/routes/+page.svelte | 89 ++++++++++++------- web/src/routes/review/+page.svelte | 34 ++++--- .../tags/[category]/[[value]]/+page.svelte | 34 +++---- 14 files changed, 323 insertions(+), 119 deletions(-) create mode 100644 web/src/lib/components/feedback/EmptyState.svelte create mode 100644 web/src/lib/components/feedback/InlineLoader.svelte create mode 100644 web/src/lib/components/feedback/index.ts diff --git a/web/src/lib/components/duplicates/DuplicatesView.svelte b/web/src/lib/components/duplicates/DuplicatesView.svelte index e6d1c91..800b745 100644 --- a/web/src/lib/components/duplicates/DuplicatesView.svelte +++ b/web/src/lib/components/duplicates/DuplicatesView.svelte @@ -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'}
{#if pending} -

Loading stacks…

+ {:else if error} -

- Could not load stacks: {error instanceof Error ? error.message : 'unknown error'} -

+ {:else if groups.length === 0} -
-

No stacks.

-

- 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. -

-
+ + {#snippet descriptionSnippet()} +

+ 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. +

+ {/snippet} +
{:else}
{#each groups as group, i (group.photo.UID)} @@ -122,22 +128,26 @@ {#if crossQuery.isFetching && !crossQuery.data} -

Hashing files under originals…

+ {:else if crossQuery.isError} -

- Scan failed: {crossQuery.error instanceof Error + + /> {:else if crossCount === 0} -

- No cross-folder duplicates found. - {#if crossQuery.data} - - (scanned in {crossQuery.data.scannedMs} ms) - - {/if} -

+ + {#snippet descriptionSnippet()} + {#if crossQuery.data} +

+ scanned in {crossQuery.data.scannedMs} ms +

+ {/if} + {/snippet} +
{:else}
{#each crossQuery.data?.groups ?? [] as group, i (group.hash)} diff --git a/web/src/lib/components/feedback/EmptyState.svelte b/web/src/lib/components/feedback/EmptyState.svelte new file mode 100644 index 0000000..837729c --- /dev/null +++ b/web/src/lib/components/feedback/EmptyState.svelte @@ -0,0 +1,84 @@ + + + +{#if size === 'compact'} +
+ {#if Icon} +
+{:else} +
+ {#if Icon} +
+{/if} diff --git a/web/src/lib/components/feedback/InlineLoader.svelte b/web/src/lib/components/feedback/InlineLoader.svelte new file mode 100644 index 0000000..f456cef --- /dev/null +++ b/web/src/lib/components/feedback/InlineLoader.svelte @@ -0,0 +1,39 @@ + + + +

+

diff --git a/web/src/lib/components/feedback/index.ts b/web/src/lib/components/feedback/index.ts new file mode 100644 index 0000000..c9f5041 --- /dev/null +++ b/web/src/lib/components/feedback/index.ts @@ -0,0 +1,2 @@ +export { default as EmptyState } from './EmptyState.svelte'; +export { default as InlineLoader } from './InlineLoader.svelte'; diff --git a/web/src/lib/components/layout/HeapConvertDialog.svelte b/web/src/lib/components/layout/HeapConvertDialog.svelte index 971cc0a..44d5921 100644 --- a/web/src/lib/components/layout/HeapConvertDialog.svelte +++ b/web/src/lib/components/layout/HeapConvertDialog.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 @@
{#if foldersQuery.isPending} -

Loading folders…

+ {:else if (foldersQuery.data ?? []).length === 0} -

- No folders. Create one from the sidebar first. -

+ {:else} {null} diff --git a/web/src/lib/components/sidebar/TagsBrowserSidebar.svelte b/web/src/lib/components/sidebar/TagsBrowserSidebar.svelte index 293ffca..5a488ed 100644 --- a/web/src/lib/components/sidebar/TagsBrowserSidebar.svelte +++ b/web/src/lib/components/sidebar/TagsBrowserSidebar.svelte @@ -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} -

Loading labels…

+ {:else if labelsQuery.isError} -

Failed to load labels.

+ {:else if filteredLabels.length === 0} -

- {filterText ? 'No labels match the filter.' : 'No labels yet.'} -

+ {:else}
{#each visibleLabels as label (label.UID ?? label.Slug)} @@ -276,17 +280,21 @@ {/if} {:else if category === 'keywords'} {#if keywordsQuery.isPending} -

- Loading keywords… (aggregates from photo details — first load may take a few seconds) -

+ {:else if keywordsQuery.isError} -

Failed to load keywords.

+ {:else if filteredKeywords.length === 0} -

- {filterText - ? 'No keywords match the filter.' - : 'No user-set keywords yet. Add them from a photo’s right-sidebar metadata panel.'} -

+ {:else}
{#each visibleKeywords as kw (kw.keyword)} diff --git a/web/src/lib/components/timeline/BulkActionBar.svelte b/web/src/lib/components/timeline/BulkActionBar.svelte index 821d1ed..ca34d08 100644 --- a/web/src/lib/components/timeline/BulkActionBar.svelte +++ b/web/src/lib/components/timeline/BulkActionBar.svelte @@ -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} -

Loading…

+ {:else if (heapsQuery.data ?? []).length === 0} -

No heaps yet

+ {:else} {#each heapsQuery.data ?? [] as heap, i (heap.UID)}
@@ -944,15 +970,16 @@ {:else if focusedPhotoQuery.data} {:else if focusedPhotoQuery.isFetching} -

Loading…

+ {:else} -
-
-

- Use arrow keys or +click - on a thumbnail to view its metadata here. -

-
+ + {#snippet descriptionSnippet()} +

+ Use arrow keys or +click on a thumbnail to view its metadata here. +

+ {/snippet} +
{/if}