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