ui(image-assistant): 💄 Update FilterItem and FilterPanel components with modern CSS styling and TypeScript interactivity for refined visuals and enhanced filter controls

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-03-18 22:51:11 -07:00
parent 8f90beda0c
commit df7d41fe96
4 changed files with 256 additions and 0 deletions

View file

@ -0,0 +1,53 @@
.item {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.45rem 1rem;
border: none;
background: transparent;
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 {
background: rgba(255, 255, 255, 0.04);
color: rgba(255, 255, 255, 0.85);
}
.item.active {
background: rgba(167, 139, 250, 0.1);
color: #a78bfa;
}
.icon {
flex-shrink: 0;
display: flex;
align-items: center;
opacity: 0.7;
}
.item.active .icon {
opacity: 1;
}
.label {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.count {
font-size: 0.7rem;
color: rgba(255, 255, 255, 0.25);
flex-shrink: 0;
font-variant-numeric: tabular-nums;
}
.item.active .count {
color: rgba(167, 139, 250, 0.5);
}

View file

@ -0,0 +1,26 @@
import type { JSX, ReactNode } from 'react';
import styles from './FilterItem.module.css';
export interface FilterItemProps {
icon: ReactNode;
label: string;
count?: string | number;
active: boolean;
onClick: () => void;
}
export function FilterItem({ icon, label, count, active, onClick }: FilterItemProps): JSX.Element {
return (
<button
type="button"
className={`${styles.item} ${active ? styles.active : ''}`}
onClick={onClick}
>
<span className={styles.icon}>{icon}</span>
<span className={styles.label}>{label}</span>
{count !== undefined && (
<span className={styles.count}>{Number(count).toLocaleString()}</span>
)}
</button>
);
}

View file

@ -0,0 +1,25 @@
.nav {
flex: 1;
padding: 0.5rem 0 1rem;
display: flex;
flex-direction: column;
}
.section {
display: flex;
flex-direction: column;
padding: 0.75rem 0 0.25rem;
}
.section + .section {
border-top: 1px solid rgba(255, 255, 255, 0.05);
}
.sectionTitle {
font-size: 0.68rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.25);
padding: 0 1rem 0.5rem;
}

View file

@ -0,0 +1,152 @@
import type { JSX } from 'react';
import {
GridIcon,
ImageIcon,
VideoIcon,
LayersIcon,
HeartIcon,
MonitorIcon,
TagIcon,
} from '@lilith/ui-icons';
import type { CategoryCount, PhotoStats } from '@/api/types';
import { MediaType, PhotoCategory } from '@/api/types';
import { FilterItem } from './FilterItem';
import styles from './FilterPanel.module.css';
// ─── ActiveFilter — owned here, exported for consumers ───────────────────────
export type ActiveFilter =
| { kind: 'all' }
| { kind: 'mediaType'; value: MediaType }
| { kind: 'favorites' }
| { kind: 'screenshots' }
| { kind: 'category'; value: PhotoCategory };
export const INITIAL_FILTER: ActiveFilter = { kind: 'all' };
export function filterMatches(a: ActiveFilter, b: ActiveFilter): boolean {
if (a.kind !== b.kind) return false;
if (a.kind === 'mediaType' && b.kind === 'mediaType') return a.value === b.value;
if (a.kind === 'category' && b.kind === 'category') return a.value === b.value;
return true;
}
// ─── Constants ────────────────────────────────────────────────────────────────
const MEDIA_TYPE_LABELS: Record<string, string> = {
image: 'Photos',
video: 'Videos',
live_photo: 'Live Photos',
};
const CATEGORY_LABELS: Record<string, string> = {
screenshot_receipt: 'Receipts',
screenshot_conversation: 'Conversations',
screenshot_shopping: 'Shopping',
screenshot_meme: 'Memes',
screenshot_event: 'Events',
screenshot_reservation: 'Reservations',
screenshot_hottie: 'Hotties',
screenshot_other: 'Other Screenshots',
self_clothed: 'Selfies',
self_nude: 'Nude',
self_explicit: 'Explicit',
self_with_others: 'With Others',
friends: 'Friends',
unclassified: 'Unclassified',
};
function mediaTypeIcon(mediaType: string): JSX.Element {
switch (mediaType) {
case 'video': return <VideoIcon size={14} />;
case 'live_photo': return <LayersIcon size={14} />;
default: return <ImageIcon size={14} />;
}
}
// ─── Props ────────────────────────────────────────────────────────────────────
export interface FilterPanelProps {
photoStats: PhotoStats | undefined;
categoryCounts: CategoryCount[] | undefined;
activeFilter: ActiveFilter;
onSelect: (filter: ActiveFilter) => void;
}
// ─── Component ────────────────────────────────────────────────────────────────
export function FilterPanel({
photoStats,
categoryCounts,
activeFilter,
onSelect,
}: FilterPanelProps): JSX.Element {
const totalAll = photoStats?.byMediaType
.reduce((sum, r) => sum + Number(r.count), 0)
.toString();
const visibleCategories = (categoryCounts ?? []).filter((cc) => Number(cc.count) > 0);
return (
<nav className={styles.nav}>
<div className={styles.section}>
<span className={styles.sectionTitle}>Show</span>
<FilterItem
icon={<GridIcon size={14} />}
label="All"
count={totalAll}
active={filterMatches(activeFilter, { kind: 'all' })}
onClick={() => onSelect({ kind: 'all' })}
/>
{photoStats?.byMediaType.map(({ mediaType, count }) => (
<FilterItem
key={mediaType}
icon={mediaTypeIcon(mediaType)}
label={MEDIA_TYPE_LABELS[mediaType] ?? mediaType}
count={count}
active={filterMatches(activeFilter, { kind: 'mediaType', value: mediaType as MediaType })}
onClick={() => onSelect({ kind: 'mediaType', value: mediaType as MediaType })}
/>
))}
{Number(photoStats?.favoriteCount ?? 0) > 0 && (
<FilterItem
icon={<HeartIcon size={14} />}
label="Favorites"
count={photoStats?.favoriteCount}
active={filterMatches(activeFilter, { kind: 'favorites' })}
onClick={() => onSelect({ kind: 'favorites' })}
/>
)}
{Number(photoStats?.screenshotCount ?? 0) > 0 && (
<FilterItem
icon={<MonitorIcon size={14} />}
label="Screenshots"
count={photoStats?.screenshotCount}
active={filterMatches(activeFilter, { kind: 'screenshots' })}
onClick={() => onSelect({ kind: 'screenshots' })}
/>
)}
</div>
{visibleCategories.length > 0 && (
<div className={styles.section}>
<span className={styles.sectionTitle}>Categories</span>
{visibleCategories.map((cc) => (
<FilterItem
key={cc.category ?? 'unclassified'}
icon={<TagIcon size={14} />}
label={CATEGORY_LABELS[cc.category ?? 'unclassified'] ?? cc.category ?? 'Unknown'}
count={cc.count}
active={filterMatches(activeFilter, { kind: 'category', value: cc.category as PhotoCategory })}
onClick={() => onSelect({ kind: 'category', value: cc.category as PhotoCategory })}
/>
))}
</div>
)}
</nav>
);
}