feat(media-gallery): Add identity-based filtering to FilterPanel and integrate with AlbumsPage and GalleryPage

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-04-03 11:02:01 -07:00
parent 833dc2e5be
commit bee1b4d7f8
5 changed files with 184 additions and 44 deletions

View file

@ -75,9 +75,13 @@ export interface FilterPanelProps {
categoryCounts: CategoryCount[] | undefined;
identities: Identity[] | undefined;
activeFilter: ActiveFilter;
editingIdentityId: string | null;
onSelect: (filter: ActiveFilter) => void;
onMarkAsSelf: (identityId: string) => void;
onCreateIdentity: () => void;
onRenameIdentity: (id: string, name: string) => void;
onIdentityEditStart: (id: string) => void;
onIdentityEditEnd: () => void;
}
// ─── Component ────────────────────────────────────────────────────────────────
@ -87,9 +91,13 @@ export function FilterPanel({
categoryCounts,
identities,
activeFilter,
editingIdentityId,
onSelect,
onMarkAsSelf,
onCreateIdentity,
onRenameIdentity,
onIdentityEditStart,
onIdentityEditEnd,
}: FilterPanelProps): JSX.Element {
const totalAll = photoStats?.byMediaType
.reduce((sum, r) => sum + Number(r.count), 0)
@ -182,8 +190,12 @@ export function FilterPanel({
key={identity.id}
identity={identity}
active={filterMatches(activeFilter, { kind: 'identity', value: identity.id })}
editingId={editingIdentityId}
onSelect={() => onSelect({ kind: 'identity', value: identity.id })}
onMarkAsSelf={() => onMarkAsSelf(identity.id)}
onRename={onRenameIdentity}
onEditStart={onIdentityEditStart}
onEditEnd={onIdentityEditEnd}
/>
))}
</div>

View file

