-
Notifications
You must be signed in to change notification settings - Fork 17
Expand file tree
/
Copy pathartifact-exercise.tsx
More file actions
246 lines (221 loc) · 8.08 KB
/
Copy pathartifact-exercise.tsx
File metadata and controls
246 lines (221 loc) · 8.08 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
'use client'
import { useState, useRef, useCallback, useEffect } from 'react'
import { Button } from '@/components/ui/button'
import ExerciseResultSummary from '@/components/exercises/exercise-result-summary'
import ExerciseBrief from '@/components/exercises/exercise-brief'
import ExerciseHeader from '@/components/exercises/exercise-header'
import ExerciseWorkspace, { initialWorkspacePanel } from '@/components/exercises/exercise-workspace'
import { useTranslations } from 'next-intl'
import confetti from 'canvas-confetti'
import { toast } from 'sonner'
import { IconLoader2, IconAlertTriangle, IconArrowRight } from '@tabler/icons-react'
interface ArtifactExerciseProps {
exercise: {
id: number
title: string
instructions: string
exercise_type: string
difficulty_level: string
time_limit?: number
exercise_config?: {
artifact_type?: string
artifact_html?: string
passing_score?: number
}
exercise_completions?: { score?: number; completed_at?: string }[]
}
isExerciseCompleted: boolean
passingScore: number
isExerciseCompletedSection?: React.ReactNode
/** Last graded attempt from exercise_evaluations. The route has always written
* one; it was simply never read back, so reloading the page lost the feedback. */
initialEvaluation?: EvaluationResult | null
}
interface EvaluationResult {
score: number
feedback: string
passed: boolean
strengths: string[]
improvements: string[]
passingScore: number
}
type SubmitState = 'idle' | 'evaluating' | 'done' | 'error'
export default function ArtifactExercise({
exercise,
isExerciseCompleted,
passingScore,
isExerciseCompletedSection,
initialEvaluation = null,
}: ArtifactExerciseProps) {
const t = useTranslations('exercises.artifact')
const tWorkspace = useTranslations('exercises.workspace')
const tGamification = useTranslations('gamification')
const config = exercise.exercise_config ?? {}
const artifactHtml = config.artifact_html ?? ''
const iframeRef = useRef<HTMLIFrameElement>(null)
const [submitState, setSubmitState] = useState<SubmitState>('idle')
const [evaluation, setEvaluation] = useState<EvaluationResult | null>(initialEvaluation)
const [passed, setPassed] = useState<boolean>(isExerciseCompleted)
const [errorMsg, setErrorMsg] = useState<string | null>(null)
const [rateLimited, setRateLimited] = useState(false)
/** Bumped on every fresh grade so the workspace can surface the result panel. */
const [gradedNonce, setGradedNonce] = useState(0)
const handleSubmit = useCallback(async (content: string, metadata: Record<string, unknown> = {}) => {
setSubmitState('evaluating')
setErrorMsg(null)
setRateLimited(false)
try {
const res = await fetch('/api/exercises/artifact/evaluate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
exerciseId: exercise.id,
content,
metadata,
}),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
if (data.rateLimited) {
setRateLimited(true)
setSubmitState('error')
return
}
throw new Error(data.error || 'Evaluation failed')
}
const result: EvaluationResult = await res.json()
setEvaluation(result)
setPassed(result.passed)
setSubmitState('done')
setGradedNonce((n) => n + 1)
// Send feedback back to iframe
iframeRef.current?.contentWindow?.postMessage(
{ type: 'FEEDBACK', payload: result },
'*'
)
if (result.passed) {
confetti({
particleCount: 150,
spread: 70,
origin: { y: 0.6 },
colors: ['#3b82f6', '#10b981', '#f59e0b'],
})
toast.success(tGamification('xpAwarded.exercise_completion'))
}
} catch (err: any) {
console.error('Artifact evaluation error:', err)
setErrorMsg(err.message || 'Something went wrong. Please try again.')
setSubmitState('error')
}
}, [exercise.id])
// Listen for postMessage from iframe
useEffect(() => {
function handler(event: MessageEvent) {
// Validate source
if (event.source !== iframeRef.current?.contentWindow) return
const { type, payload } = event.data ?? {}
if (type === 'SUBMIT' && payload) {
handleSubmit(payload.content ?? '', payload.metadata ?? {})
}
}
window.addEventListener('message', handler)
return () => window.removeEventListener('message', handler)
}, [handleSubmit])
const handleTryAgain = () => {
setSubmitState('idle')
setErrorMsg(null)
setEvaluation(null)
}
const taskPanel = (
<div className="space-y-4">
{rateLimited && (
<div className="rounded-xl border border-amber-500/30 bg-amber-500/[0.05] px-4 py-3">
<p className="flex items-center gap-2.5 text-sm font-semibold text-amber-800 dark:text-amber-300">
<IconAlertTriangle size={16} className="shrink-0" aria-hidden="true" />
{t('rateLimited')}
</p>
</div>
)}
{submitState === 'evaluating' && (
<div
className="flex items-center gap-3 rounded-xl border bg-muted/30 px-4 py-3"
role="status"
>
<IconLoader2 size={16} className="animate-spin text-primary shrink-0" aria-hidden="true" />
<p className="text-sm font-medium">{t('evaluating')}</p>
</div>
)}
{errorMsg && (
<div className="rounded-lg border border-destructive/20 bg-destructive/5 px-4 py-3 text-sm text-destructive" role="alert">
{errorMsg}
</div>
)}
{artifactHtml && (
// The srcDoc is authored as a light-mode document, so the frame keeps a
// white backing in both themes rather than showing a dark seam round it.
<div className="rounded-xl border overflow-hidden bg-white">
<iframe
ref={iframeRef}
srcDoc={artifactHtml}
sandbox="allow-scripts"
className="w-full border-0"
style={{ minHeight: '500px' }}
title={exercise.title}
/>
</div>
)}
</div>
)
// One result card across every engine. This block used to be a near-copy of
// ExerciseResultSummary that printed the score over the PASS MARK, alongside
// a second trophy card printing a *different* score from exercise_completions.
const resultPanel =
evaluation && submitState !== 'evaluating' ? (
<div className="space-y-4">
<ExerciseResultSummary
score={evaluation.score}
passed={evaluation.passed}
feedback={evaluation.feedback}
strengths={evaluation.strengths}
improvements={evaluation.improvements}
// The API response carries its own threshold; the prop is the
// fallback for a restored attempt that predates that field.
passingScore={evaluation.passingScore ?? passingScore}
/>
{!evaluation.passed && !rateLimited && (
<Button
onClick={handleTryAgain}
className="w-full gap-2.5 h-11 text-sm font-semibold tracking-wide"
>
{t('tryAgain')}
<IconArrowRight size={16} className="ml-auto opacity-60" />
</Button>
)}
</div>
) : undefined
return (
<div className="space-y-4 sm:space-y-6">
<ExerciseHeader
typeLabel={t('typeLabel')}
title={exercise.title}
difficulty={exercise.difficulty_level}
timeLimit={exercise.time_limit}
completed={isExerciseCompleted || passed}
/>
<ExerciseWorkspace
brief={<ExerciseBrief instructions={exercise.instructions} />}
task={taskPanel}
taskLabel={tWorkspace('task')}
result={resultPanel}
resultPassed={evaluation?.passed}
related={isExerciseCompletedSection}
initialPanel={initialWorkspacePanel({
hasResult: Boolean(resultPanel),
passed: evaluation?.passed,
attempted: isExerciseCompleted || Boolean(initialEvaluation),
})}
revealResultNonce={gradedNonce}
/>
</div>
)
}