feat(sprite-generation): ✨ Add interactive sprite sequence controls and improved layout for SpriteTheaterPage
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
c27abb5ef9
commit
cfa7142e26
1 changed files with 173 additions and 62 deletions
|
|
@ -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<HTMLDivElement, CardProps>(function Card({ v, isNew, index }, ref) {
|
||||
const labeled = isLabeled(v)
|
||||
const Card = memo(forwardRef<HTMLDivElement, CardProps>(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<HTMLDivElement, CardProps>(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 (
|
||||
<div
|
||||
|
|
@ -184,7 +179,12 @@ const Card = memo(forwardRef<HTMLDivElement, CardProps>(function Card({ v, isNew
|
|||
...animStyle,
|
||||
}}
|
||||
>
|
||||
<div style={{ position: 'relative' }}>
|
||||
{/* Clean image — clickable, no overlays */}
|
||||
<a
|
||||
href={`/sprite/${v.sprite_id}?variant=${v.variant_id}`}
|
||||
style={{ display: 'block', cursor: 'pointer' }}
|
||||
title={`#${v.variant_id} — ${v.sprite_id} (click to open)`}
|
||||
>
|
||||
<img
|
||||
src={rawImageUrl(filename)}
|
||||
alt={v.entity_id}
|
||||
|
|
@ -193,47 +193,41 @@ const Card = memo(forwardRef<HTMLDivElement, CardProps>(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 && (
|
||||
<span style={{
|
||||
position: 'absolute', top: 8, right: 8,
|
||||
padding: '2px 8px', borderRadius: 12, fontSize: 11, fontWeight: 700,
|
||||
background: labeled
|
||||
? (passing ? '#10b981' : conf >= 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)}%
|
||||
</span>
|
||||
)}
|
||||
<span style={{
|
||||
position: 'absolute', top: 8, left: 8,
|
||||
padding: '2px 8px', borderRadius: 12, fontSize: 10, fontWeight: 600,
|
||||
background: labeled ? 'rgba(0,0,0,0.7)' : 'rgba(0,0,0,0.3)',
|
||||
color: labeled ? colors.muted : '#4a5060',
|
||||
}}>
|
||||
{v.category}
|
||||
</span>
|
||||
{v.is_approved && (
|
||||
<span style={{
|
||||
position: 'absolute', bottom: 8, right: 8,
|
||||
padding: '2px 8px', borderRadius: 12, fontSize: 11, fontWeight: 700,
|
||||
background: '#8b5cf6', color: '#000',
|
||||
}}>
|
||||
APPROVED
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
{/* Info below image — ID, category, scores */}
|
||||
<div style={{ padding: '8px 10px', fontSize: 11, color: textColor }}>
|
||||
<div style={{ fontWeight: 600, color: entityColor, marginBottom: 2 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 3 }}>
|
||||
<a
|
||||
href={`/sprite/${v.sprite_id}?variant=${v.variant_id}`}
|
||||
style={{ fontWeight: 700, color: entityColor, fontSize: 12, textDecoration: 'none' }}
|
||||
title={`Click to open variant #${v.variant_id}`}
|
||||
>
|
||||
#{v.variant_id}
|
||||
</a>
|
||||
<span style={{
|
||||
padding: '1px 6px', borderRadius: 10, fontSize: 10, fontWeight: 600,
|
||||
background: 'rgba(255,255,255,0.08)',
|
||||
color: colors.muted,
|
||||
}}>
|
||||
{v.category}
|
||||
</span>
|
||||
{conf > 0 && (
|
||||
<span style={{
|
||||
padding: '1px 6px', borderRadius: 10, fontSize: 11, fontWeight: 700,
|
||||
background: confColor,
|
||||
color: '#000',
|
||||
}}>
|
||||
{(conf * 100).toFixed(0)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ color: entityColor, marginBottom: 2 }}>
|
||||
{v.entity_id}
|
||||
</div>
|
||||
<div>seed: {v.seed}</div>
|
||||
<div style={{ fontSize: 10, color: '#4a5060' }}>seed: {v.seed}</div>
|
||||
{scores && (
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap', marginTop: 4 }}>
|
||||
{Object.entries(scores).map(([k, val]) => (
|
||||
|
|
@ -241,11 +235,51 @@ const Card = memo(forwardRef<HTMLDivElement, CardProps>(function Card({ v, isNew
|
|||
key={k}
|
||||
label={k.replace('_', ' ').replace('production ', 'prod ')}
|
||||
value={val}
|
||||
muted={!labeled}
|
||||
muted={false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!!v.is_approved ? (
|
||||
<div style={{
|
||||
marginTop: 4, padding: '2px 6px', borderRadius: 4, fontSize: 10, fontWeight: 700,
|
||||
background: '#8b5cf6', color: '#000', display: 'inline-block',
|
||||
}}>
|
||||
APPROVED
|
||||
</div>
|
||||
) : scores && (
|
||||
<div style={{ display: 'flex', gap: 6, marginTop: 6 }}>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
approveVariant(v.sprite_id, v.variant_id).then(
|
||||
() => { onApprove(v.variant_id) },
|
||||
(err) => { console.error('Approve failed:', err) },
|
||||
)
|
||||
}}
|
||||
style={{
|
||||
flex: 1, padding: '4px 0', borderRadius: 4, fontSize: 11, fontWeight: 600,
|
||||
background: '#10b98133', color: '#10b981', border: '1px solid #10b98155',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Approve
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onApprove(-v.variant_id)
|
||||
}}
|
||||
style={{
|
||||
flex: 1, padding: '4px 0', borderRadius: 4, fontSize: 11, fontWeight: 600,
|
||||
background: '#ef444433', color: '#ef4444', border: '1px solid #ef444455',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Skip
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -272,7 +306,7 @@ function LeavingCard({
|
|||
animation: `fadeOut ${FADE_OUT_MS}ms ease forwards`,
|
||||
}}
|
||||
>
|
||||
<Card v={v} isNew={false} index={0} />
|
||||
<Card v={v} isNew={false} index={0} onApprove={() => {}} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -300,11 +334,33 @@ function useCardRefCallbacks(cardRefs: React.MutableRefObject<Map<number, HTMLDi
|
|||
|
||||
/* ── page component ────────────────────────────────────────────── */
|
||||
|
||||
type FilterMode = 'all' | 'review'
|
||||
|
||||
export default function SpriteTheaterPage(): ReactNode {
|
||||
const [variants, setVariants] = useState<RecentVariant[]>([])
|
||||
const [newIds, setNewIds] = useState<Set<number>>(new Set())
|
||||
const [connected, setConnected] = useState(false)
|
||||
const [leaving, setLeaving] = useState<Array<{ v: RecentVariant; rect: DOMRect }>>([])
|
||||
const searchParams = new URLSearchParams(window.location.search)
|
||||
const [filter, setFilter] = useState<FilterMode>(
|
||||
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<HTMLDivElement>(null)
|
||||
const cardRefs = useRef<Map<number, HTMLDivElement>>(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
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
{(['all', 'review'] as const).map(mode => (
|
||||
<button
|
||||
key={mode}
|
||||
onClick={() => {
|
||||
setFilter(mode)
|
||||
const url = new URL(window.location.href)
|
||||
if (mode === 'review') {
|
||||
url.searchParams.set('onlyInReview', 'true')
|
||||
} else {
|
||||
url.searchParams.delete('onlyInReview')
|
||||
}
|
||||
window.history.replaceState({}, '', url.toString())
|
||||
}}
|
||||
style={{
|
||||
padding: '3px 10px', borderRadius: 4, fontSize: 11, fontWeight: 600,
|
||||
background: filter === mode ? '#ffffff18' : 'transparent',
|
||||
color: filter === mode ? colors.text : colors.muted,
|
||||
border: filter === mode ? '1px solid #ffffff22' : '1px solid transparent',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{mode === 'all' ? 'All' : 'In Review (≥70%)'}
|
||||
</button>
|
||||
))}
|
||||
<span style={{ fontSize: 11, color: colors.muted }}>
|
||||
{cols}×{rows} grid · {visible.length}/{sorted.length}
|
||||
{visible.length}/{sorted.length}
|
||||
</span>
|
||||
<a href="/" style={{ color: colors.muted, textDecoration: 'none', fontSize: 13 }}>
|
||||
Dashboard
|
||||
|
|
@ -585,6 +695,7 @@ export default function SpriteTheaterPage(): ReactNode {
|
|||
v={v}
|
||||
isNew={newIds.has(v.variant_id)}
|
||||
index={i}
|
||||
onApprove={handleApproveOrSkip}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue