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:
parent
a8b9d1c488
commit
80e1eccfb0
2 changed files with 60 additions and 184 deletions
|
|
@ -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 />} />
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
▦
|
||||
</ControlButton>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue