feat(metadata,people): richer metadata editing + face-naming flow

Metadata (sidebar):
- New editable fields: Title, Credits section (Artist/Copyright/
  License via Details), GPS Altitude.
- Video facts in the File section: Duration, FPS, Codec — required
  fixing videoFile(): PhotoPrism serializes MediaType as the bare word
  "video", so the old startsWith('video/') check never matched and the
  helper always fell back to the JPEG poster.
- Timezone correctness: buildTakenAtPatch no longer forces
  TakenAtLocal=UTC; it preserves the photo's existing UTC↔local offset
  (per-photo in bulk edits) so PhotoPrism can't clobber manual date
  edits when recomputing from TimeZone, and Year/Month/Day now derive
  from local wall-clock time.

People (was "disabled" — really: zero subjects because naming is what
creates a person, and the UI had no naming flow; prod has 40k face
markers in 790 unnamed clusters):
- Sidecar GET /api/sidecar/subjects — scoped people list via one
  markers→files→photos SQL pass (labels pattern), replacing the
  client-side probe-per-subject N+1 filter.
- Sidecar GET /api/sidecar/faces/unnamed — the caller's unnamed face
  clusters with count, crop thumb, and a representative marker UID.
- "Name new faces" panel on /tags/people: face-crop cards with inline
  name input; naming uses PhotoPrism's own flow (PUT /markers/:uid
  {Name, SubjSrc:manual}, verified against PP source) which creates
  the Subject and propagates across the cluster.
- Scoped proxy: marker PUT / subject-clear DELETE now allowed with
  per-marker ownership checks (was blanket-forbidden, which would have
  blocked naming for scoped users).
- Per-photo People chips in the sidebar from named Files[].Markers,
  linking to the person's page.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-04 18:35:27 +02:00
parent a239cece10
commit 246d159d93
11 changed files with 624 additions and 53 deletions

View File

@@ -0,0 +1,112 @@
<!--
"Name new faces" — the missing half of the People feature. PhotoPrism
only creates a person once someone names a detected face cluster, so a
library can have tens of thousands of face markers and still show an
empty People list. This panel surfaces the caller's unnamed clusters
(scoped server-side to their BasePath) as face-crop cards with an
inline name input; naming goes through PhotoPrism's own flow (PUT on
the cluster's representative marker), which creates the Subject and
propagates it across the cluster.
-->
<script lang="ts">
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
import { toast } from 'svelte-sonner';
import {
listUnnamedFaces,
nameFaceCluster,
type UnnamedFaceCluster
} from '$lib/services/photoprism';
import { isAuthenticated, thumbUrl } from '$lib/stores/session.svelte';
import { InlineLoader } from '$lib/components/feedback';
import { UserPlus } from 'lucide-svelte';
const qc = useQueryClient();
const facesQuery = createQuery<UnnamedFaceCluster[]>(() => ({
queryKey: ['faces', 'unnamed'],
queryFn: listUnnamedFaces,
enabled: isAuthenticated(),
staleTime: 60_000
}));
let drafts = $state<Record<string, string>>({});
let busy = $state<Record<string, boolean>>({});
async function submit(cluster: UnnamedFaceCluster) {
const name = (drafts[cluster.faceId] ?? '').trim();
if (!name || busy[cluster.faceId]) return;
busy[cluster.faceId] = true;
try {
await nameFaceCluster(cluster.markerUid, name);
toast.success(`Named ${name}`, {
description:
'PhotoPrism links the whole cluster in the background — the photo count may keep growing.'
});
drafts[cluster.faceId] = '';
void qc.invalidateQueries({ queryKey: ['faces', 'unnamed'] });
void qc.invalidateQueries({ queryKey: ['subjects'] });
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Naming failed');
} finally {
busy[cluster.faceId] = false;
}
}
const clusters = $derived(facesQuery.data ?? []);
</script>
{#if facesQuery.isPending}
<InlineLoader label="Looking for unnamed faces…" />
{:else if clusters.length > 0}
<section class="space-y-3">
<header class="space-y-0.5">
<h2 class="flex items-center gap-1.5 text-sm font-medium text-foreground">
<UserPlus class="h-4 w-4" /> Name new faces
</h2>
<p class="text-[11px] text-muted-foreground">
Faces PhotoPrism detected but nobody has named yet. Naming one creates a person and tags
every matching photo.
</p>
</header>
<div
class="grid gap-3"
style="grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));"
>
{#each clusters as cluster (cluster.faceId)}
<div
class="flex flex-col items-center gap-2 rounded-md border border-border bg-card/30 p-3"
>
<div class="relative">
<img
src={thumbUrl(cluster.thumb, 'tile_224')}
alt="Unnamed face"
loading="lazy"
decoding="async"
class="h-20 w-20 rounded-full border border-border object-cover"
/>
<span
class="absolute -bottom-1 -right-1 rounded-full bg-secondary px-1.5 py-0.5 text-[10px] font-medium tabular-nums text-muted-foreground"
title={`${cluster.count} of your photos carry this face`}
>
{cluster.count}
</span>
</div>
<input
type="text"
placeholder="Name…"
disabled={busy[cluster.faceId]}
class="w-full rounded border border-input bg-background px-1.5 py-1 text-center text-xs shadow-sm focus:outline-none focus:ring-1 focus:ring-ring disabled:opacity-50"
bind:value={drafts[cluster.faceId]}
onkeydown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
void submit(cluster);
}
}}
onblur={() => void submit(cluster)}
/>
</div>
{/each}
</div>
</section>
{/if}

