diff --git a/studio/src/components/FilmStrip/index.tsx b/studio/src/components/FilmStrip/index.tsx index 5b18652e..32c773d6 100644 --- a/studio/src/components/FilmStrip/index.tsx +++ b/studio/src/components/FilmStrip/index.tsx @@ -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 ( + + + + + ); + } + + 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 ( + + + {fraction > 0 && fraction < 1 && ( + + )} + {fraction >= 1 && ( + + )} + + ); +} + +function ActiveSkeleton({ attempt, total }: { attempt: number | null; total: number }): ReactElement { + return ( + + + + {attempt !== null && ( + {attempt}/{total} + )} + + + ); +} + +function QueuedSkeleton(): ReactElement { + return ; +} + +// ─── 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 Generated images appear here; } @@ -132,6 +263,10 @@ export function FilmStrip({ images, onOpenLightbox, onToggleFavorite }: FilmStri return ( <> + {activeGeneration && ( + + )} + {Array.from({ length: queuedCount }, (_, i) => )} {reversed.map((img, i) => ( onOpenLightbox(i)} /> + {img.analysis?.ratingViolation && ( + + SFW VIOLATION + + )} {img.qualityScore !== undefined && ( )}