Separate into components

This commit is contained in:
Joey Yakimowich-Payne 2025-10-13 11:40:17 -06:00
commit d97ffc9837
No known key found for this signature in database
GPG key ID: 6BFE655FA5ABD1E1
13 changed files with 441 additions and 143 deletions

View file

@ -0,0 +1,64 @@
'use client'
import Button from '@/app/components/base/button'
import Textarea from '@/app/components/base/textarea'
import { RiLoader4Line } from '@remixicon/react'
import type { AttemptResult, LiveOutputState, Translate } from './types'
import LiveOutputCard from './live-output-card'
import AttemptHistory from './attempt-history'
type AttemptComposerProps = {
userInput: string
onUserInputChange: (value: string) => void
onSubmit: () => Promise<void> | void
submitting: boolean
t: Translate
liveOutput: LiveOutputState
attempts: AttemptResult[]
}
export default function AttemptComposer({
userInput,
onUserInputChange,
onSubmit,
submitting,
t,
liveOutput,
attempts,
}: AttemptComposerProps) {
return (
<div className='rounded-xl border border-divider-subtle bg-components-panel-bg p-6 shadow-xs'>
<h2 className='mb-4 text-lg font-semibold text-text-primary'>
{t('challenges.player.yourAttempt')}
</h2>
<Textarea
value={userInput}
onChange={event => onUserInputChange(event.target.value)}
placeholder='Enter your response here...'
rows={8}
className='mb-4 w-full'
/>
<Button
type='primary'
onClick={onSubmit}
loading={submitting}
disabled={submitting || !userInput.trim()}
className='w-full'
>
{submitting ? (
<>
<RiLoader4Line className='mr-2 h-4 w-4 animate-spin' />
{t('challenges.player.processing', 'Processing…')}
</>
) : (
t('challenges.player.submitButton', 'Submit')
)}
</Button>
<LiveOutputCard liveOutput={liveOutput} t={t} />
<AttemptHistory attempts={attempts} t={t} />
</div>
)
}

View file

