Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion app/api/exercises/[id]/session/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down
54 changes: 54 additions & 0 deletions app/api/instructor/exercises/[id]/questions/sync/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
10 changes: 7 additions & 3 deletions app/api/instructor/exercises/[id]/questions/upload/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
9 changes: 5 additions & 4 deletions app/api/run-code/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand All @@ -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`, {
Expand Down
46 changes: 38 additions & 8 deletions app/instructor/exercises/[id]/QuestionManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<Question[]>(initialQuestions);
const [expanded, setExpanded] = useState<string | null>(null);
const [editing, setEditing] = useState<string | null>(null);
const [editDraft, setEditDraft] = useState<Partial<Question>>({});
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<HTMLInputElement>(null);

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -224,7 +249,12 @@ export default function QuestionManager({ exerciseId, initialQuestions }: Props)
<div className="card">
<div className="card-header">
<span className="card-title">Bulk Import from Markdown</span>
<span className="badge badge-orange">Replaces all questions</span>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
<button className="btn btn-sm btn-secondary" disabled={syncing} onClick={syncFromFiles} title="Re-read questions from docs/ files and update the database">
<RefreshCw size={12} /> {syncing ? 'Syncing…' : 'Sync from files'}
</button>
<span className="badge badge-orange">Replaces all questions</span>
</div>
</div>
<p style={{ fontSize: 12, color: 'var(--text3)', marginBottom: '1rem', lineHeight: 1.7 }}>
Upload a <code style={{ background: 'var(--bg3)', border: '1px solid var(--border)', padding: '1px 5px', borderRadius: 3, color: 'var(--accent2)' }}>.md</code> file
Expand Down
1 change: 1 addition & 0 deletions app/instructor/exercises/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ export default async function ExercisePage({ params }: Props) {
</div>
<QuestionManager
exerciseId={id}
exerciseSlug={exercise.slug}
initialQuestions={questionRows as { id: string; question_index: number; text: string; type: 'written' | 'code'; language: string; starter: string }[]}
/>
</div>
Expand Down
19 changes: 12 additions & 7 deletions app/participant/session/[id]/CodeEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<RunResult | null>(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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 });
Expand All @@ -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 (
Expand Down Expand Up @@ -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 }}
/>
</div>
Expand Down Expand Up @@ -379,11 +380,15 @@ export default function CodeEditor({ sessionId, questionIndex, language, starter
<pre style={{ margin: 0, fontSize: 12, color: '#fca5a5', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>{result.compile_output}</pre>
</div>
)}
{result?.stdout && (
{result?.stdout ? (
<div style={{ padding: '0.75rem', borderBottom: result?.stderr ? '1px solid var(--border)' : undefined }}>
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--green)', marginBottom: '0.4rem', textTransform: 'uppercase' }}>stdout</div>
<pre style={{ margin: 0, fontSize: 12, color: 'var(--text)', whiteSpace: 'pre-wrap', wordBreak: 'break-word', maxHeight: 300, overflowY: 'auto' }}>{result.stdout}</pre>
</div>
) : !result?.stderr && !result?.compile_output && (
<div style={{ padding: '0.75rem' }}>
<span style={{ fontSize: 12, color: 'var(--text3)', fontStyle: 'italic' }}>No output — your program ran but printed nothing. Did you forget to provide stdin?</span>
</div>
)}
{result?.stderr && (
<div style={{ padding: '0.75rem' }}>
Expand Down
3 changes: 2 additions & 1 deletion app/participant/session/[id]/SessionView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -226,6 +226,7 @@ export default function SessionView({ exerciseId }: { exerciseId: string }) {
language={question.language}
starter={question.starter}
isClosed={sessionClosed}
exerciseSlug={sessionState.exercise_slug}
/>
) : (
<ResponseEditor
Expand Down
6 changes: 1 addition & 5 deletions docs/go-reloaded/question0.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
## Drill 1 — Read Input Text from Stdin

Write a function that reads the entire input from stdin and returns it as a string:

```go
func readInput() string
```
Write a function `readInput() string` that reads the entire input from stdin and returns it as a string:

**Requirements:**
- Read all lines from stdin
Expand Down
6 changes: 1 addition & 5 deletions docs/go-reloaded/question1.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
## Drill 2 — Convert `(hex)` Tags

Write a function that finds every `(hex)` tag and replaces the word immediately before it with its decimal value:

```go
func convertHex(text string) string
```
Write a function `convertHex(text string) string` that finds every `(hex)` tag and replaces the word immediately before it with its decimal value:

**Requirements:**
- The word before `(hex)` is always a valid hexadecimal number
Expand Down
6 changes: 1 addition & 5 deletions docs/go-reloaded/question2.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
## Drill 3 — Convert `(bin)` Tags

Write a function that finds every `(bin)` tag and replaces the word immediately before it with its decimal value:

```go
func convertBin(text string) string
```
Write a function `convertBin(text string) string` that finds every `(bin)` tag and replaces the word immediately before it with its decimal value:

**Requirements:**
- The word before `(bin)` is always a valid binary number
Expand Down
6 changes: 1 addition & 5 deletions docs/go-reloaded/question3.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
## Drill 4 — Single-Word `(up)`, `(low)`, `(cap)`

Write a function that handles single-word case modifiers:

```go
func applySingleCaseModifiers(text string) string
```
Write a function `applySingleCaseModifiers(text string) string` that handles single-word case modifiers:

**Requirements:**
- `(up)` → converts the word immediately before it to UPPERCASE
Expand Down
6 changes: 1 addition & 5 deletions docs/go-reloaded/question4.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
## Drill 5 — Multi-Word `(up, N)`, `(low, N)`, `(cap, N)`

Extend your case modifier to handle the numbered variant:

```go
func applyCaseModifiers(text string) string
```
Extend your case modifier to handle the numbered variant. Write `applyCaseModifiers(text string) string`:

**Requirements:**
- `(up, 3)` → converts the 3 words before the tag to uppercase
Expand Down
6 changes: 1 addition & 5 deletions docs/go-reloaded/question5.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
## Drill 6 — Fix Punctuation Spacing

Write a function that corrects spacing around punctuation marks:

```go
func fixPunctuation(text string) string
```
Write a function `fixPunctuation(text string) string` that corrects spacing around punctuation marks:

**Requirements:**
- Single punctuation marks (`.`, `,`, `!`, `?`, `:`, `;`) must sit directly after the previous word with no space before them
Expand Down
6 changes: 1 addition & 5 deletions docs/go-reloaded/question6.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
## Drill 7 — Fix Single-Quote Formatting

Write a function that removes spaces between single-quote marks and the words they wrap:

```go
func fixSingleQuotes(text string) string
```
Write a function `fixSingleQuotes(text string) string` that removes spaces between single-quote marks and the words they wrap:

**Requirements:**
- Find pairs of `'` marks
Expand Down
6 changes: 1 addition & 5 deletions docs/go-reloaded/question7.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
## Drill 8 — Fix `a` → `an` Article Rule

Write a function that converts `a` to `an` when the next word starts with a vowel or `h`:

```go
func fixArticles(text string) string
```
Write a function `fixArticles(text string) string` that converts `a` to `an` when the next word starts with a vowel or `h`:

**Requirements:**
- Every standalone `a` or `A` followed by a word starting with `a`, `e`, `i`, `o`, `u`, or `h` (case-insensitive) becomes `an` / `An`
Expand Down
Loading
Loading