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:
parent
833dc2e5be
commit
bee1b4d7f8
5 changed files with 184 additions and 44 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue