From a40693fc02a574623bf1f216ac704895c066caca Mon Sep 17 00:00:00 2001 From: jvcByte Date: Fri, 17 Apr 2026 13:24:58 +0100 Subject: [PATCH] =?UTF-8?q?fix:=20go-reloaded=20code=20editor=20UX=20?= =?UTF-8?q?=E2=80=94=20correct=20starter,=20stdin=20visibility,=20empty=20?= =?UTF-8?q?output=20message,=20banner=20injection=20scoped=20to=20ascii-ar?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/exercises/[id]/session/route.ts | 3 +- .../exercises/[id]/questions/sync/route.ts | 54 +++++++++++++++++++ .../exercises/[id]/questions/upload/route.ts | 10 ++-- app/api/run-code/route.ts | 9 ++-- .../exercises/[id]/QuestionManager.tsx | 46 +++++++++++++--- app/instructor/exercises/[id]/page.tsx | 1 + app/participant/session/[id]/CodeEditor.tsx | 19 ++++--- app/participant/session/[id]/SessionView.tsx | 3 +- docs/go-reloaded/question0.md | 6 +-- docs/go-reloaded/question1.md | 6 +-- docs/go-reloaded/question2.md | 6 +-- docs/go-reloaded/question3.md | 6 +-- docs/go-reloaded/question4.md | 6 +-- docs/go-reloaded/question5.md | 6 +-- docs/go-reloaded/question6.md | 6 +-- docs/go-reloaded/question7.md | 6 +-- docs/go-reloaded/question8.md | 6 +-- 17 files changed, 130 insertions(+), 69 deletions(-) create mode 100644 app/api/instructor/exercises/[id]/questions/sync/route.ts diff --git a/app/api/exercises/[id]/session/route.ts b/app/api/exercises/[id]/session/route.ts index 5e83cd7..88e897f 100644 --- a/app/api/exercises/[id]/session/route.ts +++ b/app/api/exercises/[id]/session/route.ts @@ -19,7 +19,7 @@ export async function GET( // Check exercise-level start_time BEFORE creating a session // This way participants who haven't started yet are blocked at the exercise level const exerciseRows = await sql` - SELECT start_time, end_time, duration_limit, question_count + SELECT slug, start_time, end_time, duration_limit, question_count FROM exercises WHERE id = ${exerciseId} LIMIT 1 `; if (exerciseRows.length === 0) { @@ -117,6 +117,7 @@ export async function GET( return NextResponse.json({ session_id: row.id, + exercise_slug: exercise.slug as string, current_question_index: row.current_question_index as number, question_count: row.question_count as number, remaining_seconds: remainingSeconds, diff --git a/app/api/instructor/exercises/[id]/questions/sync/route.ts b/app/api/instructor/exercises/[id]/questions/sync/route.ts new file mode 100644 index 0000000..3cc1f3c --- /dev/null +++ b/app/api/instructor/exercises/[id]/questions/sync/route.ts @@ -0,0 +1,54 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { sql } from '@/lib/db'; +import { audit } from '@/lib/audit'; +import { loadExercise } from '@/lib/questions'; + +/** + * POST /api/instructor/exercises/:id/questions/sync + * + * Re-reads the exercise question files from disk and replaces all questions in the DB. + * Only works for exercises that have a matching docs/ directory. + */ +export async function POST( + _req: NextRequest, + { params }: { params: { id: string } } +) { + const session = await getServerSession(authOptions); + if (!session?.user?.role || session.user.role !== 'instructor') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const exerciseRows = await sql`SELECT id, slug FROM exercises WHERE id = ${params.id} LIMIT 1`; + if (exerciseRows.length === 0) { + return NextResponse.json({ error: 'Exercise not found' }, { status: 404 }); + } + + const slug = exerciseRows[0].slug as string; + + let exercise; + try { + exercise = loadExercise(slug); + } catch (e: unknown) { + return NextResponse.json({ error: e instanceof Error ? e.message : 'Failed to load exercise files' }, { status: 422 }); + } + + await sql`DELETE FROM questions WHERE exercise_id = ${params.id}`; + + for (const q of exercise.questions) { + await sql` + INSERT INTO questions (exercise_id, question_index, text, type, language, starter) + VALUES (${params.id}, ${q.index}, ${q.text}, ${q.type}, ${q.language}, ${q.starter}) + `; + } + + await sql`UPDATE exercises SET question_count = ${exercise.questions.length} WHERE id = ${params.id}`; + + await audit(session.user.id, 'questions.synced', 'exercise', params.id, { + slug, + count: exercise.questions.length, + }); + + return NextResponse.json({ synced: exercise.questions.length }); +} diff --git a/app/api/instructor/exercises/[id]/questions/upload/route.ts b/app/api/instructor/exercises/[id]/questions/upload/route.ts index 14e2140..5bcbaa9 100644 --- a/app/api/instructor/exercises/[id]/questions/upload/route.ts +++ b/app/api/instructor/exercises/[id]/questions/upload/route.ts @@ -51,9 +51,13 @@ export async function POST( const content = await file.text(); - // Detect question type from form field - const questionType = (formData.get('type') as string) ?? 'written'; - const language = (formData.get('language') as string) ?? 'text'; + const CODE_EXERCISE_SLUGS = new Set(['ascii-art', 'ascii-art-web', 'go-reloaded']); + const exerciseSlug = exerciseRows[0].slug as string; + const slugDefault = CODE_EXERCISE_SLUGS.has(exerciseSlug) ? { type: 'code', language: 'go' } : { type: 'written', language: 'text' }; + + // Detect question type from form field, falling back to slug-based default + const questionType = (formData.get('type') as string) || slugDefault.type; + const language = (formData.get('language') as string) || slugDefault.language; const parts = splitMarkdownQuestions(content); diff --git a/app/api/run-code/route.ts b/app/api/run-code/route.ts index 74ccd28..ef4a2b9 100644 --- a/app/api/run-code/route.ts +++ b/app/api/run-code/route.ts @@ -34,14 +34,14 @@ export async function POST(req: NextRequest) { }, { status: 503 }); } - let body: { code: string; language: string; stdin?: string }; + let body: { code: string; language: string; stdin?: string; exercise?: string }; try { body = await req.json(); } catch { return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); } - const { code, language, stdin = '' } = body; + const { code, language, stdin = '', exercise = '' } = body; if (!code || typeof code !== 'string') { return NextResponse.json({ error: 'code is required' }, { status: 400 }); @@ -53,8 +53,9 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: 'Code exceeds 64KB limit' }, { status: 413 }); } - // Auto-inject banner as stdin for Go exercises if no stdin provided - const effectiveStdin = stdin || (language === 'go' ? getBannerContent() : ''); + const BANNER_EXERCISE_SLUGS = new Set(['ascii-art', 'ascii-art-web']); + // Auto-inject banner as stdin only for banner exercises when no stdin provided + const effectiveStdin = stdin || (language === 'go' && BANNER_EXERCISE_SLUGS.has(exercise) ? getBannerContent() : ''); try { const res = await fetch(`${RUNNER_URL}/run`, { diff --git a/app/instructor/exercises/[id]/QuestionManager.tsx b/app/instructor/exercises/[id]/QuestionManager.tsx index ba01f1b..005742c 100644 --- a/app/instructor/exercises/[id]/QuestionManager.tsx +++ b/app/instructor/exercises/[id]/QuestionManager.tsx @@ -3,7 +3,7 @@ import { useState, useRef } from 'react'; import ReactMarkdown from 'react-markdown'; import { toast } from 'sonner'; -import { Plus, Trash2, Edit3, ChevronDown, ChevronUp, Save, X, Upload, FileText } from 'lucide-react'; +import { Plus, Trash2, Edit3, ChevronDown, ChevronUp, Save, X, Upload, FileText, RefreshCw } from 'lucide-react'; interface Question { id: string; @@ -14,22 +14,32 @@ interface Question { starter: string; } +const CODE_EXERCISE_SLUGS = new Set(['ascii-art', 'ascii-art-web', 'go-reloaded']); + +function defaultsForSlug(slug: string): { type: string; language: string } { + if (CODE_EXERCISE_SLUGS.has(slug)) return { type: 'code', language: 'go' }; + return { type: 'written', language: 'text' }; +} + interface Props { exerciseId: string; + exerciseSlug: string; initialQuestions: Question[]; } -export default function QuestionManager({ exerciseId, initialQuestions }: Props) { +export default function QuestionManager({ exerciseId, exerciseSlug, initialQuestions }: Props) { + const defaults = defaultsForSlug(exerciseSlug); const [questions, setQuestions] = useState(initialQuestions); const [expanded, setExpanded] = useState(null); const [editing, setEditing] = useState(null); const [editDraft, setEditDraft] = useState>({}); const [adding, setAdding] = useState(false); - const [newQ, setNewQ] = useState({ text: '', type: 'written', language: 'text', starter: '' }); + const [newQ, setNewQ] = useState({ text: '', type: defaults.type, language: defaults.language, starter: '' }); const [saving, setSaving] = useState(false); const [uploading, setUploading] = useState(false); - const [uploadType, setUploadType] = useState('written'); - const [uploadLang, setUploadLang] = useState('text'); + const [syncing, setSyncing] = useState(false); + const [uploadType, setUploadType] = useState(defaults.type); + const [uploadLang, setUploadLang] = useState(defaults.language); const [dragOver, setDragOver] = useState(false); const fileInputRef = useRef(null); @@ -60,8 +70,23 @@ export default function QuestionManager({ exerciseId, initialQuestions }: Props) } } - function handleDrop(e: React.DragEvent) { - e.preventDefault(); + async function syncFromFiles() { + if (!confirm('Sync questions from docs files? This will replace all current questions.')) return; + setSyncing(true); + try { + const res = await fetch(`/api/instructor/exercises/${exerciseId}/questions/sync`, { method: 'POST' }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error ?? 'Sync failed'); + toast.success(`Synced ${data.synced} questions from files`); + window.location.reload(); + } catch (e: unknown) { + toast.error(e instanceof Error ? e.message : 'Sync failed'); + } finally { + setSyncing(false); + } + } + + function handleDrop(e: React.DragEvent) { e.preventDefault(); setDragOver(false); const file = e.dataTransfer.files?.[0]; if (!file) return; @@ -224,7 +249,12 @@ export default function QuestionManager({ exerciseId, initialQuestions }: Props)
Bulk Import from Markdown - Replaces all questions +
+ + Replaces all questions +

Upload a .md file diff --git a/app/instructor/exercises/[id]/page.tsx b/app/instructor/exercises/[id]/page.tsx index 42abbc8..439ae88 100644 --- a/app/instructor/exercises/[id]/page.tsx +++ b/app/instructor/exercises/[id]/page.tsx @@ -103,6 +103,7 @@ export default async function ExercisePage({ params }: Props) {

diff --git a/app/participant/session/[id]/CodeEditor.tsx b/app/participant/session/[id]/CodeEditor.tsx index d1ee46a..fb96c75 100644 --- a/app/participant/session/[id]/CodeEditor.tsx +++ b/app/participant/session/[id]/CodeEditor.tsx @@ -97,15 +97,16 @@ interface Props { language: string; starter: string; isClosed: boolean; + exerciseSlug: string; } -export default function CodeEditor({ sessionId, questionIndex, language, starter, isClosed }: Props) { +export default function CodeEditor({ sessionId, questionIndex, language, starter, isClosed, exerciseSlug }: Props) { const [code, setCode] = useState(starter); const [running, setRunning] = useState(false); const [result, setResult] = useState(null); const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'failed'>('idle'); const [stdin, setStdin] = useState(''); - const [showStdin, setShowStdin] = useState(false); + const [showStdin, setShowStdin] = useState(exerciseSlug === 'go-reloaded'); const [editorTheme, setEditorTheme] = useState('recoding-dark'); const codeRef = useRef(code); @@ -142,7 +143,7 @@ export default function CodeEditor({ sessionId, questionIndex, language, starter const res = await fetch(`/api/submissions/${sessionId}/restore?q=${questionIndex}`); if (!res.ok) return; const data = await res.json(); - if (data.response_text) { + if (data.response_text && data.response_text.trim().length > starter.trim().length) { setCode(data.response_text); lastSavedRef.current = data.response_text; prevLengthRef.current = data.response_text.length; @@ -289,7 +290,7 @@ export default function CodeEditor({ sessionId, questionIndex, language, starter const res = await fetch('/api/run-code', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ code: codeRef.current, language, stdin }), + body: JSON.stringify({ code: codeRef.current, language, stdin, exercise: exerciseSlug }), }); const data = await res.json(); setResult(res.ok ? data as RunResult : { stdout: '', stderr: data.error ?? 'Unknown error', compile_output: '', exit_code: 1 }); @@ -300,7 +301,7 @@ export default function CodeEditor({ sessionId, questionIndex, language, starter } } - const hasOutput = result && (result.stdout || result.stderr || result.compile_output); + const hasOutput = result !== null; const success = result?.exit_code === 0; return ( @@ -330,7 +331,7 @@ export default function CodeEditor({ sessionId, questionIndex, language, starter rows={3} value={stdin} onChange={(e) => setStdin(e.target.value)} - placeholder="Input to pass via stdin…" + placeholder={exerciseSlug === 'go-reloaded' ? 'Type your test input here…' : 'Input to pass via stdin…'} style={{ minHeight: 'unset', fontFamily: 'monospace', fontSize: 13 }} /> @@ -379,11 +380,15 @@ export default function CodeEditor({ sessionId, questionIndex, language, starter
{result.compile_output}
)} - {result?.stdout && ( + {result?.stdout ? (
stdout
{result.stdout}
+ ) : !result?.stderr && !result?.compile_output && ( +
+ No output — your program ran but printed nothing. Did you forget to provide stdin? +
)} {result?.stderr && (
diff --git a/app/participant/session/[id]/SessionView.tsx b/app/participant/session/[id]/SessionView.tsx index 9aeca32..f318e8d 100644 --- a/app/participant/session/[id]/SessionView.tsx +++ b/app/participant/session/[id]/SessionView.tsx @@ -8,7 +8,7 @@ import { AlertTriangle } from 'lucide-react'; interface QuestionStatus { question_index: number; has_draft: boolean; is_final: boolean; } interface SessionState { - session_id: string; current_question_index: number; question_count: number; + session_id: string; exercise_slug: string; current_question_index: number; question_count: number; remaining_seconds: number | null; warning_low_time: boolean; question_statuses: QuestionStatus[]; } interface Question { @@ -226,6 +226,7 @@ export default function SessionView({ exerciseId }: { exerciseId: string }) { language={question.language} starter={question.starter} isClosed={sessionClosed} + exerciseSlug={sessionState.exercise_slug} /> ) : (