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}
-
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)}
{:else if photosQuery.isError}
-
- Failed to load photos: {photosQuery.error instanceof Error
+
+ />
{:else if photos.length === 0}
-
- {#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}
-