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:
parent
26bd4ca0f9
commit
3b2ee8c5ee
1 changed files with 143 additions and 3 deletions
|
|
@ -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)}`} />
|
||||
)}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue