feat(sprite-generation): Introduce real-time sprite streaming UI components and preview logic in App.tsx and SpriteStream.tsx

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-03-29 06:36:31 -07:00
parent a8b9d1c488
commit 80e1eccfb0
2 changed files with 60 additions and 184 deletions

View file

@ -1,4 +1,4 @@
import { Routes, Route, Link, useSearchParams } from 'react-router-dom'
import { Routes, Route, Link, useLocation } from 'react-router-dom'
import type { ReactElement } from 'react'
import { DashboardPage } from './pages/DashboardPage'
import { ReviewQueuePage } from './pages/ReviewQueuePage'
@ -9,17 +9,9 @@ import VariantPage from './pages/VariantPage'
import { SpriteStream } from './SpriteStream'
import { colors } from './pages/theme'
function DashboardOrTheater(): ReactElement {
const [params] = useSearchParams()
if (params.get('spriteTheater') === 'true') {
return <SpriteTheaterPage />
}
return <DashboardPage />
}
export function App(): ReactElement {
const [params] = useSearchParams()
const isTheater = params.get('spriteTheater') === 'true'
const location = useLocation()
const isTheater = location.pathname === '/theater'
return (
<div
@ -56,17 +48,33 @@ export function App(): ReactElement {
Sprite Review
</Link>
<div style={{ display: 'flex', gap: 16 }}>
<Link to="/?spriteTheater=true" style={{ color: colors.muted, textDecoration: 'none', fontSize: 13 }}>
Theater
</Link>
<Link to="/review" style={{ color: colors.muted, textDecoration: 'none', fontSize: 13 }}>
Review
</Link>
{[
{ to: '/theater', label: 'Theater' },
{ to: '/review', label: 'Review' },
].map(({ to, label }) => {
const active = location.pathname === to
return (
<Link
key={to}
to={to}
style={{
color: active ? colors.text : colors.muted,
textDecoration: 'none',
fontSize: 13,
fontWeight: active ? 600 : 400,
borderBottom: active ? `2px solid ${colors.highlight}` : '2px solid transparent',
paddingBottom: 2,
}}
>
{label}
</Link>
)
})}
</div>
</nav>
)}
<Routes>
<Route path="/" element={<DashboardOrTheater />} />
<Route path="/" element={<DashboardPage />} />
<Route path="/theater" element={<SpriteTheaterPage />} />
<Route path="/review" element={<ReviewQueuePage />} />
<Route path="/category/:name" element={<CategoryPage />} />

View file

@ -1,12 +1,12 @@
import { useEffect, useRef, useState, useCallback, type ReactNode, type CSSProperties } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { useEffect, useRef, useState, useCallback, type ReactNode } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import { fetchRecentVariants, variantStreamUrl, bestImageUrl, rawImageUrl } from './api'
import type { RecentVariant } from './types'
import { colors } from './pages/theme'
const MAX_SPRITES = 60
const STREAM_HEIGHT = 110
const SPRITE_SIZE = 80
const STREAM_HEIGHT = 132
const SPRITE_SIZE = 100
const CARD_GAP = 16
const CARD_TOTAL = SPRITE_SIZE + CARD_GAP
const SSE_RETRY_DELAYS = [5000, 10000, 20000, 30000]
@ -30,46 +30,23 @@ const categoryColors: Record<string, string> = {
ui: '#6b7280',
}
interface RatingSentiment {
emoji: string
label: string
color: string
}
function getRatingSentiment(rating: number): RatingSentiment {
if (rating >= 4.5) return { emoji: '\u2728', label: 'stellar', color: '#fbbf24' }
if (rating >= 3.5) return { emoji: '\u2705', label: 'good', color: '#10b981' }
if (rating >= 2.5) return { emoji: '\uD83D\uDE10', label: 'okay', color: '#f59e0b' }
if (rating >= 1.5) return { emoji: '\uD83D\uDC4E', label: 'weak', color: '#ef4444' }
return { emoji: '\u274C', label: 'poor', color: '#dc2626' }
}
function parseTopScore(notes: string | null): string | null {
if (!notes) return null
const parsed: unknown = JSON.parse(notes)
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null
const entries = Object.entries(parsed as Record<string, unknown>)
.filter((e): e is [string, number] => typeof e[1] === 'number')
.sort((a, b) => b[1] - a[1])
if (entries.length === 0) return null
const [key, val] = entries[0]
return `${key.replace(/_/g, ' ')} ${Math.round(val * 100)}%`
function ratingColor(rating: number): string {
if (rating >= 4.5) return '#fbbf24'
if (rating >= 3.5) return '#10b981'
if (rating >= 2.5) return '#f59e0b'
return '#ef4444'
}
function RatingBadge({
variant,
size,
justScored,
}: {
variant: RecentVariant
size: number
justScored: boolean
}): ReactNode {
if (variant.rating === null) return null
if (variant.rating === null || variant.rating <= 0) return null
const sentiment = getRatingSentiment(variant.rating)
const topScore = parseTopScore(variant.notes)
const isLarge = size > 100
const color = ratingColor(variant.rating)
return (
<div
@ -80,41 +57,27 @@ function RatingBadge({
right: 3,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: isLarge ? '3px 6px' : '2px 4px',
gap: 4,
padding: '2px 5px',
borderRadius: 4,
background: 'rgba(0,0,0,0.75)',
backdropFilter: 'blur(4px)',
animation: justScored ? `scoreFlash ${SCORE_FLASH_DURATION}ms ease-out` : undefined,
}}
>
<span style={{ fontSize: isLarge ? 11 : 9, lineHeight: 1 }}>
{sentiment.emoji}
</span>
<span
style={{
fontSize: isLarge ? 10 : 8,
color: sentiment.color,
fontWeight: 700,
letterSpacing: '0.2px',
width: 6,
height: 6,
borderRadius: '50%',
background: color,
flexShrink: 0,
boxShadow: `0 0 4px ${color}88`,
}}
>
/>
<span style={{ fontSize: 10, color, fontWeight: 700, lineHeight: 1 }}>
{variant.rating.toFixed(1)}
</span>
{isLarge && topScore && (
<span
style={{
fontSize: 8,
color: colors.muted,
maxWidth: 70,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{topScore}
</span>
)}
</div>
)
}
@ -149,7 +112,7 @@ function SpriteCard({
alignItems: 'center',
flexShrink: 0,
cursor: 'pointer',
opacity: loaded ? 0.7 : 0,
opacity: loaded ? 0.75 : 0,
transition: 'opacity 0.4s ease, transform 0.2s ease',
animation: isNew ? `spriteGlow ${GLOW_DURATION}ms ease-out forwards` : undefined,
padding: '4px 2px',
@ -159,7 +122,7 @@ function SpriteCard({
e.currentTarget.style.transform = 'scale(1.08)'
}}
onMouseLeave={(e): void => {
e.currentTarget.style.opacity = '0.7'
e.currentTarget.style.opacity = '0.75'
e.currentTarget.style.transform = 'scale(1)'
}}
>
@ -170,7 +133,7 @@ function SpriteCard({
height: size,
borderRadius: 6,
overflow: 'hidden',
border: `1px solid ${variant.rating !== null ? getRatingSentiment(variant.rating).color + '66' : colors.accent + '66'}`,
border: `1px solid ${variant.rating !== null ? ratingColor(variant.rating) + '66' : colors.accent + '66'}`,
background: colors.bg,
transition: 'border-color 0.6s ease',
}}
@ -202,7 +165,7 @@ function SpriteCard({
right: 3,
padding: '1px 5px',
borderRadius: 6,
fontSize: size > 100 ? 9 : 8,
fontSize: 8,
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: '0.3px',
@ -221,21 +184,21 @@ function SpriteCard({
left: 3,
padding: '1px 5px',
borderRadius: 6,
fontSize: size > 100 ? 9 : 8,
fontSize: 8,
fontWeight: 700,
background: '#22c55e44',
color: '#22c55e',
border: '1px solid #22c55e33',
}}
>
{'\u2713'}
</div>
)}
<RatingBadge variant={variant} size={size} justScored={justScored} />
<RatingBadge variant={variant} justScored={justScored} />
</div>
<span
style={{
fontSize: size > 100 ? 10 : 9,
fontSize: 9,
color: colors.muted,
marginTop: 3,
maxWidth: size + 10,
@ -251,60 +214,13 @@ function SpriteCard({
)
}
const controlBtnStyle: CSSProperties = {
background: 'none',
border: 'none',
color: colors.muted,
cursor: 'pointer',
fontSize: 16,
padding: '4px 8px',
lineHeight: 1,
borderRadius: 4,
transition: 'color 0.15s, background 0.15s',
}
function ControlButton({
onClick,
title,
children,
active,
}: {
onClick: () => void
title: string
children: ReactNode
active?: boolean
}): ReactNode {
return (
<button
onClick={onClick}
title={title}
style={{
...controlBtnStyle,
color: active ? colors.highlight : colors.muted,
background: active ? colors.highlight + '22' : 'transparent',
}}
onMouseEnter={(e): void => {
if (!active) e.currentTarget.style.color = colors.text
e.currentTarget.style.background = active ? colors.highlight + '33' : colors.accent + '88'
}}
onMouseLeave={(e): void => {
e.currentTarget.style.color = active ? colors.highlight : colors.muted
e.currentTarget.style.background = active ? colors.highlight + '22' : 'transparent'
}}
>
{children}
</button>
)
}
export function SpriteStream(): ReactNode {
const navigate = useNavigate()
const [searchParams, setSearchParams] = useSearchParams()
const location = useLocation()
const theater = location.pathname === '/theater'
const [sprites, setSprites] = useState<RecentVariant[]>([])
const [newIds, setNewIds] = useState<Set<number>>(new Set())
const [scoredIds, setScoredIds] = useState<Set<number>>(new Set())
const [theater, setTheaterState] = useState(searchParams.get('spriteTheater') === 'true')
const [containerWidth, setContainerWidth] = useState(0)
const containerRef = useRef<HTMLDivElement>(null)
const trackRef = useRef<HTMLDivElement>(null)
@ -314,18 +230,6 @@ export function SpriteStream(): ReactNode {
const prevLengthRef = useRef(0)
const shiftingRef = useRef(false)
const toggleTheater = useCallback((on: boolean): void => {
setTheaterState(on)
setSearchParams(prev => {
if (on) {
prev.set('spriteTheater', 'true')
} else {
prev.delete('spriteTheater')
}
return prev
}, { replace: true })
}, [setSearchParams])
const visibleCount = containerWidth > 0 ? Math.floor(containerWidth / CARD_TOTAL) : 0
// Track container width via ResizeObserver
@ -344,7 +248,6 @@ export function SpriteStream(): ReactNode {
const mergeSprites = useCallback((incoming: RecentVariant[], markNew: boolean): void => {
setSprites(prev => {
const existingMap = new Map(prev.map(s => [s.variant_id, s]))
const freshIds: number[] = []
const justScoredIds: number[] = []
for (const v of incoming) {
@ -355,13 +258,10 @@ export function SpriteStream(): ReactNode {
}
existingMap.set(v.variant_id, v)
} else {
freshIds.push(v.variant_id)
existingMap.set(v.variant_id, v)
}
}
if (freshIds.length === 0 && justScoredIds.length === 0) return prev
if (justScoredIds.length > 0) {
setScoredIds(p => {
const next = new Set(p)
@ -474,9 +374,6 @@ export function SpriteStream(): ReactNode {
}, [mergeSprites, connectSSE, stopPolling])
// Animate shift when new sprites arrive
// Sprites are newest-first. We display them reversed (oldest left, newest right).
// When new items prepend to `sprites`, the visible window gains items on the right
// and we animate the track shifting left by the number of new cards.
useEffect((): void => {
const track = trackRef.current
if (!track || shiftingRef.current) return
@ -486,11 +383,9 @@ export function SpriteStream(): ReactNode {
const shiftPx = newCount * CARD_TOTAL
shiftingRef.current = true
// Start shifted right (new items off-screen right)
track.style.transition = 'none'
track.style.transform = `translateX(${shiftPx}px)`
// Force reflow, then animate to 0
void track.offsetHeight
track.style.transition = `transform ${SHIFT_DURATION_MS}ms ease-out`
track.style.transform = 'translateX(0)'
@ -510,29 +405,22 @@ export function SpriteStream(): ReactNode {
const keyframeStyles = `
@keyframes spriteGlow {
0% { opacity: 1; filter: drop-shadow(0 0 12px ${colors.highlight}aa) drop-shadow(0 0 24px ${colors.highlight}44); }
100% { opacity: 0.7; filter: drop-shadow(0 0 0px transparent); }
100% { opacity: 0.75; filter: drop-shadow(0 0 0px transparent); }
}
@keyframes spritePulse {
0%, 100% { opacity: 0.3; }
50% { opacity: 0.6; }
}
@keyframes theaterFadeIn {
0% { opacity: 0; transform: translateY(8px) scale(0.95); }
100% { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes scoreFlash {
0% { background: rgba(251, 191, 36, 0.4); box-shadow: 0 0 8px rgba(251, 191, 36, 0.6); }
100% { background: rgba(0, 0, 0, 0.75); box-shadow: none; }
}
`
// When theater mode is active, SpriteTheaterPage handles the full view.
// SpriteStream hides itself to avoid rendering on top.
if (theater) {
return null
}
// Sprites are newest-first. Reverse so oldest is left, newest is right.
const displaySprites = visibleCount > 0
? sprites.slice(0, visibleCount).reverse()
: sprites.slice(0, 8).reverse()
@ -578,7 +466,7 @@ export function SpriteStream(): ReactNode {
position: 'relative',
}}
>
{/* Sprite track — items flow left-to-right, newest on the right */}
{/* Sprite track */}
<div
ref={trackRef}
style={{
@ -588,7 +476,7 @@ export function SpriteStream(): ReactNode {
gap: CARD_GAP,
height: '100%',
paddingLeft: 12,
paddingRight: 48,
paddingRight: 16,
}}
>
{displaySprites.map(variant => (
@ -617,26 +505,6 @@ export function SpriteStream(): ReactNode {
}}
/>
{/* Theater toggle — bottom-right */}
<div
style={{
position: 'absolute',
bottom: 6,
right: 8,
zIndex: 2,
background: colors.bg + 'cc',
borderRadius: 6,
padding: '2px 4px',
border: `1px solid ${colors.accent}66`,
}}
>
<ControlButton
onClick={(): void => toggleTheater(true)}
title="Theater mode"
>
&#9638;
</ControlButton>
</div>
</div>
</>
)