Concept project · runs in your browserTypeScript · React.js · Next.js · zod
Mini Learning Management System Demo
A small learning platform with two sample courses. As a student, enrol, read lessons, mark them complete and pass the quizzes to earn a printable certificate. Switch to the instructor role to write and publish lessons and see every student's progress. The dev panel shows each API request and the SQL it would run.
Interactive demo
## How it works
Every click calls a function in api.ts written like a NestJS controller: a RolesGuard and an EnrolledGuard run first, request bodies go through a zod validation pipe, and errors come back with Nest's { statusCode, error, message } shape. Open a lesson in “SQL basics” before enrolling and you get a 403.
Completions have the primary key (student_id, lesson_id) and are inserted with ON CONFLICT DO NOTHING. The double-click test sends the request twice: the dev panel shows INSERT 0 1 then INSERT 0 0, and progress moves once.
Progress is completed published lessons divided by published lessons, so unpublishing a lesson removes it from totals without deleting anyone's completions. Quiz questions are selected without correct_index; grading happens on submit, and the last lesson or passing quiz issues a certificate with a verification code in the same transaction.
## View source
The code running in the window above, exactly as deployed.
app/lab/mini-lms/api.ts562 lines
/**
* The API layer, written the way the NestJS service would be: controllers call services,
* guards run before handlers (RolesGuard, EnrolledGuard), DTOs go through a validation pipe,
* and multi-statement writes run in a transaction that rolls back on any error.
*
* Every call returns { status, body, sql } where `sql` is the PostgreSQL the real service
* would send. The caller gets back a new DB only when the request succeeded, so a failed
* request leaves state untouched, exactly like a ROLLBACK.
*/
import { z } from "zod";
import { certificateCode, type DB, type Lesson, type Role } from "./db";
export type Actor = { userId: string; role: Role };
export type ApiResponse<T = unknown> = { status: number; body: T; sql: string[] };
export type Exec<T = unknown> = { res: ApiResponse<T>; db: DB };
export type ErrorBody = { statusCode: number; error: string; message: string | string[] };
class HttpException extends Error {
constructor(
readonly status: number,
readonly error: string,
readonly detail: string | string[],
) {
super(Array.isArray(detail) ? detail.join("; ") : detail);
}
}
const Forbidden = (m: string) => new HttpException(403, "Forbidden", m);
const NotFound = (m: string) => new HttpException(404, "Not Found", m);
const BadRequest = (m: string | string[]) => new HttpException(400, "Bad Request", m);
const Unprocessable = (m: string) => new HttpException(422, "Unprocessable Entity", m);
type Ctx = { db: DB; sql: string[]; inTx: boolean; now: number };
/** Log one statement, with its bound parameters as a trailing comment. */
function q(ctx: Ctx, text: string, params: unknown[] = []) {
const p = params.length ? ` -- ${params.map((v, i) => `$${i + 1}=${JSON.stringify(v)}`).join(", ")}` : "";
ctx.sql.push(text.replace(/\s+/g, " ").trim() + p);
}
function tx<T>(ctx: Ctx, fn: () => T): T {
q(ctx, "BEGIN");
ctx.inTx = true;
const out = fn();
q(ctx, "COMMIT");
ctx.inTx = false;
return out;
}
/** Runs a handler against a copy of the DB; commits the copy only on success. */
function run<T>(db: DB, now: number, fn: (ctx: Ctx) => { status: number; body: T }): Exec<T | ErrorBody> {
const ctx: Ctx = { db: structuredClone(db), sql: [], inTx: false, now };
try {
const { status, body } = fn(ctx);
return { res: { status, body, sql: ctx.sql }, db: ctx.db };
} catch (e) {
if (!(e instanceof HttpException)) throw e;
if (ctx.inTx) q(ctx, "ROLLBACK");
return {
res: { status: e.status, body: { statusCode: e.status, error: e.error, message: e.detail }, sql: ctx.sql },
db,
};
}
}
/* ------------------------------------------------------------------ */
/* Guards and shared queries */
/* ------------------------------------------------------------------ */
/** @Roles('instructor') + RolesGuard: runs before any SQL. */
function rolesGuard(actor: Actor, role: Role) {
if (actor.role !== role) throw Forbidden(`Requires role '${role}'`);
}
function findCourse(ctx: Ctx, actor: Actor, courseId: string) {
q(ctx, "SELECT id, title, instructor_id, published FROM courses WHERE id = $1", [courseId]);
const c = ctx.db.courses.find((x) => x.id === courseId);
// Students cannot tell an unpublished course from a missing one.
if (!c || (!c.published && actor.role === "student")) throw NotFound("Course not found");
if (actor.role === "instructor" && c.instructorId !== actor.userId) throw Forbidden("Not your course");
return c;
}
/** EnrolledGuard: students must be enrolled before lesson bodies or quizzes are served. */
function enrolledGuard(ctx: Ctx, actor: Actor, courseId: string) {
if (actor.role === "instructor") return;
q(ctx, "SELECT 1 FROM enrolments WHERE student_id = $1 AND course_id = $2", [actor.userId, courseId]);
if (!ctx.db.enrolments.some((e) => e.studentId === actor.userId && e.courseId === courseId)) {
throw Forbidden("You are not enrolled in this course");
}
}
/** Progress = completed published lessons / published lessons. Unpublished lessons drop out of both sides. */
export function progressOf(db: DB, studentId: string, courseId: string) {
const published = db.lessons.filter((l) => l.courseId === courseId && l.published);
const ids = new Set(published.map((l) => l.id));
const done = db.completions.filter((c) => c.studentId === studentId && ids.has(c.lessonId)).length;
return { done, total: published.length, percent: published.length ? Math.round((done / published.length) * 100) : 0 };
}
function bestScores(db: DB, studentId: string, courseId: string) {
return db.quizzes
.filter((qz) => qz.courseId === courseId)
.map((qz) => {
const tries = db.attempts.filter((a) => a.studentId === studentId && a.quizId === qz.id);
const best = tries.length ? Math.max(...tries.map((a) => a.score)) : null;
return {
quizId: qz.id,
title: qz.title,
best,
total: qz.questions.length,
passed: best !== null && (best / qz.questions.length) * 100 >= qz.passPercent,
};
});
}
const PROGRESS_SQL = `SELECT count(c.lesson_id) AS done, count(l.id) AS total
FROM lessons l LEFT JOIN completions c ON c.lesson_id = l.id AND c.student_id = $1
WHERE l.course_id = $2 AND l.published`;
/** Issues a certificate once every published lesson is done and every quiz passed. Idempotent. */
function maybeIssueCertificate(ctx: Ctx, studentId: string, courseId: string) {
q(ctx, PROGRESS_SQL, [studentId, courseId]);
const p = progressOf(ctx.db, studentId, courseId);
q(
ctx,
`SELECT q.id, max(a.score) AS best FROM quizzes q LEFT JOIN quiz_attempts a
ON a.quiz_id = q.id AND a.student_id = $1 WHERE q.course_id = $2 GROUP BY q.id`,
[studentId, courseId],
);
const quizzesPassed = bestScores(ctx.db, studentId, courseId).every((s) => s.passed);
if (p.total === 0 || p.done < p.total || !quizzesPassed) return null;
const existing = ctx.db.certificates.find((c) => c.studentId === studentId && c.courseId === courseId);
const code = existing?.code ?? certificateCode(studentId, courseId, ctx.now);
q(
ctx,
`INSERT INTO certificates (code, student_id, course_id, issued_at) VALUES ($1, $2, $3, now())
ON CONFLICT (student_id, course_id) DO NOTHING`,
[code, studentId, courseId],
);
if (!existing) ctx.db.certificates.push({ code, studentId, courseId, issuedAt: ctx.now });
return code;
}
/* ------------------------------------------------------------------ */
/* Catalogue and courses */
/* ------------------------------------------------------------------ */
export type CourseCard = {
id: string;
title: string;
summary: string;
tags: string[];
published: boolean;
lessons: number;
minutes: number;
enrolled: boolean;
progress: { done: number; total: number; percent: number };
students: number;
};
/** GET /courses?q=&tag= */
export function listCourses(db: DB, actor: Actor, query: { q?: string; tag?: string }, now: number) {
return run(db, now, (ctx) => {
const term = (query.q ?? "").trim().toLowerCase();
q(
ctx,
`SELECT c.id, c.title, c.tags, count(l.id) AS lessons, e.student_id IS NOT NULL AS enrolled
FROM courses c LEFT JOIN lessons l ON l.course_id = c.id AND l.published
LEFT JOIN enrolments e ON e.course_id = c.id AND e.student_id = $1
WHERE ${actor.role === "student" ? "c.published AND " : "c.instructor_id = $1 AND "}($2 = '' OR c.title ILIKE '%' || $2 || '%')
AND ($3::text IS NULL OR $3 = ANY(c.tags))
GROUP BY c.id, e.student_id ORDER BY c.title`,
[actor.userId, term, query.tag ?? null],
);
const body: CourseCard[] = ctx.db.courses
.filter((c) => (actor.role === "student" ? c.published : c.instructorId === actor.userId))
.filter((c) => !term || c.title.toLowerCase().includes(term) || c.summary.toLowerCase().includes(term))
.filter((c) => !query.tag || c.tags.includes(query.tag))
.sort((a, b) => a.title.localeCompare(b.title))
.map((c) => {
const ls = ctx.db.lessons.filter((l) => l.courseId === c.id && l.published);
return {
id: c.id,
title: c.title,
summary: c.summary,
tags: c.tags,
published: c.published,
lessons: ls.length,
minutes: ls.reduce((s, l) => s + l.minutes, 0),
enrolled: ctx.db.enrolments.some((e) => e.courseId === c.id && e.studentId === actor.userId),
progress: progressOf(ctx.db, actor.userId, c.id),
students: ctx.db.enrolments.filter((e) => e.courseId === c.id).length,
};
});
return { status: 200, body };
});
}
export type Outline = {
course: { id: string; title: string; summary: string; published: boolean; tags: string[] };
enrolled: boolean;
progress: { done: number; total: number; percent: number };
certificate: string | null;
sections: {
id: string;
title: string;
lessons: { id: string; title: string; minutes: number; published: boolean; completed: boolean }[];
quizzes: { id: string; title: string; questions: number; best: number | null; passed: boolean }[];
}[];
};
/** GET /courses/:id — the outline is public (catalogue preview); lesson bodies are not. */
export function getCourse(db: DB, actor: Actor, courseId: string, now: number) {
return run(db, now, (ctx) => {
const c = findCourse(ctx, actor, courseId);
q(
ctx,
`SELECT s.id, s.title, l.id, l.title, l.minutes, l.published, (cp.lesson_id IS NOT NULL) AS completed
FROM sections s JOIN lessons l ON l.section_id = s.id
LEFT JOIN completions cp ON cp.lesson_id = l.id AND cp.student_id = $2
WHERE s.course_id = $1 ${actor.role === "student" ? "AND l.published " : ""}ORDER BY s.position, l.position`,
[courseId, actor.userId],
);
const done = new Set(ctx.db.completions.filter((x) => x.studentId === actor.userId).map((x) => x.lessonId));
const scores = bestScores(ctx.db, actor.userId, courseId);
const body: Outline = {
course: { id: c.id, title: c.title, summary: c.summary, published: c.published, tags: c.tags },
enrolled: ctx.db.enrolments.some((e) => e.courseId === c.id && e.studentId === actor.userId),
progress: progressOf(ctx.db, actor.userId, c.id),
certificate: ctx.db.certificates.find((x) => x.courseId === c.id && x.studentId === actor.userId)?.code ?? null,
sections: ctx.db.sections
.filter((s) => s.courseId === c.id)
.sort((a, b) => a.position - b.position)
.map((s) => ({
id: s.id,
title: s.title,
lessons: ctx.db.lessons
.filter((l) => l.sectionId === s.id && (actor.role === "instructor" || l.published))
.sort((a, b) => a.position - b.position)
.map((l) => ({ id: l.id, title: l.title, minutes: l.minutes, published: l.published, completed: done.has(l.id) })),
quizzes: ctx.db.quizzes
.filter((qz) => qz.sectionId === s.id)
.map((qz) => {
const sc = scores.find((x) => x.quizId === qz.id);
return { id: qz.id, title: qz.title, questions: qz.questions.length, best: sc?.best ?? null, passed: !!sc?.passed };
}),
})),
};
return { status: 200, body };
});
}
/** POST /courses/:id/enrolments — idempotent thanks to the primary key. */
export function enrol(db: DB, actor: Actor, courseId: string, now: number) {
return run(db, now, (ctx) => {
rolesGuard(actor, "student");
findCourse(ctx, actor, courseId);
q(
ctx,
`INSERT INTO enrolments (student_id, course_id, enrolled_at) VALUES ($1, $2, now())
ON CONFLICT (student_id, course_id) DO NOTHING RETURNING enrolled_at`,
[actor.userId, courseId],
);
const exists = ctx.db.enrolments.some((e) => e.studentId === actor.userId && e.courseId === courseId);
if (!exists) ctx.db.enrolments.push({ studentId: actor.userId, courseId, at: now });
return { status: exists ? 200 : 201, body: { courseId, enrolled: true, created: !exists } };
});
}
/* ------------------------------------------------------------------ */
/* Lessons and progress */
/* ------------------------------------------------------------------ */
/** GET /lessons/:id — EnrolledGuard first, then the body. */
export function getLesson(db: DB, actor: Actor, lessonId: string, now: number) {
return run(db, now, (ctx) => {
q(ctx, "SELECT id, course_id, section_id, title, body, minutes, published FROM lessons WHERE id = $1", [lessonId]);
const l = ctx.db.lessons.find((x) => x.id === lessonId);
if (!l || (!l.published && actor.role === "student")) throw NotFound("Lesson not found");
findCourse(ctx, actor, l.courseId);
enrolledGuard(ctx, actor, l.courseId);
q(ctx, "SELECT 1 FROM completions WHERE student_id = $1 AND lesson_id = $2", [actor.userId, lessonId]);
const completed = ctx.db.completions.some((c) => c.studentId === actor.userId && c.lessonId === lessonId);
return { status: 200, body: { ...l, completed } };
});
}
/**
* POST /lessons/:id/completion — the (student_id, lesson_id) primary key plus
* ON CONFLICT DO NOTHING means a double click inserts one row, never two.
*/
export function completeLesson(db: DB, actor: Actor, lessonId: string, now: number) {
return run(db, now, (ctx) => {
rolesGuard(actor, "student");
q(ctx, "SELECT id, course_id, published FROM lessons WHERE id = $1", [lessonId]);
const l = ctx.db.lessons.find((x) => x.id === lessonId);
if (!l || !l.published) throw NotFound("Lesson not found");
enrolledGuard(ctx, actor, l.courseId);
return tx(ctx, () => {
q(
ctx,
`INSERT INTO completions (student_id, lesson_id, completed_at) VALUES ($1, $2, now())
ON CONFLICT (student_id, lesson_id) DO NOTHING`,
[actor.userId, lessonId],
);
const already = ctx.db.completions.some((c) => c.studentId === actor.userId && c.lessonId === lessonId);
if (!already) ctx.db.completions.push({ studentId: actor.userId, lessonId, at: now });
// Show what PostgreSQL would answer: "INSERT 0 0" means the conflict swallowed the duplicate.
ctx.sql.push(`${ctx.sql.pop() ?? ""} → ${already ? "INSERT 0 0" : "INSERT 0 1"}`);
const certificate = maybeIssueCertificate(ctx, actor.userId, l.courseId);
return {
status: 200,
body: { lessonId, inserted: !already, progress: progressOf(ctx.db, actor.userId, l.courseId), certificate },
};
});
});
}
/* ------------------------------------------------------------------ */
/* Quizzes */
/* ------------------------------------------------------------------ */
export type PublicQuiz = {
id: string;
courseId: string;
title: string;
passPercent: number;
questions: { id: string; prompt: string; options: string[] }[];
};
/** GET /quizzes/:id — the select list never includes correct_index, so answers cannot leak. */
export function getQuiz(db: DB, actor: Actor, quizId: string, now: number) {
return run(db, now, (ctx) => {
q(ctx, "SELECT id, course_id, title, pass_percent FROM quizzes WHERE id = $1", [quizId]);
const qz = ctx.db.quizzes.find((x) => x.id === quizId);
if (!qz) throw NotFound("Quiz not found");
findCourse(ctx, actor, qz.courseId);
enrolledGuard(ctx, actor, qz.courseId);
q(ctx, "SELECT id, prompt, options FROM quiz_questions WHERE quiz_id = $1 ORDER BY position", [quizId]);
const body: PublicQuiz = {
id: qz.id,
courseId: qz.courseId,
title: qz.title,
passPercent: qz.passPercent,
questions: qz.questions.map(({ id, prompt, options }) => ({ id, prompt, options })),
};
return { status: 200, body };
});
}
export type AttemptResult = {
attemptId: number;
score: number;
total: number;
percent: number;
passed: boolean;
results: { questionId: string; chosen: number; correctIndex: number; correct: boolean }[];
certificate: string | null;
};
/** POST /quizzes/:id/attempts — graded on the server; correct answers are returned only now. */
export function submitAttempt(db: DB, actor: Actor, quizId: string, input: unknown, now: number) {
return run(db, now, (ctx) => {
rolesGuard(actor, "student");
const qz = ctx.db.quizzes.find((x) => x.id === quizId);
// ValidationPipe: shape first, then per-question range checks against the quiz.
const dto = z.object({ answers: z.array(z.number().int().min(0)) }).strict().safeParse(input);
if (!dto.success) throw BadRequest(dto.error.issues.map((i) => `${i.path.join(".") || "body"}: ${i.message}`));
q(ctx, "SELECT id, course_id, pass_percent FROM quizzes WHERE id = $1", [quizId]);
if (!qz) throw NotFound("Quiz not found");
enrolledGuard(ctx, actor, qz.courseId);
const { answers } = dto.data;
const missing = qz.questions.map((_, i) => i).filter((i) => answers[i] === undefined);
if (answers.length !== qz.questions.length || missing.length) {
throw BadRequest(`answers must contain exactly ${qz.questions.length} items (one per question)`);
}
q(ctx, "SELECT id, correct_index FROM quiz_questions WHERE quiz_id = $1 ORDER BY position", [quizId]);
const results = qz.questions.map((qq, i) => {
const chosen = answers[i]!;
if (chosen >= qq.options.length) throw BadRequest(`answers.${i} must be between 0 and ${qq.options.length - 1}`);
return { questionId: qq.id, chosen, correctIndex: qq.correctIndex, correct: chosen === qq.correctIndex };
});
const score = results.filter((r) => r.correct).length;
return tx(ctx, () => {
const attemptId = ++ctx.db.seq.attempt;
q(
ctx,
`INSERT INTO quiz_attempts (student_id, quiz_id, answers, score, total, submitted_at)
VALUES ($1, $2, $3, $4, $5, now()) RETURNING id`,
[actor.userId, quizId, answers, score, results.length],
);
ctx.db.attempts.push({ id: attemptId, studentId: actor.userId, quizId, score, total: results.length, at: now });
const certificate = maybeIssueCertificate(ctx, actor.userId, qz.courseId);
const percent = Math.round((score / results.length) * 100);
const body: AttemptResult = { attemptId, score, total: results.length, percent, passed: percent >= qz.passPercent, results, certificate };
return { status: 201, body };
});
});
}
/* ------------------------------------------------------------------ */
/* Instructor */
/* ------------------------------------------------------------------ */
export const LessonDto = z
.object({
sectionId: z.string().min(1, "choose a section"),
title: z.string().trim().min(3, "title must be at least 3 characters").max(80, "title must be at most 80 characters"),
minutes: z.number().int("minutes must be a whole number").min(1, "minutes must be at least 1").max(120, "minutes must be at most 120"),
body: z.string().trim().min(20, "body must be at least 20 characters").max(5000, "body must be at most 5000 characters"),
published: z.boolean(),
})
.strict();
export type LessonInput = z.infer<typeof LessonDto>;
function validate<T>(schema: z.ZodType<T>, input: unknown): T {
const r = schema.safeParse(input);
if (!r.success) throw BadRequest(r.error.issues.map((i) => `${i.path.join(".") || "body"}: ${i.message}`));
return r.data;
}
/** POST /courses/:id/lessons (instructor) */
export function createLesson(db: DB, actor: Actor, courseId: string, input: unknown, now: number) {
return run(db, now, (ctx) => {
rolesGuard(actor, "instructor");
const dto = validate(LessonDto, input);
findCourse(ctx, actor, courseId);
q(ctx, "SELECT id FROM sections WHERE id = $1 AND course_id = $2", [dto.sectionId, courseId]);
if (!ctx.db.sections.some((s) => s.id === dto.sectionId && s.courseId === courseId)) throw Unprocessable("Section does not belong to this course");
const position = Math.max(0, ...ctx.db.lessons.filter((l) => l.sectionId === dto.sectionId).map((l) => l.position)) + 1;
const lesson: Lesson = { id: `l_new${++ctx.db.seq.lesson}`, courseId, position, ...dto };
q(
ctx,
`INSERT INTO lessons (course_id, section_id, title, minutes, body, published, position)
VALUES ($1, $2, $3, $4, $5, $6, (SELECT coalesce(max(position), 0) + 1 FROM lessons WHERE section_id = $2))
RETURNING id, position`,
[courseId, dto.sectionId, dto.title, dto.minutes, `${dto.body.slice(0, 24)}…`, dto.published],
);
ctx.db.lessons.push(lesson);
return { status: 201, body: lesson };
});
}
/** PATCH /lessons/:id (instructor) — partial update; unpublishing keeps completions. */
export function updateLesson(db: DB, actor: Actor, lessonId: string, input: unknown, now: number) {
return run(db, now, (ctx) => {
rolesGuard(actor, "instructor");
const dto = validate(LessonDto.partial(), input);
q(ctx, "SELECT id, course_id FROM lessons WHERE id = $1", [lessonId]);
const l = ctx.db.lessons.find((x) => x.id === lessonId);
if (!l) throw NotFound("Lesson not found");
findCourse(ctx, actor, l.courseId);
const keys = Object.keys(dto) as (keyof LessonInput)[];
if (!keys.length) throw BadRequest("Nothing to update");
q(
ctx,
`UPDATE lessons SET ${keys.map((k, i) => `${k === "sectionId" ? "section_id" : k} = $${i + 2}`).join(", ")}, updated_at = now() WHERE id = $1`,
[lessonId, ...keys.map((k) => (k === "body" ? `${String(dto[k]).slice(0, 24)}…` : dto[k]))],
);
Object.assign(l, dto);
return { status: 200, body: l };
});
}
/** PATCH /courses/:id { published } — a course needs at least one published lesson to go live. */
export function setCoursePublished(db: DB, actor: Actor, courseId: string, published: boolean, now: number) {
return run(db, now, (ctx) => {
rolesGuard(actor, "instructor");
const c = findCourse(ctx, actor, courseId);
if (published) {
q(ctx, "SELECT count(*) FROM lessons WHERE course_id = $1 AND published", [courseId]);
if (!ctx.db.lessons.some((l) => l.courseId === courseId && l.published)) {
throw Unprocessable("Publish at least one lesson before publishing the course");
}
}
q(ctx, "UPDATE courses SET published = $2, updated_at = now() WHERE id = $1", [courseId, published]);
c.published = published;
return { status: 200, body: { id: c.id, published } };
});
}
export type ReportRow = {
studentId: string;
name: string;
done: number;
total: number;
percent: number;
quizzes: { quizId: string; title: string; best: number | null; total: number; passed: boolean }[];
certificate: string | null;
};
/** GET /courses/:id/report (instructor) — one row per enrolled student. */
export function courseReport(db: DB, actor: Actor, courseId: string, now: number) {
return run(db, now, (ctx) => {
rolesGuard(actor, "instructor");
findCourse(ctx, actor, courseId);
q(
ctx,
`WITH pub AS (SELECT id FROM lessons WHERE course_id = $1 AND published)
SELECT u.id, u.name,
count(c.lesson_id) AS done, (SELECT count(*) FROM pub) AS total,
round(100.0 * count(c.lesson_id) / nullif((SELECT count(*) FROM pub), 0)) AS percent,
cert.code AS certificate
FROM enrolments e JOIN users u ON u.id = e.student_id
LEFT JOIN completions c ON c.student_id = u.id AND c.lesson_id IN (SELECT id FROM pub)
LEFT JOIN certificates cert ON cert.student_id = u.id AND cert.course_id = $1
WHERE e.course_id = $1 GROUP BY u.id, u.name, cert.code ORDER BY percent DESC NULLS LAST, u.name`,
[courseId],
);
q(
ctx,
`SELECT a.student_id, a.quiz_id, max(a.score) AS best FROM quiz_attempts a
JOIN quizzes q ON q.id = a.quiz_id WHERE q.course_id = $1 GROUP BY a.student_id, a.quiz_id`,
[courseId],
);
const body: ReportRow[] = ctx.db.enrolments
.filter((e) => e.courseId === courseId)
.map((e) => {
const u = ctx.db.users.find((x) => x.id === e.studentId);
const p = progressOf(ctx.db, e.studentId, courseId);
return {
studentId: e.studentId,
name: u?.name ?? e.studentId,
...p,
quizzes: bestScores(ctx.db, e.studentId, courseId),
certificate: ctx.db.certificates.find((c) => c.studentId === e.studentId && c.courseId === courseId)?.code ?? null,
};
})
.sort((a, b) => b.percent - a.percent || a.name.localeCompare(b.name));
return { status: 200, body };
});
}
/* ------------------------------------------------------------------ */
/* Certificates */
/* ------------------------------------------------------------------ */
export type CertificateView = { code: string; student: string; course: string; issuedAt: number; lessons: number };
/** GET /certificates/:code — public verification endpoint, no auth needed. */
export function verifyCertificate(db: DB, code: string, now: number) {
return run(db, now, (ctx) => {
const clean = code.trim().toUpperCase();
q(
ctx,
`SELECT cert.code, u.name, c.title, cert.issued_at FROM certificates cert
JOIN users u ON u.id = cert.student_id JOIN courses c ON c.id = cert.course_id WHERE cert.code = $1`,
[clean],
);
const cert = ctx.db.certificates.find((c) => c.code === clean);
if (!cert) throw NotFound("No certificate with that code");
const body: CertificateView = {
code: cert.code,
student: ctx.db.users.find((u) => u.id === cert.studentId)?.name ?? "Unknown",
course: ctx.db.courses.find((c) => c.id === cert.courseId)?.title ?? "Unknown",
issuedAt: cert.issuedAt,
lessons: progressOf(ctx.db, cert.studentId, cert.courseId).total,
};
return { status: 200, body };
});
}app/lab/mini-lms/db.ts331 lines
/**
* In-memory stand-in for the PostgreSQL schema the NestJS service would use.
* Every table is a plain array of rows; api.ts is the only code that writes to it.
*
* courses(id, title, instructor_id, published) sections(id, course_id, position)
* lessons(id, section_id, position, body, published) quizzes(id, section_id) + quiz_questions
* enrolments PK (student_id, course_id)
* completions PK (student_id, lesson_id) <- double clicks can never double count
* quiz_attempts(id, student_id, quiz_id, score, total)
* certificates UNIQUE (student_id, course_id), code UNIQUE
*/
export type Role = "student" | "instructor";
export type User = { id: string; name: string; role: Role };
export type Course = {
id: string;
title: string;
summary: string;
tags: string[];
instructorId: string;
published: boolean;
};
export type Section = { id: string; courseId: string; title: string; position: number };
export type Lesson = {
id: string;
courseId: string;
sectionId: string;
title: string;
minutes: number;
position: number;
published: boolean;
body: string;
};
export type Question = { id: string; prompt: string; options: string[]; correctIndex: number };
export type Quiz = { id: string; courseId: string; sectionId: string; title: string; passPercent: number; questions: Question[] };
export type Enrolment = { studentId: string; courseId: string; at: number };
export type Completion = { studentId: string; lessonId: string; at: number };
export type Attempt = { id: number; studentId: string; quizId: string; score: number; total: number; at: number };
export type Certificate = { code: string; studentId: string; courseId: string; issuedAt: number };
export type DB = {
users: User[];
courses: Course[];
sections: Section[];
lessons: Lesson[];
quizzes: Quiz[];
enrolments: Enrolment[];
completions: Completion[];
attempts: Attempt[];
certificates: Certificate[];
seq: { attempt: number; lesson: number };
};
export const ME = "u_you";
export const INSTRUCTOR = "u_ins";
const DAY = 86_400_000;
const L = (
id: string,
courseId: string,
sectionId: string,
position: number,
title: string,
minutes: number,
body: string,
published = true,
): Lesson => ({ id, courseId, sectionId, position, title, minutes, body: body.trim(), published });
const lessons: Lesson[] = [
L("l_git1", "c_git", "s_git1", 1, "What Git actually stores", 6, `
Git does not store diffs. Every commit is a **snapshot** of the whole project, plus a pointer to its parent.
## Three places your files live
- The **working tree**: the files you edit.
- The **staging area** (index): what will go into the next commit.
- The **repository**: commits stored in \`.git\`.
Run \`git status\` often. It tells you which of the three places each change is in.
`),
L("l_git2", "c_git", "s_git1", 2, "Your first commit", 8, `
Create a repository, stage a file and commit it.
\`\`\`bash
git init
git add README.md
git commit -m "Add README"
\`\`\`
A good commit message says **why**, not only what. Keep the first line under about 50 characters.
`),
L("l_git3", "c_git", "s_git1", 3, "Reading history", 7, `
\`git log --oneline --graph\` draws the commit graph in the terminal.
- \`git show <hash>\` prints one commit.
- \`git diff\` compares the working tree with the index.
- \`git blame file\` shows who last touched each line.
`),
L("l_git4", "c_git", "s_git2", 1, "Branches are pointers", 6, `
A branch is a file containing one commit hash. Creating a branch is cheap because nothing is copied.
\`\`\`bash
git switch -c feature/login
\`\`\`
\`HEAD\` points at the branch you are on. New commits move that branch forward.
`),
L("l_git5", "c_git", "s_git2", 2, "Merging and conflicts", 10, `
\`git merge\` joins two lines of history. When both sides changed the same lines, Git stops and asks you.
## Resolving a conflict
- Open the file and find the \`<<<<<<<\` markers.
- Keep the right code and delete the markers.
- \`git add\` the file, then \`git commit\`.
`),
L("l_git6", "c_git", "s_git2", 3, "Rebasing (draft)", 9, `
Rebasing replays your commits on top of another branch. This lesson is still a draft.
`, false),
L("l_sql1", "c_sql", "s_sql1", 1, "SELECT and WHERE", 7, `
Every query starts with what you want and where it comes from.
\`\`\`sql
SELECT name, price
FROM products
WHERE price < 20;
\`\`\`
\`WHERE\` filters rows **before** anything is grouped or sorted.
`),
L("l_sql2", "c_sql", "s_sql1", 2, "Sorting and limits", 5, `
\`ORDER BY\` sorts, \`LIMIT\` keeps the first rows.
\`\`\`sql
SELECT name, price FROM products
ORDER BY price DESC
LIMIT 5;
\`\`\`
Without \`ORDER BY\` the database may return rows in any order.
`),
L("l_sql3", "c_sql", "s_sql1", 3, "Aggregates and GROUP BY", 9, `
Aggregates such as \`count\`, \`sum\` and \`avg\` collapse many rows into one per group.
\`\`\`sql
SELECT category, count(*) AS items, avg(price) AS avg_price
FROM products
GROUP BY category
HAVING count(*) > 3;
\`\`\`
\`HAVING\` filters groups, \`WHERE\` filters rows.
`),
L("l_sql4", "c_sql", "s_sql2", 1, "Inner joins", 8, `
An inner join keeps rows that match on both sides.
\`\`\`sql
SELECT o.id, c.name
FROM orders o
JOIN customers c ON c.id = o.customer_id;
\`\`\`
`),
L("l_sql5", "c_sql", "s_sql2", 2, "Left joins and NULLs", 9, `
A left join keeps every row from the left table. Missing matches come back as \`NULL\`.
- \`count(o.id)\` counts only matched rows.
- \`count(*)\` counts every row, including the empty match.
- Compare with \`IS NULL\`, never \`= NULL\`.
`),
L("l_http1", "c_http", "s_http1", 1, "Requests and responses", 6, `
A request has a **method**, a path, headers and an optional body. A response has a status code, headers and a body.
`, false),
L("l_http2", "c_http", "s_http1", 2, "Status codes that mean something", 7, `
- \`201\` created, \`204\` no content
- \`400\` bad input, \`401\` not logged in, \`403\` not allowed
- \`409\` conflict, \`422\` valid shape but rejected by a rule
`, false),
];
const Q = (id: string, prompt: string, options: string[], correctIndex: number): Question => ({ id, prompt, options, correctIndex });
const quizzes: Quiz[] = [
{
id: "q_git1",
courseId: "c_git",
sectionId: "s_git1",
title: "Quiz: Git basics",
passPercent: 70,
questions: [
Q("q_git1_1", "What does a Git commit store?", ["A diff against the previous commit", "A snapshot of the project and a parent pointer", "Only the files you changed"], 1),
Q("q_git1_2", "Which command shows which area each change is in?", ["git status", "git log", "git show"], 0),
Q("q_git1_3", "What does `git add` do?", ["Creates a commit", "Uploads to the remote", "Stages changes for the next commit"], 2),
],
},
{
id: "q_git2",
courseId: "c_git",
sectionId: "s_git2",
title: "Quiz: Branching",
passPercent: 70,
questions: [
Q("q_git2_1", "What is a branch in Git?", ["A full copy of the repository", "A movable pointer to a commit", "A folder inside .git/objects"], 1),
Q("q_git2_2", "What marks the start of a conflict in a file?", ["<<<<<<<", "#####", "/* conflict */"], 0),
Q("q_git2_3", "After fixing a conflict you should…", ["Delete the branch", "git add the file and commit", "Run git init again"], 1),
],
},
{
id: "q_sql1",
courseId: "c_sql",
sectionId: "s_sql1",
title: "Quiz: Querying",
passPercent: 70,
questions: [
Q("q_sql1_1", "Which clause filters groups after aggregation?", ["WHERE", "HAVING", "LIMIT"], 1),
Q("q_sql1_2", "Without ORDER BY, the row order is…", ["Insertion order", "Alphabetical", "Not guaranteed"], 2),
Q("q_sql1_3", "`count(*)` returns…", ["The number of rows", "The sum of a column", "The number of columns"], 0),
],
},
{
id: "q_sql2",
courseId: "c_sql",
sectionId: "s_sql2",
title: "Quiz: Joins",
passPercent: 70,
questions: [
Q("q_sql2_1", "A LEFT JOIN keeps…", ["Only matching rows", "Every row from the left table", "Every row from both tables"], 1),
Q("q_sql2_2", "How do you test for a missing value?", ["= NULL", "IS NULL", "== null"], 1),
Q("q_sql2_3", "Which count ignores the unmatched side of a left join?", ["count(*)", "count(o.id)", "count(1)"], 1),
],
},
];
const STUDENTS: [string, string][] = [
["u_s1", "Aisha K."],
["u_s2", "Bilal M."],
["u_s3", "Chen L."],
["u_s4", "Diego R."],
["u_s5", "Emma S."],
["u_s6", "Farah N."],
];
/** Deterministic seed: the same data on every reset, timestamps relative to `now`. */
export function seed(now: number): DB {
const enrolments: Enrolment[] = [{ studentId: ME, courseId: "c_git", at: now - 3 * DAY }];
const completions: Completion[] = [{ studentId: ME, lessonId: "l_git1", at: now - 2 * DAY }];
const attempts: Attempt[] = [];
const certificates: Certificate[] = [];
let attemptId = 0;
const gitLessons = ["l_git1", "l_git2", "l_git3", "l_git4", "l_git5"];
const sqlLessons = ["l_sql1", "l_sql2", "l_sql3", "l_sql4", "l_sql5"];
STUDENTS.forEach(([id], i) => {
// Everyone takes Git; every other student also takes SQL. Progress varies by index.
enrolments.push({ studentId: id, courseId: "c_git", at: now - (12 - i) * DAY });
gitLessons.slice(0, (i * 2 + 1) % 6).forEach((l, j) => completions.push({ studentId: id, lessonId: l, at: now - (10 - j) * DAY }));
if (i % 2 === 0) {
enrolments.push({ studentId: id, courseId: "c_sql", at: now - (9 - i) * DAY });
sqlLessons.slice(0, 5 - i).forEach((l, j) => completions.push({ studentId: id, lessonId: l, at: now - (8 - j) * DAY }));
}
if (i !== 3) attempts.push({ id: ++attemptId, studentId: id, quizId: "q_git1", score: [3, 2, 3, 0, 1, 3][i]!, total: 3, at: now - 6 * DAY });
if (i === 2 || i === 5) attempts.push({ id: ++attemptId, studentId: id, quizId: "q_git2", score: 3, total: 3, at: now - 4 * DAY });
if (i === 0) {
attempts.push({ id: ++attemptId, studentId: id, quizId: "q_sql1", score: 3, total: 3, at: now - 5 * DAY });
attempts.push({ id: ++attemptId, studentId: id, quizId: "q_sql2", score: 3, total: 3, at: now - 3 * DAY });
}
});
// u_s3 finished Git (5/5 lessons, both quizzes passed), so they hold a certificate.
certificates.push({ code: certificateCode("u_s3", "c_git", now - 4 * DAY), studentId: "u_s3", courseId: "c_git", issuedAt: now - 4 * DAY });
certificates.push({ code: certificateCode("u_s1", "c_sql", now - 3 * DAY), studentId: "u_s1", courseId: "c_sql", issuedAt: now - 3 * DAY });
return {
users: [
{ id: ME, name: "You (demo student)", role: "student" },
{ id: INSTRUCTOR, name: "Demo Instructor", role: "instructor" },
...STUDENTS.map(([id, name]) => ({ id, name, role: "student" as const })),
],
courses: [
{
id: "c_git",
title: "Intro to Git",
summary: "Snapshots, commits, branches and merge conflicts, explained from what Git stores on disk.",
tags: ["git", "tools", "beginner"],
instructorId: INSTRUCTOR,
published: true,
},
{
id: "c_sql",
title: "SQL basics",
summary: "Filter, sort, group and join tables with plain PostgreSQL queries.",
tags: ["sql", "postgresql", "beginner"],
instructorId: INSTRUCTOR,
published: true,
},
{
id: "c_http",
title: "HTTP for web developers",
summary: "Methods, status codes and headers. Still being written, so students cannot see it yet.",
tags: ["http", "web"],
instructorId: INSTRUCTOR,
published: false,
},
],
sections: [
{ id: "s_git1", courseId: "c_git", title: "Getting started", position: 1 },
{ id: "s_git2", courseId: "c_git", title: "Branching", position: 2 },
{ id: "s_sql1", courseId: "c_sql", title: "Querying", position: 1 },
{ id: "s_sql2", courseId: "c_sql", title: "Joining tables", position: 2 },
{ id: "s_http1", courseId: "c_http", title: "The basics", position: 1 },
],
lessons: lessons.map((l) => ({ ...l })),
quizzes: quizzes.map((q) => ({ ...q, questions: q.questions.map((x) => ({ ...x, options: [...x.options] })) })),
enrolments,
completions,
attempts,
certificates,
seq: { attempt: attemptId, lesson: 100 },
};
}
/** Short, non-sequential verification code (FNV-1a over the identifying fields). */
export function certificateCode(studentId: string, courseId: string, issuedAt: number): string {
let h = 0x811c9dc5;
for (const ch of `${studentId}:${courseId}:${issuedAt}`) {
h ^= ch.charCodeAt(0);
h = Math.imul(h, 0x01000193) >>> 0;
}
return `LMS-${h.toString(36).toUpperCase().padStart(7, "0")}`;
}app/lab/mini-lms/lesson-markdown.ts79 lines
/**
* A deliberately small Markdown parser for lesson bodies. It returns a tree of plain data
* that React renders as elements, so lesson text can never inject HTML: a "<script>" in a
* lesson is just text. Supports: headings (#, ##), paragraphs, "-" lists, fenced code,
* `inline code` and **bold**. Production would use remark with sanitising instead.
*/
export type Inline = { t: "text" | "code" | "bold"; v: string };
export type Block =
| { t: "heading"; inl: Inline[] }
| { t: "p"; inl: Inline[] }
| { t: "ul"; items: Inline[][] }
| { t: "code"; lang: string; v: string };
export function parseInline(s: string): Inline[] {
const out: Inline[] = [];
const re = /(`[^`]+`|\*\*[^*]+\*\*)/g;
let last = 0;
for (const m of s.matchAll(re)) {
const i = m.index ?? 0;
if (i > last) out.push({ t: "text", v: s.slice(last, i) });
const tok = m[0];
out.push(tok.startsWith("`") ? { t: "code", v: tok.slice(1, -1) } : { t: "bold", v: tok.slice(2, -2) });
last = i + tok.length;
}
if (last < s.length) out.push({ t: "text", v: s.slice(last) });
return out;
}
export function parseMarkdown(src: string): Block[] {
const blocks: Block[] = [];
const lines = src.replace(/\r\n?/g, "\n").split("\n");
let para: string[] = [];
let list: Inline[][] | null = null;
const flush = () => {
if (para.length) blocks.push({ t: "p", inl: parseInline(para.join(" ")) });
if (list) blocks.push({ t: "ul", items: list });
para = [];
list = null;
};
for (let i = 0; i < lines.length; i++) {
const line = lines[i]!;
const fence = /^```(\w*)\s*$/.exec(line);
if (fence) {
flush();
const code: string[] = [];
i++;
while (i < lines.length && !/^```\s*$/.test(lines[i]!)) code.push(lines[i++]!);
blocks.push({ t: "code", lang: fence[1] || "text", v: code.join("\n") });
continue;
}
const h = /^#{1,3}\s+(.+)$/.exec(line);
if (h) {
flush();
blocks.push({ t: "heading", inl: parseInline(h[1]!) });
continue;
}
const li = /^\s*[-*]\s+(.+)$/.exec(line);
if (li) {
if (para.length) {
blocks.push({ t: "p", inl: parseInline(para.join(" ")) });
para = [];
}
(list ??= []).push(parseInline(li[1]!));
continue;
}
if (!line.trim()) {
flush();
continue;
}
if (list) flush();
para.push(line.trim());
}
flush();
return blocks;
}app/lab/mini-lms/Demo.tsx1006 lines
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import * as api from "./api";
import { INSTRUCTOR, ME, seed, type DB, type Lesson, type Role } from "./db";
import { DevPanel, type LogEntry } from "./DevPanel";
import { Markdown } from "./Markdown";
const KEY = "lab:mini-lms:v1";
const LATENCY = 250; // simulated network round trip, so loading states are visible
type View =
| { k: "catalogue" }
| { k: "course"; id: string }
| { k: "lesson"; id: string }
| { k: "quiz"; id: string }
| { k: "cert"; code: string }
| { k: "teach"; id: string };
type Editor = { mode: "new" } | { mode: "edit"; lessonId: string };
type Notice = { tone: "ok" | "err"; text: string } | null;
const ACTORS: Record<Role, api.Actor> = {
student: { userId: ME, role: "student" },
instructor: { userId: INSTRUCTOR, role: "instructor" },
};
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const errText = (body: unknown) => {
const m = (body as api.ErrorBody | undefined)?.message;
return Array.isArray(m) ? m.join("; ") : (m ?? "Request failed");
};
const fmtDate = (t: number) => new Date(t).toLocaleDateString("en-GB", { day: "numeric", month: "long", year: "numeric" });
const btn =
"inline-flex items-center justify-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-sm hover:border-accent hover:text-accent disabled:cursor-not-allowed disabled:opacity-50";
const btnPrimary =
"inline-flex items-center justify-center gap-1.5 rounded-md bg-accent px-3 py-1.5 text-sm font-medium text-accent-fg hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50";
const input = "w-full rounded-md border border-border bg-bg-elev px-3 py-2 text-sm outline-none focus:border-accent";
function Progress({ percent, label }: { percent: number; label: string }) {
return (
<div
role="progressbar"
aria-label={label}
aria-valuenow={percent}
aria-valuemin={0}
aria-valuemax={100}
className="h-2 w-full overflow-hidden rounded-full bg-bg-muted"
>
<div className="h-full rounded-full bg-accent transition-[width] duration-500" style={{ width: `${percent}%` }} />
</div>
);
}
function printCertificate(c: api.CertificateView) {
const esc = (s: string) => s.replace(/[&<>"']/g, (ch) => `&#${ch.charCodeAt(0)};`);
const w = window.open("", "_blank", "width=900,height=650");
if (!w) {
window.print();
return;
}
w.document.write(`<!doctype html><html><head><meta charset="utf-8"><title>Certificate ${esc(c.code)}</title>
<style>body{font-family:Georgia,serif;margin:0;display:grid;place-items:center;min-height:100vh;color:#111}
.c{border:6px double #333;padding:56px 72px;text-align:center;max-width:720px}h1{font-size:34px;margin:0 0 8px}
.n{font-size:30px;margin:24px 0 8px;font-style:italic}.m{font-family:monospace;color:#555;margin-top:32px}</style></head>
<body><div class="c"><p>Mini LMS (demo) certifies that</p><p class="n">${esc(c.student)}</p><p>has completed every lesson and passed every quiz in</p>
<h1>${esc(c.course)}</h1><p>${esc(fmtDate(c.issuedAt))} · ${c.lessons} lessons</p><p class="m">Verification code ${esc(c.code)}</p>
<p class="m">Demo certificate, not an accredited qualification.</p></div></body></html>`);
w.document.close();
w.focus();
w.print();
}
export default function Demo() {
const [db, setDb] = useState<DB | null>(null);
const dbRef = useRef<DB | null>(null);
const [role, setRole] = useState<Role>("student");
const [view, setView] = useState<View>({ k: "catalogue" });
const [devOpen, setDevOpen] = useState(true);
const [log, setLog] = useState<LogEntry[]>([]);
const logId = useRef(0);
const [notice, setNotice] = useState<Notice>(null);
const [busy, setBusy] = useState<string | null>(null);
// catalogue
const [search, setSearch] = useState("");
const [tag, setTag] = useState<string | null>(null);
const searchRef = useRef<HTMLInputElement>(null);
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
// quiz
const [answers, setAnswers] = useState<(number | null)[]>([]);
const [result, setResult] = useState<api.AttemptResult | null>(null);
const [quizError, setQuizError] = useState<string | null>(null);
// instructor editor
const [editor, setEditor] = useState<Editor | null>(null);
const [form, setForm] = useState({ sectionId: "", title: "", minutes: "5", body: "", published: false });
const [formErrors, setFormErrors] = useState<string[]>([]);
// verify
const [verifyCode, setVerifyCode] = useState("");
const [verified, setVerified] = useState<{ ok: boolean; text: string } | null>(null);
const actor = ACTORS[role];
// Load after mount: the server render has no localStorage.
useEffect(() => {
let saved: { db: DB; role: Role } | null = null;
try {
const raw = localStorage.getItem(KEY);
if (raw) saved = JSON.parse(raw) as { db: DB; role: Role };
} catch {
/* blocked or corrupted storage: start from the seed */
}
const start = saved?.db?.courses?.length ? saved.db : seed(Date.now());
dbRef.current = start;
setDb(start);
if (saved?.role === "instructor") {
setRole("instructor");
setView({ k: "teach", id: "c_git" });
}
}, []);
useEffect(() => {
if (!db) return;
try {
localStorage.setItem(KEY, JSON.stringify({ db, role }));
} catch {
/* ignore */
}
}, [db, role]);
// "/" focuses the catalogue search, Escape closes the lesson editor.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const t = e.target as HTMLElement | null;
const typing = t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.tagName === "SELECT");
if (e.key === "/" && !typing && searchRef.current) {
e.preventDefault();
searchRef.current.focus();
}
if (e.key === "Escape") setEditor(null);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
/** Send one request to the in-browser API, log it, and commit the new DB state on success. */
function call<T>(
method: LogEntry["method"],
path: string,
exec: (d: DB) => api.Exec<T>,
request?: unknown,
as: api.Actor = actor,
): api.ApiResponse<T> {
const current = dbRef.current!;
const out = exec(current);
if (method !== "GET" && out.res.status < 400) {
dbRef.current = out.db;
setDb(out.db);
}
const entry: LogEntry = {
id: ++logId.current,
method,
path,
role: as.role,
status: out.res.status,
sql: out.res.sql,
request,
response: out.res.body,
};
setLog((l) => [entry, ...l].slice(0, 40));
return out.res;
}
async function mutate(key: string, fn: () => void | Promise<void>) {
setBusy(key);
await sleep(LATENCY);
try {
await fn();
} finally {
setBusy(null);
}
}
function go(v: View, as: api.Actor = actor) {
setNotice(null);
setVerified(null);
if (v.k === "catalogue") call("GET", `/courses${search ? `?q=${encodeURIComponent(search)}` : ""}`, (d) => api.listCourses(d, as, { q: search, tag: tag ?? undefined }, Date.now()), undefined, as);
if (v.k === "course") call("GET", `/courses/${v.id}`, (d) => api.getCourse(d, as, v.id, Date.now()), undefined, as);
if (v.k === "lesson") call("GET", `/lessons/${v.id}`, (d) => api.getLesson(d, as, v.id, Date.now()), undefined, as);
if (v.k === "quiz") {
call("GET", `/quizzes/${v.id}`, (d) => api.getQuiz(d, as, v.id, Date.now()), undefined, as);
setResult(null);
setQuizError(null);
const qz = dbRef.current?.quizzes.find((x) => x.id === v.id);
setAnswers(qz ? qz.questions.map(() => null) : []);
}
if (v.k === "cert") call("GET", `/certificates/${v.code}`, (d) => api.verifyCertificate(d, v.code, Date.now()), undefined, as);
if (v.k === "teach") {
call("GET", `/courses/${v.id}`, (d) => api.getCourse(d, as, v.id, Date.now()), undefined, as);
call("GET", `/courses/${v.id}/report`, (d) => api.courseReport(d, as, v.id, Date.now()), undefined, as);
setEditor(null);
}
setView(v);
}
function switchRole(r: Role) {
if (r === role) return;
setRole(r);
go(r === "instructor" ? { k: "teach", id: "c_git" } : { k: "catalogue" }, ACTORS[r]);
}
function reset() {
const s = seed(Date.now());
dbRef.current = s;
setDb(s);
setLog([]);
setRole("student");
setView({ k: "catalogue" });
setSearch("");
setTag(null);
setEditor(null);
setNotice({ tone: "ok", text: "Demo reset to the seeded data." });
}
/* ---------------- reads used for rendering (not logged; the logged GET happens on navigation) ---------------- */
const data = useMemo(() => {
if (!db) return null;
const a = actor;
return {
courses: api.listCourses(db, a, { q: search, tag: tag ?? undefined }, 0).res,
outline:
view.k === "course" || view.k === "teach"
? api.getCourse(db, a, view.id, 0).res
: view.k === "lesson" || view.k === "quiz"
? (() => {
const cid = (view.k === "lesson" ? db.lessons.find((l) => l.id === view.id) : db.quizzes.find((x) => x.id === view.id))?.courseId;
return cid ? api.getCourse(db, a, cid, 0).res : null;
})()
: null,
lesson: view.k === "lesson" ? api.getLesson(db, a, view.id, 0).res : null,
quiz: view.k === "quiz" ? api.getQuiz(db, a, view.id, 0).res : null,
cert: view.k === "cert" ? api.verifyCertificate(db, view.code, 0).res : null,
report: view.k === "teach" ? api.courseReport(db, a, view.id, 0).res : null,
allTags: [...new Set(db.courses.filter((c) => c.published || role === "instructor").flatMap((c) => c.tags))].sort(),
};
}, [db, actor, view, search, tag, role]);
if (!db || !data) {
return (
<div className="space-y-3 p-6" aria-busy="true">
<div className="h-6 w-48 animate-pulse rounded bg-bg-muted" />
<div className="h-24 animate-pulse rounded bg-bg-muted" />
<div className="h-24 animate-pulse rounded bg-bg-muted" />
<span className="sr-only">Loading the demo…</span>
</div>
);
}
const courses = data.courses.status === 200 ? (data.courses.body as api.CourseCard[]) : [];
const outline = data.outline?.status === 200 ? (data.outline.body as api.Outline) : null;
/* ---------------- actions ---------------- */
function enrol(courseId: string) {
void mutate(`enrol:${courseId}`, () => {
const r = call("POST", `/courses/${courseId}/enrolments`, (d) => api.enrol(d, actor, courseId, Date.now()));
setNotice(r.status < 400 ? { tone: "ok", text: "Enrolled. Lessons are now unlocked." } : { tone: "err", text: errText(r.body) });
});
}
function complete(lessonId: string, times = 1) {
void mutate(`complete:${lessonId}`, () => {
let last: api.ApiResponse<unknown> | null = null;
for (let i = 0; i < times; i++) {
last = call("POST", `/lessons/${lessonId}/completion`, (d) => api.completeLesson(d, actor, lessonId, Date.now()));
}
if (!last) return;
if (last.status >= 400) return setNotice({ tone: "err", text: errText(last.body) });
const b = last.body as { progress: { percent: number }; certificate: string | null };
setNotice({
tone: "ok",
text: b.certificate
? `Course complete. Certificate ${b.certificate} issued.`
: times > 1
? `Sent ${times} requests; one row stored. Progress ${b.progress.percent}%.`
: `Lesson complete. Progress ${b.progress.percent}%.`,
});
});
}
function submitQuiz(quizId: string) {
// Unanswered questions are dropped, so the server's ValidationPipe rejects the body with 400.
const sent = { answers: answers.filter((x): x is number => x !== null) };
void mutate(`quiz:${quizId}`, () => {
const r = call("POST", `/quizzes/${quizId}/attempts`, (d) => api.submitAttempt(d, actor, quizId, sent, Date.now()), sent);
if (r.status >= 400) {
setQuizError(errText(r.body));
return;
}
setQuizError(null);
const b = r.body as api.AttemptResult;
setResult(b);
setNotice({
tone: b.passed ? "ok" : "err",
text: `${b.score}/${b.total} (${b.percent}%). ${b.passed ? "Passed." : "Not passed, 70% needed."}${b.certificate ? ` Certificate ${b.certificate} issued.` : ""}`,
});
});
}
function verify(code: string) {
const r = call("GET", `/certificates/${encodeURIComponent(code.trim().toUpperCase())}`, (d) => api.verifyCertificate(d, code, Date.now()), undefined);
if (r.status === 200) {
const c = r.body as api.CertificateView;
setVerified({ ok: true, text: `Valid: ${c.student} completed “${c.course}” on ${fmtDate(c.issuedAt)}.` });
} else setVerified({ ok: false, text: `${r.status}: ${errText(r.body)}` });
}
function openEditor(e: Editor, courseId: string) {
setFormErrors([]);
if (e.mode === "edit") {
const l = db!.lessons.find((x) => x.id === e.lessonId);
if (!l) return;
setForm({ sectionId: l.sectionId, title: l.title, minutes: String(l.minutes), body: l.body, published: l.published });
} else {
const first = db!.sections.find((s) => s.courseId === courseId);
setForm({ sectionId: first?.id ?? "", title: "", minutes: "5", body: "", published: false });
}
setEditor(e);
}
function saveLesson(courseId: string) {
const payload = { ...form, minutes: Number(form.minutes) };
void mutate("save-lesson", () => {
const r =
editor?.mode === "edit"
? call("PATCH", `/lessons/${editor.lessonId}`, (d) => api.updateLesson(d, actor, editor.lessonId, payload, Date.now()), payload)
: call("POST", `/courses/${courseId}/lessons`, (d) => api.createLesson(d, actor, courseId, payload, Date.now()), payload);
if (r.status >= 400) {
const m = (r.body as api.ErrorBody).message;
setFormErrors(Array.isArray(m) ? m : [m]);
return;
}
setFormErrors([]);
setEditor(null);
setNotice({ tone: "ok", text: editor?.mode === "edit" ? "Lesson saved." : "Lesson created." });
});
}
function toggleLesson(lessonId: string, published: boolean) {
void mutate(`pub:${lessonId}`, () => {
const r = call("PATCH", `/lessons/${lessonId}`, (d) => api.updateLesson(d, actor, lessonId, { published }, Date.now()), { published });
setNotice(
r.status < 400
? { tone: "ok", text: published ? "Lesson published. Student progress totals now include it." : "Lesson unpublished. Completions are kept but no longer counted." }
: { tone: "err", text: errText(r.body) },
);
});
}
function toggleCourse(courseId: string, published: boolean) {
void mutate(`course:${courseId}`, () => {
const r = call("PATCH", `/courses/${courseId}`, (d) => api.setCoursePublished(d, actor, courseId, published, Date.now()), { published });
setNotice(r.status < 400 ? { tone: "ok", text: published ? "Course is live in the catalogue." : "Course hidden from students." } : { tone: "err", text: `${r.status}: ${errText(r.body)}` });
});
}
function onSearch(value: string) {
setSearch(value);
if (searchTimer.current) clearTimeout(searchTimer.current);
searchTimer.current = setTimeout(() => {
call("GET", `/courses?q=${encodeURIComponent(value)}${tag ? `&tag=${tag}` : ""}`, (d) => api.listCourses(d, actor, { q: value, tag: tag ?? undefined }, Date.now()));
}, 350);
}
function onTag(t: string | null) {
setTag(t);
call("GET", `/courses?${new URLSearchParams({ ...(search ? { q: search } : {}), ...(t ? { tag: t } : {}) })}`, (d) => api.listCourses(d, actor, { q: search, tag: t ?? undefined }, Date.now()));
}
/* ---------------- views ---------------- */
const crumbsBack = (label: string, v: View) => (
<button type="button" onClick={() => go(v)} className="font-mono text-xs text-fg-muted hover:text-accent">
← {label}
</button>
);
function Catalogue() {
return (
<div className="space-y-4">
<div className="flex flex-wrap items-end gap-3">
<label className="min-w-0 flex-1">
<span className="text-sm font-medium">
Search courses <kbd className="ml-1 rounded border border-border px-1 font-mono text-[10px] text-fg-muted">/</kbd>
</span>
<input
ref={searchRef}
type="search"
value={search}
onChange={(e) => onSearch(e.target.value)}
placeholder="git, joins, branches…"
className={`mt-1 ${input}`}
/>
</label>
</div>
<div role="group" aria-label="Filter by tag" className="flex flex-wrap gap-2">
{[null, ...data!.allTags].map((t) => (
<button
key={t ?? "all"}
type="button"
aria-pressed={tag === t}
onClick={() => onTag(t)}
className={`rounded-full border px-3 py-0.5 font-mono text-xs ${tag === t ? "border-accent bg-accent-soft text-accent" : "border-border text-fg-muted hover:border-accent"}`}
>
{t ?? "all"}
</button>
))}
</div>
{courses.length === 0 ? (
<p className="rounded-md border border-dashed border-border p-6 text-center text-sm text-fg-muted">
No courses match “{search}”{tag ? ` with tag ${tag}` : ""}.{" "}
<button type="button" className="text-accent underline" onClick={() => { setSearch(""); onTag(null); }}>
Clear filters
</button>
</p>
) : (
<ul className="grid gap-4 md:grid-cols-2">
{courses.map((c) => (
<li key={c.id} className="card flex flex-col gap-3 p-4">
<div>
<h3 className="font-semibold">{c.title}</h3>
<p className="mt-1 text-sm text-fg-muted">{c.summary}</p>
</div>
<p className="flex flex-wrap gap-x-3 gap-y-1 font-mono text-xs text-fg-muted">
<span>{c.lessons} lessons</span>
<span>{c.minutes} min</span>
<span>{c.students} enrolled</span>
{c.tags.map((t) => (
<span key={t} className="text-accent-2">#{t}</span>
))}
</p>
{c.enrolled && (
<div className="space-y-1">
<Progress percent={c.progress.percent} label={`${c.title} progress`} />
<p className="font-mono text-xs text-fg-muted">
{c.progress.done}/{c.progress.total} lessons · {c.progress.percent}%
</p>
</div>
)}
<div className="mt-auto flex flex-wrap gap-2">
<button type="button" className={c.enrolled ? btnPrimary : btn} onClick={() => go({ k: "course", id: c.id })}>
{c.enrolled ? (c.progress.done ? "Continue" : "Start") : "View outline"}
</button>
{!c.enrolled && (
<button type="button" className={btnPrimary} disabled={busy === `enrol:${c.id}`} onClick={() => enrol(c.id)}>
{busy === `enrol:${c.id}` ? "Enrolling…" : "Enrol (free)"}
</button>
)}
</div>
</li>
))}
</ul>
)}
{VerifyBox()}
</div>
);
}
function VerifyBox() {
return (
<form
className="flex flex-wrap items-end gap-2 border-t border-border pt-4"
onSubmit={(e) => {
e.preventDefault();
verify(verifyCode);
}}
>
<label className="min-w-0 flex-1">
<span className="text-sm font-medium">Verify a certificate</span>
<input value={verifyCode} onChange={(e) => setVerifyCode(e.target.value)} placeholder="LMS-…" className={`mt-1 font-mono ${input}`} />
</label>
<button type="submit" className={btn} disabled={!verifyCode.trim()}>
Verify
</button>
{verified && (
<p role="status" className={`w-full text-sm ${verified.ok ? "text-accent" : "text-danger"}`}>
{verified.text}
</p>
)}
</form>
);
}
function CourseView() {
if (!outline) return ErrorState({ res: data!.outline, back: true });
return (
<div className="space-y-5">
{crumbsBack("All courses", { k: "catalogue" })}
<header className="space-y-2">
<h3 className="text-xl font-semibold">{outline.course.title}</h3>
<p className="text-sm text-fg-muted">{outline.course.summary}</p>
{outline.enrolled ? (
<div className="max-w-md space-y-1">
<Progress percent={outline.progress.percent} label="Course progress" />
<p className="font-mono text-xs text-fg-muted">
{outline.progress.done}/{outline.progress.total} published lessons complete · {outline.progress.percent}%
</p>
</div>
) : (
<div className="flex flex-wrap items-center gap-3 rounded-md border border-warn-fg/40 bg-warn-bg p-3 text-sm text-warn-fg">
You can see the outline, but lessons and quizzes need enrolment. Opening a lesson now returns 403.
<button type="button" className={btnPrimary} disabled={busy === `enrol:${outline.course.id}`} onClick={() => enrol(outline.course.id)}>
{busy === `enrol:${outline.course.id}` ? "Enrolling…" : "Enrol (free)"}
</button>
</div>
)}
{outline.certificate && (
<p className="flex flex-wrap items-center gap-3 rounded-md border border-accent/40 bg-accent-soft p-3 text-sm">
<span>Course complete. Your certificate is ready.</span>
<button type="button" className={btnPrimary} onClick={() => go({ k: "cert", code: outline.certificate! })}>
View certificate
</button>
</p>
)}
</header>
<ol className="space-y-4">
{outline.sections.map((s, si) => (
<li key={s.id} className="card p-4">
<h4 className="font-mono text-sm font-semibold">
<span className="text-accent">{si + 1}.</span> {s.title}
</h4>
<ul className="mt-2 divide-y divide-border">
{s.lessons.map((l) => (
<li key={l.id}>
<button type="button" onClick={() => go({ k: "lesson", id: l.id })} className="flex w-full items-center gap-3 py-2 text-left text-sm hover:text-accent">
<span aria-hidden="true" className={`grid size-5 place-items-center rounded-full border text-[11px] ${l.completed ? "border-accent bg-accent text-accent-fg" : "border-border"}`}>
{l.completed ? "✓" : ""}
</span>
<span className="flex-1">
{l.title}
<span className="sr-only">{l.completed ? " (completed)" : ""}</span>
</span>
<span className="font-mono text-xs text-fg-muted">{l.minutes} min</span>
</button>
</li>
))}
{s.quizzes.map((qz) => (
<li key={qz.id}>
<button type="button" onClick={() => go({ k: "quiz", id: qz.id })} className="flex w-full items-center gap-3 py-2 text-left text-sm hover:text-accent">
<span aria-hidden="true" className="grid size-5 place-items-center rounded border border-accent-2 font-mono text-[10px] text-accent-2">
?
</span>
<span className="flex-1">{qz.title}</span>
<span className={`font-mono text-xs ${qz.passed ? "text-accent" : "text-fg-muted"}`}>
{qz.best === null ? `${qz.questions} questions` : `best ${qz.best}/${qz.questions}${qz.passed ? " ✓" : ""}`}
</span>
</button>
</li>
))}
</ul>
</li>
))}
</ol>
</div>
);
}
function ErrorState({ res, back, courseId }: { res: api.ApiResponse<unknown> | null; back?: boolean; courseId?: string }) {
return (
<div className="space-y-4">
{back && crumbsBack("All courses", { k: "catalogue" })}
<div role="alert" className="rounded-md border border-danger/40 p-5">
<p className="font-mono text-sm text-danger">
{res?.status ?? 404} {(res?.body as api.ErrorBody | undefined)?.error ?? "Not Found"}
</p>
<p className="mt-1 text-sm">{res ? errText(res.body) : "Not found"}</p>
{courseId && res?.status === 403 && (
<div className="mt-3 flex flex-wrap gap-2">
<button type="button" className={btnPrimary} disabled={busy === `enrol:${courseId}`} onClick={() => enrol(courseId)}>
{busy === `enrol:${courseId}` ? "Enrolling…" : "Enrol and continue"}
</button>
<button type="button" className={btn} onClick={() => go({ k: "course", id: courseId })}>
Back to outline
</button>
</div>
)}
</div>
</div>
);
}
function LessonView({ id }: { id: string }) {
const res = data!.lesson;
const courseId = db!.lessons.find((l) => l.id === id)?.courseId;
if (!res || res.status !== 200) return ErrorState({ res, back: true, courseId });
const l = res.body as Lesson & { completed: boolean };
const flat = outline?.sections.flatMap((s) => s.lessons) ?? [];
const idx = flat.findIndex((x) => x.id === id);
const next = flat[idx + 1];
const pending = busy === `complete:${id}`;
const done = l.completed || pending; // optimistic tick while the request is in flight
return (
<article className="space-y-4">
{crumbsBack(outline?.course.title ?? "Course", { k: "course", id: courseId ?? "" })}
<header>
<p className="font-mono text-xs text-fg-muted">
Lesson {idx + 1} of {flat.length} · {l.minutes} min
</p>
<h3 className="text-xl font-semibold">{l.title}</h3>
</header>
<Markdown source={l.body} />
<div className="flex flex-wrap items-center gap-2 border-t border-border pt-4">
<button type="button" className={done ? btn : btnPrimary} disabled={pending} onClick={() => complete(id)} aria-pressed={done}>
{done ? "✓ Completed" : "Mark complete"}
</button>
<button type="button" className={btn} disabled={pending} onClick={() => complete(id, 2)} title="Sends the completion request twice in a row">
Double-click test
</button>
{next && (
<button type="button" className={`${btn} ml-auto`} onClick={() => go({ k: "lesson", id: next.id })}>
Next: {next.title} →
</button>
)}
</div>
</article>
);
}
function QuizView({ id }: { id: string }) {
const res = data!.quiz;
const courseId = db!.quizzes.find((x) => x.id === id)?.courseId;
if (!res || res.status !== 200) return ErrorState({ res, back: true, courseId });
const qz = res.body as api.PublicQuiz;
const pending = busy === `quiz:${id}`;
return (
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
submitQuiz(id);
}}
>
{crumbsBack(outline?.course.title ?? "Course", { k: "course", id: qz.courseId })}
<header>
<h3 className="text-xl font-semibold">{qz.title}</h3>
<p className="text-sm text-fg-muted">
{qz.questions.length} questions · pass mark {qz.passPercent}%. Answers are checked on the server; open the dev panel to see that the
quiz response has no answer key.
</p>
</header>
{qz.questions.map((qq, qi) => {
const r = result?.results[qi];
return (
<fieldset key={qq.id} className="card p-4" disabled={!!result}>
<legend className="px-1 text-sm font-medium">
{qi + 1}. {qq.prompt}
</legend>
<div className="mt-2 space-y-1">
{qq.options.map((o, oi) => {
const mark = r ? (oi === r.correctIndex ? "correct" : oi === r.chosen ? "wrong" : null) : null;
return (
<label
key={oi}
className={`flex cursor-pointer items-center gap-2 rounded px-2 py-1 text-sm ${mark === "correct" ? "bg-accent-soft text-accent" : mark === "wrong" ? "text-danger line-through" : "hover:bg-bg-muted"}`}
>
<input
type="radio"
name={qq.id}
checked={answers[qi] === oi}
onChange={() => setAnswers((a) => a.map((x, i) => (i === qi ? oi : x)))}
/>
{o}
{mark === "correct" && <span className="sr-only">(correct answer)</span>}
{mark === "wrong" && <span className="sr-only">(your answer, incorrect)</span>}
</label>
);
})}
</div>
</fieldset>
);
})}
{quizError && (
<p role="alert" className="text-sm text-danger">
400 Bad Request: {quizError}
</p>
)}
{result ? (
<div className="flex flex-wrap items-center gap-3" aria-live="polite">
<p className={`font-mono text-sm ${result.passed ? "text-accent" : "text-danger"}`}>
{result.score}/{result.total} · {result.percent}% · {result.passed ? "passed" : "not passed"}
</p>
<button type="button" className={btn} onClick={() => go({ k: "quiz", id })}>
Try again
</button>
{result.certificate && (
<button type="button" className={btnPrimary} onClick={() => go({ k: "cert", code: result.certificate! })}>
View certificate
</button>
)}
</div>
) : (
<button type="submit" className={btnPrimary} disabled={pending}>
{pending ? "Grading…" : "Submit answers"}
</button>
)}
</form>
);
}
function CertView() {
const res = data!.cert;
if (!res || res.status !== 200) return ErrorState({ res, back: true });
const c = res.body as api.CertificateView;
const courseId = db!.courses.find((x) => x.title === c.course)?.id;
return (
<div className="space-y-4">
{crumbsBack("Back to course", courseId ? { k: "course", id: courseId } : { k: "catalogue" })}
<div className="mx-auto max-w-xl rounded-md border-4 border-double border-border bg-bg-elev p-6 text-center sm:p-10">
<p className="text-xs tracking-widest text-fg-muted uppercase">Certificate of completion</p>
<p className="mt-4 text-sm text-fg-muted">This certifies that</p>
<p className="mt-1 font-serif text-2xl italic">{c.student}</p>
<p className="mt-3 text-sm text-fg-muted">completed every lesson and passed every quiz in</p>
<p className="mt-1 text-xl font-semibold">{c.course}</p>
<p className="mt-4 text-sm">
{fmtDate(c.issuedAt)} · {c.lessons} lessons
</p>
<p className="mt-4 font-mono text-xs text-accent">Verification code {c.code}</p>
<p className="mt-1 text-[11px] text-fg-muted">Demo certificate, not an accredited qualification.</p>
</div>
<div className="flex flex-wrap justify-center gap-2">
<button type="button" className={btnPrimary} onClick={() => printCertificate(c)}>
Print or save as PDF
</button>
<button type="button" className={btn} onClick={() => verify(c.code)}>
Verify code via API
</button>
</div>
{verified && (
<p role="status" className={`text-center text-sm ${verified.ok ? "text-accent" : "text-danger"}`}>
{verified.text}
</p>
)}
</div>
);
}
function TeachView({ id }: { id: string }) {
const report = data!.report?.status === 200 ? (data!.report.body as api.ReportRow[]) : [];
const course = db!.courses.find((c) => c.id === id);
const sections = db!.sections.filter((s) => s.courseId === id).sort((a, b) => a.position - b.position);
const quizCols = db!.quizzes.filter((q) => q.courseId === id);
return (
<div className="space-y-6">
<div role="tablist" aria-label="Your courses" className="flex flex-wrap gap-2">
{db!.courses.map((c) => (
<button
key={c.id}
type="button"
role="tab"
aria-selected={c.id === id}
onClick={() => go({ k: "teach", id: c.id })}
className={`rounded-md border px-3 py-1.5 text-sm ${c.id === id ? "border-accent bg-accent-soft text-accent" : "border-border hover:border-accent"}`}
>
{c.title}
<span className={`ml-2 font-mono text-[10px] ${c.published ? "text-accent" : "text-warn-fg"}`}>{c.published ? "live" : "draft"}</span>
</button>
))}
</div>
{course && (
<div className="flex flex-wrap items-center gap-3">
<h3 className="text-xl font-semibold">{course.title}</h3>
<button type="button" className={btn} disabled={busy === `course:${id}`} onClick={() => toggleCourse(id, !course.published)}>
{busy === `course:${id}` ? "Saving…" : course.published ? "Unpublish course" : "Publish course"}
</button>
<button type="button" className={`${btnPrimary} ml-auto`} onClick={() => openEditor({ mode: "new" }, id)}>
+ New lesson
</button>
</div>
)}
{editor && (
<form
className="card space-y-3 border-accent/50 p-4"
noValidate
aria-label={editor.mode === "new" ? "New lesson" : "Edit lesson"}
onSubmit={(e) => {
e.preventDefault();
saveLesson(id);
}}
>
<h4 className="font-mono text-sm font-semibold">{editor.mode === "new" ? "POST new lesson" : "PATCH lesson"}</h4>
<div className="grid gap-3 sm:grid-cols-[1fr_1fr_6rem]">
<label>
<span className="text-sm font-medium">Title</span>
<input value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} className={`mt-1 ${input}`} aria-invalid={formErrors.some((m) => m.startsWith("title"))} />
</label>
<label>
<span className="text-sm font-medium">Section</span>
<select value={form.sectionId} onChange={(e) => setForm({ ...form, sectionId: e.target.value })} className={`mt-1 ${input}`}>
{sections.map((s) => (
<option key={s.id} value={s.id}>
{s.title}
</option>
))}
</select>
</label>
<label>
<span className="text-sm font-medium">Minutes</span>
<input type="number" min={1} max={120} value={form.minutes} onChange={(e) => setForm({ ...form, minutes: e.target.value })} className={`mt-1 ${input}`} aria-invalid={formErrors.some((m) => m.startsWith("minutes"))} />
</label>
</div>
<div className="grid gap-3 lg:grid-cols-2">
<label>
<span className="text-sm font-medium">Body (Markdown: #, -, **bold**, `code`, fenced code)</span>
<textarea
rows={9}
value={form.body}
onChange={(e) => setForm({ ...form, body: e.target.value })}
className={`mt-1 font-mono ${input}`}
aria-invalid={formErrors.some((m) => m.startsWith("body"))}
placeholder={"## Heading\n\nA paragraph with `code`.\n\n- a list item"}
/>
</label>
<div>
<span className="text-sm font-medium">Preview</span>
<div className="mt-1 max-h-56 overflow-auto rounded-md border border-border p-3">
{form.body.trim() ? <Markdown source={form.body} /> : <p className="text-sm text-fg-muted">Nothing to preview yet.</p>}
</div>
</div>
</div>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={form.published} onChange={(e) => setForm({ ...form, published: e.target.checked })} />
Published (visible to enrolled students)
</label>
{formErrors.length > 0 && (
<div role="alert" className="rounded-md border border-danger/40 p-3 text-sm text-danger">
<p className="font-mono">400 Bad Request (ValidationPipe)</p>
<ul className="mt-1 list-disc pl-5">
{formErrors.map((m) => (
<li key={m}>{m}</li>
))}
</ul>
</div>
)}
<div className="flex gap-2">
<button type="submit" className={btnPrimary} disabled={busy === "save-lesson"}>
{busy === "save-lesson" ? "Saving…" : "Save lesson"}
</button>
<button type="button" className={btn} onClick={() => setEditor(null)}>
Cancel <kbd className="font-mono text-[10px] text-fg-muted">Esc</kbd>
</button>
</div>
</form>
)}
<div className="space-y-3">
{sections.map((s) => (
<section key={s.id} className="card p-4" aria-label={`Section ${s.title}`}>
<h4 className="font-mono text-sm font-semibold">{s.title}</h4>
<ul className="mt-2 divide-y divide-border">
{db!.lessons
.filter((l) => l.sectionId === s.id)
.sort((a, b) => a.position - b.position)
.map((l) => (
<li key={l.id} className="flex flex-wrap items-center gap-2 py-2 text-sm">
<span className={`min-w-0 flex-1 ${l.published ? "" : "text-fg-muted"}`}>
{l.title} <span className="font-mono text-xs text-fg-muted">· {l.minutes} min</span>
</span>
<span className={`rounded-full px-2 py-0.5 font-mono text-[10px] ${l.published ? "bg-accent-soft text-accent" : "bg-warn-bg text-warn-fg"}`}>
{l.published ? "published" : "draft"}
</span>
<button type="button" className={btn} disabled={busy === `pub:${l.id}`} onClick={() => toggleLesson(l.id, !l.published)}>
{l.published ? "Unpublish" : "Publish"}
</button>
<button type="button" className={btn} onClick={() => openEditor({ mode: "edit", lessonId: l.id }, id)} aria-label={`Edit ${l.title}`}>
Edit
</button>
</li>
))}
</ul>
</section>
))}
</div>
<section aria-labelledby="report-h">
<h4 id="report-h" className="font-mono text-sm font-semibold">
Students <span className="text-fg-muted">({report.length} enrolled)</span>
</h4>
{report.length === 0 ? (
<p className="mt-2 rounded-md border border-dashed border-border p-4 text-sm text-fg-muted">No students enrolled yet.</p>
) : (
<div className="mt-2 overflow-x-auto">
<table className="w-full min-w-[34rem] text-left text-sm">
<caption className="sr-only">Completion and best quiz score per enrolled student</caption>
<thead className="font-mono text-xs text-fg-muted">
<tr className="border-b border-border">
<th className="py-2 pr-3 font-normal">student</th>
<th className="py-2 pr-3 font-normal">progress</th>
{quizCols.map((q) => (
<th key={q.id} className="py-2 pr-3 font-normal">
{q.title.replace(/^Quiz: /, "")}
</th>
))}
<th className="py-2 font-normal">certificate</th>
</tr>
</thead>
<tbody>
{report.map((r) => (
<tr key={r.studentId} className={`border-b border-border ${r.studentId === ME ? "bg-accent-soft/50" : ""}`}>
<td className="py-2 pr-3">{r.name}</td>
<td className="py-2 pr-3">
<div className="flex items-center gap-2">
<div className="w-20">
<Progress percent={r.percent} label={`${r.name} progress`} />
</div>
<span className="font-mono text-xs">{r.percent}%</span>
</div>
</td>
{r.quizzes.map((q) => (
<td key={q.quizId} className={`py-2 pr-3 font-mono text-xs ${q.passed ? "text-accent" : q.best === null ? "text-fg-muted" : "text-danger"}`}>
{q.best === null ? "—" : `${q.best}/${q.total}`}
</td>
))}
<td className="py-2 font-mono text-xs">
{r.certificate ? (
<button type="button" className="text-accent hover:underline" onClick={() => go({ k: "cert", code: r.certificate! })}>
{r.certificate}
</button>
) : (
<span className="text-fg-muted">—</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
{VerifyBox()}
</div>
);
}
return (
<div>
<div className="flex flex-wrap items-center gap-2 border-b border-border bg-bg-elev px-4 py-3">
<div role="group" aria-label="Signed in as" className="flex overflow-hidden rounded-md border border-border font-mono text-xs">
{(["student", "instructor"] as const).map((r) => (
<button
key={r}
type="button"
aria-pressed={role === r}
onClick={() => switchRole(r)}
className={`px-3 py-1.5 ${role === r ? "bg-accent text-accent-fg" : "hover:text-accent"}`}
>
{r}
</button>
))}
</div>
<span className="hidden text-xs text-fg-muted sm:inline">
{role === "student" ? "You (demo student)" : "Demo Instructor"}
</span>
<div className="ml-auto flex flex-wrap gap-2">
{role === "student" && view.k !== "catalogue" && (
<button type="button" className={btn} onClick={() => go({ k: "catalogue" })}>
Catalogue
</button>
)}
<button type="button" className={btn} aria-expanded={devOpen} aria-controls="lms-dev" onClick={() => setDevOpen((o) => !o)}>
Dev panel
</button>
<button type="button" className={btn} onClick={reset}>
Reset demo
</button>
</div>
</div>
<div className={devOpen ? "grid xl:grid-cols-[1fr_26rem]" : ""}>
<div className="min-w-0 p-4 sm:p-6">
<p aria-live="polite" className="min-h-0">
{notice && (
<span className={`mb-4 block rounded-md border px-3 py-2 text-sm ${notice.tone === "ok" ? "border-accent/40 bg-accent-soft" : "border-danger/40 text-danger"}`}>
{notice.text}
</span>
)}
</p>
{view.k === "catalogue" && Catalogue()}
{view.k === "course" && CourseView()}
{view.k === "lesson" && LessonView({ id: view.id })}
{view.k === "quiz" && QuizView({ id: view.id })}
{view.k === "cert" && CertView()}
{view.k === "teach" && TeachView({ id: view.id })}
</div>
{devOpen && (
<div id="lms-dev" className="min-w-0 xl:border-l xl:border-border">
<DevPanel entries={log} onClear={() => setLog([])} />
</div>
)}
</div>
</div>
);
}app/lab/mini-lms/DevPanel.tsx71 lines
"use client";
import type { Role } from "./db";
export type LogEntry = {
id: number;
method: "GET" | "POST" | "PATCH";
path: string;
role: Role;
status: number;
sql: string[];
request?: unknown;
response: unknown;
};
const statusTone = (s: number) => (s >= 500 ? "text-[#f87171]" : s >= 400 ? "text-[#fbbf24]" : "text-[#4ade80]");
const sqlTone = (line: string) =>
/^(BEGIN|COMMIT)/.test(line) ? "text-[#c084fc]" : /^ROLLBACK/.test(line) ? "text-[#f87171]" : "text-[#93c5fd]";
/** Always-dark terminal: what each click would send over HTTP and to PostgreSQL. */
export function DevPanel({ entries, onClear }: { entries: LogEntry[]; onClear: () => void }) {
return (
<section aria-label="Developer panel" className="border-t border-border bg-[#0b0b0d] text-[#a1a1aa]">
<div className="flex items-center justify-between gap-3 border-b border-[#27272a] px-4 py-2 font-mono text-xs">
<span>
<span className="text-[#4ade80]">$</span> requests + SQL ({entries.length})
</span>
<button
type="button"
onClick={onClear}
className="rounded border border-[#3f3f46] px-2 py-0.5 hover:border-[#4ade80] hover:text-[#4ade80]"
>
clear
</button>
</div>
<ol role="log" aria-live="polite" className="max-h-96 overflow-y-auto p-3 font-mono text-[12px] leading-relaxed">
{entries.length === 0 && <li className="text-[#71717a]">$ waiting for requests… click around the app.</li>}
{entries.map((e) => (
<li key={e.id} className="border-b border-[#1f1f23] py-2 last:border-b-0">
<div className="flex flex-wrap gap-x-3">
<span className="text-[#e4e4e7]">
{e.method} {e.path}
</span>
<span className={statusTone(e.status)}>{e.status}</span>
<span className="text-[#71717a]">role={e.role}</span>
</div>
{e.request !== undefined && (
<div className="break-all text-[#71717a]">body {JSON.stringify(e.request)}</div>
)}
<ul className="mt-1 space-y-0.5 border-l border-[#27272a] pl-3">
{e.sql.length === 0 && <li className="text-[#71717a]">-- rejected by a guard or pipe before any SQL ran</li>}
{e.sql.map((s, i) => (
<li key={i} className={`break-words ${sqlTone(s)}`}>
{s}
</li>
))}
</ul>
<details className="mt-1">
<summary className="cursor-pointer text-[#71717a] hover:text-[#e4e4e7]">response body</summary>
<pre className="mt-1 max-h-60 overflow-auto whitespace-pre-wrap break-words text-[#d4d4d8]">
{JSON.stringify(e.response, null, 2)}
</pre>
</details>
</li>
))}
</ol>
</section>
);
}app/lab/mini-lms/Markdown.tsx65 lines
import { Fragment } from "react";
import { parseMarkdown, type Inline } from "./lesson-markdown";
function Inlines({ items }: { items: Inline[] }) {
return (
<>
{items.map((x, i) =>
x.t === "code" ? (
<code key={i} className="rounded bg-bg-muted px-1 py-0.5 font-mono text-[0.9em]">
{x.v}
</code>
) : x.t === "bold" ? (
<strong key={i}>{x.v}</strong>
) : (
<Fragment key={i}>{x.v}</Fragment>
),
)}
</>
);
}
/** Renders parsed lesson Markdown as React elements; nothing is ever set as raw HTML. */
export function Markdown({ source }: { source: string }) {
return (
<div className="space-y-3 text-[15px] leading-relaxed">
{parseMarkdown(source).map((b, i) => {
switch (b.t) {
case "heading":
return (
<h4 key={i} className="pt-2 font-semibold">
<Inlines items={b.inl} />
</h4>
);
case "ul":
return (
<ul key={i} className="list-disc space-y-1 pl-5">
{b.items.map((it, j) => (
<li key={j}>
<Inlines items={it} />
</li>
))}
</ul>
);
case "code":
return (
<pre
key={i}
aria-label={`${b.lang} code`}
className="overflow-x-auto rounded-md border border-border bg-[#0b0b0d] p-3 font-mono text-[13px] text-[#e4e4e7]"
>
<code>{b.v}</code>
</pre>
);
default:
return (
<p key={i}>
<Inlines items={b.inl} />
</p>
);
}
})}
</div>
);
}