Initial commit
This commit is contained in:
@@ -0,0 +1,9 @@
|
|||||||
|
NODE_ENV=development
|
||||||
|
PORT=4000
|
||||||
|
DATABASE_URL=file:../data/app.db
|
||||||
|
ADMIN_PASSWORD=change-admin-password
|
||||||
|
STUDENT_PASSWORD=change-student-password
|
||||||
|
SESSION_SECRET=change-this-long-random-secret
|
||||||
|
PUBLIC_URL=http://localhost:4000
|
||||||
|
JUDGE_VIEWPORT_WIDTH=800
|
||||||
|
JUDGE_VIEWPORT_HEIGHT=600
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.env
|
||||||
|
data
|
||||||
|
coverage
|
||||||
|
playwright-report
|
||||||
|
test-results
|
||||||
|
*.log
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
FROM mcr.microsoft.com/playwright:v1.49.1-jammy AS build
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
COPY backend/package.json backend/package.json
|
||||||
|
COPY frontend/package.json frontend/package.json
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM mcr.microsoft.com/playwright:v1.49.1-jammy
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=4000
|
||||||
|
ENV DATABASE_URL=file:/app/backend/data/app.db
|
||||||
|
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
COPY backend/package.json backend/package.json
|
||||||
|
COPY frontend/package.json frontend/package.json
|
||||||
|
COPY --from=build /app/node_modules node_modules
|
||||||
|
COPY --from=build /app/backend/node_modules backend/node_modules
|
||||||
|
COPY --from=build /app/frontend/node_modules frontend/node_modules
|
||||||
|
RUN npm prune --omit=dev
|
||||||
|
|
||||||
|
COPY --from=build /app/backend/dist backend/dist
|
||||||
|
COPY --from=build /app/backend/prisma backend/prisma
|
||||||
|
COPY --from=build /app/frontend/dist frontend/dist
|
||||||
|
|
||||||
|
RUN mkdir -p /app/backend/data
|
||||||
|
VOLUME ["/app/backend/data"]
|
||||||
|
|
||||||
|
EXPOSE 4000
|
||||||
|
CMD ["node", "backend/dist/server.js"]
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# Frontend Judge
|
||||||
|
|
||||||
|
A small classroom judge for 11th grade students practicing basic HTML, CSS, and JavaScript.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Student shared-password access with browser-based HTML/CSS/JS editors.
|
||||||
|
- Live iframe preview while students type.
|
||||||
|
- Optional student name stored in the browser and attached to submissions.
|
||||||
|
- Student-visible submission history per task and entered name.
|
||||||
|
- Admin shared-password dashboard.
|
||||||
|
- Ordered day groups with one group per task.
|
||||||
|
- Markdown task descriptions.
|
||||||
|
- Uploaded images or downloaded image URLs inserted into task markdown.
|
||||||
|
- Reference HTML/CSS/JS that generates an expected screenshot.
|
||||||
|
- Rule-based judging with DOM, text, attribute, CSS, element-count, and visual-match checks.
|
||||||
|
- SQLite persistence and generated images stored in a Docker volume.
|
||||||
|
|
||||||
|
## Local Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
npm install
|
||||||
|
npm run prisma:generate
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
The backend runs on [http://localhost:4000](http://localhost:4000).
|
||||||
|
The SQLite tables are created automatically when the backend starts.
|
||||||
|
|
||||||
|
For frontend hot reload in a second terminal:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev --workspace frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open [http://localhost:5173](http://localhost:5173).
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
Create a production `.env` next to `docker-compose.yml`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ADMIN_PASSWORD=your-admin-password
|
||||||
|
STUDENT_PASSWORD=your-student-password
|
||||||
|
SESSION_SECRET=a-long-random-secret
|
||||||
|
PUBLIC_URL=https://your-domain.example
|
||||||
|
```
|
||||||
|
|
||||||
|
Start the app:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up --build -d
|
||||||
|
```
|
||||||
|
|
||||||
|
If your server uses the legacy Compose binary, use `docker-compose up --build -d`.
|
||||||
|
|
||||||
|
Open:
|
||||||
|
|
||||||
|
- Student app: `http://localhost:4000`
|
||||||
|
- Admin app: `http://localhost:4000/admin`
|
||||||
|
|
||||||
|
Use HTTPS on the public server, ideally through your reverse proxy.
|
||||||
|
|
||||||
|
## Judging Notes
|
||||||
|
|
||||||
|
Visual matching is intentionally tolerant. The browser renders both the reference solution and student submission with the same viewport, then compares screenshots. Use structure and CSS checks for hard requirements, and visual matching as a broader similarity signal.
|
||||||
|
|
||||||
|
The backend renders student JavaScript only inside Playwright, with short timeouts and network requests blocked during judging. The live preview uses a sandboxed iframe.
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"name": "@frontend-judge/backend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/server.ts",
|
||||||
|
"build": "prisma generate && tsc",
|
||||||
|
"start": "node dist/server.js",
|
||||||
|
"test": "vitest run",
|
||||||
|
"prisma:generate": "prisma generate",
|
||||||
|
"prisma:migrate": "prisma migrate dev --name init"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@prisma/client": "^6.1.0",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
|
"dotenv": "^16.6.1",
|
||||||
|
"express": "^4.21.2",
|
||||||
|
"helmet": "^8.0.0",
|
||||||
|
"multer": "^2.2.0",
|
||||||
|
"pixelmatch": "^6.0.0",
|
||||||
|
"playwright": "^1.49.1",
|
||||||
|
"pngjs": "^7.0.0",
|
||||||
|
"zod": "^3.24.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/cookie-parser": "^1.4.8",
|
||||||
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/multer": "^1.4.12",
|
||||||
|
"@types/node": "^22.10.2",
|
||||||
|
"@types/pixelmatch": "^5.2.6",
|
||||||
|
"@types/pngjs": "^6.0.5",
|
||||||
|
"prisma": "^6.1.0",
|
||||||
|
"supertest": "^7.0.0",
|
||||||
|
"tsx": "^4.19.2",
|
||||||
|
"typescript": "^5.7.2",
|
||||||
|
"vitest": "^2.1.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "sqlite"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
model DayGroup {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
title String
|
||||||
|
order Int @default(0)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
tasks Task[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model Task {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
title String
|
||||||
|
descriptionMarkdown String
|
||||||
|
groupId String
|
||||||
|
order Int @default(0)
|
||||||
|
visible Boolean @default(true)
|
||||||
|
starterHtml String @default("")
|
||||||
|
starterCss String @default("")
|
||||||
|
starterJs String @default("")
|
||||||
|
referenceHtml String @default("")
|
||||||
|
referenceCss String @default("")
|
||||||
|
referenceJs String @default("")
|
||||||
|
expectedScreenshotPath String?
|
||||||
|
visualThreshold Float @default(0.85)
|
||||||
|
passingScore Float @default(0.8)
|
||||||
|
checksJson String @default("[]")
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
group DayGroup @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
||||||
|
submissions Submission[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model Submission {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
taskId String
|
||||||
|
studentName String
|
||||||
|
studentKey String
|
||||||
|
html String
|
||||||
|
css String
|
||||||
|
js String
|
||||||
|
passed Boolean
|
||||||
|
score Float
|
||||||
|
resultJson String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([taskId, studentKey])
|
||||||
|
}
|
||||||
@@ -0,0 +1,675 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
import dns from "node:dns/promises";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import net from "node:net";
|
||||||
|
import path from "node:path";
|
||||||
|
import cookieParser from "cookie-parser";
|
||||||
|
import express from "express";
|
||||||
|
import helmet from "helmet";
|
||||||
|
import multer from "multer";
|
||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
clearAuthCookies,
|
||||||
|
hasAdmin,
|
||||||
|
hasStudentAccess,
|
||||||
|
normalizeStudentName,
|
||||||
|
requireAdmin,
|
||||||
|
requireStudentAccess,
|
||||||
|
setAdminCookie,
|
||||||
|
setStudentCookie,
|
||||||
|
studentKey
|
||||||
|
} from "./auth.js";
|
||||||
|
import { config } from "./config.js";
|
||||||
|
import { ensureDatabaseSchema, prisma } from "./db.js";
|
||||||
|
import { generateExpectedScreenshot, judgeSubmission } from "./judge.js";
|
||||||
|
import type { CheckDefinition } from "./types.js";
|
||||||
|
|
||||||
|
const upload = multer({
|
||||||
|
storage: multer.memoryStorage(),
|
||||||
|
limits: { fileSize: 5 * 1024 * 1024 }
|
||||||
|
});
|
||||||
|
|
||||||
|
const checkSchema = z.object({
|
||||||
|
type: z.enum(
|
||||||
|
[
|
||||||
|
"selector-exists",
|
||||||
|
"text-contains",
|
||||||
|
"attribute-equals",
|
||||||
|
"css-property",
|
||||||
|
"element-count",
|
||||||
|
"visual-match"
|
||||||
|
],
|
||||||
|
{ errorMap: () => ({ message: "Choose a supported check type." }) }
|
||||||
|
),
|
||||||
|
selector: z.string().optional(),
|
||||||
|
value: z.string().optional(),
|
||||||
|
attribute: z.string().optional(),
|
||||||
|
property: z.string().optional(),
|
||||||
|
count: z.coerce.number({ invalid_type_error: "Enter a valid element count." }).int().nonnegative().optional(),
|
||||||
|
threshold: z.coerce.number({ invalid_type_error: "Enter a valid visual threshold." }).min(0).max(1).optional(),
|
||||||
|
weight: z.coerce.number({ invalid_type_error: "Enter a valid check weight." }).positive("Check weight must be greater than 0.").default(1),
|
||||||
|
message: z.string().trim().min(1, "Add feedback text for this check.")
|
||||||
|
});
|
||||||
|
|
||||||
|
const taskSchema = z.object({
|
||||||
|
title: z.string().trim().min(1, "Add a task title."),
|
||||||
|
descriptionMarkdown: z.string().default(""),
|
||||||
|
groupId: z.string().trim().min(1, "Choose a day group for this task."),
|
||||||
|
order: z.coerce.number({ invalid_type_error: "Enter a valid task order." }).int("Task order must be a whole number.").default(0),
|
||||||
|
visible: z.boolean().default(true),
|
||||||
|
starterHtml: z.string().default(""),
|
||||||
|
starterCss: z.string().default(""),
|
||||||
|
starterJs: z.string().default(""),
|
||||||
|
referenceHtml: z.string().default(""),
|
||||||
|
referenceCss: z.string().default(""),
|
||||||
|
referenceJs: z.string().default(""),
|
||||||
|
visualThreshold: z
|
||||||
|
.coerce.number({ invalid_type_error: "Enter a valid visual threshold." })
|
||||||
|
.min(0, "Visual threshold must be between 0 and 1.")
|
||||||
|
.max(1, "Visual threshold must be between 0 and 1.")
|
||||||
|
.default(0.85),
|
||||||
|
passingScore: z
|
||||||
|
.coerce.number({ invalid_type_error: "Enter a valid passing score." })
|
||||||
|
.min(0, "Passing score must be between 0 and 1.")
|
||||||
|
.max(1, "Passing score must be between 0 and 1.")
|
||||||
|
.default(0.8),
|
||||||
|
checks: z.array(checkSchema).default([]),
|
||||||
|
regenerateExpected: z.boolean().default(true)
|
||||||
|
}).superRefine((task, context) => {
|
||||||
|
task.checks.forEach((check, index) => {
|
||||||
|
const label = `Check ${index + 1}`;
|
||||||
|
const needsSelector = check.type !== "visual-match";
|
||||||
|
const needsValue = ["text-contains", "attribute-equals", "css-property"].includes(check.type);
|
||||||
|
|
||||||
|
if (needsSelector && !check.selector?.trim()) {
|
||||||
|
context.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["checks", index, "selector"],
|
||||||
|
message: `${label}: enter a CSS selector.`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needsValue && !check.value?.trim()) {
|
||||||
|
context.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["checks", index, "value"],
|
||||||
|
message: `${label}: enter the expected value.`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (check.type === "attribute-equals" && !check.attribute?.trim()) {
|
||||||
|
context.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["checks", index, "attribute"],
|
||||||
|
message: `${label}: enter the attribute name.`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (check.type === "css-property" && !check.property?.trim()) {
|
||||||
|
context.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["checks", index, "property"],
|
||||||
|
message: `${label}: enter the CSS property name.`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (check.type === "element-count" && check.count === undefined) {
|
||||||
|
context.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["checks", index, "count"],
|
||||||
|
message: `${label}: enter the expected number of elements.`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (check.type === "visual-match" && check.threshold !== undefined && (check.threshold < 0 || check.threshold > 1)) {
|
||||||
|
context.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["checks", index, "threshold"],
|
||||||
|
message: `${label}: visual threshold must be between 0 and 1.`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const submissionSchema = z.object({
|
||||||
|
studentName: z.string().min(1).max(120),
|
||||||
|
html: z.string().max(50000),
|
||||||
|
css: z.string().max(50000),
|
||||||
|
js: z.string().max(50000)
|
||||||
|
});
|
||||||
|
|
||||||
|
function publicTask(task: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
descriptionMarkdown: string;
|
||||||
|
groupId: string;
|
||||||
|
order: number;
|
||||||
|
starterHtml: string;
|
||||||
|
starterCss: string;
|
||||||
|
starterJs: string;
|
||||||
|
expectedScreenshotPath: string | null;
|
||||||
|
visualThreshold: number;
|
||||||
|
passingScore: number;
|
||||||
|
}) {
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
function taskWithChecks(task: { checksJson: string }) {
|
||||||
|
return {
|
||||||
|
...task,
|
||||||
|
checks: parseChecks(task.checksJson),
|
||||||
|
checksJson: undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseChecks(checksJson: string): CheckDefinition[] {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(checksJson);
|
||||||
|
return Array.isArray(parsed) ? parsed : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function routeParam(value: string | string[] | undefined) {
|
||||||
|
return Array.isArray(value) ? value[0] : value ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function validationError(error: z.ZodError, title = "Please fix the highlighted fields.") {
|
||||||
|
return {
|
||||||
|
error: title,
|
||||||
|
details: error.issues.map((issue) => issue.message)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPrivateIp(address: string) {
|
||||||
|
if (net.isIPv4(address)) {
|
||||||
|
const parts = address.split(".").map(Number);
|
||||||
|
return (
|
||||||
|
parts[0] === 10 ||
|
||||||
|
parts[0] === 127 ||
|
||||||
|
(parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) ||
|
||||||
|
(parts[0] === 192 && parts[1] === 168) ||
|
||||||
|
(parts[0] === 169 && parts[1] === 254)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (net.isIPv6(address)) {
|
||||||
|
return address === "::1" || address.startsWith("fc") || address.startsWith("fd") || address.startsWith("fe80");
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertSafeDownloadUrl(rawUrl: string) {
|
||||||
|
const url = new URL(rawUrl);
|
||||||
|
if (!["http:", "https:"].includes(url.protocol)) {
|
||||||
|
throw new Error("Only http and https image URLs are allowed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const records = await dns.lookup(url.hostname, { all: true });
|
||||||
|
if (records.some((record) => isPrivateIp(record.address))) {
|
||||||
|
throw new Error("Image URLs must not resolve to private or local addresses.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function imageExtension(contentType: string) {
|
||||||
|
if (contentType.includes("png")) return "png";
|
||||||
|
if (contentType.includes("jpeg") || contentType.includes("jpg")) return "jpg";
|
||||||
|
if (contentType.includes("webp")) return "webp";
|
||||||
|
if (contentType.includes("gif")) return "gif";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveAsset(buffer: Buffer, contentType: string) {
|
||||||
|
const extension = imageExtension(contentType);
|
||||||
|
if (!extension) {
|
||||||
|
throw new Error("Only png, jpg, webp, and gif images are supported.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileName = `${crypto.randomUUID()}.${extension}`;
|
||||||
|
const assetPath = path.join(config.generatedDir, "assets", fileName);
|
||||||
|
await fs.mkdir(path.dirname(assetPath), { recursive: true });
|
||||||
|
await fs.writeFile(assetPath, buffer);
|
||||||
|
return `/generated/assets/${fileName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function maybeGenerateExpected(task: {
|
||||||
|
id: string;
|
||||||
|
referenceHtml: string;
|
||||||
|
referenceCss: string;
|
||||||
|
referenceJs: string;
|
||||||
|
}) {
|
||||||
|
if (!task.referenceHtml.trim() && !task.referenceCss.trim() && !task.referenceJs.trim()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return generateExpectedScreenshot(task.id, {
|
||||||
|
html: task.referenceHtml,
|
||||||
|
css: task.referenceCss,
|
||||||
|
js: task.referenceJs
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedSampleData() {
|
||||||
|
const count = await prisma.dayGroup.count();
|
||||||
|
if (count > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const group = await prisma.dayGroup.create({
|
||||||
|
data: { title: "Day 1 - HTML basics", order: 1 }
|
||||||
|
});
|
||||||
|
const task = await prisma.task.create({
|
||||||
|
data: {
|
||||||
|
title: "Create a welcome button",
|
||||||
|
descriptionMarkdown:
|
||||||
|
"Build a tiny page with a heading and a button. The button should say **Start** and use a green background.",
|
||||||
|
groupId: group.id,
|
||||||
|
order: 1,
|
||||||
|
starterHtml: "<h1>Welcome</h1>\n<button>Start</button>",
|
||||||
|
starterCss: "body {\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n}\nbutton {\n padding: 12px 18px;\n}",
|
||||||
|
referenceHtml: "<h1>Welcome</h1>\n<button>Start</button>",
|
||||||
|
referenceCss:
|
||||||
|
"body { display: flex; flex-direction: column; align-items: center; justify-content: center; background: #f7fafc; }\nbutton { padding: 12px 18px; background: rgb(22, 163, 74); color: white; border: 0; border-radius: 6px; }",
|
||||||
|
checksJson: JSON.stringify([
|
||||||
|
{
|
||||||
|
type: "selector-exists",
|
||||||
|
selector: "button",
|
||||||
|
message: "Add a button element."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "text-contains",
|
||||||
|
selector: "button",
|
||||||
|
value: "Start",
|
||||||
|
message: "The button should contain the text Start."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "css-property",
|
||||||
|
selector: "button",
|
||||||
|
property: "background-color",
|
||||||
|
value: "rgb(22, 163, 74)",
|
||||||
|
message: "Make the button background green."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "visual-match",
|
||||||
|
threshold: 0.75,
|
||||||
|
message: "The page should roughly match the reference screenshot."
|
||||||
|
}
|
||||||
|
])
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const expectedScreenshotPath = await maybeGenerateExpected(task);
|
||||||
|
if (expectedScreenshotPath) {
|
||||||
|
await prisma.task.update({ where: { id: task.id }, data: { expectedScreenshotPath } });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Could not generate sample expected screenshot:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createApp() {
|
||||||
|
await fs.mkdir(config.generatedDir, { recursive: true });
|
||||||
|
await ensureDatabaseSchema();
|
||||||
|
await seedSampleData();
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.disable("x-powered-by");
|
||||||
|
app.use(
|
||||||
|
helmet({
|
||||||
|
contentSecurityPolicy: false,
|
||||||
|
crossOriginEmbedderPolicy: false
|
||||||
|
})
|
||||||
|
);
|
||||||
|
app.use(express.json({ limit: "2mb" }));
|
||||||
|
app.use(cookieParser());
|
||||||
|
app.use("/generated", express.static(config.generatedDir));
|
||||||
|
|
||||||
|
app.get("/api/health", (_req, res) => {
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/auth/admin", (req, res) => {
|
||||||
|
if (req.body?.password !== config.adminPassword) {
|
||||||
|
res.status(401).json({ error: "Invalid admin password." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAdminCookie(res);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/auth/student", (req, res) => {
|
||||||
|
if (req.body?.password !== config.studentPassword) {
|
||||||
|
res.status(401).json({ error: "Invalid student password." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStudentCookie(res);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/auth/logout", (_req, res) => {
|
||||||
|
clearAuthCookies(res);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/auth/me", (req, res) => {
|
||||||
|
res.json({
|
||||||
|
admin: hasAdmin(req),
|
||||||
|
studentAccess: hasStudentAccess(req)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/groups", requireStudentAccess, async (_req, res) => {
|
||||||
|
const groups = await prisma.dayGroup.findMany({
|
||||||
|
orderBy: [{ order: "asc" }, { createdAt: "asc" }],
|
||||||
|
include: {
|
||||||
|
tasks: {
|
||||||
|
where: { visible: true },
|
||||||
|
orderBy: [{ order: "asc" }, { createdAt: "asc" }],
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
descriptionMarkdown: true,
|
||||||
|
groupId: true,
|
||||||
|
order: true,
|
||||||
|
starterHtml: true,
|
||||||
|
starterCss: true,
|
||||||
|
starterJs: true,
|
||||||
|
expectedScreenshotPath: true,
|
||||||
|
visualThreshold: true,
|
||||||
|
passingScore: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
res.json(groups);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/tasks/:id", requireStudentAccess, async (req, res) => {
|
||||||
|
const taskId = routeParam(req.params.id);
|
||||||
|
const task = await prisma.task.findFirst({
|
||||||
|
where: { id: taskId, visible: true },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
descriptionMarkdown: true,
|
||||||
|
groupId: true,
|
||||||
|
order: true,
|
||||||
|
starterHtml: true,
|
||||||
|
starterCss: true,
|
||||||
|
starterJs: true,
|
||||||
|
expectedScreenshotPath: true,
|
||||||
|
visualThreshold: true,
|
||||||
|
passingScore: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!task) {
|
||||||
|
res.status(404).json({ error: "Task not found." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(publicTask(task));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/tasks/:id/submissions", requireStudentAccess, async (req, res) => {
|
||||||
|
const taskId = routeParam(req.params.id);
|
||||||
|
const name = normalizeStudentName(String(req.query.studentName ?? ""));
|
||||||
|
if (!name) {
|
||||||
|
res.json([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const submissions = await prisma.submission.findMany({
|
||||||
|
where: { taskId, studentKey: studentKey(name) },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: 20
|
||||||
|
});
|
||||||
|
res.json(submissions.map((submission) => ({ ...submission, result: JSON.parse(submission.resultJson) })));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/tasks/:id/submissions", requireStudentAccess, async (req, res) => {
|
||||||
|
const taskId = routeParam(req.params.id);
|
||||||
|
const parsed = submissionSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json(validationError(parsed.error, "Please fix the submission before sending it."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = await prisma.task.findFirst({ where: { id: taskId, visible: true } });
|
||||||
|
if (!task) {
|
||||||
|
res.status(404).json({ error: "Task not found." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const studentName = normalizeStudentName(parsed.data.studentName);
|
||||||
|
const result = await judgeSubmission({
|
||||||
|
taskId: task.id,
|
||||||
|
checksJson: task.checksJson,
|
||||||
|
expectedScreenshotPath: task.expectedScreenshotPath,
|
||||||
|
visualThreshold: task.visualThreshold,
|
||||||
|
passingScore: task.passingScore,
|
||||||
|
bundle: {
|
||||||
|
html: parsed.data.html,
|
||||||
|
css: parsed.data.css,
|
||||||
|
js: parsed.data.js
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const submission = await prisma.submission.create({
|
||||||
|
data: {
|
||||||
|
taskId: task.id,
|
||||||
|
studentName,
|
||||||
|
studentKey: studentKey(studentName),
|
||||||
|
html: parsed.data.html,
|
||||||
|
css: parsed.data.css,
|
||||||
|
js: parsed.data.js,
|
||||||
|
passed: result.passed,
|
||||||
|
score: result.score,
|
||||||
|
resultJson: JSON.stringify(result)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
res.json({ submission: { ...submission, result }, result });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/admin/groups", requireAdmin, async (_req, res) => {
|
||||||
|
const groups = await prisma.dayGroup.findMany({
|
||||||
|
orderBy: [{ order: "asc" }, { createdAt: "asc" }],
|
||||||
|
include: { tasks: { orderBy: [{ order: "asc" }, { createdAt: "asc" }] } }
|
||||||
|
});
|
||||||
|
res.json(groups.map((group) => ({ ...group, tasks: group.tasks.map(taskWithChecks) })));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/admin/groups", requireAdmin, async (req, res) => {
|
||||||
|
const parsed = z.object({ title: z.string().min(1), order: z.coerce.number().int().default(0) }).safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json(validationError(parsed.error, "Please fix the day group before saving it."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const group = await prisma.dayGroup.create({ data: parsed.data });
|
||||||
|
res.json(group);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put("/api/admin/groups/:id", requireAdmin, async (req, res) => {
|
||||||
|
const groupId = routeParam(req.params.id);
|
||||||
|
const parsed = z.object({ title: z.string().min(1), order: z.coerce.number().int().default(0) }).safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json(validationError(parsed.error, "Please fix the day group before saving it."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const group = await prisma.dayGroup.update({ where: { id: groupId }, data: parsed.data });
|
||||||
|
res.json(group);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete("/api/admin/groups/:id", requireAdmin, async (req, res) => {
|
||||||
|
const groupId = routeParam(req.params.id);
|
||||||
|
await prisma.dayGroup.delete({ where: { id: groupId } });
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/admin/tasks", requireAdmin, async (req, res) => {
|
||||||
|
const parsed = taskSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json(validationError(parsed.error, "Please fix the task before saving it."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { checks, regenerateExpected, ...data } = parsed.data;
|
||||||
|
const task = await prisma.task.create({
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
checksJson: JSON.stringify(checks)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let expectedScreenshotPath = task.expectedScreenshotPath;
|
||||||
|
if (regenerateExpected) {
|
||||||
|
expectedScreenshotPath = await maybeGenerateExpected(task);
|
||||||
|
if (expectedScreenshotPath) {
|
||||||
|
await prisma.task.update({ where: { id: task.id }, data: { expectedScreenshotPath } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await prisma.task.findUniqueOrThrow({ where: { id: task.id } });
|
||||||
|
res.json(taskWithChecks(created));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put("/api/admin/tasks/:id", requireAdmin, async (req, res) => {
|
||||||
|
const taskId = routeParam(req.params.id);
|
||||||
|
const parsed = taskSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json(validationError(parsed.error, "Please fix the task before saving it."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { checks, regenerateExpected, ...data } = parsed.data;
|
||||||
|
const task = await prisma.task.update({
|
||||||
|
where: { id: taskId },
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
checksJson: JSON.stringify(checks)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (regenerateExpected) {
|
||||||
|
const expectedScreenshotPath = await maybeGenerateExpected(task);
|
||||||
|
if (expectedScreenshotPath) {
|
||||||
|
await prisma.task.update({ where: { id: task.id }, data: { expectedScreenshotPath } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.task.findUniqueOrThrow({ where: { id: taskId } });
|
||||||
|
res.json(taskWithChecks(updated));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/admin/tasks/:id/generate-expected", requireAdmin, async (req, res) => {
|
||||||
|
const taskId = routeParam(req.params.id);
|
||||||
|
const task = await prisma.task.findUnique({ where: { id: taskId } });
|
||||||
|
if (!task) {
|
||||||
|
res.status(404).json({ error: "Task not found." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const expectedScreenshotPath = await maybeGenerateExpected(task);
|
||||||
|
if (!expectedScreenshotPath) {
|
||||||
|
res.status(400).json({ error: "Add reference HTML, CSS, or JS first." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const updated = await prisma.task.update({ where: { id: task.id }, data: { expectedScreenshotPath } });
|
||||||
|
res.json(taskWithChecks(updated));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete("/api/admin/tasks/:id", requireAdmin, async (req, res) => {
|
||||||
|
const taskId = routeParam(req.params.id);
|
||||||
|
await prisma.task.delete({ where: { id: taskId } });
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/admin/submissions", requireAdmin, async (_req, res) => {
|
||||||
|
const submissions = await prisma.submission.findMany({
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: 200,
|
||||||
|
include: { task: { select: { title: true, group: { select: { title: true } } } } }
|
||||||
|
});
|
||||||
|
res.json(submissions.map((submission) => ({ ...submission, result: JSON.parse(submission.resultJson) })));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/admin/submissions/:id/reevaluate", requireAdmin, async (req, res) => {
|
||||||
|
const submissionId = routeParam(req.params.id);
|
||||||
|
const submission = await prisma.submission.findUnique({
|
||||||
|
where: { id: submissionId },
|
||||||
|
include: { task: { include: { group: { select: { title: true } } } } }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!submission) {
|
||||||
|
res.status(404).json({ error: "Submission not found." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await judgeSubmission({
|
||||||
|
taskId: submission.taskId,
|
||||||
|
checksJson: submission.task.checksJson,
|
||||||
|
expectedScreenshotPath: submission.task.expectedScreenshotPath,
|
||||||
|
visualThreshold: submission.task.visualThreshold,
|
||||||
|
passingScore: submission.task.passingScore,
|
||||||
|
bundle: {
|
||||||
|
html: submission.html,
|
||||||
|
css: submission.css,
|
||||||
|
js: submission.js
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const updated = await prisma.submission.update({
|
||||||
|
where: { id: submission.id },
|
||||||
|
data: {
|
||||||
|
passed: result.passed,
|
||||||
|
score: result.score,
|
||||||
|
resultJson: JSON.stringify(result)
|
||||||
|
},
|
||||||
|
include: { task: { select: { title: true, group: { select: { title: true } } } } }
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ ...updated, result });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/admin/assets/upload", requireAdmin, upload.single("image"), async (req, res) => {
|
||||||
|
if (!req.file) {
|
||||||
|
res.status(400).json({ error: "Image file is required." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = await saveAsset(req.file.buffer, req.file.mimetype);
|
||||||
|
res.json({ url, markdown: `` });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/admin/assets/download", requireAdmin, async (req, res) => {
|
||||||
|
const parsed = z.object({ url: z.string().url() }).safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json(validationError(parsed.error, "Enter a valid image URL."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await assertSafeDownloadUrl(parsed.data.url);
|
||||||
|
const response = await fetch(parsed.data.url, { redirect: "error" });
|
||||||
|
const contentType = response.headers.get("content-type") ?? "";
|
||||||
|
const length = Number(response.headers.get("content-length") ?? 0);
|
||||||
|
if (!response.ok || length > 5 * 1024 * 1024) {
|
||||||
|
res.status(400).json({ error: "Could not download a valid image under 5 MB." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = Buffer.from(await response.arrayBuffer());
|
||||||
|
const url = await saveAsset(buffer, contentType);
|
||||||
|
res.json({ url, markdown: `` });
|
||||||
|
});
|
||||||
|
|
||||||
|
if (config.nodeEnv === "production") {
|
||||||
|
app.use(express.static(config.frontendDistDir));
|
||||||
|
app.get("*", (_req, res) => {
|
||||||
|
res.sendFile(path.join(config.frontendDistDir, "index.html"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
import type { NextFunction, Request, Response } from "express";
|
||||||
|
import { config } from "./config.js";
|
||||||
|
|
||||||
|
const COOKIE_OPTIONS = {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "lax" as const,
|
||||||
|
secure: config.nodeEnv === "production",
|
||||||
|
maxAge: 1000 * 60 * 60 * 12
|
||||||
|
};
|
||||||
|
|
||||||
|
const ADMIN_COOKIE = "fj_admin";
|
||||||
|
const STUDENT_COOKIE = "fj_student";
|
||||||
|
|
||||||
|
type Role = "admin" | "student";
|
||||||
|
|
||||||
|
function sign(value: string) {
|
||||||
|
return crypto.createHmac("sha256", config.sessionSecret).update(value).digest("base64url");
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeToken(role: Role) {
|
||||||
|
const payload = `${role}.${Date.now()}`;
|
||||||
|
return `${payload}.${sign(payload)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyToken(token: string | undefined, role: Role) {
|
||||||
|
if (!token) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = token.split(".");
|
||||||
|
if (parts.length !== 3) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = `${parts[0]}.${parts[1]}`;
|
||||||
|
const expected = sign(payload);
|
||||||
|
if (parts[0] !== role || parts[2].length !== expected.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return crypto.timingSafeEqual(Buffer.from(parts[2]), Buffer.from(expected));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setAdminCookie(res: Response) {
|
||||||
|
res.cookie(ADMIN_COOKIE, makeToken("admin"), COOKIE_OPTIONS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setStudentCookie(res: Response) {
|
||||||
|
res.cookie(STUDENT_COOKIE, makeToken("student"), COOKIE_OPTIONS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearAuthCookies(res: Response) {
|
||||||
|
res.clearCookie(ADMIN_COOKIE);
|
||||||
|
res.clearCookie(STUDENT_COOKIE);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasAdmin(req: Request) {
|
||||||
|
return verifyToken(req.cookies?.[ADMIN_COOKIE], "admin");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasStudentAccess(req: Request) {
|
||||||
|
return hasAdmin(req) || verifyToken(req.cookies?.[STUDENT_COOKIE], "student");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireAdmin(req: Request, res: Response, next: NextFunction) {
|
||||||
|
if (!hasAdmin(req)) {
|
||||||
|
res.status(401).json({ error: "Admin access required." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireStudentAccess(req: Request, res: Response, next: NextFunction) {
|
||||||
|
if (!hasStudentAccess(req)) {
|
||||||
|
res.status(401).json({ error: "Student access required." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeStudentName(name: string) {
|
||||||
|
return name.trim().replace(/\s+/g, " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function studentKey(name: string) {
|
||||||
|
return normalizeStudentName(name).toLocaleLowerCase("en-US");
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
export type CodeBundle = {
|
||||||
|
html: string;
|
||||||
|
css: string;
|
||||||
|
js: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function buildHtmlDocument(bundle: CodeBundle) {
|
||||||
|
return `<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after { box-sizing: border-box; }
|
||||||
|
body { margin: 0; min-height: 100vh; font-family: Arial, Helvetica, sans-serif; }
|
||||||
|
${bundle.css}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
${bundle.html}
|
||||||
|
<script>
|
||||||
|
window.alert = () => {};
|
||||||
|
window.confirm = () => false;
|
||||||
|
window.prompt = () => null;
|
||||||
|
</script>
|
||||||
|
<script>${bundle.js}</script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import dotenv from "dotenv";
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
export const backendDir = path.resolve(path.dirname(__filename), "..");
|
||||||
|
export const projectDir = path.resolve(backendDir, "..");
|
||||||
|
|
||||||
|
dotenv.config({ path: path.resolve(projectDir, ".env") });
|
||||||
|
dotenv.config({ path: path.resolve(backendDir, ".env") });
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
nodeEnv: process.env.NODE_ENV ?? "development",
|
||||||
|
port: Number(process.env.PORT ?? 4000),
|
||||||
|
adminPassword: process.env.ADMIN_PASSWORD ?? "change-admin-password",
|
||||||
|
studentPassword: process.env.STUDENT_PASSWORD ?? "change-student-password",
|
||||||
|
sessionSecret: process.env.SESSION_SECRET ?? "dev-session-secret-change-me",
|
||||||
|
publicUrl: process.env.PUBLIC_URL ?? "http://localhost:4000",
|
||||||
|
viewport: {
|
||||||
|
width: Number(process.env.JUDGE_VIEWPORT_WIDTH ?? 800),
|
||||||
|
height: Number(process.env.JUDGE_VIEWPORT_HEIGHT ?? 600)
|
||||||
|
},
|
||||||
|
dataDir: path.resolve(backendDir, "data"),
|
||||||
|
generatedDir: path.resolve(backendDir, "data", "generated"),
|
||||||
|
frontendDistDir: path.resolve(projectDir, "frontend", "dist")
|
||||||
|
};
|
||||||
|
|
||||||
|
export function assertProductionConfig() {
|
||||||
|
if (config.nodeEnv !== "production") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const weakValues = new Set([
|
||||||
|
"change-admin-password",
|
||||||
|
"change-student-password",
|
||||||
|
"dev-session-secret-change-me",
|
||||||
|
"change-this-long-random-secret"
|
||||||
|
]);
|
||||||
|
|
||||||
|
for (const [name, value] of [
|
||||||
|
["ADMIN_PASSWORD", config.adminPassword],
|
||||||
|
["STUDENT_PASSWORD", config.studentPassword],
|
||||||
|
["SESSION_SECRET", config.sessionSecret]
|
||||||
|
]) {
|
||||||
|
if (!value || weakValues.has(value)) {
|
||||||
|
throw new Error(`${name} must be set to a strong value in production.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
|
||||||
|
export const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
export async function ensureDatabaseSchema() {
|
||||||
|
await prisma.$executeRawUnsafe(`
|
||||||
|
CREATE TABLE IF NOT EXISTS "DayGroup" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"order" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await prisma.$executeRawUnsafe(`
|
||||||
|
CREATE TABLE IF NOT EXISTS "Task" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"descriptionMarkdown" TEXT NOT NULL,
|
||||||
|
"groupId" TEXT NOT NULL,
|
||||||
|
"order" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"visible" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"starterHtml" TEXT NOT NULL DEFAULT '',
|
||||||
|
"starterCss" TEXT NOT NULL DEFAULT '',
|
||||||
|
"starterJs" TEXT NOT NULL DEFAULT '',
|
||||||
|
"referenceHtml" TEXT NOT NULL DEFAULT '',
|
||||||
|
"referenceCss" TEXT NOT NULL DEFAULT '',
|
||||||
|
"referenceJs" TEXT NOT NULL DEFAULT '',
|
||||||
|
"expectedScreenshotPath" TEXT,
|
||||||
|
"visualThreshold" REAL NOT NULL DEFAULT 0.85,
|
||||||
|
"passingScore" REAL NOT NULL DEFAULT 0.8,
|
||||||
|
"checksJson" TEXT NOT NULL DEFAULT '[]',
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "Task_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "DayGroup" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
const taskColumns = await prisma.$queryRawUnsafe<Array<{ name: string }>>(`PRAGMA table_info("Task");`);
|
||||||
|
if (!taskColumns.some((column) => column.name === "passingScore")) {
|
||||||
|
await prisma.$executeRawUnsafe(`ALTER TABLE "Task" ADD COLUMN "passingScore" REAL NOT NULL DEFAULT 0.8;`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.$executeRawUnsafe(`
|
||||||
|
CREATE TABLE IF NOT EXISTS "Submission" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"taskId" TEXT NOT NULL,
|
||||||
|
"studentName" TEXT NOT NULL,
|
||||||
|
"studentKey" TEXT NOT NULL,
|
||||||
|
"html" TEXT NOT NULL,
|
||||||
|
"css" TEXT NOT NULL,
|
||||||
|
"js" TEXT NOT NULL,
|
||||||
|
"passed" BOOLEAN NOT NULL,
|
||||||
|
"score" REAL NOT NULL,
|
||||||
|
"resultJson" TEXT NOT NULL,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "Submission_taskId_fkey" FOREIGN KEY ("taskId") REFERENCES "Task" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await prisma.$executeRawUnsafe(`
|
||||||
|
CREATE INDEX IF NOT EXISTS "Submission_taskId_studentKey_idx" ON "Submission" ("taskId", "studentKey");
|
||||||
|
`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { buildHtmlDocument } from "./codeDocument.js";
|
||||||
|
import { summarizeWeightedResults } from "./judge.js";
|
||||||
|
|
||||||
|
describe("buildHtmlDocument", () => {
|
||||||
|
it("combines html, css, and js into a browser document", () => {
|
||||||
|
const document = buildHtmlDocument({
|
||||||
|
html: "<button>Start</button>",
|
||||||
|
css: "button { color: red; }",
|
||||||
|
js: "document.body.dataset.ready = 'true';"
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(document).toContain("<button>Start</button>");
|
||||||
|
expect(document).toContain("button { color: red; }");
|
||||||
|
expect(document).toContain("document.body.dataset.ready");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("summarizeWeightedResults", () => {
|
||||||
|
it("passes by default when 80 percent of equal-weight checks pass", () => {
|
||||||
|
const summary = summarizeWeightedResults([
|
||||||
|
{ type: "selector-exists", message: "one", passed: true },
|
||||||
|
{ type: "selector-exists", message: "two", passed: true },
|
||||||
|
{ type: "selector-exists", message: "three", passed: true },
|
||||||
|
{ type: "selector-exists", message: "four", passed: true },
|
||||||
|
{ type: "selector-exists", message: "five", passed: false }
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(summary).toEqual({ score: 0.8, passed: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses explicit check weights when calculating the score", () => {
|
||||||
|
const summary = summarizeWeightedResults([
|
||||||
|
{ type: "selector-exists", message: "important", passed: false, weight: 3 },
|
||||||
|
{ type: "selector-exists", message: "small", passed: true, weight: 1 }
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(summary).toEqual({ score: 0.25, passed: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { PNG } from "pngjs";
|
||||||
|
import pixelmatch from "pixelmatch";
|
||||||
|
import { chromium } from "playwright";
|
||||||
|
import { config } from "./config.js";
|
||||||
|
import { buildHtmlDocument, type CodeBundle } from "./codeDocument.js";
|
||||||
|
import type { CheckDefinition, CheckResult, JudgeResult } from "./types.js";
|
||||||
|
|
||||||
|
async function renderScreenshot(bundle: CodeBundle, outputPath: string) {
|
||||||
|
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
||||||
|
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport: config.viewport,
|
||||||
|
javaScriptEnabled: true
|
||||||
|
});
|
||||||
|
|
||||||
|
await context.route("**/*", (route) => {
|
||||||
|
const url = route.request().url();
|
||||||
|
if (url.startsWith("data:") || url.startsWith("about:")) {
|
||||||
|
route.continue();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
route.abort();
|
||||||
|
});
|
||||||
|
|
||||||
|
const page = await context.newPage();
|
||||||
|
page.setDefaultTimeout(1500);
|
||||||
|
page.on("dialog", (dialog) => dialog.dismiss().catch(() => undefined));
|
||||||
|
await page.setContent(buildHtmlDocument(bundle), {
|
||||||
|
waitUntil: "domcontentloaded",
|
||||||
|
timeout: 2000
|
||||||
|
});
|
||||||
|
await page.waitForTimeout(150);
|
||||||
|
await page.screenshot({ path: outputPath, fullPage: false });
|
||||||
|
return { browser, page };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseChecks(checksJson: string): CheckDefinition[] {
|
||||||
|
try {
|
||||||
|
const checks = JSON.parse(checksJson);
|
||||||
|
return Array.isArray(checks) ? checks : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPercent(value: number) {
|
||||||
|
return `${(value * 100).toFixed(1)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkWeight(check: CheckDefinition) {
|
||||||
|
return typeof check.weight === "number" && Number.isFinite(check.weight) && check.weight > 0 ? check.weight : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarizeWeightedResults(results: CheckResult[], passingScore = 0.8) {
|
||||||
|
const totalWeight = results.reduce((sum, result) => sum + (result.weight ?? 1), 0);
|
||||||
|
const passedWeight = results.reduce((sum, result) => sum + (result.passed ? result.weight ?? 1 : 0), 0);
|
||||||
|
const score = totalWeight === 0 ? 1 : Number((passedWeight / totalWeight).toFixed(4));
|
||||||
|
|
||||||
|
return {
|
||||||
|
score,
|
||||||
|
passed: score >= passingScore
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type Pixel = {
|
||||||
|
r: number;
|
||||||
|
g: number;
|
||||||
|
b: number;
|
||||||
|
a: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function pixelAt(image: PNG, index: number): Pixel {
|
||||||
|
const offset = index * 4;
|
||||||
|
return {
|
||||||
|
r: image.data[offset],
|
||||||
|
g: image.data[offset + 1],
|
||||||
|
b: image.data[offset + 2],
|
||||||
|
a: image.data[offset + 3]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function colorDistance(a: Pixel, b: Pixel) {
|
||||||
|
const dr = a.r - b.r;
|
||||||
|
const dg = a.g - b.g;
|
||||||
|
const db = a.b - b.b;
|
||||||
|
const da = (a.a - b.a) / 2;
|
||||||
|
return Math.sqrt(dr * dr + dg * dg + db * db + da * da);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cornerBackground(image: PNG) {
|
||||||
|
return pixelAt(image, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function contentComparison(actual: PNG, expected: PNG) {
|
||||||
|
const actualBackground = cornerBackground(actual);
|
||||||
|
const expectedBackground = cornerBackground(expected);
|
||||||
|
const contentTolerance = 18;
|
||||||
|
const mismatchTolerance = 30;
|
||||||
|
let contentPixels = 0;
|
||||||
|
let contentMismatches = 0;
|
||||||
|
|
||||||
|
for (let index = 0; index < actual.width * actual.height; index += 1) {
|
||||||
|
const actualPixel = pixelAt(actual, index);
|
||||||
|
const expectedPixel = pixelAt(expected, index);
|
||||||
|
const isExpectedContent = colorDistance(expectedPixel, expectedBackground) > contentTolerance;
|
||||||
|
const isActualContent = colorDistance(actualPixel, actualBackground) > contentTolerance;
|
||||||
|
const isMismatch = colorDistance(actualPixel, expectedPixel) > mismatchTolerance;
|
||||||
|
|
||||||
|
if (isExpectedContent || isActualContent || isMismatch) {
|
||||||
|
contentPixels += 1;
|
||||||
|
if (isMismatch) {
|
||||||
|
contentMismatches += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contentPixels === 0) {
|
||||||
|
return {
|
||||||
|
similarity: 1,
|
||||||
|
contentPixels,
|
||||||
|
contentMismatches
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
similarity: Number((1 - contentMismatches / contentPixels).toFixed(4)),
|
||||||
|
contentPixels,
|
||||||
|
contentMismatches
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function compareScreenshots(actualPath: string, expectedPath: string, diffPath: string) {
|
||||||
|
const [actualBuffer, expectedBuffer] = await Promise.all([
|
||||||
|
fs.readFile(actualPath),
|
||||||
|
fs.readFile(expectedPath)
|
||||||
|
]);
|
||||||
|
const actual = PNG.sync.read(actualBuffer);
|
||||||
|
const expected = PNG.sync.read(expectedBuffer);
|
||||||
|
|
||||||
|
if (actual.width !== expected.width || actual.height !== expected.height) {
|
||||||
|
return {
|
||||||
|
similarity: 0,
|
||||||
|
diffPath: undefined,
|
||||||
|
details: `Screenshot size ${actual.width}x${actual.height} did not match expected ${expected.width}x${expected.height}.`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const diff = new PNG({ width: actual.width, height: actual.height });
|
||||||
|
const mismatchedPixels = pixelmatch(actual.data, expected.data, diff.data, actual.width, actual.height, {
|
||||||
|
threshold: 0.12
|
||||||
|
});
|
||||||
|
await fs.mkdir(path.dirname(diffPath), { recursive: true });
|
||||||
|
await fs.writeFile(diffPath, PNG.sync.write(diff));
|
||||||
|
|
||||||
|
const totalPixels = actual.width * actual.height;
|
||||||
|
const content = contentComparison(actual, expected);
|
||||||
|
return {
|
||||||
|
similarity: content.similarity,
|
||||||
|
diffPath,
|
||||||
|
details: `${content.contentMismatches} of ${content.contentPixels} content pixels differed. Full screenshot: ${mismatchedPixels} of ${totalPixels} pixels differed.`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateExpectedScreenshot(taskId: string, bundle: CodeBundle) {
|
||||||
|
const outputPath = path.join(config.generatedDir, "expected", `${taskId}.png`);
|
||||||
|
const { browser } = await renderScreenshot(bundle, outputPath);
|
||||||
|
await browser.close();
|
||||||
|
return path.relative(config.dataDir, outputPath).replaceAll(path.sep, "/");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function judgeSubmission(options: {
|
||||||
|
taskId: string;
|
||||||
|
checksJson: string;
|
||||||
|
expectedScreenshotPath?: string | null;
|
||||||
|
visualThreshold: number;
|
||||||
|
passingScore?: number;
|
||||||
|
bundle: CodeBundle;
|
||||||
|
}): Promise<JudgeResult> {
|
||||||
|
const submissionScreenshot = path.join(
|
||||||
|
config.generatedDir,
|
||||||
|
"submissions",
|
||||||
|
`${options.taskId}-${Date.now()}.png`
|
||||||
|
);
|
||||||
|
const { browser, page } = await renderScreenshot(options.bundle, submissionScreenshot);
|
||||||
|
const checks = parseChecks(options.checksJson);
|
||||||
|
const results: CheckResult[] = [];
|
||||||
|
let diffScreenshotPath: string | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const check of checks) {
|
||||||
|
if (check.type === "visual-match") {
|
||||||
|
const threshold = check.threshold ?? options.visualThreshold;
|
||||||
|
if (!options.expectedScreenshotPath) {
|
||||||
|
results.push({
|
||||||
|
type: check.type,
|
||||||
|
message: check.message,
|
||||||
|
passed: false,
|
||||||
|
weight: checkWeight(check),
|
||||||
|
details: "No expected screenshot has been generated for this task."
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedPath = path.join(config.dataDir, options.expectedScreenshotPath);
|
||||||
|
const diffPath = path.join(config.generatedDir, "diffs", `${options.taskId}-${Date.now()}.png`);
|
||||||
|
const comparison = await compareScreenshots(submissionScreenshot, expectedPath, diffPath);
|
||||||
|
if (comparison.diffPath) {
|
||||||
|
diffScreenshotPath = path.relative(config.dataDir, comparison.diffPath).replaceAll(path.sep, "/");
|
||||||
|
}
|
||||||
|
results.push({
|
||||||
|
type: check.type,
|
||||||
|
message: check.message,
|
||||||
|
passed: comparison.similarity >= threshold,
|
||||||
|
score: comparison.similarity,
|
||||||
|
weight: checkWeight(check),
|
||||||
|
details: `${formatPercent(comparison.similarity)} match required ${formatPercent(threshold)}. ${comparison.details}`
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!check.selector) {
|
||||||
|
results.push({
|
||||||
|
type: check.type,
|
||||||
|
message: check.message,
|
||||||
|
passed: false,
|
||||||
|
weight: checkWeight(check),
|
||||||
|
details: "This check is missing a CSS selector."
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const locator = page.locator(check.selector);
|
||||||
|
const count = await locator.count();
|
||||||
|
|
||||||
|
if (check.type === "selector-exists") {
|
||||||
|
results.push({
|
||||||
|
type: check.type,
|
||||||
|
message: check.message,
|
||||||
|
passed: count > 0,
|
||||||
|
weight: checkWeight(check),
|
||||||
|
details: count > 0 ? `Found ${count} matching element(s).` : "No matching element was found."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (check.type === "element-count") {
|
||||||
|
const expectedCount = check.count ?? Number(check.value ?? 0);
|
||||||
|
results.push({
|
||||||
|
type: check.type,
|
||||||
|
message: check.message,
|
||||||
|
passed: count === expectedCount,
|
||||||
|
weight: checkWeight(check),
|
||||||
|
details: `Found ${count}, expected ${expectedCount}.`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (check.type === "text-contains") {
|
||||||
|
const text = count > 0 ? await locator.first().textContent() : "";
|
||||||
|
const expected = check.value ?? "";
|
||||||
|
results.push({
|
||||||
|
type: check.type,
|
||||||
|
message: check.message,
|
||||||
|
passed: Boolean(text?.toLocaleLowerCase("en-US").includes(expected.toLocaleLowerCase("en-US"))),
|
||||||
|
weight: checkWeight(check),
|
||||||
|
details: `Expected text containing "${expected}".`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (check.type === "attribute-equals") {
|
||||||
|
const actual = count > 0 ? await locator.first().getAttribute(check.attribute ?? "") : null;
|
||||||
|
const expected = check.value ?? "";
|
||||||
|
results.push({
|
||||||
|
type: check.type,
|
||||||
|
message: check.message,
|
||||||
|
passed: actual === expected,
|
||||||
|
weight: checkWeight(check),
|
||||||
|
details: `Expected ${check.attribute}="${expected}", found ${actual ?? "nothing"}.`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (check.type === "css-property") {
|
||||||
|
const actual = count > 0
|
||||||
|
? await locator.first().evaluate((el, property) => getComputedStyle(el).getPropertyValue(property), check.property ?? "")
|
||||||
|
: "";
|
||||||
|
const expected = check.value ?? "";
|
||||||
|
results.push({
|
||||||
|
type: check.type,
|
||||||
|
message: check.message,
|
||||||
|
passed: actual.trim() === expected.trim(),
|
||||||
|
weight: checkWeight(check),
|
||||||
|
details: `Expected ${check.property}: ${expected}, found ${actual || "nothing"}.`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
const passingScore = options.passingScore ?? 0.8;
|
||||||
|
const summary = summarizeWeightedResults(results, passingScore);
|
||||||
|
return {
|
||||||
|
passed: summary.passed,
|
||||||
|
score: summary.score,
|
||||||
|
passingScore,
|
||||||
|
results,
|
||||||
|
screenshotPath: path.relative(config.dataDir, submissionScreenshot).replaceAll(path.sep, "/"),
|
||||||
|
diffScreenshotPath
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { assertProductionConfig, config } from "./config.js";
|
||||||
|
import { createApp } from "./app.js";
|
||||||
|
|
||||||
|
assertProductionConfig();
|
||||||
|
|
||||||
|
const app = await createApp();
|
||||||
|
|
||||||
|
app.listen(config.port, () => {
|
||||||
|
console.log(`Frontend Judge listening on http://localhost:${config.port}`);
|
||||||
|
});
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
export type CheckType =
|
||||||
|
| "selector-exists"
|
||||||
|
| "text-contains"
|
||||||
|
| "attribute-equals"
|
||||||
|
| "css-property"
|
||||||
|
| "element-count"
|
||||||
|
| "visual-match";
|
||||||
|
|
||||||
|
export type CheckDefinition = {
|
||||||
|
id?: string;
|
||||||
|
type: CheckType;
|
||||||
|
selector?: string;
|
||||||
|
value?: string;
|
||||||
|
attribute?: string;
|
||||||
|
property?: string;
|
||||||
|
count?: number;
|
||||||
|
threshold?: number;
|
||||||
|
weight?: number;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CheckResult = {
|
||||||
|
type: CheckType;
|
||||||
|
message: string;
|
||||||
|
passed: boolean;
|
||||||
|
details?: string;
|
||||||
|
score?: number;
|
||||||
|
weight?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type JudgeResult = {
|
||||||
|
passed: boolean;
|
||||||
|
score: number;
|
||||||
|
passingScore: number;
|
||||||
|
results: CheckResult[];
|
||||||
|
screenshotPath?: string;
|
||||||
|
diffScreenshotPath?: string;
|
||||||
|
};
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"resolveJsonModule": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
services:
|
||||||
|
frontend-judge:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "4000:4000"
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
PORT: 4000
|
||||||
|
DATABASE_URL: file:/app/backend/data/app.db
|
||||||
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-change-admin-password}
|
||||||
|
STUDENT_PASSWORD: ${STUDENT_PASSWORD:-change-student-password}
|
||||||
|
SESSION_SECRET: ${SESSION_SECRET:-change-this-long-random-secret}
|
||||||
|
PUBLIC_URL: ${PUBLIC_URL:-http://localhost:4000}
|
||||||
|
JUDGE_VIEWPORT_WIDTH: ${JUDGE_VIEWPORT_WIDTH:-800}
|
||||||
|
JUDGE_VIEWPORT_HEIGHT: ${JUDGE_VIEWPORT_HEIGHT:-600}
|
||||||
|
volumes:
|
||||||
|
- frontend_judge_data:/app/backend/data
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
frontend_judge_data:
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Frontend Judge</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "@frontend-judge/frontend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host 0.0.0.0",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview --host 0.0.0.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/lang-css": "^6.3.1",
|
||||||
|
"@codemirror/lang-html": "^6.4.9",
|
||||||
|
"@codemirror/lang-javascript": "^6.2.2",
|
||||||
|
"@uiw/react-codemirror": "^4.23.7",
|
||||||
|
"lucide-react": "^0.468.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-markdown": "^9.0.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.18",
|
||||||
|
"@types/react-dom": "^18.3.5",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"typescript": "^5.7.2",
|
||||||
|
"vite": "^6.0.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,684 @@
|
|||||||
|
:root {
|
||||||
|
color: #17202a;
|
||||||
|
background: #f4f6f8;
|
||||||
|
font-family:
|
||||||
|
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
border: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
cursor: wait;
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid #cbd5df;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.65rem 0.75rem;
|
||||||
|
background: #fff;
|
||||||
|
color: #17202a;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.38rem;
|
||||||
|
color: #405060;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
p {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-panel {
|
||||||
|
width: min(100%, 380px);
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 2rem;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #d8e0e8;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 16px 40px rgba(25, 34, 43, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
min-height: 76px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
background: #ffffff;
|
||||||
|
border-bottom: 1px solid #d8e0e8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar h1 {
|
||||||
|
margin-bottom: 0.1rem;
|
||||||
|
font-size: 1.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar p {
|
||||||
|
margin-bottom: 0;
|
||||||
|
color: #5b6b79;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-layout,
|
||||||
|
.admin-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 290px minmax(0, 1fr);
|
||||||
|
min-height: calc(100vh - 76px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: #ffffff;
|
||||||
|
border-right: 1px solid #d8e0e8;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-group {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-group h2 {
|
||||||
|
margin: 0.4rem 0 0.2rem;
|
||||||
|
font-size: 0.86rem;
|
||||||
|
color: #617283;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-editor {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 64px 38px 38px;
|
||||||
|
gap: 0.35rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-editor input {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-link,
|
||||||
|
.history-row {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.65rem 0.7rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #23313d;
|
||||||
|
background: #eef3f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-link span {
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-link.active {
|
||||||
|
background: #d7ecff;
|
||||||
|
color: #0b4f81;
|
||||||
|
}
|
||||||
|
|
||||||
|
.passed-task-icon {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: #16814b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace,
|
||||||
|
.admin-main {
|
||||||
|
padding: 1rem;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-description {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 280px;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #d8e0e8;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-description img,
|
||||||
|
.markdown-preview img,
|
||||||
|
.expected-image {
|
||||||
|
max-width: 100%;
|
||||||
|
border: 1px solid #d8e0e8;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expected-image {
|
||||||
|
width: 100%;
|
||||||
|
align-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expected-thumb {
|
||||||
|
position: relative;
|
||||||
|
margin: 0;
|
||||||
|
align-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expected-image-button {
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fff;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expected-image-button:focus-visible {
|
||||||
|
outline: 3px solid rgba(31, 122, 140, 0.35);
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expected-image-action {
|
||||||
|
position: absolute;
|
||||||
|
right: 0.55rem;
|
||||||
|
top: 0.55rem;
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border: 1px solid rgba(21, 33, 43, 0.16);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(255, 255, 255, 0.94);
|
||||||
|
color: #17202a;
|
||||||
|
box-shadow: 0 8px 24px rgba(21, 33, 43, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.expected-image.large {
|
||||||
|
max-height: 320px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-lightbox {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 50;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
background: rgba(14, 24, 33, 0.72);
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-lightbox-panel {
|
||||||
|
width: min(1120px, 100%);
|
||||||
|
max-height: calc(100vh - 3rem);
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||||
|
gap: 0.8rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 24px 80px rgba(8, 16, 24, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-lightbox-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-lightbox-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-lightbox-panel img {
|
||||||
|
width: 100%;
|
||||||
|
max-height: calc(100vh - 11rem);
|
||||||
|
object-fit: contain;
|
||||||
|
border: 1px solid #d8e0e8;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-lightbox-panel a {
|
||||||
|
justify-self: start;
|
||||||
|
color: #1f6f82;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
.work-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(360px, 1fr) minmax(320px, 42%);
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editors,
|
||||||
|
.preview-pane,
|
||||||
|
.history,
|
||||||
|
.result,
|
||||||
|
.editor-section,
|
||||||
|
.markdown-preview,
|
||||||
|
.checks,
|
||||||
|
.submissions-table,
|
||||||
|
.submission-detail {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-section,
|
||||||
|
.preview-pane,
|
||||||
|
.history,
|
||||||
|
.result,
|
||||||
|
.markdown-preview,
|
||||||
|
.checks,
|
||||||
|
.submissions-table {
|
||||||
|
padding: 1rem;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #d8e0e8;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title,
|
||||||
|
.section-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.45rem;
|
||||||
|
color: #405060;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-pane iframe {
|
||||||
|
width: 100%;
|
||||||
|
height: 360px;
|
||||||
|
border: 1px solid #cbd5df;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submitted-preview {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submitted-preview iframe {
|
||||||
|
width: 100%;
|
||||||
|
height: 420px;
|
||||||
|
border: 1px solid #cbd5df;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary,
|
||||||
|
.secondary,
|
||||||
|
.ghost,
|
||||||
|
.danger,
|
||||||
|
.icon,
|
||||||
|
.upload-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 0.55rem 0.8rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary {
|
||||||
|
background: #1f7a8c;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary {
|
||||||
|
background: #e8eef3;
|
||||||
|
color: #24313d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary:disabled svg {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: #405060;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger {
|
||||||
|
background: #ffe5e5;
|
||||||
|
color: #a4262c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
width: 38px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.full,
|
||||||
|
.submit {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-button {
|
||||||
|
position: relative;
|
||||||
|
width: max-content;
|
||||||
|
background: #e8eef3;
|
||||||
|
color: #24313d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-button input {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
opacity: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result.pass {
|
||||||
|
border-color: #80c99a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result.fail {
|
||||||
|
border-color: #e7aa72;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 24px minmax(0, 1fr);
|
||||||
|
gap: 0.55rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-row p {
|
||||||
|
margin: 0.2rem 0 0;
|
||||||
|
color: #5b6b79;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-threshold,
|
||||||
|
.check-weight {
|
||||||
|
color: #607080;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-weight {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visual-diff {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comparison-image {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comparison-image figcaption {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
color: #405060;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comparison-image a {
|
||||||
|
color: #1f7a8c;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comparison-image-link {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comparison-image img {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 560px;
|
||||||
|
object-fit: contain;
|
||||||
|
border: 1px solid #d8e0e8;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
align-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-height: calc(100vh - 76px);
|
||||||
|
color: #5b6b79;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 2fr 1.4fr 0.55fr 0.75fr 0.75fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wide {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-tools,
|
||||||
|
.action-row,
|
||||||
|
.inline-form {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-form {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-preview {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 340px;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.three-columns {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submitted-code {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submitted-code h3 {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-editor {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 170px repeat(5, minmax(96px, 1fr)) 38px;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-input {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submission-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 160px minmax(0, 1fr) 120px 190px;
|
||||||
|
gap: 0.8rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.65rem 0;
|
||||||
|
border-bottom: 1px solid #e1e7ed;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: #17202a;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submission-row.active {
|
||||||
|
background: #edf7fb;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding-inline: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submission-detail {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submission-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: #6b7a88;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: #a4262c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-notice {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
border: 1px solid #e6a3a3;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fff4f4;
|
||||||
|
color: #7f1d1d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-notice ul {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 1.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-notice li + li {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success {
|
||||||
|
color: #167142;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-editor {
|
||||||
|
border: 1px solid #d8e0e8;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1050px) {
|
||||||
|
.student-layout,
|
||||||
|
.admin-layout,
|
||||||
|
.work-grid,
|
||||||
|
.task-description,
|
||||||
|
.split-preview {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
border-right: 0;
|
||||||
|
border-bottom: 1px solid #d8e0e8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.three-columns,
|
||||||
|
.form-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-editor,
|
||||||
|
.submission-row,
|
||||||
|
.visual-diff {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submission-actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["DOM", "DOM.Iterable", "ES2020"],
|
||||||
|
"allowJs": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx"
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": []
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
"/api": "http://localhost:4000",
|
||||||
|
"/generated": "http://localhost:4000"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: "dist"
|
||||||
|
}
|
||||||
|
});
|
||||||
Generated
+7228
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend-judge",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"workspaces": [
|
||||||
|
"backend",
|
||||||
|
"frontend"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"dev": "npm run dev --workspace backend",
|
||||||
|
"build": "npm run build --workspace frontend && npm run build --workspace backend",
|
||||||
|
"start": "npm run start --workspace backend",
|
||||||
|
"test": "npm run test --workspace backend",
|
||||||
|
"prisma:generate": "npm run prisma:generate --workspace backend",
|
||||||
|
"prisma:migrate": "npm run prisma:migrate --workspace backend"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user