SPB Git forge

spb/uqo-chat

Public
14commits 1branches 0releases
1.4 MBsize
maindefault branch
17 days agolast push
Python 64.6% TypeScript 33.7% CSS 0.8%
4.9 KB · 96 lines tsx
Raw Blame History
1import { useState } from 'react';2import { Check, RotateCcw, X } from 'lucide-react';3import type { QuizPayload, QuizResult, ToolCallView } from '@/lib/types';4import { api } from '@/lib/api';5import { Button } from '@/components/ui/button';6import { cn } from '@/lib/cn';7import { StreamingText } from '@/components/chat/streaming-text';89export function QuizCard({ call }: { call: ToolCallView }) {10  const quiz = (call.payload as { quiz?: QuizPayload }).quiz;11  const [answers, setAnswers] = useState<Record<string, unknown>>({});12  const [result, setResult] = useState<QuizResult | null>(null);13  const [busy, setBusy] = useState(false);14  if (!quiz) return null;1516  const submit = async () => {17    setBusy(true);18    try {19      setResult(await api<QuizResult>(`/quiz/${quiz.quiz_id}/answers`, { method: 'POST', body: JSON.stringify({ answers }) }));20    } finally {21      setBusy(false);22    }23  };24  const reset = () => {25    setAnswers({});26    setResult(null);27  };28  const res = (id: string) => result?.results.find((r) => r.id === id);29  const answered = quiz.questions.every((q) => answers[q.id] !== undefined && answers[q.id] !== '');3031  return (32    <div className="space-y-4">33      <div className="flex items-center justify-between gap-2">34        <div>35          <div className="font-semibold text-uqo-blue-dark">{quiz.title}</div>36          <div className="text-xs text-neutral-muted">{quiz.course} · {quiz.questions.length} questions · {quiz.difficulty}</div>37        </div>38        {result && (39          <div className={cn('rounded-xl px-3 py-1.5 text-sm font-semibold', result.score >= 0.7 ? 'bg-[#e9f6d9] text-[#3f7a0a]' : 'bg-amber-50 text-amber-700')}>40            {result.correct}/{result.total} · {Math.round(result.score * 100)} %41          </div>42        )}43      </div>44      <ol className="space-y-4">45        {quiz.questions.map((q, i) => {46          const r = res(q.id);47          return (48            <li key={q.id} className={cn('rounded-xl border p-3 bg-white', r ? (r.correct ? 'border-uqo-green' : 'border-semantic-error') : 'border-neutral-line')}>49              <div className="flex gap-2">50                <span className="text-sm font-semibold text-uqo-blue">{i + 1}.</span>51                <div className="md text-sm flex-1"><StreamingText text={q.prompt} /></div>52                {r && (r.correct ? <Check className="text-uqo-green shrink-0" size={18} /> : <X className="text-semantic-error shrink-0" size={18} />)}53              </div>54              <div className="mt-2 pl-5 space-y-1.5">55                {q.type === 'mcq' && q.choices?.map((c, idx) => (56                  <label key={idx} className={cn('flex items-start gap-2 rounded-lg px-2 py-2 cursor-pointer min-h-[44px] hover:bg-neutral-surface', r && Number(r.answer) === idx && 'bg-[#e9f6d9]')}>57                    <input type="radio" name={q.id} className="mt-1 accent-uqo-blue" disabled={!!result} checked={answers[q.id] === idx} onChange={() => setAnswers((a) => ({ ...a, [q.id]: idx }))} />58                    <span className="text-sm">{c}</span>59                  </label>60                ))}61                {q.type === 'true_false' && (62                  <div className="flex gap-2">63                    {[true, false].map((v) => (64                      <button key={String(v)} disabled={!!result} onClick={() => setAnswers((a) => ({ ...a, [q.id]: v }))}65                        className={cn('h-11 px-4 rounded-xl border text-sm font-medium', answers[q.id] === v ? 'bg-uqo-blue text-white border-uqo-blue' : 'border-neutral-line bg-white', r && r.answer === v && 'ring-2 ring-uqo-green')}>66                        {v ? 'Vrai' : 'Faux'}67                      </button>68                    ))}69                  </div>70                )}71                {q.type === 'numeric' && (72                  <div className="flex items-center gap-2">73                    <input type="text" inputMode="decimal" disabled={!!result} placeholder="Réponse" value={(answers[q.id] as string) ?? ''}74                      onChange={(e) => setAnswers((a) => ({ ...a, [q.id]: e.target.value }))}75                      className="h-11 w-44 rounded-xl border border-neutral-line px-3 text-sm" />76                    {q.unit && <span className="text-sm text-neutral-muted">{q.unit}</span>}77                    {r && !r.correct && <span className="text-sm text-neutral-muted">Réponse : <b>{String(r.answer)}</b> {r.unit}</span>}78                  </div>79                )}80                {r && r.explanation && <div className="md text-sm mt-2 rounded-lg bg-uqo-blue-light/60 p-2.5"><StreamingText text={r.explanation} /></div>}81              </div>82            </li>83          );84        })}85      </ol>86      <div className="flex gap-2">87        {!result ? (88          <Button onClick={submit} disabled={!answered || busy}>{busy ? 'Correction…' : 'Corriger'}</Button>89        ) : (90          <Button variant="secondary" onClick={reset}><RotateCcw size={16} /> Refaire</Button>91        )}92      </div>93    </div>94  );95}96