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"
/> />
<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 <input
type="text" 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" 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={filename} bind:value={basename}
disabled={renaming} disabled={renaming}
onblur={commitFilename} onblur={commitFilename}
onkeydown={(e) => e.key === 'Enter' && (e.currentTarget as HTMLInputElement).blur()} onkeydown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
(e.currentTarget as HTMLInputElement).blur();
}
}}
title={renaming ? 'Renaming…' : 'Click to rename file on disk'} title={renaming ? 'Renaming…' : 'Click to rename file on disk'}
/> />
<!-- Inline spinner next to the filename so the user sees the rename </div>
in flight without having to scan to the bottom of the sidebar. --> <!-- 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,7 +203,26 @@
{/snippet} {/snippet}
</Toolbar> </Toolbar>
<div class="flex min-h-0 flex-1"> <!--
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"> <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={{}}> <main class="min-h-0 flex-1 overflow-y-auto px-6 py-4 pb-6" use:gridKeyNav={{}}>
{#if reviewQuery.isPending} {#if reviewQuery.isPending}
@@ -157,7 +239,8 @@
<p class="text-xs"> <p class="text-xs">
PhotoPrism's indexer flags photos with a low quality score for human PhotoPrism's indexer flags photos with a low quality score for human
review. New arrivals with missing EXIF, low resolution, or unknown review. New arrivals with missing EXIF, low resolution, or unknown
cameras will land here. cameras will land here. The Stacks and Cross-folder tabs above stay
available for duplicate cleanup.
</p> </p>
</div> </div>
{:else if activeGroup} {:else if activeGroup}
@@ -196,4 +279,5 @@
></div> ></div>
</aside> </aside>
{/if} {/if}
</div> </div>
{/if}