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:
@@ -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