11'use client'
22
33import { useState , useRef , useCallback , useEffect } from 'react'
4- import { Badge } from '@/components/ui/badge'
54import { Button } from '@/components/ui/button'
65import ExerciseResultSummary from '@/components/exercises/exercise-result-summary'
7- import { cn } from '@/lib/utils'
8- import Markdown from 'react-markdown'
6+ import ExerciseBrief from '@/components/exercises/exercise-brief'
7+ import ExerciseHeader from '@/components/exercises/exercise-header'
8+ import ExerciseWorkspace , { initialWorkspacePanel } from '@/components/exercises/exercise-workspace'
99import { useTranslations } from 'next-intl'
1010import confetti from 'canvas-confetti'
1111import { toast } from 'sonner'
12- import {
13- IconCheck ,
14- IconClock ,
15- IconFlame ,
16- IconInfoCircle ,
17- IconSparkles ,
18- IconLoader2 ,
19- IconAlertTriangle ,
20- IconArrowRight ,
21- IconTrophy ,
22- IconCode ,
23- } from '@tabler/icons-react'
12+ import { IconLoader2 , IconAlertTriangle , IconArrowRight } from '@tabler/icons-react'
2413
2514interface ArtifactExerciseProps {
2615 exercise : {
@@ -64,7 +53,7 @@ export default function ArtifactExercise({
6453 initialEvaluation = null ,
6554} : ArtifactExerciseProps ) {
6655 const t = useTranslations ( 'exercises.artifact' )
67- const tAudio = useTranslations ( 'exercises.audio ' )
56+ const tWorkspace = useTranslations ( 'exercises.workspace ' )
6857 const tGamification = useTranslations ( 'gamification' )
6958
7059 const config = exercise . exercise_config ?? { }
@@ -76,25 +65,8 @@ export default function ArtifactExercise({
7665 const [ passed , setPassed ] = useState < boolean > ( isExerciseCompleted )
7766 const [ errorMsg , setErrorMsg ] = useState < string | null > ( null )
7867 const [ rateLimited , setRateLimited ] = useState ( false )
79-
80- const completionData = exercise . exercise_completions ?. [ 0 ]
81- const completionScore = completionData ?. score ?? ( evaluation ?. score || 0 )
82- const completionDate = completionData ?. completed_at
83-
84- const difficultyLabels : Record < string , string > = {
85- easy : tAudio ( 'beginner' ) ,
86- medium : tAudio ( 'intermediate' ) ,
87- hard : tAudio ( 'advanced' ) ,
88- }
89- const difficultyConfig : Record < string , { color : string ; icon : typeof IconFlame } > = {
90- easy : { color : 'text-emerald-600 bg-emerald-500/10 border-emerald-500/20' , icon : IconSparkles } ,
91- medium : { color : 'text-amber-600 bg-amber-500/10 border-amber-500/20' , icon : IconFlame } ,
92- hard : { color : 'text-rose-600 bg-rose-500/10 border-rose-500/20' , icon : IconFlame } ,
93- }
94-
95- const difficulty = difficultyConfig [ exercise . difficulty_level ] || difficultyConfig . easy
96- const difficultyLabel = difficultyLabels [ exercise . difficulty_level ] || difficultyLabels . easy
97- const DifficultyIcon = difficulty . icon
68+ /** Bumped on every fresh grade so the workspace can surface the result panel. */
69+ const [ gradedNonce , setGradedNonce ] = useState ( 0 )
9870
9971 const handleSubmit = useCallback ( async ( content : string , metadata : Record < string , unknown > = { } ) => {
10072 setSubmitState ( 'evaluating' )
@@ -126,6 +98,7 @@ export default function ArtifactExercise({
12698 setEvaluation ( result )
12799 setPassed ( result . passed )
128100 setSubmitState ( 'done' )
101+ setGradedNonce ( ( n ) => n + 1 )
129102
130103 // Send feedback back to iframe
131104 iframeRef . current ?. contentWindow ?. postMessage (
@@ -171,174 +144,103 @@ export default function ArtifactExercise({
171144 setEvaluation ( null )
172145 }
173146
174- return (
175- < div className = "space-y-6" >
176- { /* Header */ }
177- < div className = "space-y-4" >
178- < div className = "flex flex-wrap items-center gap-2" >
179- < Badge variant = "outline" className = "font-bold border-2 text-primary border-primary/20 bg-primary/5 uppercase tracking-wider text-[10px] px-2.5 py-0.5" >
180- < IconCode size = { 10 } className = "mr-1" aria-hidden = "true" />
181- { t ( 'typeLabel' ) }
182- </ Badge >
183- < Badge variant = "outline" className = { cn ( 'font-bold border text-[10px] px-2.5 py-0.5 uppercase tracking-wider' , difficulty . color ) } >
184- < DifficultyIcon size = { 11 } className = "mr-1" aria-hidden = "true" />
185- { difficultyLabel }
186- </ Badge >
187- { exercise . time_limit && (
188- < Badge variant = "outline" className = "font-bold border text-[10px] px-2.5 py-0.5 uppercase tracking-wider text-muted-foreground" >
189- < IconClock size = { 11 } className = "mr-1" aria-hidden = "true" />
190- { exercise . time_limit } min
191- </ Badge >
192- ) }
193- { ( isExerciseCompleted || passed ) && (
194- < Badge className = "bg-emerald-500 text-white font-bold text-[10px] px-2.5 py-0.5 uppercase tracking-wider" >
195- < IconCheck size = { 11 } className = "mr-1" aria-hidden = "true" />
196- { tAudio ( 'completed' ) }
197- </ Badge >
198- ) }
147+ const taskPanel = (
148+ < div className = "space-y-4" >
149+ { rateLimited && (
150+ < div className = "rounded-xl border border-amber-500/30 bg-amber-500/[0.05] px-4 py-3" >
151+ < p className = "flex items-center gap-2.5 text-sm font-semibold text-amber-800 dark:text-amber-300" >
152+ < IconAlertTriangle size = { 16 } className = "shrink-0" aria-hidden = "true" />
153+ { t ( 'rateLimited' ) }
154+ </ p >
199155 </ div >
200-
201- < h1 className = "text-2xl md:text-3xl font-black tracking-tight text-balance leading-tight" >
202- { exercise . title }
203- </ h1 >
204- </ div >
205-
206- { /* Main Layout */ }
207- < div className = "grid grid-cols-1 lg:grid-cols-12 gap-6" >
208- { /* Left: Instructions */ }
209- < div className = "lg:col-span-4 space-y-6" >
210- < div className = "rounded-2xl border-2 border-primary/10 bg-gradient-to-b from-primary/[0.03] to-transparent overflow-hidden" >
211- < div className = "px-5 py-3.5 border-b border-primary/10 bg-primary/[0.03]" >
212- < h2 className = "font-bold text-xs flex items-center gap-2 text-primary uppercase tracking-wider" >
213- < IconInfoCircle size = { 14 } aria-hidden = "true" />
214- { tAudio ( 'instructions' ) }
215- </ h2 >
216- </ div >
217- < div className = "px-5 py-4" >
218- < div className = "prose prose-sm prose-neutral max-w-none dark:prose-invert prose-p:leading-relaxed prose-p:text-foreground/80 prose-strong:text-foreground prose-headings:text-foreground prose-headings:font-bold prose-li:text-foreground/80 prose-headings:text-sm" >
219- < Markdown > { exercise . instructions } </ Markdown >
220- </ div >
221- </ div >
222- </ div >
223-
224- { isExerciseCompletedSection && (
225- < div className = "space-y-4" > { isExerciseCompletedSection } </ div >
226- ) }
156+ ) }
157+
158+ { submitState === 'evaluating' && (
159+ < div
160+ className = "flex items-center gap-3 rounded-xl border bg-muted/30 px-4 py-3"
161+ role = "status"
162+ >
163+ < IconLoader2 size = { 16 } className = "animate-spin text-primary shrink-0" aria-hidden = "true" />
164+ < p className = "text-sm font-medium" > { t ( 'evaluating' ) } </ p >
227165 </ div >
166+ ) }
228167
229- { /* Right: Artifact + Results */ }
230- < div className = "lg:col-span-8" >
231- < div className = "lg:sticky lg:top-6 space-y-4" >
232- { /* Completion summary */ }
233- { ( isExerciseCompleted || passed ) && (
234- < div className = "rounded-2xl border-2 border-emerald-500/20 bg-gradient-to-br from-emerald-500/[0.06] to-emerald-500/[0.02] p-6 space-y-4" >
235- < div className = "flex items-center justify-between" >
236- < div >
237- < h3 className = "font-bold text-emerald-700 dark:text-emerald-400 flex items-center gap-2" >
238- < IconTrophy size = { 18 } />
239- { tAudio ( 'completedScore' ) }
240- </ h3 >
241- { completionDate && (
242- < p className = "text-xs text-emerald-600/70 dark:text-emerald-400/60 mt-1" >
243- { tAudio ( 'completedOn' , {
244- date : new Date ( completionDate ) . toLocaleDateString ( undefined , {
245- month : 'long' , day : 'numeric' , year : 'numeric' ,
246- } ) ,
247- } ) }
248- </ p >
249- ) }
250- </ div >
251- < div className = "flex items-baseline gap-1" >
252- < span className = "text-4xl font-black tabular-nums tracking-tight text-emerald-600 dark:text-emerald-400" >
253- { Math . round ( completionScore ) }
254- </ span >
255- < span className = "text-lg font-bold text-emerald-600/40" > /</ span >
256- < span className = "text-lg font-bold text-emerald-600/60 tabular-nums" > 100</ span >
257- </ div >
258- </ div >
259- < p className = "text-sm text-emerald-600/80 dark:text-emerald-400/70" >
260- { tAudio ( 'exerciseMarkedComplete' ) }
261- </ p >
262- </ div >
263- ) }
264-
265- { /* Rate limit banner */ }
266- { rateLimited && (
267- < div className = "rounded-xl border-2 border-amber-500/20 bg-amber-500/[0.05] p-5" >
268- < div className = "flex items-center gap-3" >
269- < div className = "rounded-full bg-amber-500/10 p-2" >
270- < IconAlertTriangle size = { 20 } className = "text-amber-600" />
271- </ div >
272- < div >
273- < h3 className = "font-bold text-amber-700 dark:text-amber-400" > { t ( 'rateLimited' ) } </ h3 >
274- </ div >
275- </ div >
276- </ div >
277- ) }
278-
279- { /* Evaluating state */ }
280- { submitState === 'evaluating' && (
281- < div className = "flex items-center gap-3 rounded-xl border bg-muted/30 px-4 py-3" >
282- < IconLoader2 size = { 16 } className = "animate-spin text-primary shrink-0" />
283- < div >
284- < p className = "text-sm font-medium" > { t ( 'evaluating' ) } </ p >
285- </ div >
286- </ div >
287- ) }
288-
289- { /* Error */ }
290- { errorMsg && (
291- < div className = "rounded-lg border border-destructive/20 bg-destructive/5 px-4 py-3 text-sm text-destructive" >
292- { errorMsg }
293- </ div >
294- ) }
295-
296- { /* Sandboxed iframe */ }
297- { artifactHtml && (
298- < div className = "rounded-xl border-2 border-border/50 overflow-hidden bg-white" >
299- < iframe
300- ref = { iframeRef }
301- srcDoc = { artifactHtml }
302- sandbox = "allow-scripts"
303- className = "w-full border-0"
304- style = { { minHeight : '500px' } }
305- title = { exercise . title }
306- />
307- </ div >
308- ) }
309-
310- { /* Evaluation result — also shown for a restored prior attempt, whose
311- submitState is still 'idle' because nothing was submitted this visit. */ }
312- { /* One result card across every engine. This block used to be a
313- near-copy of ExerciseResultSummary that printed the score over
314- the PASS MARK, twice: "62 / 70" reads as 89% when 62 is a fail. */ }
315- { evaluation && submitState !== 'evaluating' && (
316- < div className = "space-y-4" >
317- < ExerciseResultSummary
318- score = { evaluation . score }
319- passed = { evaluation . passed }
320- feedback = { evaluation . feedback }
321- strengths = { evaluation . strengths }
322- improvements = { evaluation . improvements }
323- // The API response carries its own threshold; the prop is the
324- // fallback for a restored attempt that predates that field.
325- passingScore = { evaluation . passingScore ?? passingScore }
326- />
327-
328- { ! evaluation . passed && ! rateLimited && (
329- < Button
330- onClick = { handleTryAgain }
331- className = "w-full gap-2.5 h-11 text-sm font-semibold tracking-wide"
332- >
333- { t ( 'tryAgain' ) }
334- < IconArrowRight size = { 16 } className = "ml-auto opacity-60" />
335- </ Button >
336- ) }
337- </ div >
338- ) }
339- </ div >
168+ { errorMsg && (
169+ < div className = "rounded-lg border border-destructive/20 bg-destructive/5 px-4 py-3 text-sm text-destructive" role = "alert" >
170+ { errorMsg }
171+ </ div >
172+ ) }
173+
174+ { artifactHtml && (
175+ // The srcDoc is authored as a light-mode document, so the frame keeps a
176+ // white backing in both themes rather than showing a dark seam round it.
177+ < div className = "rounded-xl border overflow-hidden bg-white" >
178+ < iframe
179+ ref = { iframeRef }
180+ srcDoc = { artifactHtml }
181+ sandbox = "allow-scripts"
182+ className = "w-full border-0"
183+ style = { { minHeight : '500px' } }
184+ title = { exercise . title }
185+ />
340186 </ div >
187+ ) }
188+ </ div >
189+ )
190+
191+ // One result card across every engine. This block used to be a near-copy of
192+ // ExerciseResultSummary that printed the score over the PASS MARK, alongside
193+ // a second trophy card printing a *different* score from exercise_completions.
194+ const resultPanel =
195+ evaluation && submitState !== 'evaluating' ? (
196+ < div className = "space-y-4" >
197+ < ExerciseResultSummary
198+ score = { evaluation . score }
199+ passed = { evaluation . passed }
200+ feedback = { evaluation . feedback }
201+ strengths = { evaluation . strengths }
202+ improvements = { evaluation . improvements }
203+ // The API response carries its own threshold; the prop is the
204+ // fallback for a restored attempt that predates that field.
205+ passingScore = { evaluation . passingScore ?? passingScore }
206+ />
207+
208+ { ! evaluation . passed && ! rateLimited && (
209+ < Button
210+ onClick = { handleTryAgain }
211+ className = "w-full gap-2.5 h-11 text-sm font-semibold tracking-wide"
212+ >
213+ { t ( 'tryAgain' ) }
214+ < IconArrowRight size = { 16 } className = "ml-auto opacity-60" />
215+ </ Button >
216+ ) }
341217 </ div >
218+ ) : undefined
219+
220+ return (
221+ < div className = "space-y-4 sm:space-y-6" >
222+ < ExerciseHeader
223+ typeLabel = { t ( 'typeLabel' ) }
224+ title = { exercise . title }
225+ difficulty = { exercise . difficulty_level }
226+ timeLimit = { exercise . time_limit }
227+ completed = { isExerciseCompleted || passed }
228+ />
229+
230+ < ExerciseWorkspace
231+ brief = { < ExerciseBrief instructions = { exercise . instructions } /> }
232+ task = { taskPanel }
233+ taskLabel = { tWorkspace ( 'task' ) }
234+ result = { resultPanel }
235+ resultPassed = { evaluation ?. passed }
236+ related = { isExerciseCompletedSection }
237+ initialPanel = { initialWorkspacePanel ( {
238+ hasResult : Boolean ( resultPanel ) ,
239+ passed : evaluation ?. passed ,
240+ attempted : isExerciseCompleted || Boolean ( initialEvaluation ) ,
241+ } ) }
242+ revealResultNonce = { gradedNonce }
243+ />
342244 </ div >
343245 )
344246}
0 commit comments