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:
parent
8f90beda0c
commit
df7d41fe96
4 changed files with 256 additions and 0 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue