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

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