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:
2026-05-18 21:48:38 +02:00
parent a38c3c6e9b
commit d70244f17e
15 changed files with 294 additions and 703 deletions

View File

@@ -7,9 +7,7 @@ import {
batchArchive,
batchDelete,
batchRestore,
likePhoto,
removeFromHeap,
unlikePhoto,
type PpAlbum
} from '$lib/services/photoprism';
import { queryClient } from '$lib/queryClient';
@@ -55,7 +53,7 @@ export interface GridKeyNavParams {
* - Arrow-key focus navigation (with shift-extend) inside the visible grid
* - Click + shift/ctrl click selection mutations
* - Window-level shortcuts mirroring mule-image's keyboard layer:
* x archive-toggle, u restore, f favorite-toggle, s + (19) add to
* x archive-toggle, u restore, s + (19) add to
* heap N (bare s adds to the currently-viewed heap), b/Tab toggles
* left sidebar, i toggles right sidebar, space/enter opens preview,
* esc clears, ⌘Z undoes, ⌘A selects all visible.
@@ -302,46 +300,6 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
toast.success(`Kept ${ids.length}`);
}
/** Flip the Favorite (heart) flag on cull targets. Reads the first
* target's cached `Favorite` to decide direction so a mixed selection
* resolves to "make them all favorited" when the first isn't, mirroring
* the way `toggleArchive('toggle')` works. */
async function toggleFavoriteOnTargets() {
const ids = cullTargets();
if (ids.length === 0) {
toast.message('Nothing to favorite', {
description: 'Click a photo or select some first'
});
return;
}
const first = cachedPhoto(ids[0]);
const next = !(first?.Favorite ?? false);
const { updated, errors } = await batchEdit(ids, (id) =>
next ? likePhoto(id) : unlikePhoto(id)
);
void queryClient.invalidateQueries({ queryKey: ['photos'] });
for (const id of ids) {
void queryClient.invalidateQueries({ queryKey: ['photo', id] });
}
const verb = next ? 'Favorited' : 'Unfavorited';
if (errors.length) {
// Surface the actual first error message — silent failures here are
// the #1 reason `f` "doesn't work" (e.g. permission, network, 404).
toast.error(`${verb} ${updated.length}; ${errors.length} failed`, {
description: errors[0].message
});
return;
}
toast.success(`${verb} ${ids.length}`);
pushUndo(`${verb} ${ids.length}`, async () => {
await batchEdit(ids, (id) => (next ? unlikePhoto(id) : likePhoto(id)));
void queryClient.invalidateQueries({ queryKey: ['photos'] });
for (const id of ids) {
void queryClient.invalidateQueries({ queryKey: ['photo', id] });
}
});
}
// ── S chord (add-to-heap) ────────────────────────────────────────────
// Press S: arm a short timer. A digit 19 within the window adds the
// cull targets to the Nth heap in the heap list. Any other key cancels
@@ -548,12 +506,6 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
e.preventDefault();
void toggleArchive('restore');
return;
case 'f':
case 'F':
if (meta || shift) return;
e.preventDefault();
void toggleFavoriteOnTargets();
return;
case 's':
case 'S':
if (meta || shift) return;

View File

@@ -63,7 +63,6 @@
'default',
'browse',
'albums',
'favorites',
'calendar',
'moments',
'people',

View File