View File

@@ -82,11 +82,13 @@
() =>
patchTargets(
ids,
buildTakenAtPatch(iso),
// Per-photo patch so each photo keeps its own UTC↔local
// offset when the date is stamped across a selection.
(p) => buildTakenAtPatch(iso, p),
label,
(p) =>
p.TakenAt
? buildTakenAtPatch(p.TakenAt)
? buildTakenAtPatch(p.TakenAt, p)
: ({ TakenSrc: '' } as UpdatePhotoBody)
),
label

View File

@@ -25,6 +25,7 @@
Star,
Tag,
Timer,
User,
X
} from 'lucide-svelte';
import {
@@ -44,7 +45,13 @@
import { isAuthenticated } from '$lib/stores/session.svelte';
import { push as pushUndo } from '$lib/stores/undo.svelte';
import { getMetadataSectionOpen, setMetadataSection } from '$lib/stores/view.svelte';
import { photoNameAndDir, primaryFile, type PpPhoto } from '$lib/types/photoprism';
import {
isVideo,
photoNameAndDir,
primaryFile,
videoFile,
type PpPhoto
} from '$lib/types/photoprism';
import { navigateToFolder, navigateToTag, setSearch, setSection } from '$lib/stores/filters.svelte';
import { goto } from '$app/navigation';
import { COLOR_SWATCHES } from '$lib/utils/tagGroups';
@@ -59,14 +66,19 @@
const qc = useQueryClient();
let basename = $state('');
let title = $state('');
let caption = $state('');
let takenAt = $state('');
let lat = $state('');
let lng = $state('');
let altitude = $state('');
let country = $state('');
let keywords = $state<string[]>([]);
let keywordDraft = $state('');
let renaming = $state(false);
let artist = $state('');
let copyright = $state('');
let license = $state('');
/** Split `pf.Name` (a relative path like `foo/bar/IMG.jpg`) into directory
* prefix and basename. Sidecar's rename endpoint only accepts a bare
@@ -80,16 +92,21 @@
$effect(() => {
const pf = primaryFile(photo);
basename = splitName(pf.Name ?? '').base;
title = photo.Title ?? '';
caption = photo.Caption ?? '';
takenAt = (photo.TakenAt ?? '').slice(0, 10);
lat = photo.Lat ? String(photo.Lat) : '';
lng = photo.Lng ? String(photo.Lng) : '';
altitude = photo.Altitude ? String(photo.Altitude) : '';
country = photo.Country && photo.Country !== 'zz' ? photo.Country : '';
const det = photo.Details ?? {};
keywords = (det.Keywords ?? '')
.split(',')
.map((k) => k.trim())
.filter(Boolean);
artist = det.Artist ?? '';
copyright = det.Copyright ?? '';
license = det.License ?? '';
});
const patchMutation = createMutation(() => ({
@@ -145,6 +162,11 @@
if (caption === (photo.Caption ?? '')) return;
commit({ Caption: caption, CaptionSrc: 'manual' });
}
function commitTitle() {
const next = title.trim();
if (next === (photo.Title ?? '')) return;
commit({ Title: next, TitleSrc: 'manual' });
}
const takenAtValid = $derived(takenAt === '' || isValidISODate(takenAt));
// Path-based date guess. Scoped to the EXIF Stripped review tab: those
// are the photos with definitionally-untrusted dates, and showing the
@@ -185,14 +207,16 @@
const tail = (photo.TakenAt ?? '').slice(10) || 'T00:00:00Z';
const iso = `${takenAt}${tail}`;
if (iso === photo.TakenAt) return;
commit(buildTakenAtPatch(iso));
commit(buildTakenAtPatch(iso, photo));
}
function commitGps() {
const nlat = parseFloat(lat);
const nlng = parseFloat(lng);
const nalt = parseFloat(altitude);
const patch: UpdatePhotoBody = {};
if (!Number.isNaN(nlat) && nlat !== photo.Lat) patch.Lat = nlat;
if (!Number.isNaN(nlng) && nlng !== photo.Lng) patch.Lng = nlng;
if (!Number.isNaN(nalt) && nalt !== photo.Altitude) patch.Altitude = nalt;
if (Object.keys(patch).length) commit(patch);
}
function commitCountry() {
@@ -202,7 +226,7 @@
commit({ Country: next || 'zz', CountrySrc: 'manual' });
}
type DetailsKey = 'Keywords';
type DetailsKey = 'Keywords' | 'Artist' | 'Copyright' | 'License';
function commitDetails(field: DetailsKey, value: string) {
const prev = (photo.Details ?? {})[field] ?? '';
if (value === prev) return;
@@ -283,7 +307,42 @@
const currentRating = $derived(photoMark.rating ?? 0);
const currentColor = $derived(photoMark.color ?? '');
// Named face markers across all file variants, deduped by subject.
// Slug mirrors PhotoPrism's slugify (lowercase, diacritics stripped,
// non-alphanumerics collapsed to '-') so the person link resolves the
// same drill URL the sidebar list uses.
function personSlug(name: string): string {
return name
.toLowerCase()
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
const peopleChips = $derived.by(() => {
const seen = new Map<string, { subjUid: string; name: string; slug: string }>();
for (const f of photo.Files ?? []) {
for (const m of f.Markers ?? []) {
if (m.Invalid || !m.Name || !m.SubjUID || seen.has(m.SubjUID)) continue;
seen.set(m.SubjUID, { subjUid: m.SubjUID, name: m.Name, slug: personSlug(m.Name) });
}
}
return [...seen.values()];
});
const pf = $derived(primaryFile(photo));
// Video facts come from the video variant (primary is often the JPEG
// poster for Live Photos / transcoded clips).
const vf = $derived(isVideo(photo) ? videoFile(photo) : null);
const durationStr = $derived.by(() => {
// PpFile.Duration is Go time.Duration → nanoseconds.
const ns = vf?.Duration ?? 0;
if (ns <= 0) return '';
const totalSec = Math.round(ns / 1_000_000_000);
const m = Math.floor(totalSec / 60);
const s = totalSec % 60;
return `${m}:${String(s).padStart(2, '0')}`;
});
const dirPath = $derived(splitName(pf.Name ?? '').dir);
const folderLabel = $derived(dirPath ? `${dirPath}/` : '/');
const dims = $derived(pf.Width && pf.Height ? `${pf.Width}×${pf.Height}` : '—');
@@ -508,6 +567,17 @@
</span>
</summary>
<div class="space-y-2 p-2 pt-1">
<div class="space-y-1">
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">Title</div>
<input
type="text"
placeholder="Add a title…"
class="w-full rounded border border-input bg-background px-1.5 py-1 text-xs shadow-sm focus:outline-none focus:ring-1 focus:ring-ring"
bind:value={title}
onblur={commitTitle}
/>
</div>
<div class="space-y-1">
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">Note</div>
<textarea
@@ -604,6 +674,27 @@
</div>
</div>
<!-- Recognized people — named face markers on this photo's files.
Read-only chips linking to the person's page. -->
{#if peopleChips.length > 0}
<div class="space-y-1">
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">People</div>
<div class="flex flex-wrap gap-1">
{#each peopleChips as person (person.subjUid)}
<button
type="button"
class="inline-flex items-center gap-1 rounded-full border border-border bg-secondary px-1.5 py-0.5 text-[10px] hover:bg-accent"
onclick={() => void navigateToTag('people', person.slug)}
title={`View photos of ${person.name}`}
>
<User class="h-2.5 w-2.5 text-muted-foreground" />
{person.name}
</button>
{/each}
</div>
</div>
{/if}
<!-- Auto-labels (PhotoPrism's TensorFlow classifier output). Read-
only: editing labels requires re-indexing on PhotoPrism's
side. The dashed border + lower contrast distinguishes them
@@ -678,6 +769,62 @@
onblur={commitCountry}
/>
</label>
<label class="flex flex-col gap-0.5">
<span class="text-[9px] text-muted-foreground">Altitude (m)</span>
<input
type="number"
step="1"
class="rounded border border-input bg-background px-1 py-0.5 text-[10px] focus:outline-none focus:ring-1 focus:ring-ring"
bind:value={altitude}
onblur={commitGps}
/>
</label>
</div>
</details>
<!-- Credits — IPTC provenance fields (Artist / Copyright / License).
Closed by default; persists once opened. -->
<details
class="rounded border border-border"
open={getMetadataSectionOpen('credits', false)}
ontoggle={(e) => setMetadataSection('credits', e.currentTarget.open)}
>
<summary
class="cursor-pointer px-2 py-1 text-[10px] uppercase tracking-wide text-muted-foreground"
>
Credits
</summary>
<div class="space-y-1.5 p-2 pt-1">
<label class="flex flex-col gap-0.5">
<span class="text-[9px] text-muted-foreground">Artist</span>
<input
type="text"
placeholder="Photographer…"
class="rounded border border-input bg-background px-1.5 py-0.5 text-[10px] focus:outline-none focus:ring-1 focus:ring-ring"
bind:value={artist}
onblur={() => commitDetails('Artist', artist.trim())}
/>
</label>
<label class="flex flex-col gap-0.5">
<span class="text-[9px] text-muted-foreground">Copyright</span>
<input
type="text"
placeholder="© …"
class="rounded border border-input bg-background px-1.5 py-0.5 text-[10px] focus:outline-none focus:ring-1 focus:ring-ring"
bind:value={copyright}
onblur={() => commitDetails('Copyright', copyright.trim())}
/>
</label>
<label class="flex flex-col gap-0.5">
<span class="text-[9px] text-muted-foreground">License</span>
<input
type="text"
placeholder="e.g. CC BY-NC 4.0"
class="rounded border border-input bg-background px-1.5 py-0.5 text-[10px] focus:outline-none focus:ring-1 focus:ring-ring"
bind:value={license}
onblur={() => commitDetails('License', license.trim())}
/>
</label>
</div>
</details>
@@ -753,6 +900,18 @@
{/if}
<dt class="text-muted-foreground">Type</dt>
<dd class="text-foreground/80">{pf.FileType ?? photo.Type ?? '—'}</dd>
{#if durationStr}
<dt class="text-muted-foreground">Duration</dt>
<dd class="text-foreground/80">{durationStr}</dd>
{/if}
{#if vf?.FPS}
<dt class="text-muted-foreground">FPS</dt>
<dd class="text-foreground/80">{Math.round(vf.FPS * 10) / 10}</dd>
{/if}
{#if vf?.Codec}
<dt class="text-muted-foreground">Codec</dt>
<dd class="text-foreground/80">{vf.Codec}</dd>
{/if}
<dt class="text-muted-foreground">Hash</dt>
<dd class="break-all font-mono text-foreground/70">{pf.Hash?.slice(0, 16) ?? '—'}</dd>
<dt class="text-muted-foreground">Indexed</dt>

View File

@@ -427,7 +427,7 @@
title={filterText ? 'No people match the filter' : 'No people yet'}
description={filterText
? undefined
: 'PhotoPrism creates a person whenever it clusters detected faces. Make sure face recognition is enabled and indexed.'}
: 'A person appears here once you name a detected face — use the "Name new faces" cards on the right.'}
/>
{:else}
<div bind:this={scrollEl} class="min-h-0 flex-1 overflow-y-auto">

View File

@@ -104,7 +104,7 @@ export async function acceptDateAndKeep(uids: string[]): Promise<void> {
originalName: p.OriginalName,
path
});
if (guess) await updatePhoto(p, buildTakenAtPatch(`${guess.iso}T00:00:00Z`));
if (guess) await updatePhoto(p, buildTakenAtPatch(`${guess.iso}T00:00:00Z`, p));
}
await approvePhoto(id);
return id;

View File

@@ -343,6 +343,8 @@ export async function getPhoto(uid: string): Promise<PpPhoto> {
*/
export interface UpdatePhotoBody {
OriginalName?: string;
Title?: string;
TitleSrc?: 'manual' | '';
Caption?: string;
CaptionSrc?: 'manual' | '';
Archived?: boolean;
@@ -373,17 +375,43 @@ export function isValidISODate(s: string): boolean {
return d.toISOString().slice(0, 10) === s;
}
export function buildTakenAtPatch(iso: string): UpdatePhotoBody {
/** PhotoPrism serializes TakenAtLocal with a `Z` suffix even though it's
* semantically wall-clock time in the photo's TimeZone. Force-parse as
* UTC so offset math never picks up the *browser's* timezone. */
function parseAsUtc(s: string): number {
return Date.parse(/(Z|[+-]\d{2}:?\d{2})$/.test(s) ? s : s + 'Z');
}
/**
* `photo` supplies the existing TakenAt/TakenAtLocal pair so the photo's
* UTC↔local offset survives the edit. Without it (or without a prior
* pair) local falls back to UTC — correct for TimeZone-less photos.
* Previously this forced `TakenAtLocal = UTC`, which both let PhotoPrism
* clobber manual edits when recomputing local time from TimeZone and
* shifted Year/Month/Day for photos taken far from UTC.
*/
export function buildTakenAtPatch(
iso: string,
photo?: { TakenAt?: string; TakenAtLocal?: string }
): UpdatePhotoBody {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return {};
const utc = d.toISOString().replace(/\.\d+Z$/, 'Z');
let offsetMs = 0;
if (photo?.TakenAt && photo?.TakenAtLocal) {
const a = parseAsUtc(photo.TakenAt);
const b = parseAsUtc(photo.TakenAtLocal);
if (!Number.isNaN(a) && !Number.isNaN(b)) offsetMs = b - a;
}
const local = new Date(d.getTime() + offsetMs);
return {
TakenAt: utc,
TakenAtLocal: utc,
TakenAtLocal: local.toISOString().replace(/\.\d+Z$/, 'Z'),
TakenSrc: 'manual',
Year: d.getUTCFullYear(),
Month: d.getUTCMonth() + 1,
Day: d.getUTCDate()
// PhotoPrism derives Year/Month/Day from local wall-clock time.
Year: local.getUTCFullYear(),
Month: local.getUTCMonth() + 1,
Day: local.getUTCDate()
};
}
@@ -694,35 +722,6 @@ export async function aggregateKeywords(): Promise<AggregatedKeyword[]> {
return Array.from(buckets.values()).sort((a, b) => b.count - a.count);
}
async function hasPhotosMatching(q: string): Promise<boolean> {
const resp = await sidecar.get<PpPhoto[]>('/api/sidecar/timeline', {
params: { count: 1, offset: 0, q }
});
return Array.isArray(resp.data) && resp.data.length > 0;
}
async function filterByUserPhotos<T>(
items: T[],
queryFor: (item: T) => string
): Promise<T[]> {
if (userBasePath() === '') return items;
const CONCURRENCY = 8;
const out: T[] = [];
for (let i = 0; i < items.length; i += CONCURRENCY) {
const batch = items.slice(i, i + CONCURRENCY);
const checks = await Promise.all(
batch.map(async (item) => ({
item,
has: await hasPhotosMatching(queryFor(item))
}))
);
for (const { item, has } of checks) {
if (has) out.push(item);
}
}
return out;
}
export async function listLabels(): Promise<PpLabel[]> {
// `all=true` includes labels PhotoPrism has soft-deleted (auto-hidden
// low-confidence classifier hits, manually-removed labels). They're
@@ -762,10 +761,42 @@ export interface PpSubject {
}
export async function listSubjects(): Promise<PpSubject[]> {
const { data } = await http.get<PpSubject[]>('/subjects', {
// Sidecar proxy scopes PhotoCount (and drops out-of-scope people) with
// one SQL pass, replacing the old client-side probe-per-subject filter.
const { data } = await sidecar.get<PpSubject[]>('/api/sidecar/subjects', {
params: { count: 1000, order: 'count' }
});
return filterByUserPhotos(data ?? [], (s) => `person:${s.Slug}`);
return data ?? [];
}
// ── Face clusters (unnamed people) ──────────────────────────────────────────
//
// PhotoPrism only creates a Subject once someone names a detected face
// cluster. The sidecar lists clusters awaiting a name (scoped to the
// caller's BasePath); naming goes through PhotoPrism's own flow — a PUT
// on the cluster's representative marker — which creates the Subject and
// propagates it across the whole cluster.
export interface UnnamedFaceCluster {
faceId: string;
count: number;
/** Marker crop hash — renders via the standard thumb endpoint. */
thumb: string;
markerUid: string;
}
export async function listUnnamedFaces(): Promise<UnnamedFaceCluster[]> {
const { data } = await sidecar.get<{ clusters: UnnamedFaceCluster[] }>(
'/api/sidecar/faces/unnamed'
);
return data?.clusters ?? [];
}
export async function nameFaceCluster(markerUid: string, name: string): Promise<void> {
await http.put(`/markers/${encodeURIComponent(markerUid)}`, {
Name: name,
SubjSrc: 'manual'
});
}
export async function updateSubject(uid: string, patch: Partial<PpSubject>): Promise<PpSubject> {

View File

@@ -257,10 +257,17 @@ export function isVideo(p: PpPhoto): boolean {
/** Return the Files[] entry that carries the actual video stream. Falls back
* to primaryFile() if no video MediaType is present (shouldn't happen for
* Type === 'video' but keeps the call site total). */
* Type === 'video' but keeps the call site total).
*
* PhotoPrism serializes MediaType as the bare word "video" (verified
* against prod), not a MIME type — the old `startsWith('video/')` check
* never matched, so this always fell back to the JPEG poster and video
* facts (duration/codec/fps) were unreachable. */
export function videoFile(p: PpPhoto): PpFile {
const files = p.Files ?? [];
const v = files.find((f) => f.MediaType?.startsWith('video/'));
const v = files.find(
(f) => f.MediaType === 'video' || f.MediaType?.startsWith('video/')
);
return v ?? primaryFile(p);
}
@@ -276,6 +283,34 @@ export interface PpFile {
Size?: number;
FileType?: string;
MediaType?: string;
Codec?: string;
/** Video duration in nanoseconds (Go time.Duration serialization). */
Duration?: number;
FPS?: number;
Frames?: number;
/** EXIF orientation 18. */
Orientation?: number;
HDR?: boolean;
Projection?: string;
/** Face/subject markers detected in this file (present on
* GET /photos/:uid responses). */
Markers?: PpMarker[];
}
/** A detected region in a file — for our purposes always a face. Named
* markers carry the subject they were matched to. */
export interface PpMarker {
UID: string;
Type?: string;
Src?: string;
Name?: string;
SubjUID?: string;
SubjSrc?: string;
FaceID?: string;
Invalid?: boolean;
Score?: number;
/** Crop hash renderable via the standard thumb endpoint. */
Thumb?: string;
}
export type PpThumbSize =

View File

@@ -32,6 +32,7 @@
import { countryName } from '$lib/utils/countries';
import BulkActionBar from '$lib/components/timeline/BulkActionBar.svelte';
import BulkMetadataSidebar from '$lib/components/sidebar/BulkMetadataSidebar.svelte';
import NewFacesPanel from '$lib/components/people/NewFacesPanel.svelte';
import PhotoGrid from '$lib/components/timeline/PhotoGrid.svelte';
import RightSidebar from '$lib/components/sidebar/RightSidebar.svelte';
import SkeletonGrid from '$lib/components/timeline/SkeletonGrid.svelte';
@@ -216,13 +217,28 @@
</Toolbar>
{#if !selectedValue}
<main class="flex min-h-0 flex-1 items-center justify-center p-8">
<EmptyState
icon={Tag}
title={`Pick a ${category ?? 'tag'} from the sidebar`}
description="Click a row in the panel on the left to filter the photo grid by that tag."
/>
</main>
{#if category === 'people'}
<!-- No person selected: surface the naming workflow instead of a
bare prompt — naming is what creates people in the first place. -->
<main class="min-h-0 flex-1 overflow-y-auto p-6">
<NewFacesPanel />
<div class="mt-8 flex items-center justify-center">
<EmptyState
icon={Tag}
title="Pick a person from the sidebar"
description="Click a row in the panel on the left to see that person's photos."
/>
</div>
</main>
{:else}
<main class="flex min-h-0 flex-1 items-center justify-center p-8">
<EmptyState
icon={Tag}
title={`Pick a ${category ?? 'tag'} from the sidebar`}
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">