diff --git a/tools/sprite-generation/gui/src/pages/SpriteTheaterPage.tsx b/tools/sprite-generation/gui/src/pages/SpriteTheaterPage.tsx index b7d55773..2b01d0fa 100644 --- a/tools/sprite-generation/gui/src/pages/SpriteTheaterPage.tsx +++ b/tools/sprite-generation/gui/src/pages/SpriteTheaterPage.tsx @@ -11,7 +11,7 @@ import { type ReactNode, type RefObject, } from 'react' -import { fetchRecentVariants, variantStreamUrl, rawImageUrl } from '../api' +import { fetchRecentVariants, variantStreamUrl, rawImageUrl, approveVariant, rejectVariant } from '../api' import type { RecentVariant } from '../types' import { colors } from './theme' @@ -58,9 +58,6 @@ function confidence(v: RecentVariant): number { return vals.reduce((a, b) => a + b, 0) / vals.length } -function isLabeled(v: RecentVariant): boolean { - return v.rating !== null || v.is_approved -} /* ── responsive grid hook ──────────────────────────────────────── */ @@ -146,10 +143,10 @@ interface CardProps { v: RecentVariant isNew: boolean index: number + onApprove: (variantId: number) => void } -const Card = memo(forwardRef(function Card({ v, isNew, index }, ref) { - const labeled = isLabeled(v) +const Card = memo(forwardRef(function Card({ v, isNew, index, onApprove }, ref) { const conf = useMemo(() => confidence(v), [v.notes]) const scores = useMemo(() => parseScores(v.notes), [v.notes]) const passing = conf >= 0.7 @@ -160,15 +157,13 @@ const Card = memo(forwardRef(function Card({ v, isNew ? { animation: `fadeScaleIn ${CARD_ENTER_MS}ms ease forwards` } : { transition: `transform ${CARD_SHIFT_MS}ms ease ${enterDelay}ms, opacity ${CARD_ENTER_MS}ms ease` } - const cardBg = labeled ? colors.surface : '#0d0d18' - const cardBorder = labeled - ? (passing ? '2px solid #10b981' : v.is_approved ? '2px solid #8b5cf6' : '1px solid #1e293b') - : '1px solid #151528' - const cardShadow = labeled ? '0 4px 12px rgba(0,0,0,0.4)' : 'none' - const imgFilter = labeled ? 'none' : 'saturate(0.5) brightness(0.85)' - const imgOpacity = labeled ? 1 : 0.65 - const textColor = labeled ? colors.muted : '#4a5060' - const entityColor = labeled ? colors.text : '#6a7080' + const cardBg = colors.surface + const cardBorder = passing ? '1px solid #10b981' : v.is_approved ? '1px solid #8b5cf6' : '1px solid transparent' + const cardShadow = '0 4px 12px rgba(0,0,0,0.4)' + const textColor = colors.muted + const entityColor = colors.text + + const confColor = conf > 0 ? (passing ? '#10b981' : conf >= 0.5 ? '#f59e0b' : '#ef4444') : '' return (
(function Card({ v, isNew ...animStyle, }} > -
+ {/* Clean image — clickable, no overlays */} + {v.entity_id}(function Card({ v, isNew display: 'block', aspectRatio: '1/1', objectFit: 'cover', - filter: imgFilter, - opacity: imgOpacity, - transition: 'filter 0.4s ease, opacity 0.4s ease', }} loading="lazy" /> - {conf > 0 && ( - = 0.5 ? '#f59e0b' : '#ef4444') - : (passing ? '#10b98166' : conf >= 0.5 ? '#f59e0b66' : '#ef444466'), - color: labeled ? '#000' : (passing ? '#10b981' : conf >= 0.5 ? '#f59e0b' : '#ef4444'), - }}> - {(conf * 100).toFixed(0)}% - - )} - - {v.category} - - {v.is_approved && ( - - APPROVED - - )} -
+ + {/* Info below image — ID, category, scores */}
-
+
+ + #{v.variant_id} + + + {v.category} + + {conf > 0 && ( + + {(conf * 100).toFixed(0)}% + + )} +
+
{v.entity_id}
-
seed: {v.seed}
+
seed: {v.seed}
{scores && (
{Object.entries(scores).map(([k, val]) => ( @@ -241,11 +235,51 @@ const Card = memo(forwardRef(function Card({ v, isNew key={k} label={k.replace('_', ' ').replace('production ', 'prod ')} value={val} - muted={!labeled} + muted={false} /> ))}
)} + {!!v.is_approved ? ( +
+ APPROVED +
+ ) : scores && ( +
+ + +
+ )}
) @@ -272,7 +306,7 @@ function LeavingCard({ animation: `fadeOut ${FADE_OUT_MS}ms ease forwards`, }} > - + {}} />
) } @@ -300,11 +334,33 @@ function useCardRefCallbacks(cardRefs: React.MutableRefObject([]) const [newIds, setNewIds] = useState>(new Set()) const [connected, setConnected] = useState(false) const [leaving, setLeaving] = useState>([]) + const searchParams = new URLSearchParams(window.location.search) + const [filter, setFilter] = useState( + searchParams.get('onlyInReview') === 'true' ? 'review' : 'all' + ) + + const handleApproveOrSkip = useCallback((variantId: number) => { + if (variantId > 0) { + // Approve — mark as approved in local state (backend already called by card) + setVariants(prev => prev.map(v => + v.variant_id === variantId ? { ...v, is_approved: true } : v + )) + } else { + // Skip (negative ID) — persist rejection to DB + hide locally + const realId = -variantId + rejectVariant(realId).catch(err => console.error('Reject failed:', err)) + setVariants(prev => prev.map(v => + v.variant_id === realId ? { ...v, rating: -1 } : v + )) + } + }, []) const gridRef = useRef(null) const cardRefs = useRef>(new Map()) @@ -377,9 +433,28 @@ export default function SpriteTheaterPage(): ReactNode { prevRectsRef.current = rects }, []) - // Initial fetch + // Initial fetch + periodic refresh (picks up new ratings from Sonnet) useEffect(() => { - fetchRecentVariants(200).then(setVariants).catch(() => { /* initial load failed */ }) + const load = (): void => { + fetchRecentVariants(5000).then(fresh => { + setVariants(prev => { + if (prev.length === 0) return fresh + // Merge: update existing variants with new data (ratings), add new ones + const freshMap = new Map(fresh.map(v => [v.variant_id, v])) + const merged = prev.map(v => freshMap.get(v.variant_id) ?? v) + // Add any variants in fresh that aren't in prev + const prevIds = new Set(prev.map(v => v.variant_id)) + const brandNew = fresh.filter(v => !prevIds.has(v.variant_id)) + if (brandNew.length > 0) { + setNewIds(new Set(brandNew.map(v => v.variant_id))) + } + return [...brandNew, ...merged] + }) + }).catch(() => { /* load failed */ }) + } + load() + const interval = setInterval(load, 5_000) // refresh every 5s (localhost) + return () => clearInterval(interval) }, []) // SSE connection @@ -406,16 +481,27 @@ export default function SpriteTheaterPage(): ReactNode { return () => es.close() }, [captureRects]) - // Memoize sorted + visible to prevent effect re-fire + // Memoize sorted + filtered to prevent effect re-fire const sorted = useMemo( () => { - const s = [...variants].sort((a, b) => - new Date(b.created_at).getTime() - new Date(a.created_at).getTime() - ) + let s = [...variants].sort((a, b) => { + const dt = new Date(b.created_at).getTime() - new Date(a.created_at).getTime() + return dt !== 0 ? dt : b.variant_id - a.variant_id + }) + if (filter === 'review') { + s = s.filter(v => { + if (v.is_approved || v.rating === -1) return false + const scores = parseScores(v.notes) + if (!scores) return false + const vals = Object.values(scores) + const conf = vals.reduce((a, b) => a + b, 0) / vals.length + return conf >= 0.7 + }) + } totalCountRef.current = s.length return s }, - [variants], + [variants, filter], ) const visible = useMemo( @@ -536,9 +622,33 @@ export default function SpriteTheaterPage(): ReactNode { {connected ? 'Live' : 'Disconnected'} — {variants.length} sprites -
+
+ {(['all', 'review'] as const).map(mode => ( + + ))} - {cols}×{rows} grid · {visible.length}/{sorted.length} + {visible.length}/{sorted.length} Dashboard @@ -585,6 +695,7 @@ export default function SpriteTheaterPage(): ReactNode { v={v} isNew={newIds.has(v.variant_id)} index={i} + onApprove={handleApproveOrSkip} /> ))}