@ -1,16 +1,11 @@
.item {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.35rem 1rem;
border: none;
background: transparent;
gap: 0;
width: 100%;
color: rgba(255, 255, 255, 0.55);
font-size: 0.813rem;
cursor: pointer;
transition: background 0.12s ease, color 0.12s ease;
text-align: left;
width: 100%;
}
.item:hover {
@ -23,6 +18,21 @@
color: #a78bfa;
}
.mainBtn {
flex: 1;
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.35rem 0 0.35rem 1rem;
border: none;
background: transparent;
color: inherit;
font-size: inherit;
cursor: pointer;
text-align: left;
min-width: 0;
}
.avatar {
flex-shrink: 0;
width: 22px;
@ -59,6 +69,18 @@
overflow: hidden;
text-overflow: ellipsis;
font-size: 0.813rem;
cursor: text;
}
.nameInput {
width: 100%;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(167, 139, 250, 0.5);
border-radius: 3px;
color: white;
font-size: 0.813rem;
padding: 1px 4px;
outline: none;
}
.badges {
@ -84,12 +106,43 @@
color: rgba(255, 255, 255, 0.25);
flex-shrink: 0;
font-variant-numeric: tabular-nums;
margin-right: 0.25rem;
}
.item.active .count {
color: rgba(167, 139, 250, 0.5);
}
.actions {
display: flex;
align-items: center;
gap: 2px;
padding-right: 0.5rem;
opacity: 0;
transition: opacity 0.12s;
}
.item:hover .actions {
opacity: 1;
}
.renameBtn {
font-size: 0.75rem;
padding: 2px 5px;
border-radius: 3px;
border: 1px solid transparent;
background: transparent;
color: rgba(255, 255, 255, 0.35);
cursor: pointer;
transition: border-color 0.12s, color 0.12s;
line-height: 1;
}
.renameBtn:hover {
border-color: rgba(255, 255, 255, 0.2);
color: rgba(255, 255, 255, 0.7);
}
.markSelfBtn {
flex-shrink: 0;
font-size: 0.65rem;

View file

@ -1,4 +1,4 @@
import type { JSX } from 'react';
import { type JSX, useEffect, useRef, useState } from 'react';
import { UserIcon } from '@lilith/ui-icons';
import type { Identity } from '@/api/types';
import styles from './IdentityFilterItem.module.css';
@ -6,57 +6,119 @@ import styles from './IdentityFilterItem.module.css';
export interface IdentityFilterItemProps {
identity: Identity;
active: boolean;
editingId: string | null;
onSelect: () => void;
onMarkAsSelf: () => void;
onRename: (id: string, name: string) => void;
onEditStart: (id: string) => void;
onEditEnd: () => void;
}
export function IdentityFilterItem({
identity,
active,
editingId,
onSelect,
onMarkAsSelf,
onRename,
onEditStart,
onEditEnd,
}: IdentityFilterItemProps): JSX.Element {
const isEditing = editingId === identity.id;
const [draft, setDraft] = useState(identity.name ?? '');
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isEditing) {
setDraft(identity.name ?? '');
inputRef.current?.focus();
inputRef.current?.select();
}
}, [isEditing, identity.name]);
function commit(): void {
const trimmed = draft.trim();
if (trimmed && trimmed !== (identity.name ?? '')) {
onRename(identity.id, trimmed);
}
onEditEnd();
}
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>): void {
if (e.key === 'Enter') { e.preventDefault(); commit(); }
if (e.key === 'Escape') { setDraft(identity.name ?? ''); onEditEnd(); }
}
return (
<button
type="button"
className={`${styles.item} ${active ? styles.active : ''}`}
onClick={onSelect}
>
<span className={styles.avatar}>
{identity.coverThumbnailUrl ? (
<img
className={styles.avatarImg}
src={identity.coverThumbnailUrl}
alt={identity.name ?? 'Identity'}
/>
) : (
<UserIcon size={12} />
)}
</span>
<div className={`${styles.item} ${active ? styles.active : ''}`}>
<button
type="button"
className={styles.mainBtn}
onClick={onSelect}
aria-pressed={active}
>
<span className={styles.avatar}>
{identity.coverThumbnailUrl ? (
<img
className={styles.avatarImg}
src={identity.coverThumbnailUrl}
alt={identity.name ?? 'Identity'}
/>
) : (
<UserIcon size={12} />
)}
</span>
<span className={styles.info}>
<span className={styles.name}>{identity.name ?? 'Unknown'}</span>
{identity.isSelf && (
<span className={styles.badges}>
<span className={styles.selfBadge}>Self</span>
</span>
)}
</span>
<span className={styles.info}>
{isEditing ? (
<input
ref={inputRef}
className={styles.nameInput}
value={draft}
placeholder="Name…"
onChange={(e) => setDraft(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={commit}
onClick={(e) => e.stopPropagation()}
/>
) : (
<span
className={styles.name}
onDoubleClick={(e) => { e.stopPropagation(); onEditStart(identity.id); }}
title="Double-click to rename"
>
{identity.name ?? 'Unnamed'}
</span>
)}
{identity.isSelf && (
<span className={styles.badges}>
<span className={styles.selfBadge}>Self</span>
</span>
)}
</span>
<span className={styles.count}>{identity.photoCount.toLocaleString()}</span>
<span className={styles.count}>{identity.photoCount.toLocaleString()}</span>
</button>
{!identity.isSelf && (
<div className={styles.actions}>
<button
type="button"
className={styles.markSelfBtn}
onClick={(e) => {
e.stopPropagation();
onMarkAsSelf();
}}
className={styles.renameBtn}
onClick={(e) => { e.stopPropagation(); onEditStart(identity.id); }}
title="Rename"
>
Mark as Self
</button>
)}
</button>
{!identity.isSelf && (
<button
type="button"
className={styles.markSelfBtn}
onClick={(e) => { e.stopPropagation(); onMarkAsSelf(); }}
>
Self
</button>
)}
</div>
</div>
);
}

View file

@ -1,3 +1,4 @@
import { type JSX } from 'react';
import { Link } from '@lilith/ui-router';
import { FolderOpenIcon, ImageIcon } from '@lilith/ui-icons';
import { useAlbums } from '@/api/hooks';

View file

@ -1,5 +1,5 @@
import { useCallback, useMemo, useState, type JSX } from 'react';
import { usePhotos, useCategoryCounts, usePhotoStats, useIdentities, useMarkAsSelf, useCreateIdentity } from '@/api/hooks';
import { usePhotos, useCategoryCounts, usePhotoStats, useIdentities, useMarkAsSelf, useCreateIdentity, useRenameIdentity } from '@/api/hooks';
import type { PhotoFilters } from '@/api/types';
import { ViewMode } from '@/api/types';
import { FilterPanel, INITIAL_FILTER, filterMatches } from '@/components/FilterPanel';
@ -36,9 +36,11 @@ export function GalleryPage(): JSX.Element {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = usePhotos(filters);
const { data: photoStats } = usePhotoStats();
const { data: categoryCounts } = useCategoryCounts();
const [editingIdentityId, setEditingIdentityId] = useState<string | null>(null);
const { data: identities } = useIdentities();
const markAsSelfMutation = useMarkAsSelf();
const createIdentityMutation = useCreateIdentity();
const renameIdentityMutation = useRenameIdentity();
const photos = useMemo(() => data?.pages.flatMap((p) => p.photos) ?? [], [data]);
const totalCount = data?.pages[0]?.total ?? 0;
@ -54,9 +56,15 @@ export function GalleryPage(): JSX.Element {
}, [markAsSelfMutation]);
const handleCreateIdentity = useCallback((): void => {
createIdentityMutation.mutate();
createIdentityMutation.mutate(undefined, {
onSuccess: (identity) => setEditingIdentityId(identity.id),
});
}, [createIdentityMutation]);
const handleRenameIdentity = useCallback((id: string, name: string): void => {
renameIdentityMutation.mutate({ id, name });
}, [renameIdentityMutation]);
return (
<div className={styles.page}>
<aside className={styles.panel}>
@ -69,9 +77,13 @@ export function GalleryPage(): JSX.Element {
categoryCounts={categoryCounts}
identities={identities}
activeFilter={activeFilter}
editingIdentityId={editingIdentityId}
onSelect={handleSelect}
onMarkAsSelf={handleMarkAsSelf}
onCreateIdentity={handleCreateIdentity}
onRenameIdentity={handleRenameIdentity}
onIdentityEditStart={setEditingIdentityId}
onIdentityEditEnd={() => setEditingIdentityId(null)}
/>
</aside>