ui(film-strip): 💄 Improve thumbnail display and navigation in FilmStrip with optimized rendering and interactive controls

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-03-30 15:11:18 -07:00
parent 26bd4ca0f9
commit 3b2ee8c5ee

View file

@ -1,8 +1,119 @@
import { ReactElement } from 'react';
import styled from 'styled-components';
import styled, { keyframes } from 'styled-components';
import { theme } from '../../theme';
import type { GeneratedImage } from '../../types';
// ─── Skeleton / in-progress slots ────────────────────────────────────────────
const shimmer = keyframes`
0% { background-position: -200% center; }
100% { background-position: 200% center; }
`;
const SkeletonThumb = styled.div`
position: relative;
flex-shrink: 0;
height: 140px;
width: 96px;
border-right: 1px solid ${theme.colors.border};
background: linear-gradient(
90deg,
${theme.colors.bgCard} 25%,
${theme.colors.bgActive} 50%,
${theme.colors.bgCard} 75%
);
background-size: 200% 100%;
animation: ${shimmer} 1.6s ease-in-out infinite;
display: flex;
align-items: center;
justify-content: center;
`;
const PieWrap = styled.div`
position: relative;
width: 48px;
height: 48px;
opacity: 0.9;
`;
const PieLabel = styled.div`
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: 700;
color: white;
text-shadow: 0 1px 3px rgba(0,0,0,0.8);
pointer-events: none;
`;
function PieSVG({ attempt, total }: { attempt: number | null; total: number }): ReactElement {
const r = 20;
const cx = 24;
const cy = 24;
if (attempt === null) {
// Spinning ring — not yet started
return (
<svg width="48" height="48" viewBox="0 0 48 48">
<circle cx={cx} cy={cy} r={r} fill="none" stroke="rgba(255,255,255,0.12)" strokeWidth="3" />
<circle
cx={cx} cy={cy} r={r}
fill="none"
stroke={theme.colors.accent}
strokeWidth="3"
strokeDasharray={`${0.25 * 2 * Math.PI * r} ${0.75 * 2 * Math.PI * r}`}
strokeLinecap="round"
style={{ opacity: 0.7 }}
/>
</svg>
);
}
const fraction = attempt / total;
const angle = fraction * 2 * Math.PI;
const x = cx + r * Math.sin(angle);
const y = cy - r * Math.cos(angle);
const large = fraction > 0.5 ? 1 : 0;
return (
<svg width="48" height="48" viewBox="0 0 48 48">
<circle cx={cx} cy={cy} r={r} fill="none" stroke="rgba(255,255,255,0.12)" strokeWidth="2" />
{fraction > 0 && fraction < 1 && (
<path
d={`M ${cx} ${cy} L ${cx} ${cy - r} A ${r} ${r} 0 ${large} 1 ${x.toFixed(2)} ${y.toFixed(2)} Z`}
fill={theme.colors.accent}
opacity={0.85}
/>
)}
{fraction >= 1 && (
<circle cx={cx} cy={cy} r={r} fill={theme.colors.accent} opacity={0.85} />
)}
</svg>
);
}
function ActiveSkeleton({ attempt, total }: { attempt: number | null; total: number }): ReactElement {
return (
<SkeletonThumb>
<PieWrap>
<PieSVG attempt={attempt} total={total} />
{attempt !== null && (
<PieLabel>{attempt}/{total}</PieLabel>
)}
</PieWrap>
</SkeletonThumb>
);
}
function QueuedSkeleton(): ReactElement {
return <SkeletonThumb />;
}
// ─── Completed image thumb ────────────────────────────────────────────────────
const Thumb = styled.div<{ $favorited: boolean }>`
position: relative;
flex-shrink: 0;
@ -102,6 +213,22 @@ const QualityDot = styled.div<{ $score: number }>`
box-shadow: 0 0 0 1px rgba(0,0,0,0.4);
`;
const ViolationBadge = styled.div`
position: absolute;
top: 4px;
left: 6px;
background: ${theme.colors.error};
color: white;
font-size: 9px;
font-weight: 700;
padding: 1px 4px;
border-radius: 3px;
letter-spacing: 0.04em;
pointer-events: none;
text-shadow: none;
box-shadow: 0 1px 3px rgba(0,0,0,0.5);
`;
const Empty = styled.div`
flex: 1;
display: flex;
@ -121,10 +248,14 @@ interface FilmStripProps {
images: GeneratedImage[];
onOpenLightbox: (index: number) => void;
onToggleFavorite: (id: string, favorited: boolean) => void;
activeGeneration: { attempt: number | null; totalAttempts: number } | null;
queuedCount: number;
}
export function FilmStrip({ images, onOpenLightbox, onToggleFavorite }: FilmStripProps): ReactElement {
if (images.length === 0) {
export function FilmStrip({ images, onOpenLightbox, onToggleFavorite, activeGeneration, queuedCount }: FilmStripProps): ReactElement {
const hasContent = images.length > 0 || activeGeneration !== null || queuedCount > 0;
if (!hasContent) {
return <Empty>Generated images appear here</Empty>;
}
@ -132,6 +263,10 @@ export function FilmStrip({ images, onOpenLightbox, onToggleFavorite }: FilmStri
return (
<>
{activeGeneration && (
<ActiveSkeleton attempt={activeGeneration.attempt} total={activeGeneration.totalAttempts} />
)}
{Array.from({ length: queuedCount }, (_, i) => <QueuedSkeleton key={i} />)}
{reversed.map((img, i) => (
<Thumb key={img.id} $favorited={img.isFavorited ?? false}>
<ThumbImg
@ -139,6 +274,11 @@ export function FilmStrip({ images, onOpenLightbox, onToggleFavorite }: FilmStri
alt={img.prompt.slice(0, 60)}
onClick={() => onOpenLightbox(i)}
/>
{img.analysis?.ratingViolation && (
<ViolationBadge title={`Rating violation: nudity detected (score ${img.analysis.ratingViolationScore?.toFixed(2) ?? '?'}) but declared ${img.maturityRating.toUpperCase()}`}>
SFW VIOLATION
</ViolationBadge>
)}
{img.qualityScore !== undefined && (
<QualityDot $score={img.qualityScore} title={`Quality: ${img.qualityScore.toFixed(2)}`} />
)}