feat(web): fold duplicates+inbox into /review; sidebar UX cleanup
- /duplicates and /inbox routes removed and folded into /review as additional tabs alongside cause tabs; /duplicates keeps a redirect for bookmarks. - LeftSidebar: drop import/inbox tile and favorites; show per-user BasePath label at the folder root. - RightSidebar: split file header into read-only path over editable basename (matches sidecar rename contract); date field switches to plain-text ISO YYYY-MM-DD (no native datetime picker) with strict validation and revert-on-invalid-blur; preserves original hour. - BulkMetadataSidebar: same ISO-only date input with invalid-state styling and apply-button gating. - BulkActionBar: drop redundant Restore and Undo buttons; ⌘Z still reachable via gridKeyNav. - gridKeyNav: remove favorite toggle (F) alongside the favorites view retirement. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -94,8 +94,6 @@
|
||||
const sectionLabel = $derived(buildSectionLabel());
|
||||
function buildSectionLabel(): string {
|
||||
switch (filters.section) {
|
||||
case "favorites":
|
||||
return "Favorites";
|
||||
case "review":
|
||||
return "Review";
|
||||
case "archive":
|
||||
@@ -184,7 +182,7 @@
|
||||
// Root-folder scope is the only client-side filter we apply, and only
|
||||
// when the timeline is actually showing a folder view — never when the
|
||||
// user is in a heap, has a free-form search, or is on a non-default
|
||||
// section (favorites / archive / review / hidden). Those views are
|
||||
// section (archive / review / hidden). Those views are
|
||||
// scoped server-side via the q-DSL and must not be re-filtered here,
|
||||
// or labels / search will silently drop subfolder photos when the
|
||||
// store hasn't fully hydrated from the URL yet.
|
||||
@@ -867,8 +865,6 @@
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{#if filters.section === "archive"}
|
||||
Archive is empty.
|
||||
{:else if filters.section === "favorites"}
|
||||
No favorites yet. Heart a photo to add it here.
|
||||
{: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
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { createQuery } from '@tanstack/svelte-query';
|
||||
import {
|
||||
listDuplicateGroups,
|
||||
type DuplicateGroup
|
||||
} from '$lib/services/adapters/duplicates';
|
||||
import {
|
||||
scanCrossFolderDuplicates,
|
||||
type CrossFolderScanResult
|
||||
} from '$lib/services/photoprism';
|
||||
import { isAuthenticated } from '$lib/stores/session.svelte';
|
||||
import {
|
||||
setThumbnailSize,
|
||||
THUMBNAIL_SIZE_LABELS,
|
||||
THUMBNAIL_SIZE_PRESETS,
|
||||
view
|
||||
} from '$lib/stores/view.svelte';
|
||||
import Toolbar from '$lib/components/layout/Toolbar.svelte';
|
||||
import DuplicatesView from '$lib/components/duplicates/DuplicatesView.svelte';
|
||||
|
||||
// Same pill-tab pattern as /tags: tab state is URL-driven so the user
|
||||
// can share / refresh / hit Back and land on the right panel.
|
||||
type Tab = 'stacks' | 'cross-folder';
|
||||
const activeTab = $derived<Tab>(parseTab(page.url.searchParams.get('tab')));
|
||||
function parseTab(raw: string | null): Tab {
|
||||
return raw === 'cross-folder' ? 'cross-folder' : 'stacks';
|
||||
}
|
||||
function setTab(tab: Tab) {
|
||||
const params = new URLSearchParams();
|
||||
if (tab !== 'stacks') params.set('tab', tab);
|
||||
void goto(`/duplicates${params.size ? '?' + params : ''}`, {
|
||||
keepFocus: true,
|
||||
noScroll: true
|
||||
});
|
||||
}
|
||||
|
||||
// Stale-time matches mule-image's DuplicatesView (30 s) so quick
|
||||
// toolbar bounces don't refetch the (potentially expensive) stack
|
||||
// listing. Invalidation by mutations is explicit, not time-driven.
|
||||
// Enabled on both tabs so the stacks-count badge stays accurate even
|
||||
// while the cross-folder tab is open.
|
||||
const dupesQuery = createQuery<DuplicateGroup[]>(() => ({
|
||||
queryKey: ['duplicates'],
|
||||
queryFn: listDuplicateGroups,
|
||||
enabled: isAuthenticated(),
|
||||
staleTime: 30_000
|
||||
}));
|
||||
|
||||
// Observe-only: DuplicatesView's matching query (same key) is what
|
||||
// actually triggers the scan when the cross-folder tab is active.
|
||||
// Here we just read the cached count for the tab badge.
|
||||
const crossQuery = createQuery<CrossFolderScanResult>(() => ({
|
||||
queryKey: ['duplicates-cross-folder'],
|
||||
queryFn: scanCrossFolderDuplicates,
|
||||
enabled: false,
|
||||
staleTime: 5 * 60_000
|
||||
}));
|
||||
|
||||
const stacksCount = $derived(dupesQuery.data?.length);
|
||||
const crossCount = $derived(crossQuery.data?.groups.length);
|
||||
const TABS = $derived<{ id: Tab; label: string; count: number | undefined }[]>([
|
||||
{ id: 'stacks', label: 'Stacks', count: stacksCount },
|
||||
{ id: 'cross-folder', label: 'Cross-folder', count: crossCount }
|
||||
]);
|
||||
</script>
|
||||
|
||||
<Toolbar>
|
||||
<span class="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground">
|
||||
Duplicates
|
||||
</span>
|
||||
<!-- Tabs mirror /tags' pill row visually: same height, same active
|
||||
treatment, same hover affordance. Count badge appears once the
|
||||
underlying query has data — cross-folder stays unbadged until
|
||||
the tab has been opened at least once (lazy scan). -->
|
||||
<div class="flex items-center gap-1">
|
||||
{#each TABS as t (t.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 rounded border px-2 py-0.5 text-[11px] {activeTab === t.id
|
||||
? 'border-primary/40 bg-primary/10 text-primary'
|
||||
: 'border-border text-muted-foreground hover:bg-accent hover:text-foreground'}"
|
||||
onclick={() => setTab(t.id)}
|
||||
>
|
||||
<span>{t.label}</span>
|
||||
{#if t.count !== undefined}
|
||||
<span
|
||||
class="flex h-4 min-w-4.5 items-center justify-center rounded px-1 text-[10px] tabular-nums {activeTab ===
|
||||
t.id
|
||||
? 'bg-primary/15 text-primary'
|
||||
: 'bg-secondary text-muted-foreground'}"
|
||||
>
|
||||
{t.count}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{#snippet trailing()}
|
||||
<!-- Thumbnail-size control mirrors the timeline Toolbar. The view
|
||||
store is global, so the picked size persists across routes —
|
||||
when you come back to the timeline it stays where you left it. -->
|
||||
<div
|
||||
class="flex items-center overflow-hidden rounded border border-border"
|
||||
role="group"
|
||||
aria-label="Thumbnail size"
|
||||
>
|
||||
{#each THUMBNAIL_SIZE_PRESETS as size, i (size)}
|
||||
<button
|
||||
type="button"
|
||||
class="px-1.5 py-0.5 text-[10px] font-medium hover:bg-accent"
|
||||
class:bg-accent={view.thumbnailSize === size}
|
||||
class:text-foreground={view.thumbnailSize === size}
|
||||
class:text-muted-foreground={view.thumbnailSize !== size}
|
||||
onclick={() => setThumbnailSize(size)}
|
||||
title={`${THUMBNAIL_SIZE_LABELS[i]} · ${size}px`}
|
||||
>
|
||||
{THUMBNAIL_SIZE_LABELS[i]}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/snippet}
|
||||
</Toolbar>
|
||||
|
||||
<main class="min-h-0 flex-1 overflow-y-auto">
|
||||
<DuplicatesView
|
||||
{activeTab}
|
||||
groups={dupesQuery.data ?? []}
|
||||
pending={dupesQuery.isPending}
|
||||
error={dupesQuery.error}
|
||||
/>
|
||||
</main>
|
||||
11
web/src/routes/duplicates/+page.ts
Normal file
11
web/src/routes/duplicates/+page.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
// /duplicates was folded into /review as additional tabs. Preserve
|
||||
// bookmarks and external links with a server-side redirect to the
|
||||
// equivalent /review URL.
|
||||
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load: PageLoad = ({ url }) => {
|
||||
const tab = url.searchParams.get('tab') === 'cross-folder' ? 'cross-folder' : 'stacks';
|
||||
redirect(307, `/review?tab=${tab}`);
|
||||
};
|
||||
@@ -1,130 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import {
|
||||
cancelImport,
|
||||
getImportInfo,
|
||||
startImport,
|
||||
type ImportInfo
|
||||
} from '$lib/services/photoprism';
|
||||
import { isAuthenticated } from '$lib/stores/session.svelte';
|
||||
import Toolbar from '$lib/components/layout/Toolbar.svelte';
|
||||
|
||||
// PhotoPrism's `/import` root holds uploaded-but-not-yet-indexed files
|
||||
// (separate from /originals which is what the timeline reads). The
|
||||
// folders endpoint returns the staging tree + counts via headers;
|
||||
// kicking off the import is a single POST to /import. Mutations land
|
||||
// in originals after PhotoPrism finishes processing — invalidate the
|
||||
// originals/folders/config caches so the rest of the UI catches up.
|
||||
const importQuery = createQuery<ImportInfo>(() => ({
|
||||
queryKey: ['import'],
|
||||
queryFn: getImportInfo,
|
||||
enabled: isAuthenticated(),
|
||||
// Refetch every 5 s while the page is open so progress is visible
|
||||
// without the user having to refresh. Cheap call — just headers
|
||||
// and a folder list.
|
||||
refetchInterval: 5_000
|
||||
}));
|
||||
|
||||
const qc = useQueryClient();
|
||||
const importMut = createMutation(() => ({
|
||||
// `move: true` is the typical workflow — once a file is indexed
|
||||
// into originals it doesn't need to linger in the staging area.
|
||||
mutationFn: () => startImport({ move: true }),
|
||||
onSuccess: (r) => {
|
||||
toast.success(r.message ?? 'Import started');
|
||||
void qc.invalidateQueries({ queryKey: ['import'] });
|
||||
void qc.invalidateQueries({ queryKey: ['photos'] });
|
||||
void qc.invalidateQueries({ queryKey: ['folders'] });
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof Error ? err.message : 'Import failed')
|
||||
}));
|
||||
|
||||
const cancelMut = createMutation(() => ({
|
||||
mutationFn: cancelImport,
|
||||
onSuccess: () => {
|
||||
toast.message('Import cancelled');
|
||||
void qc.invalidateQueries({ queryKey: ['import'] });
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof Error ? err.message : 'Cancel failed')
|
||||
}));
|
||||
|
||||
const fileCount = $derived(importQuery.data?.files ?? 0);
|
||||
const folderCount = $derived(importQuery.data?.folders ?? 0);
|
||||
const empty = $derived(fileCount === 0 && folderCount === 0);
|
||||
</script>
|
||||
|
||||
<Toolbar>
|
||||
<span class="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground">
|
||||
Inbox
|
||||
</span>
|
||||
<span class="text-[11px] text-muted-foreground">
|
||||
{fileCount} file{fileCount === 1 ? '' : 's'} · {folderCount} folder{folderCount === 1
|
||||
? ''
|
||||
: 's'}
|
||||
</span>
|
||||
{#snippet trailing()}
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-primary/40 bg-primary/10 px-2 py-0.5 text-xs text-primary hover:bg-primary/20 disabled:opacity-50"
|
||||
disabled={empty || importMut.isPending}
|
||||
onclick={() => importMut.mutate()}
|
||||
title="Index files from the inbox into the main library"
|
||||
>
|
||||
{importMut.isPending ? 'Importing…' : 'Start import'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-border px-2 py-0.5 text-xs hover:bg-accent disabled:opacity-50"
|
||||
disabled={!importMut.isPending}
|
||||
onclick={() => cancelMut.mutate()}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
{/snippet}
|
||||
</Toolbar>
|
||||
|
||||
<main class="min-h-0 flex-1 overflow-y-auto p-6">
|
||||
{#if importQuery.isPending}
|
||||
<p class="text-sm text-muted-foreground">Loading inbox…</p>
|
||||
{:else if importQuery.isError}
|
||||
<p class="text-sm text-destructive">
|
||||
Failed to read inbox: {importQuery.error instanceof Error
|
||||
? importQuery.error.message
|
||||
: 'unknown error'}
|
||||
</p>
|
||||
{:else if empty}
|
||||
<div class="space-y-2 text-sm text-muted-foreground">
|
||||
<p>The inbox is empty.</p>
|
||||
<p>
|
||||
Drop files into <code class="rounded bg-muted px-1">/photoprism/import</code> (the
|
||||
bind mount in <code class="rounded bg-muted px-1">docker-compose.photoprism.yml</code>)
|
||||
and they'll show up here. Click <strong>Start import</strong> to move them into the
|
||||
main library; PhotoPrism indexes them, deduplicates against existing originals, and
|
||||
files them under <code class="rounded bg-muted px-1">originals/{'{Y}/{M}'}</code>.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{fileCount} file{fileCount === 1 ? '' : 's'} ready to import across {folderCount} subfolder{folderCount === 1
|
||||
? ''
|
||||
: 's'}.
|
||||
</p>
|
||||
{#if importQuery.data && importQuery.data.subfolders.length > 0}
|
||||
<!-- PhotoPrism doesn't surface a per-folder file count for
|
||||
/import; we just list the staging subfolders so the
|
||||
user has a sense of what's in there. -->
|
||||
<ul class="space-y-1 text-[12px]">
|
||||
{#each importQuery.data.subfolders as f (f.Path)}
|
||||
<li class="flex items-center gap-2 rounded border border-border px-2 py-1">
|
||||
<span class="truncate font-mono">{f.Path || '/'}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
@@ -1,15 +1,18 @@
|
||||
<!--
|
||||
/review — PhotoPrism's quality-flagged photo queue, one tab per
|
||||
cause. Tiles, selection, keyboard nav, and bulk actions all come
|
||||
from the shared timeline machinery (PhotoGrid, gridKeyNav,
|
||||
BulkActionBar, BulkMetadataSidebar) so muscle memory carries
|
||||
across routes.
|
||||
/review — PhotoPrism's quality-flagged photo queue *plus* the
|
||||
duplicate-resolution panels (stacks & cross-folder). Cause tabs and
|
||||
duplicate tabs share the same pill row so the user has a single
|
||||
"things to clean up" surface instead of two routes.
|
||||
|
||||
Cause tabs use the shared timeline machinery (PhotoGrid, gridKeyNav,
|
||||
BulkActionBar, BulkMetadataSidebar); duplicate tabs are a different
|
||||
flow (per-group card with its own action buttons) so they render in
|
||||
a stripped-down layout with no BulkActionBar / right sidebar.
|
||||
|
||||
The route flips `filters.section = 'review'` while it's mounted —
|
||||
that's what swings the shared action surface into review semantics
|
||||
(BulkActionBar shows Dismiss/Archive, gridKeyNav's S maps to
|
||||
approve). The previous section is restored on unmount so going
|
||||
back to `/` lands on whatever the user had before.
|
||||
approve). The previous section is restored on unmount.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
@@ -20,6 +23,14 @@
|
||||
type CauseKey,
|
||||
type ReviewGroup
|
||||
} from '$lib/services/adapters/review';
|
||||
import {
|
||||
listDuplicateGroups,
|
||||
type DuplicateGroup
|
||||
} from '$lib/services/adapters/duplicates';
|
||||
import {
|
||||
scanCrossFolderDuplicates,
|
||||
type CrossFolderScanResult
|
||||
} from '$lib/services/photoprism';
|
||||
import { isAuthenticated } from '$lib/stores/session.svelte';
|
||||
import { clearSelection, selection } from '$lib/stores/selection.svelte';
|
||||
import { filters, setSection, type Section } from '$lib/stores/filters.svelte';
|
||||
@@ -39,6 +50,14 @@
|
||||
import RightSidebar from '$lib/components/sidebar/RightSidebar.svelte';
|
||||
import BulkMetadataSidebar from '$lib/components/sidebar/BulkMetadataSidebar.svelte';
|
||||
import CauseGroupCard from '$lib/components/review/CauseGroupCard.svelte';
|
||||
import DuplicatesView from '$lib/components/duplicates/DuplicatesView.svelte';
|
||||
|
||||
type DupTab = 'stacks' | 'cross-folder';
|
||||
type Tab = CauseKey | DupTab;
|
||||
|
||||
function isDupTab(t: Tab | null): t is DupTab {
|
||||
return t === 'stacks' || t === 'cross-folder';
|
||||
}
|
||||
|
||||
// Stash the section that was active when the user arrived; restore
|
||||
// on unmount so navigating away doesn't leak `section=review` to
|
||||
@@ -59,6 +78,24 @@
|
||||
staleTime: 30_000
|
||||
}));
|
||||
|
||||
// Stacks is a cheap PhotoPrism query so we run it eagerly — the
|
||||
// "Stacks" tab badge needs the count even while the user is on a
|
||||
// cause tab. Cross-folder is the expensive disk scan; the page
|
||||
// observes its cache (enabled:false) and DuplicatesView is what
|
||||
// triggers the actual scan when its tab is active.
|
||||
const stacksQuery = createQuery<DuplicateGroup[]>(() => ({
|
||||
queryKey: ['duplicates'],
|
||||
queryFn: listDuplicateGroups,
|
||||
enabled: isAuthenticated(),
|
||||
staleTime: 30_000
|
||||
}));
|
||||
const crossFolderQuery = createQuery<CrossFolderScanResult>(() => ({
|
||||
queryKey: ['duplicates-cross-folder'],
|
||||
queryFn: scanCrossFolderDuplicates,
|
||||
enabled: false,
|
||||
staleTime: 5 * 60_000
|
||||
}));
|
||||
|
||||
const focusedPhotoQuery = createQuery<PpPhoto | null>(() => ({
|
||||
queryKey: ['photo', selection.focused ?? ''],
|
||||
queryFn: () => (selection.focused ? getPhoto(selection.focused) : Promise.resolve(null)),
|
||||
@@ -66,20 +103,33 @@
|
||||
}));
|
||||
|
||||
const groups = $derived(reviewQuery.data ?? []);
|
||||
const tabs = $derived(
|
||||
groups.map((g) => ({ id: g.cause, label: g.meta.title, count: g.photos.length }))
|
||||
);
|
||||
const stacksCount = $derived(stacksQuery.data?.length);
|
||||
const crossFolderCount = $derived(crossFolderQuery.data?.groups.length);
|
||||
|
||||
type TabSpec = { id: Tab; label: string; count: number | undefined };
|
||||
const tabs = $derived<TabSpec[]>([
|
||||
...groups.map((g) => ({
|
||||
id: g.cause as Tab,
|
||||
label: g.meta.title,
|
||||
count: g.photos.length as number | undefined
|
||||
})),
|
||||
{ id: 'stacks', label: 'Stacks', count: stacksCount },
|
||||
{ id: 'cross-folder', label: 'Cross-folder', count: crossFolderCount }
|
||||
]);
|
||||
|
||||
const requestedTab = $derived(page.url.searchParams.get('tab'));
|
||||
const activeTab: CauseKey | null = $derived.by(() => {
|
||||
if (tabs.length === 0) return null;
|
||||
const activeTab: Tab = $derived.by(() => {
|
||||
const want = tabs.find((t) => t.id === requestedTab);
|
||||
return (want ?? tabs[0]).id;
|
||||
});
|
||||
const activeIsDup = $derived(isDupTab(activeTab));
|
||||
|
||||
// Selection is global — without this effect, a user who multi-
|
||||
// selected in `low_resolution` then switched to `stripped_exif`
|
||||
// would carry the previous tab's UIDs into the new tab's
|
||||
// BulkActionBar verbs and accidentally act on the wrong photos.
|
||||
// Also fires when switching into a duplicates tab (where selection
|
||||
// is meaningless anyway).
|
||||
$effect(() => {
|
||||
void activeTab;
|
||||
clearSelection();
|
||||
@@ -87,9 +137,13 @@
|
||||
|
||||
const activeGroup = $derived(groups.find((g) => g.cause === activeTab));
|
||||
|
||||
function setTab(id: CauseKey) {
|
||||
function setTab(id: Tab) {
|
||||
const params = new URLSearchParams();
|
||||
if (tabs.length > 0 && tabs[0].id !== id) params.set('tab', id);
|
||||
// First cause tab (if any) is the default — same convention as
|
||||
// the old /review behaviour, so back-from-cross-folder lands on
|
||||
// the user's review queue rather than the empty Stacks panel.
|
||||
const defaultId = tabs[0]?.id;
|
||||
if (defaultId !== undefined && id !== defaultId) params.set('tab', id);
|
||||
void goto(`/review${params.size ? '?' + params : ''}`, {
|
||||
keepFocus: true,
|
||||
noScroll: true
|
||||
@@ -106,13 +160,22 @@
|
||||
{#each tabs as t (t.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border px-2 py-0.5 text-[11px] {activeTab === t.id
|
||||
class="inline-flex items-center gap-1 rounded border px-2 py-0.5 text-[11px] {activeTab === t.id
|
||||
? 'border-primary/40 bg-primary/10 text-primary'
|
||||
: 'border-border text-muted-foreground hover:bg-accent hover:text-foreground'}"
|
||||
onclick={() => setTab(t.id)}
|
||||
>
|
||||
{t.label}
|
||||
<span class="ml-1 text-muted-foreground/70">({t.count})</span>
|
||||
<span>{t.label}</span>
|
||||
{#if t.count !== undefined}
|
||||
<span
|
||||
class="flex h-4 min-w-4.5 items-center justify-center rounded px-1 text-[10px] tabular-nums {activeTab ===
|
||||
t.id
|
||||
? 'bg-primary/15 text-primary'
|
||||
: 'bg-secondary text-muted-foreground'}"
|
||||
>
|
||||
{t.count}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -140,60 +203,81 @@
|
||||
{/snippet}
|
||||
</Toolbar>
|
||||
|
||||
<div class="flex min-h-0 flex-1">
|
||||
<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>
|
||||
{:else if reviewQuery.error}
|
||||
<p class="text-sm text-destructive">
|
||||
Could not load review queue: {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.
|
||||
<!--
|
||||
Two layout branches:
|
||||
• Cause tabs use the timeline-style layout (grid key nav, bulk
|
||||
action bar, right metadata sidebar) since the user is acting on
|
||||
individual photos.
|
||||
• Duplicate tabs use a stripped layout — DuplicatesView renders
|
||||
its own per-group cards with built-in actions, so the bulk bar
|
||||
and right sidebar would just clutter.
|
||||
-->
|
||||
{#if activeIsDup}
|
||||
<main class="min-h-0 flex-1 overflow-y-auto">
|
||||
<DuplicatesView
|
||||
activeTab={activeTab as DupTab}
|
||||
groups={stacksQuery.data ?? []}
|
||||
pending={stacksQuery.isPending}
|
||||
error={stacksQuery.error}
|
||||
/>
|
||||
</main>
|
||||
{:else}
|
||||
<div class="flex min-h-0 flex-1">
|
||||
<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>
|
||||
{:else if reviewQuery.error}
|
||||
<p class="text-sm text-destructive">
|
||||
Could not load review queue: {reviewQuery.error instanceof Error
|
||||
? reviewQuery.error.message
|
||||
: 'unknown error'}
|
||||
</p>
|
||||
</div>
|
||||
{:else if activeGroup}
|
||||
{#key activeGroup.cause}
|
||||
<CauseGroupCard group={activeGroup} />
|
||||
{/key}
|
||||
{/if}
|
||||
</main>
|
||||
<BulkActionBar />
|
||||
</div>
|
||||
|
||||
{#if !view.rightSidebarCollapsed && (selection.focused || selection.ids.size >= 2)}
|
||||
<aside
|
||||
class="relative h-full shrink-0 border-l border-border bg-card/30"
|
||||
style="width: {view.rightSidebarWidth}px;"
|
||||
>
|
||||
<div class="h-full overflow-y-auto">
|
||||
{#if selection.ids.size >= 2}
|
||||
<BulkMetadataSidebar ids={Array.from(selection.ids)} />
|
||||
{: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>
|
||||
{: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>
|
||||
{:else if activeGroup}
|
||||
{#key activeGroup.cause}
|
||||
<CauseGroupCard group={activeGroup} />
|
||||
{/key}
|
||||
{/if}
|
||||
</div>
|
||||
<div
|
||||
class="group absolute -left-1.5 top-0 z-20 h-full w-3 cursor-col-resize"
|
||||
use:resizable={{
|
||||
edge: 'left',
|
||||
getWidth: () => view.rightSidebarWidth,
|
||||
setWidth: setRightSidebarWidth
|
||||
}}
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="Resize info panel"
|
||||
></div>
|
||||
</aside>
|
||||
{/if}
|
||||
</div>
|
||||
</main>
|
||||
<BulkActionBar />
|
||||
</div>
|
||||
|
||||
{#if !view.rightSidebarCollapsed && (selection.focused || selection.ids.size >= 2)}
|
||||
<aside
|
||||
class="relative h-full shrink-0 border-l border-border bg-card/30"
|
||||
style="width: {view.rightSidebarWidth}px;"
|
||||
>
|
||||
<div class="h-full overflow-y-auto">
|
||||
{#if selection.ids.size >= 2}
|
||||
<BulkMetadataSidebar ids={Array.from(selection.ids)} />
|
||||
{: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>
|
||||
{/if}
|
||||
</div>
|
||||
<div
|
||||
class="group absolute -left-1.5 top-0 z-20 h-full w-3 cursor-col-resize"
|
||||
use:resizable={{
|
||||
edge: 'left',
|
||||
getWidth: () => view.rightSidebarWidth,
|
||||
setWidth: setRightSidebarWidth
|
||||
}}
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="Resize info panel"
|
||||
></div>
|
||||
</aside>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user