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, batchArchive,
batchDelete, batchDelete,
batchRestore, batchRestore,
likePhoto,
removeFromHeap, removeFromHeap,
unlikePhoto,
type PpAlbum type PpAlbum
} from '$lib/services/photoprism'; } from '$lib/services/photoprism';
import { queryClient } from '$lib/queryClient'; import { queryClient } from '$lib/queryClient';
@@ -55,7 +53,7 @@ export interface GridKeyNavParams {
* - Arrow-key focus navigation (with shift-extend) inside the visible grid * - Arrow-key focus navigation (with shift-extend) inside the visible grid
* - Click + shift/ctrl click selection mutations * - Click + shift/ctrl click selection mutations
* - Window-level shortcuts mirroring mule-image's keyboard layer: * - 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 * heap N (bare s adds to the currently-viewed heap), b/Tab toggles
* left sidebar, i toggles right sidebar, space/enter opens preview, * left sidebar, i toggles right sidebar, space/enter opens preview,
* esc clears, ⌘Z undoes, ⌘A selects all visible. * esc clears, ⌘Z undoes, ⌘A selects all visible.
@@ -302,46 +300,6 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
toast.success(`Kept ${ids.length}`); 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) ──────────────────────────────────────────── // ── S chord (add-to-heap) ────────────────────────────────────────────
// Press S: arm a short timer. A digit 19 within the window adds the // 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 // 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(); e.preventDefault();
void toggleArchive('restore'); void toggleArchive('restore');
return; return;
case 'f':
case 'F':
if (meta || shift) return;
e.preventDefault();
void toggleFavoriteOnTargets();
return;
case 's': case 's':
case 'S': case 'S':
if (meta || shift) return; if (meta || shift) return;

View File

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

View File