@@ -13,7 +13,6 @@
duplicateHeap,
getAllMarks,
getConfig,
getImportInfo,
heapDownloadUrl,
listFolderCounts,
listFolders,
@@ -24,7 +23,6 @@
scanCrossFolderDuplicates,
triggerDownload,
type CrossFolderScanResult,
type ImportInfo,
type PhotoMarksMap,
type PpAlbum,
type PpClientConfig,
@@ -73,27 +71,17 @@
enabled: isAuthenticated()
}));
// Import staging area (PhotoPrism's `/import` root). Polled at a leisurely
// 60s — the inbox only changes when files are uploaded or the indexer
// runs, neither of which happens often enough to justify a tighter cadence.
const importQuery = createQuery<ImportInfo>(() => ({
queryKey: ['import'],
queryFn: getImportInfo,
enabled: isAuthenticated(),
staleTime: 60_000
}));
// View counts come from PhotoPrism's `/config` response, which carries a
// precomputed counter for every common bucket (all/favorites/archived/
// labels/places/…) updated incrementally on every mutation. Cheap to
// refetch, and gives us a stable total — `/photos` only returns
// per-page row counts via `X-Count`, never a total.
// precomputed counter for every common bucket (all/archived/labels/
// places/…) updated incrementally on every mutation. Cheap to refetch,
// and gives us a stable total — `/photos` only returns per-page row
// counts via `X-Count`, never a total.
//
// The key sits under the `['photos', …]` prefix so it inherits the
// existing `invalidateQueries({ queryKey: ['photos'] })` calls scattered
// across mutations (favorite, archive, restore, delete, heap add) — the
// counter map refreshes whenever the photo list does. Marks-derived
// counts (ratings/colors) react through the shared `['marks']` cache.
// across mutations (archive, restore, delete, heap add) — the counter
// map refreshes whenever the photo list does. Marks-derived counts
// (ratings/colors) react through the shared `['marks']` cache.
const configQuery = createQuery<PpClientConfig>(() => ({
queryKey: ['photos', 'config'],
queryFn: getConfig,
@@ -267,6 +255,13 @@
const rootActive = $derived(filters.folderPath === '/');
const hasSubfolders = $derived((foldersQuery.data ?? []).length > 0);
// Root-folder label. Every account (admins included) gets a
// BasePath named after them on disk, so surface that identity
// here instead of an opaque "/".
const rootLabel = $derived(
session.user?.DisplayName?.trim() || session.user?.Name || '/'
);
async function onSignOut() {
await logout();
await goto('/login', { replaceState: true });
@@ -390,9 +385,9 @@
// "everything visible in the main timeline" tally), so it matches what
// the All photos view actually renders. `places` is the count of
// geocoded locations — semantically what the Map view groups by.
// Duplicates sums stacks + cross-folder groups; cross-folder only
// contributes once its tab has been visited (the scan is opt-in
// per-visit, not eager from the sidebar).
// Review rolls in the duplicates tabs hosted under /review — stacks
// always contributes; cross-folder only contributes once its tab has
// been opened (the scan is lazy, not eager from the sidebar).
type ViewItem =
| { kind: 'section'; id: Section; label: string; getCount: () => number | undefined }
| { kind: 'route'; href: string; label: string; getCount: () => number | undefined };
@@ -402,8 +397,6 @@
// separate "everything regardless of folder" destination would just
// duplicate it for users whose photos live under the root.
const views: ViewItem[] = [
{ kind: 'route', href: '/inbox', label: 'Inbox', getCount: () => importQuery.data?.files },
{ kind: 'section', id: 'favorites', label: 'Favorites', getCount: () => configQuery.data?.count?.favorites },
{ kind: 'route', href: '/map', label: 'Map', getCount: () => configQuery.data?.count?.places },
// Tags hosts four tabs (Labels (auto) / Keywords / Ratings / Colors);
// the badge shows total labels + keywords + ratings + colors so the
@@ -421,15 +414,14 @@
];
const manageViews: ViewItem[] = [
{ kind: 'route', href: '/review', label: 'Review', getCount: () => configQuery.data?.count?.review },
{
kind: 'route',
href: '/duplicates',
label: 'Duplicates',
href: '/review',
label: 'Review',
getCount: () => {
const stacks = stacksQuery.data?.length;
if (stacks === undefined) return undefined;
return stacks + (crossFolderQuery.data?.groups.length ?? 0);
const review = configQuery.data?.count?.review;
if (review === undefined) return undefined;
return review + (stacksQuery.data?.length ?? 0) + (crossFolderQuery.data?.groups.length ?? 0);
}
},
{ kind: 'section', id: 'hidden', label: 'Hidden', getCount: () => configQuery.data?.count?.hidden },
@@ -560,9 +552,9 @@
class="flex min-w-0 flex-1 items-center text-left"
class:px-1={hasSubfolders}
onclick={() => pickFolder('/')}
title="Photos directly under originals/"
title={userBasePath() === '' ? 'Your library' : `Your library (${userBasePath()})`}
>
<span class="truncate">/</span>
<span class="truncate">{rootLabel}</span>
{#if configQuery.data}
<span
class="ml-auto shrink-0 rounded px-1 text-[10px] tabular-nums {rootActive

View File

@@ -12,6 +12,7 @@
import {
buildTakenAtPatch,
bulkSetMarks,
isValidISODate,
type PhotoMark,
type PhotoMarksMap,
type UpdatePhotoBody
@@ -56,12 +57,12 @@
noteDraft = '';
}
const dateDraftValid = $derived(dateDraft === '' || isValidISODate(dateDraft));
async function applyDate() {
if (busy || !dateDraft) return;
// datetime-local omits the timezone; treat the input as UTC (same
// convention as the single-photo sidebar) and let PhotoPrism's
// backwrite stamp the local timezone field downstream.
const iso = `${dateDraft}:00Z`;
if (busy || !dateDraft || !isValidISODate(dateDraft)) return;
// Date-only input — stamp midnight UTC and let PhotoPrism's backwrite
// fill the local timezone field downstream.
const iso = `${dateDraft}T00:00:00Z`;
await withBusy(() =>
patchTargets(
ids,
@@ -200,14 +201,18 @@
<Calendar class="h-3 w-3" /> Date taken
</div>
<input
type="datetime-local"
class="w-full rounded border border-input bg-background px-1.5 py-1 text-xs shadow-sm focus:outline-none focus:ring-1 focus:ring-ring"
type="text"
inputmode="numeric"
placeholder="YYYY-MM-DD"
pattern="\d{4}-\d{2}-\d{2}"
aria-invalid={!dateDraftValid}
class="w-full rounded border border-input bg-background px-1.5 py-1 text-xs shadow-sm focus:outline-none focus:ring-1 focus:ring-ring aria-invalid:border-destructive aria-invalid:text-destructive aria-invalid:focus:ring-destructive"
bind:value={dateDraft}
disabled={busy}
/>
<button
class="w-full rounded-md border border-border px-2 py-1 text-xs hover:bg-accent disabled:opacity-50"
disabled={busy || !dateDraft}
disabled={busy || !dateDraft || !dateDraftValid}
onclick={applyDate}
>
Apply date to {ids.length}

View File

@@ -13,10 +13,8 @@
Calendar,
Camera,
ExternalLink,
Heart,
ImageIcon,
Loader2,
Lock,
MapPin,
Star,
Tag,
@@ -26,10 +24,9 @@
import {
buildTakenAtPatch,
getAllMarks,
likePhoto,
isValidISODate,
renameOnDisk,
setMark,
unlikePhoto,
updatePhoto,
type PhotoMark,
type PhotoMarksMap,
@@ -52,7 +49,7 @@
const qc = useQueryClient();
let filename = $state('');
let basename = $state('');
let caption = $state('');
let takenAt = $state('');
let lat = $state('');
@@ -60,18 +57,22 @@
let country = $state('');
let keywords = $state<string[]>([]);
let keywordDraft = $state('');
let subject = $state('');
let artist = $state('');
let copyright = $state('');
let license = $state('');
let notes = $state('');
let renaming = $state(false);
/** Split `pf.Name` (a relative path like `foo/bar/IMG.jpg`) into directory
* prefix and basename. Sidecar's rename endpoint only accepts a bare
* basename — it preserves the directory on disk — so we mirror that
* contract in the UI by exposing just the basename for edit. */
function splitName(full: string): { dir: string; base: string } {
const i = full.lastIndexOf('/');
return i < 0 ? { dir: '', base: full } : { dir: full.slice(0, i), base: full.slice(i + 1) };
}
$effect(() => {
const pf = primaryFile(photo);
filename = pf.Name ?? '';
basename = splitName(pf.Name ?? '').base;
caption = photo.Caption ?? '';
takenAt = (photo.TakenAt ?? '').slice(0, 16);
takenAt = (photo.TakenAt ?? '').slice(0, 10);
lat = photo.Lat ? String(photo.Lat) : '';
lng = photo.Lng ? String(photo.Lng) : '';
country = photo.Country && photo.Country !== 'zz' ? photo.Country : '';
@@ -80,11 +81,6 @@
.split(',')
.map((k) => k.trim())
.filter(Boolean);
subject = det.Subject ?? '';
artist = det.Artist ?? '';
copyright = det.Copyright ?? '';
license = det.License ?? '';
notes = det.Notes ?? '';
});
const patchMutation = createMutation(() => ({
@@ -100,32 +96,15 @@
toast.error(err instanceof Error ? err.message : 'Save failed')
}));
const favoriteMutation = createMutation(() => ({
mutationFn: async (next: boolean) => {
if (next) await likePhoto(photo.UID);
else await unlikePhoto(photo.UID);
return next;
},
onSuccess: (next) => {
void qc.invalidateQueries({ queryKey: ['photo', photo.UID] });
void qc.invalidateQueries({ queryKey: ['photos'] });
pushUndo(next ? 'Favorited' : 'Unfavorited', async () => {
if (next) await unlikePhoto(photo.UID);
else await likePhoto(photo.UID);
void qc.invalidateQueries({ queryKey: ['photo', photo.UID] });
void qc.invalidateQueries({ queryKey: ['photos'] });
});
}
}));
function commit(patch: UpdatePhotoBody) {
patchMutation.mutate(patch);
}
async function commitFilename() {
const pf = primaryFile(photo);
const next = filename.trim();
if (!next || next === pf.Name) return;
const { base: currentBase } = splitName(pf.Name ?? '');
const next = basename.trim();
if (!next || next === currentBase) return;
renaming = true;
try {
const result = await renameOnDisk(photo.UID, next);
@@ -139,7 +118,7 @@
});
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Rename failed');
filename = pf.Name ?? '';
basename = currentBase;
} finally {
renaming = false;
}
@@ -149,9 +128,19 @@
if (caption === (photo.Caption ?? '')) return;
commit({ Caption: caption, CaptionSrc: 'manual' });
}
const takenAtValid = $derived(takenAt === '' || isValidISODate(takenAt));
function commitTakenAt() {
if (!takenAt) return;
const iso = `${takenAt}:00Z`;
if (!isValidISODate(takenAt)) {
// Revert to the photo's stored date so the field doesn't sit in
// a broken state once focus leaves it.
takenAt = (photo.TakenAt ?? '').slice(0, 10);
return;
}
// Keep the original time-of-day so date-only edits don't clobber the
// hour. Fall back to midnight UTC when the photo has no prior TakenAt.
const tail = (photo.TakenAt ?? '').slice(10) || 'T00:00:00Z';
const iso = `${takenAt}${tail}`;
if (iso === photo.TakenAt) return;
commit(buildTakenAtPatch(iso));
}
@@ -170,7 +159,7 @@
commit({ Country: next || 'zz', CountrySrc: 'manual' });
}
type DetailsKey = 'Keywords' | 'Subject' | 'Artist' | 'Copyright' | 'License' | 'Notes';
type DetailsKey = 'Keywords';
function commitDetails(field: DetailsKey, value: string) {
const prev = (photo.Details ?? {})[field] ?? '';
if (value === prev) return;
@@ -189,14 +178,6 @@
commitDetails('Keywords', keywords.join(', '));
}
function togglePrivate() {
const prev = photo.Private ?? false;
commit({ Private: !prev });
pushUndo(prev ? 'Made public' : 'Made private', () => {
commit({ Private: prev });
});
}
// Marks (rating + color) live on the mule-sidecar — PhotoPrism's PUT
// silently drops these fields. One query holds the whole map; mutations
// patch the cache optimistically and PUT to the sidecar.
@@ -265,6 +246,7 @@
const currentColor = $derived(photoMark.color ?? '');
const pf = $derived(primaryFile(photo));
const dirPath = $derived(splitName(pf.Name ?? '').dir);
const dims = $derived(pf.Width && pf.Height ? `${pf.Width}×${pf.Height}` : '—');
const sizeStr = $derived(
pf.Size
@@ -307,47 +289,44 @@
</script>
<aside class="space-y-2.5 bg-card p-2.5 text-xs">
<!-- Header strip — thumb + filename + favorite + private -->
<div class="flex items-center gap-2">
<!-- Header strip — thumb + path (read-only) over editable basename. The
sidecar's rename endpoint only accepts a bare basename and preserves
the directory on disk, so the split UI mirrors that contract. -->
<div class="flex items-start gap-2">
<img
src={thumbUrl(pf.Hash, 'tile_100')}
alt=""
class="h-10 w-10 shrink-0 rounded object-cover"
/>
<input
type="text"
class="min-w-0 flex-1 rounded border border-transparent bg-transparent px-1 py-0.5 text-xs font-medium hover:border-input focus:border-input focus:outline-none focus:ring-1 focus:ring-ring disabled:opacity-50"
bind:value={filename}
disabled={renaming}
onblur={commitFilename}
onkeydown={(e) => e.key === 'Enter' && (e.currentTarget as HTMLInputElement).blur()}
title={renaming ? 'Renaming…' : 'Click to rename file on disk'}
/>
<!-- Inline spinner next to the filename so the user sees the rename
in flight without having to scan to the bottom of the sidebar. -->
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
{#if dirPath}
<div
class="truncate text-[10px] text-muted-foreground"
title={dirPath}
>
{dirPath}/
</div>
{/if}
<input
type="text"
class="min-w-0 rounded border border-transparent bg-transparent px-1 py-0.5 text-xs font-medium leading-snug break-all hover:border-input focus:border-input focus:outline-none focus:ring-1 focus:ring-ring disabled:opacity-50"
bind:value={basename}
disabled={renaming}
onblur={commitFilename}
onkeydown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
(e.currentTarget as HTMLInputElement).blur();
}
}}
title={renaming ? 'Renaming…' : 'Click to rename file on disk'}
/>
</div>
<!-- Inline spinner so the user sees the rename in flight without
scanning to the bottom of the sidebar. -->
{#if renaming}
<Loader2 class="h-3 w-3 shrink-0 animate-spin text-muted-foreground" />
<Loader2 class="mt-1 h-3 w-3 shrink-0 animate-spin text-muted-foreground" />
{/if}
<button
class="rounded p-1 hover:bg-accent disabled:opacity-50"
class:text-red-500={photo.Favorite}
class:text-muted-foreground={!photo.Favorite}
disabled={favoriteMutation.isPending}
onclick={() => favoriteMutation.mutate(!photo.Favorite)}
title={photo.Favorite ? 'Remove favorite (F)' : 'Add favorite (F)'}
>
<Heart class="h-3.5 w-3.5" fill={photo.Favorite ? 'currentColor' : 'none'} />
</button>
<button
class="rounded p-1 hover:bg-accent disabled:opacity-50"
class:text-foreground={photo.Private}
class:text-muted-foreground={!photo.Private}
disabled={patchMutation.isPending}
onclick={togglePrivate}
title={photo.Private ? 'Private' : 'Public'}
>
<Lock class="h-3.5 w-3.5" />
</button>
</div>
<!-- Compact info rows -->
@@ -356,8 +335,12 @@
<div class="flex items-center gap-2">
<Calendar class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<input
type="datetime-local"
class="min-w-0 flex-1 rounded border border-transparent bg-transparent px-1 py-0.5 text-xs hover:border-input focus:border-input focus:outline-none focus:ring-1 focus:ring-ring"
type="text"
inputmode="numeric"
placeholder="YYYY-MM-DD"
pattern="\d{4}-\d{2}-\d{2}"
aria-invalid={!takenAtValid}
class="min-w-0 flex-1 rounded border border-transparent bg-transparent px-1 py-0.5 text-xs hover:border-input focus:border-input focus:outline-none focus:ring-1 focus:ring-ring aria-invalid:border-destructive aria-invalid:text-destructive aria-invalid:focus:ring-destructive"
bind:value={takenAt}
onblur={commitTakenAt}
/>
@@ -606,66 +589,6 @@
</div>
</details>
<!-- IPTC credits. Static default (closed); user's choice persists. -->
<details
class="rounded border border-border"
open={getMetadataSectionOpen('credits', false)}
ontoggle={(e) => setMetadataSection('credits', e.currentTarget.open)}
>
<summary
class="cursor-pointer px-2 py-1 text-[10px] uppercase tracking-wide text-muted-foreground"
>
Credits & notes
</summary>
<div class="space-y-1 p-2 pt-1">
<label class="flex items-center gap-1">
<span class="w-16 text-[10px] text-muted-foreground">Subject</span>
<input
type="text"
class="min-w-0 flex-1 rounded border border-input bg-background px-1 py-0.5 text-[10px] focus:outline-none focus:ring-1 focus:ring-ring"
bind:value={subject}
onblur={() => commitDetails('Subject', subject)}
/>
</label>
<label class="flex items-center gap-1">
<span class="w-16 text-[10px] text-muted-foreground">Artist</span>
<input
type="text"
class="min-w-0 flex-1 rounded border border-input bg-background px-1 py-0.5 text-[10px] focus:outline-none focus:ring-1 focus:ring-ring"
bind:value={artist}
onblur={() => commitDetails('Artist', artist)}
/>
</label>
<label class="flex items-center gap-1">
<span class="w-16 text-[10px] text-muted-foreground">Copyright</span>
<input
type="text"
class="min-w-0 flex-1 rounded border border-input bg-background px-1 py-0.5 text-[10px] focus:outline-none focus:ring-1 focus:ring-ring"
bind:value={copyright}
onblur={() => commitDetails('Copyright', copyright)}
/>
</label>
<label class="flex items-center gap-1">
<span class="w-16 text-[10px] text-muted-foreground">License</span>
<input
type="text"
class="min-w-0 flex-1 rounded border border-input bg-background px-1 py-0.5 text-[10px] focus:outline-none focus:ring-1 focus:ring-ring"
bind:value={license}
onblur={() => commitDetails('License', license)}
/>
</label>
<label class="flex items-start gap-1">
<span class="w-16 pt-0.5 text-[10px] text-muted-foreground">Private notes</span>
<textarea
rows="2"
class="min-w-0 flex-1 resize-y rounded border border-input bg-background px-1 py-0.5 text-[10px] focus:outline-none focus:ring-1 focus:ring-ring"
bind:value={notes}
onblur={() => commitDetails('Notes', notes)}
></textarea>
</label>
</div>
</details>
<!-- File metadata. Closed by default; persists once opened. -->
<details
class="rounded border border-border"

View File

@@ -7,10 +7,8 @@
batchArchive,
batchDelete,
batchRestore,
likePhoto,
listHeaps,
removeFromHeap,
unlikePhoto,
type PpAlbum
} from '$lib/services/photoprism';
import { batchEdit } from '$lib/services/batch';
@@ -21,7 +19,7 @@
setFocused
} from '$lib/stores/selection.svelte';
import { filters } from '$lib/stores/filters.svelte';
import { popAndRun, push as pushUndo, undoStack } from '$lib/stores/undo.svelte';
import { push as pushUndo } from '$lib/stores/undo.svelte';
import { isAuthenticated } from '$lib/stores/session.svelte';
const qc = useQueryClient();
@@ -51,8 +49,8 @@
const isBulk = $derived(selection.ids.size > 0);
// Review section uses a two-button decision flow (Keep / Archive) —
// every other action is hidden so the choice can't be confused with
// favoriting / heap-adding / restoring. The S keybinding is rerouted
// to approve from gridKeyNav for the same reason.
// heap-adding / restoring. The S keybinding is rerouted to approve
// from gridKeyNav for the same reason.
const isReview = $derived(filters.section === 'review');
// Archive section is the parallel two-button flow: Keep (restore back
// to the timeline) or Delete (permanent, no undo). X is repurposed
@@ -152,30 +150,6 @@
});
}
async function onFavorite() {
const ids = snapshotIds();
if (ids.length === 0) return;
await withBusy(async () => {
const { updated, errors } = await batchEdit(ids, (id) => likePhoto(id));
if (errors.length) {
toast.error(`Favorited ${updated.length}; ${errors.length} failed`);
} else {
toast.success(`Favorited ${ids.length}`);
}
pushUndo(`Favorited ${ids.length}`, async () => {
await batchEdit(ids, (id) => unlikePhoto(id));
void qc.invalidateQueries({ queryKey: ['photos'] });
});
clearSelection();
});
}
async function onUndo() {
const entry = await popAndRun();
if (entry) toast.success(`Undone: ${entry.label}`);
else toast.message('Nothing to undo');
}
async function onAddToHeap(heap: PpAlbum) {
const ids = snapshotIds();
if (!ids.length) return;
@@ -244,8 +218,8 @@
{#if isReview}
<!-- Review pile = binary decision. Keep approves (Quality →
3+, lands in the main timeline); Archive batches into
the archive section. Everything else (heap, favorite,
restore) is hidden so the choice reads as decisive. -->
the archive section. Everything else (heap, restore)
is hidden so the choice reads as decisive. -->
<button
class="inline-flex items-center gap-1 rounded border border-primary/40 bg-primary/10 px-2 py-0.5 text-[11px] text-primary hover:bg-primary/20 disabled:opacity-50"
disabled={busy}
@@ -335,15 +309,6 @@
</div>
{/if}
</div>
<button
class="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[11px] hover:bg-accent disabled:opacity-50"
disabled={busy}
onclick={onFavorite}
title="Favorite"
>
♥ Favorite
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">F</kbd>
</button>
<button
class="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[11px] hover:bg-accent disabled:opacity-50"
disabled={busy}
@@ -353,25 +318,7 @@
Archive
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">X</kbd>
</button>
<button
class="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[11px] hover:bg-accent disabled:opacity-50"
disabled={busy}
onclick={onRestore}
title="Restore"
>
Restore
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">U</kbd>
</button>
{/if}
<button
class="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[11px] hover:bg-accent disabled:opacity-50"
disabled={busy || undoStack.entries.length === 0}
onclick={onUndo}
title="Undo last action"
>
Undo
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">⌘Z</kbd>
</button>
<button
class="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[11px] hover:bg-accent"
onclick={clearAll}

View File

@@ -2,7 +2,7 @@
Single photo tile — shared by the timeline (+page.svelte) and the flat
drill-in grids (PhotoGrid.svelte) so the tile chrome is single-sourced
and can't drift between the two. Owns: thumbnail, selection animation,
favorite/video badges, and the hover-only "open preview" affordance.
video badge, and the hover-only "open preview" affordance.
Sizing is delegated to the caller — PhotoTile fills its container with
h-full w-full, so the timeline can wrap it in its windowing shell and
@@ -144,12 +144,6 @@
{#if selected}
<div class="pointer-events-none absolute inset-0 bg-blue-500/40"></div>
{/if}
{#if photo.Favorite}
<span
class="absolute right-1.5 top-1.5 rounded bg-background/80 px-1 text-xs text-red-500"
></span
>
{/if}
{#if isVideo(photo)}
<span
class="absolute left-1.5 top-1.5 rounded bg-background/80 px-1 text-[10px] font-medium text-foreground"

View File

@@ -175,8 +175,6 @@ export interface UpdatePhotoBody {
OriginalName?: string;
Caption?: string;
CaptionSrc?: 'manual' | '';
Favorite?: boolean;
Private?: boolean;
Archived?: boolean;
/** TakenAt + TakenAtLocal + Year/Month/Day must move in lockstep —
* use `buildTakenAtPatch` to assemble all five fields from one ISO. */
@@ -195,6 +193,16 @@ export interface UpdatePhotoBody {
Details?: Partial<import('$lib/types/photoprism').PpDetails>;
}
/** True when `s` is a real calendar date in strict `YYYY-MM-DD` form. Rejects
* shape mismatches AND out-of-range parts that `Date` would silently roll
* over (e.g. `2026-02-30` → Mar 2). */
export function isValidISODate(s: string): boolean {
if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return false;
const d = new Date(`${s}T00:00:00Z`);
if (Number.isNaN(d.getTime())) return false;
return d.toISOString().slice(0, 10) === s;
}
export function buildTakenAtPatch(iso: string): UpdatePhotoBody {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return {};
@@ -264,18 +272,6 @@ export async function approvePhoto(uid: string): Promise<void> {
await http.post(`/photos/${uid}/approve`);
}
/**
* Toggle the heart/favorite flag. PhotoPrism has dedicated like/unlike
* routes that are atomic; preferred over PUT for this one field.
*/
export async function likePhoto(uid: string): Promise<void> {
await http.post(`/photos/${uid}/like`);
}
export async function unlikePhoto(uid: string): Promise<void> {
await http.delete(`/photos/${uid}/like`);
}
// ── Stack file operations ───────────────────────────────────────────────────
// PhotoPrism's stacks pack multiple file variants (RAW + JPG + Live + …) into
// a single Photo entity. The duplicate-resolution flow needs two ops, both
@@ -324,8 +320,6 @@ export interface PpFolder {
Root: string;
Title: string;
FileCount?: number;
Favorite?: boolean;
Private?: boolean;
}
/**
@@ -354,39 +348,6 @@ export async function listFolders(): Promise<PpFolder[]> {
.filter((f) => f.Path !== '');
}
/**
* Inbox / import staging area. PhotoPrism keeps uploaded-but-not-yet-indexed
* files in a separate `/photoprism/import` root, exposed via
* `/folders/import`. The endpoint returns the same `PpFolder[]` shape as
* `/folders/originals`, but the photo counts come from `X-Files` and
* `X-Folders` response headers since the body only lists subfolders.
*
* BasePath does NOT apply: `/folders/import` is a separate root from
* originals (PhotoPrism's `import.path`, not under originals/), so we
* don't filter the result by the signed-in user's BasePath. If/when
* per-user inbox isolation is needed, that's a PhotoPrism-side feature.
*/
export interface ImportInfo {
files: number;
folders: number;
subfolders: PpFolder[];
}
export async function getImportInfo(): Promise<ImportInfo> {
const res = await http.get<{ folders?: PpFolder[] }>('/folders/import', {
params: { recursive: true, uncached: true, files: false }
});
const num = (h: unknown) => {
const n = typeof h === 'string' ? parseInt(h, 10) : NaN;
return Number.isFinite(n) ? n : 0;
};
return {
files: num(res.headers['x-files'] ?? res.headers['X-Files']),
folders: num(res.headers['x-folders'] ?? res.headers['X-Folders']),
subfolders: res.data.folders ?? []
};
}
/**
* Per-folder photo count for each `paths[]` entry. PhotoPrism's `/folders`
* endpoint reports `FileCount: 0` even when populated, so the count has
@@ -466,7 +427,6 @@ export interface PpLabel {
Slug: string;
CustomSlug?: string;
Name: string;
Favorite?: boolean;
Priority?: number;
Description?: string;
PhotoCount?: number;
@@ -551,7 +511,6 @@ export interface PpAlbum {
Type: string;
Title: string;
Description?: string;
Favorite?: boolean;
PhotoCount?: number;
CreatedAt?: string;
UpdatedAt?: string;

View File

@@ -4,14 +4,13 @@
* string PhotoPrism's `q` parameter accepts.
*
* Sections behave like saved searches: picking a section sets a default
* filter shape (favorites → `favorite:true`, archive → `archived:true`,
* etc.), and the search box on top stacks an additional `q` term.
* filter shape (archive → `archived:true`, etc.), and the search box on
* top stacks an additional `q` term.
*/
import { toOriginalsPath, userBasePath } from '$lib/stores/session.svelte';
export type Section =
| 'all-photos'
| 'favorites'
| 'review'
| 'archive'
| 'hidden'
@@ -30,7 +29,7 @@ export interface FilterState {
// Default landing = root folder (`/`). The Folders group sits at the top
// of the sidebar; landing inside it gives users a stable starting point
// instead of dumping them into the full library. Picking any other view
// (favorites, a heap, "All photos") clears `folderPath` to `null`.
// (a heap, "All photos") clears `folderPath` to `null`.
export const filters = $state<FilterState>({
section: 'all-photos',
heapUid: null,
@@ -69,9 +68,6 @@ function quoteIfNeeded(v: string): string {
export function filtersToQ(f: FilterState = filters): string {
const parts: string[] = [];
switch (f.section) {
case 'favorites':
parts.push('favorite:true');
break;
case 'review':
// PhotoPrism's review pile: photos the indexer flagged as
// uncertain (low quality score). Cleared per-photo via the
@@ -127,7 +123,7 @@ export function filtersToQ(f: FilterState = filters): string {
export function parseUrlParams(params: URLSearchParams): Partial<FilterState> {
const sectionRaw = params.get('section') as Section | null;
const section: Section =
sectionRaw && ['all-photos', 'favorites', 'review', 'archive', 'hidden', 'heap'].includes(sectionRaw)
sectionRaw && ['all-photos', 'review', 'archive', 'hidden', 'heap'].includes(sectionRaw)
? sectionRaw
: 'all-photos';
// Bare URL (no section/folder/heap/q params) lands on the root folder

View File

@@ -55,9 +55,7 @@ export interface PpClientConfig {
documents?: number;
archived?: number;
hidden?: number;
favorites?: number;
review?: number;
private?: number;
albums?: number;
moments?: number;
months?: number;
@@ -131,8 +129,6 @@ export interface PpPhoto {
Height?: number;
Rating?: number;
Color?: string | number;
Favorite?: boolean;
Private?: boolean;
Archived?: boolean;
Files?: PpFile[];
Lat?: number;
@@ -213,7 +209,7 @@ export interface PpPhotoLabel {
Source?: string;
Priority?: number;
Uncertainty?: number;
Label?: { Slug: string; Name: string; Favorite?: boolean };
Label?: { Slug: string; Name: string };
}
/**

View File

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

View File

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

View 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}`);
};

View File

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

View File

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