feat(timeline): focus follows archive, snaps to first on view load

- New selection.focusAfter(excluded) walks selection.order forward past
  the archived/restored set so X-ing through the timeline keeps the
  cursor on the next live photo instead of falling back to photo[0]
  via the auto-anchor effect. Wired into gridKeyNav.toggleArchive (X
  key) and BulkActionBar.onArchive.
- Auto-focus effect on the timeline always re-anchors to photos[0] on
  view load (pageCount → 1), instead of preserving a stale uid from
  the previous filter.
- PhotoGrid re-anchors focus when the previously focused uid isn't in
  the new photo set, so drilling into a /tags category drops the
  cursor on its first tile instead of carrying a stale selection from
  whatever view the user came from.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-17 21:56:54 +02:00
parent 84e433ff63
commit a72619e3d1
9 changed files with 222 additions and 28 deletions

View File

@@ -191,6 +191,13 @@
* append silently — we never want the focus to jump back to the top of
* the timeline mid-scroll. `untrack` keeps Escape (which clears focus)
* from immediately re-triggering this effect.
*
* Always re-anchors to photos[0] when pageCount lands on 1 (which
* only happens on initial load or after a filter change resets the
* infinite-query) so switching views drops the user back at the top
* with a fresh focus cursor. Mid-flow mutations (archive / restore /
* etc.) advance focus themselves via `focusAfter` and don't flip
* pageCount, so they don't get clobbered by this re-anchor.
*/
$effect(() => {
// Re-read pageCount so the effect bottoms out cleanly on filter
@@ -204,10 +211,7 @@
// Only re-anchor focus on the very first page; later pages
// must not pull focus back to photo[0].
if (pages !== 1) return;
const cur = selection.focused;
if (!cur || !photos.some((p) => p.UID === cur)) {
setFocused(photos[0].UID);
}
setFocused(photos[0].UID);
});
});
@@ -658,6 +662,28 @@
e.preventDefault();
setSearch(searchDraft.trim());
}
// PhotoPrism's q-DSL is non-obvious; surfacing 4 working examples on
// focus turns the placeholder hint into a clickable cheat-sheet.
const SEARCH_EXAMPLES = [
'label:dog',
'keyword:vacation',
'taken:2024',
'"exact phrase"'
];
let searchFocused = $state(false);
function onSearchFocus() {
searchFocused = true;
}
function onSearchBlur() {
// Defer so a click on an example fires before the popover unmounts.
setTimeout(() => (searchFocused = false), 120);
}
function applySearchExample(ex: string) {
searchDraft = ex;
setSearch(ex);
searchFocused = false;
}
</script>
<Toolbar showRightToggle>
@@ -687,12 +713,14 @@
{emptyingArchive ? 'Emptying…' : 'Empty Archive'}
</button>
{/if}
<form class="flex items-center gap-1" onsubmit={onSearchSubmit}>
<form class="relative flex items-center gap-1" onsubmit={onSearchSubmit}>
<input
type="search"
placeholder='Search · label:website / "vacation"'
class="w-56 rounded border border-input bg-background px-2 py-0.5 text-xs shadow-sm focus:outline-none focus:ring-2 focus:ring-ring"
bind:value={searchDraft}
onfocus={onSearchFocus}
onblur={onSearchBlur}
/>
<button
type="submit"
@@ -713,6 +741,32 @@
</button>
{/if}
<!--
Cheat-sheet popover: opens on input focus, lists working q-DSL
patterns. Clicking an example fills the input AND fires the
search, so it doubles as a one-click "try it" affordance.
-->
{#if searchFocused}
<div
class="absolute left-0 top-full z-50 mt-1 w-56 rounded-md border border-border bg-popover p-1.5 text-popover-foreground shadow-md"
>
<div class="px-1 pb-1 text-[10px] uppercase tracking-wide text-muted-foreground">
Examples
</div>
{#each SEARCH_EXAMPLES as ex (ex)}
<button
type="button"
class="block w-full rounded px-2 py-1 text-left font-mono text-[11px] hover:bg-accent"
onmousedown={(e) => {
e.preventDefault();
applySearchExample(ex);
}}
>
{ex}
</button>
{/each}
</div>
{/if}
</form>
{#snippet trailing()}

View File

@@ -138,11 +138,15 @@
return out;
}
// Titles follow the Lightroom culling convention so users see the
// swatch's *intent* (reject/review/pick/keep), not just its color.
// Used both as tooltip on swatches and as the visible card label in
// the colors-tab picker grid below.
const COLOR_SWATCHES: { key: string; bg: string; title: string }[] = [
{ key: 'red', bg: 'bg-red-500', title: 'Red' },
{ key: 'orange', bg: 'bg-orange-500', title: 'Orange' },
{ key: 'yellow', bg: 'bg-yellow-400', title: 'Yellow' },
{ key: 'green', bg: 'bg-green-500', title: 'Green' }
{ key: 'red', bg: 'bg-red-500', title: 'Red — reject' },
{ key: 'orange', bg: 'bg-orange-500', title: 'Orange — review' },
{ key: 'yellow', bg: 'bg-yellow-400', title: 'Yellow — pick' },
{ key: 'green', bg: 'bg-green-500', title: 'Green — keep' }
];
interface ColorGroup {
key: string;