@ -0,0 +1,49 @@
'use client'
import { RiCheckLine, RiCloseLine } from '@remixicon/react'
import type { AttemptResult, Translate } from './types'
type AttemptHistoryItemProps = {
attempt: AttemptResult
t: Translate
}
export default function AttemptHistoryItem({ attempt, t }: AttemptHistoryItemProps) {
return (
<div className={`rounded-lg border p-4 ${attempt.success ? 'border-util-colors-green-green-500 bg-util-colors-green-green-50' : 'border-util-colors-orange-orange-500 bg-util-colors-orange-orange-50'}`}>
<div className='flex items-start gap-3'>
{attempt.success ? (
<RiCheckLine className='h-5 w-5 shrink-0 text-util-colors-green-green-600' />
) : (
<RiCloseLine className='h-5 w-5 shrink-0 text-util-colors-orange-orange-600' />
)}
<div className='flex-1'>
<div className='flex items-center justify-between'>
<div className={`mb-1 font-medium ${attempt.success ? 'text-util-colors-green-green-700' : 'text-util-colors-orange-orange-700'}`}>
{attempt.success ? t('challenges.player.status.success') : t('challenges.player.status.failed')}
</div>
<div className='text-xs text-text-tertiary'>
{new Date(attempt.timestamp).toLocaleTimeString()}
</div>
</div>
{attempt.thinking && (
<div className='bg-components-panel-bg/60 mt-3 space-y-2 rounded-md border border-divider-subtle p-3'>
<div className='text-xs font-medium uppercase tracking-wide text-text-tertiary'>
{t('challenges.player.modelThinking', 'Thinking')}
</div>
<div className='whitespace-pre-wrap text-sm text-text-secondary'>{attempt.thinking}</div>
</div>
)}
{attempt.message && (
<div className='mt-3 whitespace-pre-wrap text-sm text-text-secondary'>{attempt.message}</div>
)}
{attempt.rating !== undefined && (
<div className='mt-2 text-sm text-text-tertiary'>
{t('challenges.leaderboard.rating')}: {attempt.rating}/10
</div>
)}
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,30 @@
'use client'
import type { AttemptResult, Translate } from './types'
import AttemptHistoryItem from './attempt-history-item'
type AttemptHistoryProps = {
attempts: AttemptResult[]
t: Translate
}
export default function AttemptHistory({ attempts, t }: AttemptHistoryProps) {
return (
<div className='mt-6 rounded-xl border border-divider-subtle bg-components-panel-bg p-6 shadow-xs'>
<div className='mb-3 text-sm font-medium uppercase tracking-wide text-text-tertiary'>
{t('challenges.player.attemptHistory', 'Attempts')}
</div>
{attempts.length === 0 ? (
<div className='bg-components-panel-bg/60 rounded-lg border border-divider-subtle p-4 text-sm text-text-secondary'>
{t('challenges.player.noAttempts')}
</div>
) : (
<div className='space-y-4'>
{attempts.map(attempt => (
<AttemptHistoryItem key={attempt.timestamp} attempt={attempt} t={t} />
))}
</div>
)}
</div>
)
}

View file

@ -0,0 +1,18 @@
'use client'
import type { ChallengeDetail } from './types'
type ChallengeHeaderProps = {
challenge: ChallengeDetail
}
export default function ChallengeHeader({ challenge }: ChallengeHeaderProps) {
return (
<div className='mb-8'>
<h1 className='mb-2 text-3xl font-bold text-text-primary'>{challenge.name}</h1>
{challenge.description && (
<p className='text-lg text-text-secondary'>{challenge.description}</p>
)}
</div>
)
}

View file

@ -0,0 +1,13 @@
'use client'
type ChallengeNotFoundStateProps = {
message: string
}
export default function ChallengeNotFoundState({ message }: ChallengeNotFoundStateProps) {
return (
<div className='flex min-h-screen items-center justify-center bg-components-panel-bg'>
<div className='text-text-secondary'>{message}</div>
</div>
)
}

View file

@ -0,0 +1,19 @@
'use client'
import type { Translate } from './types'
type GoalCardProps = {
goal: string
t: Translate
}
export default function GoalCard({ goal, t }: GoalCardProps) {
return (
<div className='mb-6 rounded-xl border border-divider-subtle bg-components-panel-bg p-6 shadow-xs'>
<h2 className='mb-2 text-sm font-medium uppercase tracking-wide text-text-tertiary'>
{t('challenges.player.goal')}
</h2>
<p className='text-text-primary'>{goal}</p>
</div>
)
}

View file

@ -0,0 +1,21 @@
'use client'
import Leaderboard from '@/app/components/challenge/leaderboard'
import type { LeaderboardEntry } from './types'
type LeaderboardSectionProps = {
leaderboard: LeaderboardEntry[]
scoringStrategy?: string
emptyHint: string
}
export default function LeaderboardSection({ leaderboard, scoringStrategy, emptyHint }: LeaderboardSectionProps) {
if (leaderboard.length > 0)
return <Leaderboard entries={leaderboard} strategy={scoringStrategy} />
return (
<div className='rounded-xl border border-divider-subtle bg-components-panel-bg p-6 text-sm text-text-secondary shadow-xs'>
{emptyHint}
</div>
)
}

View file

@ -0,0 +1,46 @@
'use client'
import { RiLoader4Line } from '@remixicon/react'
import type { LiveOutputState, Translate } from './types'
type LiveOutputCardProps = {
liveOutput: LiveOutputState
t: Translate
}
export default function LiveOutputCard({ liveOutput, t }: LiveOutputCardProps) {
const { streamingThinking, streamingText, hasStreamingResult } = liveOutput
const hasContent = hasStreamingResult || Boolean(streamingThinking) || Boolean(streamingText)
if (!hasContent)
return null
return (
<div className='bg-components-panel-bg/60 mt-4 rounded-lg border border-divider-subtle p-4'>
<div className='flex items-center gap-2 text-sm font-medium text-text-secondary'>
{t('challenges.player.liveOutput')}
{hasStreamingResult && (
<RiLoader4Line className='h-4 w-4 animate-spin text-text-tertiary' />
)}
</div>
{streamingThinking && (
<div className='mt-3 space-y-2 rounded-md border border-divider-subtle bg-components-panel-bg p-3'>
<div className='text-xs font-medium uppercase tracking-wide text-text-tertiary'>
{t('challenges.player.modelThinking', 'Thinking')}
</div>
<div className='whitespace-pre-wrap text-sm text-text-secondary'>{streamingThinking}</div>
</div>
)}
{streamingText && (
<div className='mt-3 whitespace-pre-wrap text-sm text-text-primary'>
{streamingText}
</div>
)}
{!streamingThinking && !streamingText && (
<div className='mt-3 text-sm text-text-secondary'>
{t('challenges.player.awaitingResponse')}
</div>
)}
</div>
)
}

View file

@ -0,0 +1,13 @@
'use client'
type LoadingStateProps = {
message: string
}
export default function LoadingState({ message }: LoadingStateProps) {
return (
<div className='flex min-h-screen items-center justify-center bg-components-panel-bg'>
<div className='text-text-tertiary'>{message}</div>
</div>
)
}

View file

@ -0,0 +1,47 @@
'use client'
import type { AttemptResult, Translate } from './types'
type SessionSummaryCardProps = {
t: Translate
attemptsTotal: number
successCount: number
failureCount: number
mostRecentAttempt?: AttemptResult
}
export default function SessionSummaryCard({ t, attemptsTotal, successCount, failureCount, mostRecentAttempt }: SessionSummaryCardProps) {
return (
<div className='rounded-xl border border-divider-subtle bg-components-panel-bg p-6 shadow-xs'>
<div className='mb-3 text-sm font-medium uppercase tracking-wide text-text-tertiary'>
{t('challenges.player.sessionSummary', 'Session Summary')}
</div>
<div className='space-y-3 text-sm text-text-secondary'>
<div className='flex items-center justify-between'>
<span>{t('challenges.player.totalAttempts', 'Total attempts')}</span>
<span className='font-medium text-text-primary'>{attemptsTotal}</span>
</div>
<div className='flex items-center justify-between'>
<span>{t('challenges.player.successfulAttempts', 'Successful')}</span>
<span className='font-medium text-util-colors-green-green-600'>{successCount}</span>
</div>
<div className='flex items-center justify-between'>
<span>{t('challenges.player.failedAttempts', 'Failed')}</span>
<span className='font-medium text-util-colors-orange-orange-600'>{failureCount}</span>
</div>
{mostRecentAttempt && (
<div className='bg-components-panel-bg/60 rounded-md border border-divider-subtle p-3 text-xs text-text-tertiary'>
<div className='mb-1 font-medium uppercase tracking-wide text-text-tertiary'>
{t('challenges.player.lastAttempt', 'Last attempt')}
</div>
<div className='text-text-secondary'>
{new Date(mostRecentAttempt.timestamp).toLocaleTimeString()} · {mostRecentAttempt.success
? t('challenges.player.status.success')
: t('challenges.player.status.failed')}
</div>
</div>
)}
</div>
</div>
)
}

View file

@ -0,0 +1,32 @@
import type { ComponentProps } from 'react'
import type { useTranslation } from 'react-i18next'
import type Leaderboard from '@/app/components/challenge/leaderboard'
export type Translate = ReturnType<typeof useTranslation>['t']
export type LeaderboardEntry = ComponentProps<typeof Leaderboard>['entries'][number]
export type ChallengeDetail = {
id: string
name: string
description?: string
goal?: string
app_id?: string
app_site_code?: string
app_mode?: string
scoring_strategy?: string
}
export type AttemptResult = {
success: boolean
message?: string
rating?: number
thinking?: string
timestamp: number
}
export type LiveOutputState = {
streamingText: string
streamingThinking: string
hasStreamingResult: boolean
}

View file

@ -3,29 +3,38 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useParams } from 'next/navigation'
import { RiCheckLine, RiCloseLine, RiLoader4Line } from '@remixicon/react'
import Leaderboard from '@/app/components/challenge/leaderboard'
import Button from '@/app/components/base/button'
import Textarea from '@/app/components/base/textarea'
import Toast from '@/app/components/base/toast'
import { fetchChallengeDetail, fetchChallengeLeaderboard, submitChallengeAttempt } from '@/service/challenges'
import ChallengeNotFoundState from './components/challenge-not-found'
import ChallengeHeader from './components/challenge-header'
import GoalCard from './components/goal-card'
import AttemptComposer from './components/attempt-composer'
import SessionSummaryCard from './components/session-summary-card'
import LeaderboardSection from './components/leaderboard-section'
import LoadingState from './components/loading-state'
import type { AttemptResult, ChallengeDetail, LeaderboardEntry, LiveOutputState } from './components/types'
export default function ChallengeDetailPage() {
const { t } = useTranslation()
const params = useParams()
const id = params?.id as string
const [challenge, setChallenge] = useState<any>(null)
const [leaderboard, setLeaderboard] = useState<any[]>([])
const [challenge, setChallenge] = useState<ChallengeDetail | null>(null)
const [leaderboard, setLeaderboard] = useState<LeaderboardEntry[]>([])
const [loading, setLoading] = useState(true)
const [submitting, setSubmitting] = useState(false)
const [userInput, setUserInput] = useState('')
const [lastResult, setLastResult] = useState<{ success: boolean; message?: string; rating?: number; thinking?: string } | null>(null)
const [streamingText, setStreamingText] = useState('')
const [streamingThinking, setStreamingThinking] = useState('')
const [attempts, setAttempts] = useState<AttemptResult[]>([])
const [hasStreamingResult, setHasStreamingResult] = useState(false)
const abortControllerRef = useRef<AbortController | null>(null)
const successCount = attempts.filter(attempt => attempt.success).length
const failureCount = attempts.length - successCount
const mostRecentAttempt = attempts[0]
useEffect(() => {
const load = async () => {
try {
@ -33,11 +42,12 @@ export default function ChallengeDetailPage() {
fetchChallengeDetail(id),
fetchChallengeLeaderboard(id),
])
setChallenge(detail)
setLeaderboard(leaders)
setChallenge(detail as ChallengeDetail)
setLeaderboard((leaders ?? []) as LeaderboardEntry[])
}
catch (e: any) {
Toast.notify({ type: 'error', message: e.message || 'Failed to load challenge' })
catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Failed to load challenge'
Toast.notify({ type: 'error', message })
}
finally {
setLoading(false)
@ -76,7 +86,7 @@ export default function ChallengeDetailPage() {
return { thinking: '', response: text.trim() }
const beforeThink = text.slice(0, thinkStart)
const afterThinkStart = text.slice(thinkStart + 7) // length of '<think>'
const afterThinkStart = text.slice(thinkStart + 7)
const thinkEndRelative = afterThinkStart.toLowerCase().indexOf('</think>')
if (thinkEndRelative === -1) {
@ -86,7 +96,7 @@ export default function ChallengeDetailPage() {
}
const thinking = afterThinkStart.slice(0, thinkEndRelative).trim()
const afterThink = afterThinkStart.slice(thinkEndRelative + 8) // length of '</think>'
const afterThink = afterThinkStart.slice(thinkEndRelative + 8)
const response = `${beforeThink}${afterThink}`.trim()
return { thinking, response }
}, [])
@ -103,7 +113,6 @@ export default function ChallengeDetailPage() {
}
stopStreaming()
setSubmitting(true)
setLastResult(null)
setStreamingText('')
setStreamingThinking('')
setHasStreamingResult(false)
@ -128,6 +137,7 @@ export default function ChallengeDetailPage() {
onError: (message) => {
setHasStreamingResult(false)
setStreamingText('')
setStreamingThinking('')
Toast.notify({ type: 'error', message })
},
},
@ -140,7 +150,7 @@ export default function ChallengeDetailPage() {
const judgeFeedback = typeof result.outputs?.judge_feedback === 'string' && result.outputs.judge_feedback.trim().length > 0
? result.outputs.judge_feedback
: (typeof result.message === 'string' && result.message.trim().length > 0 ? result.message : undefined)
: undefined
const fallbackExplanation = typeof result.outputs?.message === 'string' && result.outputs.message.trim().length > 0
? result.outputs.message
: ''
@ -154,161 +164,89 @@ export default function ChallengeDetailPage() {
? [response || successFallback].filter(Boolean).join('\n')
: [judgeFeedbackLine || response || failureFallback].filter(Boolean).join('\n')
setLastResult({
success: result.success,
message: combinedMessage,
rating: result.rating,
thinking,
})
setAttempts(prev => ([
{
success: result.success,
message: combinedMessage,
rating: result.rating,
thinking,
timestamp: Date.now(),
},
...prev,
]))
if (result.success) {
Toast.notify({ type: 'success', message: 'Challenge completed!' })
// Refresh leaderboard
const leaders = await fetchChallengeLeaderboard(id)
setLeaderboard(leaders)
setLeaderboard((leaders ?? []) as LeaderboardEntry[])
}
}
catch (e: any) {
console.error('Submission error:', e)
catch (error: unknown) {
console.error('Submission error:', error)
setHasStreamingResult(false)
setStreamingText('')
setStreamingThinking('')
if (e?.name === 'AbortError')
return
if (!e?.__handled)
Toast.notify({ type: 'error', message: e.message || 'Submission failed' })
if (error instanceof Error) {
if (error.name === 'AbortError')
return
if (!(error as any).__handled)
Toast.notify({ type: 'error', message: error.message || 'Submission failed' })
}
else {
Toast.notify({ type: 'error', message: 'Submission failed' })
}
}
finally {
setSubmitting(false)
}
}
if (loading) {
return (
<div className='flex min-h-screen items-center justify-center bg-components-panel-bg'>
<div className='text-text-tertiary'>{t('common.loading')}</div>
</div>
)
}
if (loading)
return <LoadingState message={t('common.loading')} />
if (!challenge) {
return (
<div className='flex min-h-screen items-center justify-center bg-components-panel-bg'>
<div className='text-text-secondary'>Challenge not found</div>
</div>
)
if (!challenge)
return <ChallengeNotFoundState message={t('challenges.player.notFound', 'Challenge not found')} />
const liveOutputState: LiveOutputState = {
streamingText,
streamingThinking,
hasStreamingResult,
}
return (
<div className='min-h-screen overflow-y-auto bg-components-panel-bg'>
<div className='mx-auto max-w-5xl px-4 py-12 sm:px-6 lg:px-8'>
<div className='mb-8'>
<h1 className='mb-2 text-3xl font-bold text-text-primary'>{challenge.name}</h1>
{challenge.description && (
<p className='text-lg text-text-secondary'>{challenge.description}</p>
)}
</div>
<ChallengeHeader challenge={challenge} />
<div className='grid gap-6 lg:grid-cols-3'>
<div className='lg:col-span-2'>
{challenge.goal && (
<div className='mb-6 rounded-xl border border-divider-subtle bg-components-panel-bg p-6 shadow-xs'>
<h2 className='mb-2 text-sm font-medium uppercase tracking-wide text-text-tertiary'>
{t('challenges.player.goal')}
</h2>
<p className='text-text-primary'>{challenge.goal}</p>
</div>
)}
<div className='rounded-xl border border-divider-subtle bg-components-panel-bg p-6 shadow-xs'>
<h2 className='mb-4 text-lg font-semibold text-text-primary'>
{t('challenges.player.yourAttempt')}
</h2>
<Textarea
value={userInput}
onChange={e => setUserInput(e.target.value)}
placeholder='Enter your response here...'
rows={8}
className='mb-4 w-full'
/>
<Button
type='primary'
onClick={handleSubmit}
loading={submitting}
disabled={submitting || !userInput.trim()}
className='w-full'
>
{submitting ? (
<>
<RiLoader4Line className='mr-2 h-4 w-4 animate-spin' />
{t('challenges.player.processing', 'Processing…')}
</>
) : (
t('challenges.player.submitButton', 'Submit')
)}
</Button>
{(hasStreamingResult || streamingText) && (
<div className='bg-components-panel-bg/60 mt-4 rounded-lg border border-divider-subtle p-4'>
<div className='flex items-center gap-2 text-sm font-medium text-text-secondary'>
{t('challenges.player.liveOutput')}
{hasStreamingResult && (
<RiLoader4Line className='h-4 w-4 animate-spin text-text-tertiary' />
)}
</div>
{streamingThinking && (
<div className='mt-3 space-y-2 rounded-md border border-divider-subtle bg-components-panel-bg p-3'>
<div className='text-xs font-medium uppercase tracking-wide text-text-tertiary'>
{t('challenges.player.modelThinking', 'Thinking')}
</div>
<div className='whitespace-pre-wrap text-sm text-text-secondary'>{streamingThinking}</div>
</div>
)}
<div className='mt-3 whitespace-pre-wrap text-sm text-text-primary'>
{streamingText || (!streamingThinking && t('challenges.player.awaitingResponse'))}
</div>
</div>
)}
{lastResult && (
<div className={`mt-4 rounded-lg border p-4 ${lastResult.success ? 'border-util-colors-green-green-500 bg-util-colors-green-green-50' : 'border-util-colors-orange-orange-500 bg-util-colors-orange-orange-50'}`}>
<div className='flex items-start gap-3'>
{lastResult.success ? (
<RiCheckLine className='h-5 w-5 shrink-0 text-util-colors-green-green-600' />
) : (
<RiCloseLine className='h-5 w-5 shrink-0 text-util-colors-orange-orange-600' />
)}
<div className='flex-1'>
<div className={`mb-1 font-medium ${lastResult.success ? 'text-util-colors-green-green-700' : 'text-util-colors-orange-orange-700'}`}>
{lastResult.success ? t('challenges.player.status.success') : t('challenges.player.status.failed')}
</div>
{lastResult.thinking && (
<div className='bg-components-panel-bg/60 mt-3 space-y-2 rounded-md border border-divider-subtle p-3'>
<div className='text-xs font-medium uppercase tracking-wide text-text-tertiary'>
{t('challenges.player.modelThinking', 'Thinking')}
</div>
<div className='whitespace-pre-wrap text-sm text-text-secondary'>{lastResult.thinking}</div>
</div>
)}
{lastResult.message && (
<div className='mt-3 whitespace-pre-wrap text-sm text-text-secondary'>{lastResult.message}</div>
)}
{lastResult.rating !== undefined && (
<div className='mt-2 text-sm text-text-tertiary'>
{t('challenges.leaderboard.rating')}: {lastResult.rating}/10
</div>
)}
</div>
</div>
</div>
)}
</div>
{challenge.goal && <GoalCard goal={challenge.goal} t={t} />}
<AttemptComposer
userInput={userInput}
onUserInputChange={value => setUserInput(value)}
onSubmit={handleSubmit}
submitting={submitting}
t={t}
liveOutput={liveOutputState}
attempts={attempts}
/>
</div>
<div className='lg:col-span-1'>
<Leaderboard entries={leaderboard} strategy={challenge.scoring_strategy} />
<div className='space-y-6'>
<SessionSummaryCard
t={t}
attemptsTotal={attempts.length}
successCount={successCount}
failureCount={failureCount}
mostRecentAttempt={mostRecentAttempt}
/>
<LeaderboardSection
leaderboard={leaderboard}
scoringStrategy={challenge.scoring_strategy}
emptyHint={t('challenges.player.leaderboardEmptyHint')}
/>
</div>
</div>
</div>
</div>

View file

@ -59,6 +59,14 @@ export default {
ratingLine: 'Judge rating: {{rating}}/10',
judgeFeedbackLine: '{{feedback}}',
modelThinking: 'Thinking',
attemptHistory: 'Attempt History',
noAttempts: 'Make your first attempt to see results here.',
sessionSummary: 'Session Summary',
totalAttempts: 'Total attempts',
successfulAttempts: 'Successful',
failedAttempts: 'Failed',
lastAttempt: 'Last attempt',
leaderboardEmptyHint: 'No leaderboard data yet. Complete a challenge successfully to appear here.',
},
leaderboard: {
title: 'Leaderboard',