@@ -13,7 +13,6 @@
duplicateHeap, duplicateHeap,
getAllMarks, getAllMarks,
getConfig, getConfig,
getImportInfo,
heapDownloadUrl, heapDownloadUrl,
listFolderCounts, listFolderCounts,
listFolders, listFolders,
@@ -24,7 +23,6 @@
scanCrossFolderDuplicates, scanCrossFolderDuplicates,
triggerDownload, triggerDownload,
type CrossFolderScanResult, type CrossFolderScanResult,
type ImportInfo,
type PhotoMarksMap, type PhotoMarksMap,
type PpAlbum, type PpAlbum,
type PpClientConfig, type PpClientConfig,
@@ -73,27 +71,17 @@
enabled: isAuthenticated() 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 // View counts come from PhotoPrism's `/config` response, which carries a
// precomputed counter for every common bucket (all/favorites/archived/ // precomputed counter for every common bucket (all/archived/labels/
// labels/places/…) updated incrementally on every mutation. Cheap to // places/…) updated incrementally on every mutation. Cheap to refetch,
// refetch, and gives us a stable total — `/photos` only returns // and gives us a stable total — `/photos` only returns per-page row
// per-page row counts via `X-Count`, never a total. // counts via `X-Count`, never a total.
// //
// The key sits under the `['photos', …]` prefix so it inherits the // The key sits under the `['photos', …]` prefix so it inherits the
// existing `invalidateQueries({ queryKey: ['photos'] })` calls scattered // existing `invalidateQueries({ queryKey: ['photos'] })` calls scattered
// across mutations (favorite, archive, restore, delete, heap add) — the // across mutations (archive, restore, delete, heap add) — the counter
// counter map refreshes whenever the photo list does. Marks-derived // map refreshes whenever the photo list does. Marks-derived counts
// counts (ratings/colors) react through the shared `['marks']` cache. // (ratings/colors) react through the shared `['marks']` cache.
const configQuery = createQuery<PpClientConfig>(() => ({ const configQuery = createQuery<PpClientConfig>(() => ({
queryKey: ['photos', 'config'], queryKey: ['photos', 'config'],
queryFn: getConfig, queryFn: getConfig,
@@ -267,6 +255,13 @@
const rootActive = $derived(filters.folderPath === '/'); const rootActive = $derived(filters.folderPath === '/');
const hasSubfolders = $derived((foldersQuery.data ?? []).length > 0); 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() { async function onSignOut() {
await logout(); await logout();
await goto('/login', { replaceState: true }); await goto('/login', { replaceState: true });
@@ -390,9 +385,9 @@
// "everything visible in the main timeline" tally), so it matches what // "everything visible in the main timeline" tally), so it matches what
// the All photos view actually renders. `places` is the count of // the All photos view actually renders. `places` is the count of
// geocoded locations — semantically what the Map view groups by. // geocoded locations — semantically what the Map view groups by.
// Duplicates sums stacks + cross-folder groups; cross-folder only // Review rolls in the duplicates tabs hosted under /review — stacks
// contributes once its tab has been visited (the scan is opt-in // always contributes; cross-folder only contributes once its tab has
// per-visit, not eager from the sidebar). // been opened (the scan is lazy, not eager from the sidebar).
type ViewItem = type ViewItem =
| { kind: 'section'; id: Section; label: string; getCount: () => number | undefined } | { kind: 'section'; id: Section; label: string; getCount: () => number | undefined }
| { kind: 'route'; href: string; 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 // separate "everything regardless of folder" destination would just
// duplicate it for users whose photos live under the root. // duplicate it for users whose photos live under the root.
const views: ViewItem[] = [ 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 }, { kind: 'route', href: '/map', label: 'Map', getCount: () => configQuery.data?.count?.places },
// Tags hosts four tabs (Labels (auto) / Keywords / Ratings / Colors); // Tags hosts four tabs (Labels (auto) / Keywords / Ratings / Colors);
// the badge shows total labels + keywords + ratings + colors so the // the badge shows total labels + keywords + ratings + colors so the
@@ -421,15 +414,14 @@
]; ];
const manageViews: ViewItem[] = [ const manageViews: ViewItem[] = [
{ kind: 'route', href: '/review', label: 'Review', getCount: () => configQuery.data?.count?.review },
{ {
kind: 'route', kind: 'route',
href: '/duplicates', href: '/review',
label: 'Duplicates', label: 'Review',
getCount: () => { getCount: () => {
const stacks = stacksQuery.data?.length; const review = configQuery.data?.count?.review;
if (stacks === undefined) return undefined; if (review === undefined) return undefined;
return stacks + (crossFolderQuery.data?.groups.length ?? 0); return review + (stacksQuery.data?.length ?? 0) + (crossFolderQuery.data?.groups.length ?? 0);
} }
}, },
{ kind: 'section', id: 'hidden', label: 'Hidden', getCount: () => configQuery.data?.count?.hidden }, { 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="flex min-w-0 flex-1 items-center text-left"
class:px-1={hasSubfolders} class:px-1={hasSubfolders}
onclick={() => pickFolder('/')} 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} {#if configQuery.data}
<span <span
class="ml-auto shrink-0 rounded px-1 text-[10px] tabular-nums {rootActive class="ml-auto shrink-0 rounded px-1 text-[10px] tabular-nums {rootActive

View File

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

View File

@@ -13,10 +13,8 @@
Calendar, Calendar,
Camera, Camera,
ExternalLink, ExternalLink,
Heart,
ImageIcon, ImageIcon,
Loader2, Loader2,
Lock,
MapPin, MapPin,
Star, Star,
Tag, Tag,
@@ -26,10 +24,9 @@
import { import {
buildTakenAtPatch, buildTakenAtPatch,
getAllMarks, getAllMarks,
likePhoto, isValidISODate,
renameOnDisk, renameOnDisk,
setMark, setMark,
unlikePhoto,
updatePhoto, updatePhoto,
type PhotoMark, type PhotoMark,
type PhotoMarksMap, type PhotoMarksMap,
@@ -52,7 +49,7 @@
const qc = useQueryClient(); const qc = useQueryClient();
let filename = $state(''); let basename = $state('');
let caption = $state(''); let caption = $state('');
let takenAt = $state(''); let takenAt = $state('');
let lat = $state(''); let lat = $state('');
@@ -60,18 +57,22 @@
let country = $state(''); let country = $state('');
let keywords = $state<string[]>([]); let keywords = $state<string[]>([]);
let keywordDraft = $state(''); let keywordDraft = $state('');
let subject = $state('');
let artist = $state('');
let copyright = $state('');
let license = $state('');
let notes = $state('');
let renaming = $state(false); 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(() => { $effect(() => {
const pf = primaryFile(photo); const pf = primaryFile(photo);
filename = pf.Name ?? ''; basename = splitName(pf.Name ?? '').base;
caption = photo.Caption ?? ''; caption = photo.Caption ?? '';
takenAt = (photo.TakenAt ?? '').slice(0, 16); takenAt = (photo.TakenAt ?? '').slice(0, 10);
lat = photo.Lat ? String(photo.Lat) : ''; lat = photo.Lat ? String(photo.Lat) : '';
lng = photo.Lng ? String(photo.Lng) : ''; lng = photo.Lng ? String(photo.Lng) : '';
country = photo.Country && photo.Country !== 'zz' ? photo.Country : ''; country = photo.Country && photo.Country !== 'zz' ? photo.Country : '';
@@ -80,11 +81,6 @@
.split(',') .split(',')
.map((k) => k.trim()) .map((k) => k.trim())
.filter(Boolean); .filter(Boolean);
subject = det.Subject ?? '';
artist = det.Artist ?? '';
copyright = det.Copyright ?? '';
license = det.License ?? '';
notes = det.Notes ?? '';
}); });
const patchMutation = createMutation(() => ({ const patchMutation = createMutation(() => ({
@@ -100,32 +96,15 @@
toast.error(err instanceof Error ? err.message : 'Save failed') 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) { function commit(patch: UpdatePhotoBody) {
patchMutation.mutate(patch); patchMutation.mutate(patch);
} }
async function commitFilename() { async function commitFilename() {
const pf = primaryFile(photo); const pf = primaryFile(photo);
const next = filename.trim(); const { base: currentBase } = splitName(pf.Name ?? '');
if (!next || next === pf.Name) return; const next = basename.trim();
if (!next || next === currentBase) return;
renaming = true; renaming = true;
try { try {
const result = await renameOnDisk(photo.UID, next); const result = await renameOnDisk(photo.UID, next);
@@ -139,7 +118,7 @@
}); });
} catch (err) { } catch (err) {
toast.error(err instanceof Error ? err.message : 'Rename failed'); toast.error(err instanceof Error ? err.message : 'Rename failed');
filename = pf.Name ?? ''; basename = currentBase;
} finally { } finally {
renaming = false; renaming = false;
} }
@@ -149,9 +128,19 @@
if (caption === (photo.Caption ?? '')) return; if (caption === (photo.Caption ?? '')) return;
commit({ Caption: caption, CaptionSrc: 'manual' }); commit({ Caption: caption, CaptionSrc: 'manual' });
} }
const takenAtValid = $derived(takenAt === '' || isValidISODate(takenAt));
function commitTakenAt() { function commitTakenAt() {
if (!takenAt) return; 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; if (iso === photo.TakenAt) return;
commit(buildTakenAtPatch(iso)); commit(buildTakenAtPatch(iso));
} }
@@ -170,7 +159,7 @@
commit({ Country: next || 'zz', CountrySrc: 'manual' }); commit({ Country: next || 'zz', CountrySrc: 'manual' });
} }
type DetailsKey = 'Keywords' | 'Subject' | 'Artist' | 'Copyright' | 'License' | 'Notes'; type DetailsKey = 'Keywords';
function commitDetails(field: DetailsKey, value: string) { function commitDetails(field: DetailsKey, value: string) {
const prev = (photo.Details ?? {})[field] ?? ''; const prev = (photo.Details ?? {})[field] ?? '';
if (value === prev) return; if (value === prev) return;
@@ -189,14 +178,6 @@
commitDetails('Keywords', keywords.join(', ')); 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 // Marks (rating + color) live on the mule-sidecar — PhotoPrism's PUT
// silently drops these fields. One query holds the whole map; mutations // silently drops these fields. One query holds the whole map; mutations
// patch the cache optimistically and PUT to the sidecar. // patch the cache optimistically and PUT to the sidecar.
@@ -265,6 +246,7 @@
const currentColor = $derived(photoMark.color ?? ''); const currentColor = $derived(photoMark.color ?? '');
const pf = $derived(primaryFile(photo)); const pf = $derived(primaryFile(photo));
const dirPath = $derived(splitName(pf.Name ?? '').dir);
const dims = $derived(pf.Width && pf.Height ? `${pf.Width}×${pf.Height}` : '—'); const dims = $derived(pf.Width && pf.Height ? `${pf.Width}×${pf.Height}` : '—');
const sizeStr = $derived( const sizeStr = $derived(
pf.Size pf.Size
@@ -307,47 +289,44 @@
</script> </script>
<aside class="space-y-2.5 bg-card p-2.5 text-xs"> <aside class="space-y-2.5 bg-card p-2.5 text-xs">
<!-- Header strip — thumb + filename + favorite + private --> <!-- Header strip — thumb + path (read-only) over editable basename. The
<div class="flex items-center gap-2"> 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 <img
src={thumbUrl(pf.Hash, 'tile_100')} src={thumbUrl(pf.Hash, 'tile_100')}
alt="" alt=""
class="h-10 w-10 shrink-0 rounded object-cover" class="h-10 w-10 shrink-0 rounded object-cover"
/> />
<input <div class="flex min-w-0 flex-1 flex-col gap-0.5">
type="text" {#if dirPath}
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" <div
bind:value={filename} class="truncate text-[10px] text-muted-foreground"
disabled={renaming} title={dirPath}
onblur={commitFilename} >
onkeydown={(e) => e.key === 'Enter' && (e.currentTarget as HTMLInputElement).blur()} {dirPath}/
title={renaming ? 'Renaming…' : 'Click to rename file on disk'} </div>
/> {/if}
<!-- Inline spinner next to the filename so the user sees the rename <input
in flight without having to scan to the bottom of the sidebar. --> 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} {#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} {/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> </div>
<!-- Compact info rows --> <!-- Compact info rows -->
@@ -356,8 +335,12 @@
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Calendar class="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> <Calendar class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<input <input
type="datetime-local" type="text"
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" 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} bind:value={takenAt}
onblur={commitTakenAt} onblur={commitTakenAt}
/> />
@@ -606,66 +589,6 @@
</div> </div>
</details> </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. --> <!-- File metadata. Closed by default; persists once opened. -->
<details <details
class="rounded border border-border" class="rounded border border-border"

View File

@@ -7,10 +7,8 @@
batchArchive, batchArchive,
batchDelete, batchDelete,
batchRestore, batchRestore,
likePhoto,
listHeaps, listHeaps,
removeFromHeap, removeFromHeap,
unlikePhoto,
type PpAlbum type PpAlbum
} from '$lib/services/photoprism'; } from '$lib/services/photoprism';
import { batchEdit } from '$lib/services/batch'; import { batchEdit } from '$lib/services/batch';
@@ -21,7 +19,7 @@
setFocused setFocused
} from '$lib/stores/selection.svelte'; } from '$lib/stores/selection.svelte';
import { filters } from '$lib/stores/filters.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'; import { isAuthenticated } from '$lib/stores/session.svelte';
const qc = useQueryClient(); const qc = useQueryClient();
@@ -51,8 +49,8 @@
const isBulk = $derived(selection.ids.size > 0); const isBulk = $derived(selection.ids.size > 0);
// Review section uses a two-button decision flow (Keep / Archive) — // Review section uses a two-button decision flow (Keep / Archive) —
// every other action is hidden so the choice can't be confused with // every other action is hidden so the choice can't be confused with
// favoriting / heap-adding / restoring. The S keybinding is rerouted // heap-adding / restoring. The S keybinding is rerouted to approve
// to approve from gridKeyNav for the same reason. // from gridKeyNav for the same reason.
const isReview = $derived(filters.section === 'review'); const isReview = $derived(filters.section === 'review');
// Archive section is the parallel two-button flow: Keep (restore back // Archive section is the parallel two-button flow: Keep (restore back
// to the timeline) or Delete (permanent, no undo). X is repurposed // 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) { async function onAddToHeap(heap: PpAlbum) {
const ids = snapshotIds(); const ids = snapshotIds();
if (!ids.length) return; if (!ids.length) return;
@@ -244,8 +218,8 @@
{#if isReview} {#if isReview}
<!-- Review pile = binary decision. Keep approves (Quality → <!-- Review pile = binary decision. Keep approves (Quality →
3+, lands in the main timeline); Archive batches into 3+, lands in the main timeline); Archive batches into
the archive section. Everything else (heap, favorite, the archive section. Everything else (heap, restore)
restore) is hidden so the choice reads as decisive. --> is hidden so the choice reads as decisive. -->
<button <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" 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} disabled={busy}
@@ -335,15 +309,6 @@
</div> </div>
{/if} {/if}
</div> </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 <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" 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} disabled={busy}
@@ -353,25 +318,7 @@
Archive Archive
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">X</kbd> <kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">X</kbd>
</button> </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} {/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 <button
class="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[11px] hover:bg-accent" class="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[11px] hover:bg-accent"
onclick={clearAll} onclick={clearAll}

View File

@@ -2,7 +2,7 @@
Single photo tile — shared by the timeline (+page.svelte) and the flat Single photo tile — shared by the timeline (+page.svelte) and the flat
drill-in grids (PhotoGrid.svelte) so the tile chrome is single-sourced drill-in grids (PhotoGrid.svelte) so the tile chrome is single-sourced
and can't drift between the two. Owns: thumbnail, selection animation, 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 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 h-full w-full, so the timeline can wrap it in its windowing shell and
@@ -144,12 +144,6 @@
{#if selected} {#if selected}
<div class="pointer-events-none absolute inset-0 bg-blue-500/40"></div> <div class="pointer-events-none absolute inset-0 bg-blue-500/40"></div>
{/if} {/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)} {#if isVideo(photo)}
<span <span
class="absolute left-1.5 top-1.5 rounded bg-background/80 px-1 text-[10px] font-medium text-foreground" 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; OriginalName?: string;
Caption?: string; Caption?: string;
CaptionSrc?: 'manual' | ''; CaptionSrc?: 'manual' | '';
Favorite?: boolean;
Private?: boolean;
Archived?: boolean; Archived?: boolean;
/** TakenAt + TakenAtLocal + Year/Month/Day must move in lockstep — /** TakenAt + TakenAtLocal + Year/Month/Day must move in lockstep —
* use `buildTakenAtPatch` to assemble all five fields from one ISO. */ * use `buildTakenAtPatch` to assemble all five fields from one ISO. */
@@ -195,6 +193,16 @@ export interface UpdatePhotoBody {
Details?: Partial<import('$lib/types/photoprism').PpDetails>; 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 { export function buildTakenAtPatch(iso: string): UpdatePhotoBody {
const d = new Date(iso); const d = new Date(iso);
if (Number.isNaN(d.getTime())) return {}; if (Number.isNaN(d.getTime())) return {};
@@ -264,18 +272,6 @@ export async function approvePhoto(uid: string): Promise<void> {
await http.post(`/photos/${uid}/approve`); 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 ─────────────────────────────────────────────────── // ── Stack file operations ───────────────────────────────────────────────────
// PhotoPrism's stacks pack multiple file variants (RAW + JPG + Live + …) into // PhotoPrism's stacks pack multiple file variants (RAW + JPG + Live + …) into
// a single Photo entity. The duplicate-resolution flow needs two ops, both // a single Photo entity. The duplicate-resolution flow needs two ops, both
@@ -324,8 +320,6 @@ export interface PpFolder {
Root: string; Root: string;
Title: string; Title: string;
FileCount?: number; FileCount?: number;
Favorite?: boolean;
Private?: boolean;
} }
/** /**
@@ -354,39 +348,6 @@ export async function listFolders(): Promise<PpFolder[]> {
.filter((f) => f.Path !== ''); .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` * Per-folder photo count for each `paths[]` entry. PhotoPrism's `/folders`
* endpoint reports `FileCount: 0` even when populated, so the count has * endpoint reports `FileCount: 0` even when populated, so the count has
@@ -466,7 +427,6 @@ export interface PpLabel {
Slug: string; Slug: string;
CustomSlug?: string; CustomSlug?: string;
Name: string; Name: string;
Favorite?: boolean;
Priority?: number; Priority?: number;
Description?: string; Description?: string;
PhotoCount?: number; PhotoCount?: number;
@@ -551,7 +511,6 @@ export interface PpAlbum {
Type: string; Type: string;
Title: string; Title: string;
Description?: string; Description?: string;
Favorite?: boolean;
PhotoCount?: number; PhotoCount?: number;
CreatedAt?: string; CreatedAt?: string;
UpdatedAt?: string; UpdatedAt?: string;

View File

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

View File

@@ -55,9 +55,7 @@ export interface PpClientConfig {
documents?: number; documents?: number;
archived?: number; archived?: number;
hidden?: number; hidden?: number;
favorites?: number;
review?: number; review?: number;
private?: number;
albums?: number; albums?: number;
moments?: number; moments?: number;
months?: number; months?: number;
@@ -131,8 +129,6 @@ export interface PpPhoto {
Height?: number; Height?: number;
Rating?: number; Rating?: number;
Color?: string | number; Color?: string | number;
Favorite?: boolean;
Private?: boolean;
Archived?: boolean; Archived?: boolean;
Files?: PpFile[]; Files?: PpFile[];
Lat?: number; Lat?: number;
@@ -213,7 +209,7 @@ export interface PpPhotoLabel {
Source?: string; Source?: string;
Priority?: number; Priority?: number;
Uncertainty?: 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()); const sectionLabel = $derived(buildSectionLabel());
function buildSectionLabel(): string { function buildSectionLabel(): string {
switch (filters.section) { switch (filters.section) {
case "favorites":
return "Favorites";
case "review": case "review":
return "Review"; return "Review";
case "archive": case "archive":
@@ -184,7 +182,7 @@
// Root-folder scope is the only client-side filter we apply, and only // 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 // 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 // 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, // scoped server-side via the q-DSL and must not be re-filtered here,
// or labels / search will silently drop subfolder photos when the // or labels / search will silently drop subfolder photos when the
// store hasn't fully hydrated from the URL yet. // store hasn't fully hydrated from the URL yet.
@@ -867,8 +865,6 @@
<p class="text-sm text-muted-foreground"> <p class="text-sm text-muted-foreground">
{#if filters.section === "archive"} {#if filters.section === "archive"}
Archive is empty. Archive is empty.
{:else if filters.section === "favorites"}
No favorites yet. Heart a photo to add it here.
{:else if filters.section === "review"} {:else if filters.section === "review"}
Nothing left to review. Photos PhotoPrism's indexer wasn't sure Nothing left to review. Photos PhotoPrism's indexer wasn't sure
about land here — use Keep to accept them into the timeline or 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 /review — PhotoPrism's quality-flagged photo queue *plus* the
cause. Tiles, selection, keyboard nav, and bulk actions all come duplicate-resolution panels (stacks & cross-folder). Cause tabs and
from the shared timeline machinery (PhotoGrid, gridKeyNav, duplicate tabs share the same pill row so the user has a single
BulkActionBar, BulkMetadataSidebar) so muscle memory carries "things to clean up" surface instead of two routes.
across 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 — The route flips `filters.section = 'review'` while it's mounted —
that's what swings the shared action surface into review semantics that's what swings the shared action surface into review semantics
(BulkActionBar shows Dismiss/Archive, gridKeyNav's S maps to (BulkActionBar shows Dismiss/Archive, gridKeyNav's S maps to
approve). The previous section is restored on unmount so going approve). The previous section is restored on unmount.
back to `/` lands on whatever the user had before.
--> -->
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
@@ -20,6 +23,14 @@
type CauseKey, type CauseKey,
type ReviewGroup type ReviewGroup
} from '$lib/services/adapters/review'; } 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 { isAuthenticated } from '$lib/stores/session.svelte';
import { clearSelection, selection } from '$lib/stores/selection.svelte'; import { clearSelection, selection } from '$lib/stores/selection.svelte';
import { filters, setSection, type Section } from '$lib/stores/filters.svelte'; import { filters, setSection, type Section } from '$lib/stores/filters.svelte';
@@ -39,6 +50,14 @@
import RightSidebar from '$lib/components/sidebar/RightSidebar.svelte'; import RightSidebar from '$lib/components/sidebar/RightSidebar.svelte';
import BulkMetadataSidebar from '$lib/components/sidebar/BulkMetadataSidebar.svelte'; import BulkMetadataSidebar from '$lib/components/sidebar/BulkMetadataSidebar.svelte';
import CauseGroupCard from '$lib/components/review/CauseGroupCard.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 // Stash the section that was active when the user arrived; restore
// on unmount so navigating away doesn't leak `section=review` to // on unmount so navigating away doesn't leak `section=review` to
@@ -59,6 +78,24 @@
staleTime: 30_000 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>(() => ({ const focusedPhotoQuery = createQuery<PpPhoto | null>(() => ({
queryKey: ['photo', selection.focused ?? ''], queryKey: ['photo', selection.focused ?? ''],
queryFn: () => (selection.focused ? getPhoto(selection.focused) : Promise.resolve(null)), queryFn: () => (selection.focused ? getPhoto(selection.focused) : Promise.resolve(null)),
@@ -66,20 +103,33 @@
})); }));
const groups = $derived(reviewQuery.data ?? []); const groups = $derived(reviewQuery.data ?? []);
const tabs = $derived( const stacksCount = $derived(stacksQuery.data?.length);
groups.map((g) => ({ id: g.cause, label: g.meta.title, count: g.photos.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 requestedTab = $derived(page.url.searchParams.get('tab'));
const activeTab: CauseKey | null = $derived.by(() => { const activeTab: Tab = $derived.by(() => {
if (tabs.length === 0) return null;
const want = tabs.find((t) => t.id === requestedTab); const want = tabs.find((t) => t.id === requestedTab);
return (want ?? tabs[0]).id; return (want ?? tabs[0]).id;
}); });
const activeIsDup = $derived(isDupTab(activeTab));
// Selection is global — without this effect, a user who multi- // Selection is global — without this effect, a user who multi-
// selected in `low_resolution` then switched to `stripped_exif` // selected in `low_resolution` then switched to `stripped_exif`
// would carry the previous tab's UIDs into the new tab's // would carry the previous tab's UIDs into the new tab's
// BulkActionBar verbs and accidentally act on the wrong photos. // BulkActionBar verbs and accidentally act on the wrong photos.
// Also fires when switching into a duplicates tab (where selection
// is meaningless anyway).
$effect(() => { $effect(() => {
void activeTab; void activeTab;
clearSelection(); clearSelection();
@@ -87,9 +137,13 @@
const activeGroup = $derived(groups.find((g) => g.cause === activeTab)); const activeGroup = $derived(groups.find((g) => g.cause === activeTab));
function setTab(id: CauseKey) { function setTab(id: Tab) {
const params = new URLSearchParams(); 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 : ''}`, { void goto(`/review${params.size ? '?' + params : ''}`, {
keepFocus: true, keepFocus: true,
noScroll: true noScroll: true
@@ -106,13 +160,22 @@
{#each tabs as t (t.id)} {#each tabs as t (t.id)}
<button <button
type="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-primary/40 bg-primary/10 text-primary'
: 'border-border text-muted-foreground hover:bg-accent hover:text-foreground'}" : 'border-border text-muted-foreground hover:bg-accent hover:text-foreground'}"
onclick={() => setTab(t.id)} onclick={() => setTab(t.id)}
> >
{t.label} <span>{t.label}</span>
<span class="ml-1 text-muted-foreground/70">({t.count})</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> </button>
{/each} {/each}
</div> </div>
@@ -140,60 +203,81 @@
{/snippet} {/snippet}
</Toolbar> </Toolbar>
<div class="flex min-h-0 flex-1"> <!--
<div class="flex min-w-0 flex-1 flex-col"> Two layout branches:
<main class="min-h-0 flex-1 overflow-y-auto px-6 py-4 pb-6" use:gridKeyNav={{}}> • Cause tabs use the timeline-style layout (grid key nav, bulk
{#if reviewQuery.isPending} action bar, right metadata sidebar) since the user is acting on
<p class="text-sm text-muted-foreground">Loading review queue…</p> individual photos.
{:else if reviewQuery.error} • Duplicate tabs use a stripped layout — DuplicatesView renders
<p class="text-sm text-destructive"> its own per-group cards with built-in actions, so the bulk bar
Could not load review queue: {reviewQuery.error instanceof Error and right sidebar would just clutter.
? reviewQuery.error.message -->
: 'unknown error'} {#if activeIsDup}
</p> <main class="min-h-0 flex-1 overflow-y-auto">
{:else if groups.length === 0} <DuplicatesView
<div class="max-w-prose space-y-2 text-sm text-muted-foreground"> activeTab={activeTab as DupTab}
<p>The review queue is empty.</p> groups={stacksQuery.data ?? []}
<p class="text-xs"> pending={stacksQuery.isPending}
PhotoPrism's indexer flags photos with a low quality score for human error={stacksQuery.error}
review. New arrivals with missing EXIF, low resolution, or unknown />
cameras will land here. </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> </p>
</div> {:else if groups.length === 0}
{:else if activeGroup} <div class="max-w-prose space-y-2 text-sm text-muted-foreground">
{#key activeGroup.cause} <p>The review queue is empty.</p>
<CauseGroupCard group={activeGroup} /> <p class="text-xs">
{/key} PhotoPrism's indexer flags photos with a low quality score for human
{/if} review. New arrivals with missing EXIF, low resolution, or unknown
</main> cameras will land here. The Stacks and Cross-folder tabs above stay
<BulkActionBar /> available for duplicate cleanup.
</div> </p>
</div>
{#if !view.rightSidebarCollapsed && (selection.focused || selection.ids.size >= 2)} {:else if activeGroup}
<aside {#key activeGroup.cause}
class="relative h-full shrink-0 border-l border-border bg-card/30" <CauseGroupCard group={activeGroup} />
style="width: {view.rightSidebarWidth}px;" {/key}
>
<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} {/if}
</div> </main>
<div <BulkActionBar />
class="group absolute -left-1.5 top-0 z-20 h-full w-3 cursor-col-resize" </div>
use:resizable={{
edge: 'left', {#if !view.rightSidebarCollapsed && (selection.focused || selection.ids.size >= 2)}
getWidth: () => view.rightSidebarWidth, <aside
setWidth: setRightSidebarWidth class="relative h-full shrink-0 border-l border-border bg-card/30"
}} style="width: {view.rightSidebarWidth}px;"
role="separator" >
aria-orientation="vertical" <div class="h-full overflow-y-auto">
aria-label="Resize info panel" {#if selection.ids.size >= 2}
></div> <BulkMetadataSidebar ids={Array.from(selection.ids)} />
</aside> {:else if focusedPhotoQuery.data}
{/if} <RightSidebar photo={focusedPhotoQuery.data} showRelated />
</div> {: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}