fix(people): keep "name new faces" reachable after naming the first one

Landing on /tags/people with no value in the URL auto-selects the
first named person (so there's always something to look at) — but
that same effect made the naming workflow unreachable the moment a
second person existed to redirect into: NewFacesPanel only rendered
in the "!selectedValue" branch, and there was no way back to a null
selection once one existed.

Added a pinned "Name new faces" row in the People sidebar (with a live
unnamed-cluster count) that sets a `?view=new-faces` query param
instead of clearing the `[[value]]` route param — deliberately
independent of the value-drives-selection model so it can't be
overwritten. The auto-select-first-tag effect also needed an explicit
guard for it: navigating to a bare /tags/people URL still clears
selectedValue, which re-triggers that same effect in the same tick and
would otherwise bounce straight back to the first person before the
panel ever rendered.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-05 10:12:56 +02:00
parent c6f31b5dfb
commit a9685f64c4
2 changed files with 99 additions and 23 deletions

View File

@@ -1,4 +1,6 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { createQuery } from '@tanstack/svelte-query'; import { createQuery } from '@tanstack/svelte-query';
import { import {
aggregateKeywords, aggregateKeywords,
@@ -7,11 +9,13 @@
listLabels, listLabels,
listPhotosByUids, listPhotosByUids,
listSubjects, listSubjects,
listUnnamedFaces,
type AggregatedKeyword, type AggregatedKeyword,
type PhotoMarksMap, type PhotoMarksMap,
type PpCountry, type PpCountry,
type PpLabel, type PpLabel,
type PpSubject type PpSubject,
type UnnamedFaceCluster
} from '$lib/services/photoprism'; } from '$lib/services/photoprism';
import { isAuthenticated, thumbUrl } from '$lib/stores/session.svelte'; import { isAuthenticated, thumbUrl } from '$lib/stores/session.svelte';
import { nearBottom } from '$lib/actions/nearBottom'; import { nearBottom } from '$lib/actions/nearBottom';
@@ -25,7 +29,7 @@
import { countryFlag, countryName } from '$lib/utils/countries'; import { countryFlag, countryName } from '$lib/utils/countries';
import type { PpPhoto } from '$lib/types/photoprism'; import type { PpPhoto } from '$lib/types/photoprism';
import { EmptyState, InlineLoader } from '$lib/components/feedback'; import { EmptyState, InlineLoader } from '$lib/components/feedback';
import { Globe, Hash, Tag, User } from 'lucide-svelte'; import { Globe, Hash, Tag, User, UserPlus } from 'lucide-svelte';
interface Props { interface Props {
category: TagCategory; category: TagCategory;
@@ -34,6 +38,29 @@
} }
const { category, selectedValue, onSelect }: Props = $props(); const { category, selectedValue, onSelect }: Props = $props();
// "Name new faces" is a pinned row, not a subject — it needs to stay
// reachable even after every detected face has been named once (there's
// always another to catch as the library grows), so it lives outside
// the value-drives-URL selection model the rest of this sidebar uses.
// Query key matches NewFacesPanel's so the two share one cache entry.
const unnamedFacesQuery = createQuery<UnnamedFaceCluster[]>(() => ({
queryKey: ['faces', 'unnamed'],
queryFn: listUnnamedFaces,
enabled: isAuthenticated() && category === 'people',
staleTime: 60_000
}));
const unnamedFacesCount = $derived(unnamedFacesQuery.data?.length ?? 0);
const newFacesActive = $derived(page.url.searchParams.get('view') === 'new-faces');
function showNewFaces() {
// Deliberately NOT onSelect/navigateToTag — that drives the
// `[[value]]` route param, and the auto-select-first-tag effect
// below immediately overwrites a null value with the first real
// person, which is exactly the trap this row exists to escape.
// The `view` query param is independent state the page reads to
// show the naming panel instead of (or alongside) the photo grid.
void goto('/tags/people?view=new-faces', { keepFocus: true, noScroll: true });
}
let filterText = $state(''); let filterText = $state('');
// Reset the inline filter input whenever the user switches categories so // Reset the inline filter input whenever the user switches categories so
@@ -243,8 +270,16 @@
// fires when there's genuinely no selection — once a value is picked // fires when there's genuinely no selection — once a value is picked
// (by the user or by this effect), the URL drives selectedValue and // (by the user or by this effect), the URL drives selectedValue and
// the effect no-ops. // the effect no-ops.
//
// `newFacesActive` additionally suppresses it for People: navigating
// to the pinned "Name new faces" row necessarily clears selectedValue
// (it targets a bare `/tags/people` URL), and without this guard this
// effect would immediately redirect straight back to the first named
// person in the same tick — permanently hiding the naming workflow
// again the moment a second person exists to auto-select into.
$effect(() => { $effect(() => {
if (selectedValue != null) return; if (selectedValue != null) return;
if (newFacesActive) return;
if (firstValue == null) return; if (firstValue == null) return;
onSelect(firstValue, { replace: true }); onSelect(firstValue, { replace: true });
}); });
@@ -416,6 +451,36 @@
</div> </div>
{/if} {/if}
{:else if category === 'people'} {:else if category === 'people'}
<!-- Pinned above the named-people list (and shown regardless of its
loading/empty/error state) so naming stays reachable even after
every currently-detected face has a name — new faces keep
appearing as the library grows. -->
<button
type="button"
class="flex h-8 w-full shrink-0 items-center gap-2 border-b border-border px-3 text-left text-[12px] leading-tight hover:bg-accent"
class:bg-primary={newFacesActive}
class:text-primary-foreground={newFacesActive}
class:hover:bg-primary={newFacesActive}
onclick={showNewFaces}
>
<span
class="flex h-5 w-5 shrink-0 items-center justify-center rounded-full {newFacesActive
? 'bg-primary-foreground/15'
: 'bg-secondary'}"
>
<UserPlus class="h-3 w-3 {newFacesActive ? '' : 'text-muted-foreground'}" />
</span>
<span class="min-w-0 flex-1 truncate font-medium">Name new faces</span>
{#if unnamedFacesCount > 0}
<span
class="flex h-4 min-w-[20px] shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {newFacesActive
? 'bg-primary-foreground/15 text-primary-foreground'
: 'bg-secondary text-muted-foreground'}"
>
{unnamedFacesCount}
</span>
{/if}
</button>
{#if subjectsQuery.isPending} {#if subjectsQuery.isPending}
<InlineLoader size="sm" label="Loading people…" /> <InlineLoader size="sm" label="Loading people…" />
{:else if subjectsQuery.isError} {:else if subjectsQuery.isError}

View File

@@ -53,6 +53,16 @@
: null : null
); );
// Independent of selectedValue on purpose: the People sidebar's pinned
// "Name new faces" row sets this query param instead of the `[[value]]`
// route param, specifically so it can't be clobbered by the auto-
// select-first-tag effect (TagsBrowserSidebar) that fires whenever
// selectedValue is null — that effect is exactly what made the naming
// panel unreachable again after the first person was named.
const showNewFaces = $derived(
category === 'people' && page.url.searchParams.get('view') === 'new-faces'
);
// Mirror URL into the shared filter store so any other consumer of // Mirror URL into the shared filter store so any other consumer of
// `filters` (e.g. cross-route navigation back to `/`) sees the active // `filters` (e.g. cross-route navigation back to `/`) sees the active
// tag filter, and so `filtersToQ()` produces the correct DSL clause // tag filter, and so `filtersToQ()` produces the correct DSL clause
@@ -208,7 +218,7 @@
{#if category} {#if category}
<span class="text-[11px] capitalize text-muted-foreground">{category}</span> <span class="text-[11px] capitalize text-muted-foreground">{category}</span>
{/if} {/if}
{#if selectedValue} {#if selectedValue && !showNewFaces}
<span class="text-[11px] font-medium">{drillTitle}</span> <span class="text-[11px] font-medium">{drillTitle}</span>
<span class="text-[11px] text-muted-foreground"> <span class="text-[11px] text-muted-foreground">
{drillCount} photo{drillCount === 1 ? '' : 's'} {drillCount} photo{drillCount === 1 ? '' : 's'}
@@ -216,10 +226,12 @@
{/if} {/if}
</Toolbar> </Toolbar>
{#if !selectedValue} {#if category === 'people' && (showNewFaces || !selectedValue)}
{#if category === 'people'} <!-- Naming workflow: reachable both on bare landing (no person picked
<!-- No person selected: surface the naming workflow instead of a yet) and via the sidebar's pinned "Name new faces" row at any time
bare prompt — naming is what creates people in the first place. --> — the latter is what makes it possible to get back here after the
first person's been named, once the auto-select-first-tag effect
would otherwise always jump straight to an existing person. -->
<main class="min-h-0 flex-1 overflow-y-auto p-6"> <main class="min-h-0 flex-1 overflow-y-auto p-6">
<NewFacesPanel /> <NewFacesPanel />
<div class="mt-8 flex items-center justify-center"> <div class="mt-8 flex items-center justify-center">
@@ -230,7 +242,7 @@
/> />
</div> </div>
</main> </main>
{:else} {:else if !selectedValue}
<main class="flex min-h-0 flex-1 items-center justify-center p-8"> <main class="flex min-h-0 flex-1 items-center justify-center p-8">
<EmptyState <EmptyState
icon={Tag} icon={Tag}
@@ -238,7 +250,6 @@
description="Click a row in the panel on the left to filter the photo grid by that tag." description="Click a row in the panel on the left to filter the photo grid by that tag."
/> />
</main> </main>
{/if}
{:else} {:else}
<div class="flex min-h-0 flex-1"> <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">