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:
@@ -1,4 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { goto } from '$app/navigation';
|
||||
import { createQuery } from '@tanstack/svelte-query';
|
||||
import {
|
||||
aggregateKeywords,
|
||||
@@ -7,11 +9,13 @@
|
||||
listLabels,
|
||||
listPhotosByUids,
|
||||
listSubjects,
|
||||
listUnnamedFaces,
|
||||
type AggregatedKeyword,
|
||||
type PhotoMarksMap,
|
||||
type PpCountry,
|
||||
type PpLabel,
|
||||
type PpSubject
|
||||
type PpSubject,
|
||||
type UnnamedFaceCluster
|
||||
} from '$lib/services/photoprism';
|
||||
import { isAuthenticated, thumbUrl } from '$lib/stores/session.svelte';
|
||||
import { nearBottom } from '$lib/actions/nearBottom';
|
||||
@@ -25,7 +29,7 @@
|
||||
import { countryFlag, countryName } from '$lib/utils/countries';
|
||||
import type { PpPhoto } from '$lib/types/photoprism';
|
||||
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 {
|
||||
category: TagCategory;
|
||||
@@ -34,6 +38,29 @@
|
||||
}
|
||||
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('');
|
||||
|
||||
// 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
|
||||
// (by the user or by this effect), the URL drives selectedValue and
|
||||
// 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(() => {
|
||||
if (selectedValue != null) return;
|
||||
if (newFacesActive) return;
|
||||
if (firstValue == null) return;
|
||||
onSelect(firstValue, { replace: true });
|
||||
});
|
||||
@@ -416,6 +451,36 @@
|
||||
</div>
|
||||
{/if}
|
||||
{: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}
|
||||
<InlineLoader size="sm" label="Loading people…" />
|
||||
{:else if subjectsQuery.isError}
|
||||
|
||||
@@ -53,6 +53,16 @@
|
||||
: 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
|
||||
// `filters` (e.g. cross-route navigation back to `/`) sees the active
|
||||
// tag filter, and so `filtersToQ()` produces the correct DSL clause
|
||||
@@ -208,7 +218,7 @@
|
||||
{#if category}
|
||||
<span class="text-[11px] capitalize text-muted-foreground">{category}</span>
|
||||
{/if}
|
||||
{#if selectedValue}
|
||||
{#if selectedValue && !showNewFaces}
|
||||
<span class="text-[11px] font-medium">{drillTitle}</span>
|
||||
<span class="text-[11px] text-muted-foreground">
|
||||
{drillCount} photo{drillCount === 1 ? '' : 's'}
|
||||
@@ -216,10 +226,12 @@
|
||||
{/if}
|
||||
</Toolbar>
|
||||
|
||||
{#if !selectedValue}
|
||||
{#if category === 'people'}
|
||||
<!-- No person selected: surface the naming workflow instead of a
|
||||
bare prompt — naming is what creates people in the first place. -->
|
||||
{#if category === 'people' && (showNewFaces || !selectedValue)}
|
||||
<!-- Naming workflow: reachable both on bare landing (no person picked
|
||||
yet) and via the sidebar's pinned "Name new faces" row at any time
|
||||
— 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">
|
||||
<NewFacesPanel />
|
||||
<div class="mt-8 flex items-center justify-center">
|
||||
@@ -230,7 +242,7 @@
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
{:else}
|
||||
{:else if !selectedValue}
|
||||
<main class="flex min-h-0 flex-1 items-center justify-center p-8">
|
||||
<EmptyState
|
||||
icon={Tag}
|
||||
@@ -238,7 +250,6 @@
|
||||
description="Click a row in the panel on the left to filter the photo grid by that tag."
|
||||
/>
|
||||
</main>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="flex min-h-0 flex-1">
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
|
||||
Reference in New Issue
Block a user