diff --git a/apps/server/src/admin.ts b/apps/server/src/admin.ts
index acd0ebc..adf1da7 100644
--- a/apps/server/src/admin.ts
+++ b/apps/server/src/admin.ts
@@ -188,6 +188,16 @@ export async function registerAdmin(app: FastifyInstance, db: DB) {
return { rows: rows.slice(0, 25), hasMore: rows.length > 25 };
};
app.get("/api/support", (req) => listTickets(req, false));
+ app.get("/api/support/:id", async (req: any) => {
+ const ticketId = z.string().uuid().parse(req.params.id);
+ const result = await db.query(
+ "SELECT * FROM support_tickets WHERE id=$1 AND user_id=$2",
+ [ticketId, req.user.id],
+ );
+ if (!result.rows[0])
+ throw new DomainError(404, "NOT_FOUND", "문의를 찾을 수 없어요.");
+ return result.rows[0];
+ });
app.get("/api/admin/inquiries", (req) => listTickets(req, true));
app.post("/api/support", async (req: any) => {
const v = z
diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts
index 86461fe..a5d9db1 100644
--- a/apps/server/src/app.ts
+++ b/apps/server/src/app.ts
@@ -42,6 +42,8 @@ type Config = {
schedulerAudience?: string;
schedulerEmail?: string;
adminEmails?: string[];
+ /** Isolated browser fixture only; ignored for production and real OAuth. */
+ testRateLimit?: number;
verifyGoogle?: (
token: string,
) => Promise<{ sub: string; email: string; name: string; nonce?: string }>;
@@ -87,6 +89,10 @@ const resultInput = z.object({
runnerImage: z.string().max(256),
});
export async function createApp(c: Config) {
+ const testRateLimit =
+ process.env.NODE_ENV === "test" && !c.production && c.verifyGoogle
+ ? c.testRateLimit
+ : undefined;
const adminEmails = new Set(
(c.adminEmails ?? []).map((e) => e.trim().toLowerCase()).filter(Boolean),
);
@@ -103,7 +109,8 @@ export async function createApp(c: Config) {
await app.register(cors, { origin: c.origin, credentials: true });
await app.register(rateLimit, {
max: (req) =>
- req.routeOptions.url?.startsWith("/api/internal/") ? 1200 : 240,
+ testRateLimit ??
+ (req.routeOptions.url?.startsWith("/api/internal/") ? 1200 : 240),
timeWindow: "1 minute",
});
const google = new OAuth2Client(c.googleClientId);
@@ -292,7 +299,11 @@ export async function createApp(c: Config) {
});
app.post(
"/api/auth/challenge",
- { config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
+ {
+ config: {
+ rateLimit: { max: testRateLimit ?? 20, timeWindow: "1 minute" },
+ },
+ },
async (_req, reply) => {
const nonce = randomBytes(32).toString("hex");
await c.db.query("DELETE FROM auth_challenges WHERE expires_at<=now()");
@@ -320,7 +331,11 @@ export async function createApp(c: Config) {
}));
app.post(
"/api/auth/google",
- { config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
+ {
+ config: {
+ rateLimit: { max: testRateLimit ?? 20, timeWindow: "1 minute" },
+ },
+ },
async (req, reply) => {
if (!c.googleClientId && !c.verifyGoogle)
throw new DomainError(
diff --git a/apps/server/src/event-catalog.ts b/apps/server/src/event-catalog.ts
index 0af0bb0..69a4073 100644
--- a/apps/server/src/event-catalog.ts
+++ b/apps/server/src/event-catalog.ts
@@ -74,6 +74,11 @@ export const eventProperties = {
]),
step: small.optional(),
}),
+ PREDICTION_CHECKED: z.object({
+ topic: code,
+ index: small,
+ correct: z.boolean(),
+ }),
QUIZ_VIEWED: z.object({ topic: code, index: small }),
QUIZ_RETRIED: z.object({ topic: code }),
LANGUAGE_SELECTED: z.object({ language }),
@@ -100,6 +105,7 @@ export const clientEventTypes = [
"EXAMPLE_SELECTED",
"EXAMPLE_APPLIED",
"VISUALIZATION_CONTROL",
+ "PREDICTION_CHECKED",
"QUIZ_VIEWED",
"QUIZ_RETRIED",
"LANGUAGE_SELECTED",
diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts
index 4f66808..87caeae 100644
--- a/apps/server/src/index.ts
+++ b/apps/server/src/index.ts
@@ -7,7 +7,7 @@ import { cleanExpired } from "./domain.js";
import { processReview, recoverReviews } from "./review-worker.js";
import { GcsStorage } from "./object-storage.js";
import { maintenance } from "./maintenance.js";
-import staticFiles from "@fastify/static";
+import { registerWeb } from "./web-static.js";
const production = process.env.NODE_ENV === "production";
if (
production &&
@@ -53,11 +53,7 @@ const app = await createApp({
schedulerEmail: process.env.SCHEDULER_EMAIL,
adminEmails: (process.env.ADMIN_EMAILS ?? "").split(","),
});
-if (process.env.WEB_DIST)
- await app.register(staticFiles, {
- root: resolve(process.env.WEB_DIST),
- prefix: "/",
- });
+if (process.env.WEB_DIST) await registerWeb(app, resolve(process.env.WEB_DIST));
let busy = false;
const timer = setInterval(async () => {
if (busy) return;
diff --git a/apps/server/src/web-static.ts b/apps/server/src/web-static.ts
new file mode 100644
index 0000000..08ee01f
--- /dev/null
+++ b/apps/server/src/web-static.ts
@@ -0,0 +1,19 @@
+import type { FastifyInstance } from "fastify";
+import staticFiles from "@fastify/static";
+export async function registerWeb(app: FastifyInstance, root: string) {
+ await app.register(staticFiles, { root, prefix: "/" });
+ app.setNotFoundHandler((request, reply) => {
+ const path = request.url.split("?")[0];
+ if (
+ (request.method === "GET" || request.method === "HEAD") &&
+ path !== "/api" &&
+ !path.startsWith("/api/") &&
+ !path.split("/").at(-1)?.includes(".") &&
+ request.headers.accept?.includes("text/html")
+ )
+ return reply.type("text/html").sendFile("index.html");
+ return reply
+ .code(404)
+ .send({ code: "NOT_FOUND", message: "페이지를 찾을 수 없어요." });
+ });
+}
diff --git a/apps/server/test/admin.test.ts b/apps/server/test/admin.test.ts
index be43a96..188f8b0 100644
--- a/apps/server/test/admin.test.ts
+++ b/apps/server/test/admin.test.ts
@@ -190,6 +190,14 @@ test("inquiry ownership, idempotency, reply visibility and conflicting updates",
const created = await request(student, "POST", "/api/support", input);
assert.equal(created.statusCode, 200, created.body);
const ticket = created.json();
+ assert.equal(
+ (await request(student, "GET", `/api/support/${ticket.id}`)).json().body,
+ "PRIVATE_INQUIRY_CONTENT",
+ );
+ assert.equal(
+ (await request(other, "GET", `/api/support/${ticket.id}`)).statusCode,
+ 404,
+ );
assert.equal(
(await request(student, "POST", "/api/support", input)).json().id,
ticket.id,
diff --git a/apps/server/test/learning-flow.test.ts b/apps/server/test/learning-flow.test.ts
new file mode 100644
index 0000000..8e6e277
--- /dev/null
+++ b/apps/server/test/learning-flow.test.ts
@@ -0,0 +1,52 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { existsSync } from "node:fs";
+import {
+ checkpoints,
+ matchesAnswer,
+ answerText,
+} from "../../web/src/prediction-model.js";
+import { conceptUses } from "../../web/src/concept-uses.js";
+import { readRoute } from "../../web/src/router.js";
+test("every published concept has answerable prediction checkpoints and animation assets", () => {
+ assert.equal(Object.keys(conceptUses).length, 47);
+ for (const topic of Object.keys(conceptUses)) {
+ const checks = checkpoints(topic);
+ assert.ok(checks.length, topic);
+ for (const check of checks) {
+ assert.ok(
+ matchesAnswer(answerText(check.answer), check.answer),
+ `${topic}: ${check.field}`,
+ );
+ assert.equal(matchesAnswer("definitely-wrong", check.answer), false);
+ if (check.choices)
+ assert.ok(check.choices.some((c) => c.value === check.answer));
+ }
+ for (const theme of ["light", "dark"])
+ for (const ext of ["png", "gif"])
+ assert.ok(
+ existsSync(`apps/web/public/learning/${topic}-${theme}.${ext}`),
+ );
+ }
+});
+test("prediction answers preserve order and strings; numeric formatting is accepted", () => {
+ assert.ok(matchesAnswer("1, 2, 3", [1, 2, 3]));
+ assert.ok(matchesAnswer("[]", []));
+ assert.ok(matchesAnswer('["shortest path", "∞"]', ["shortest path", "∞"]));
+ assert.equal(matchesAnswer("3,2,1", [1, 2, 3]), false);
+ assert.ok(matchesAnswer("05", 5));
+ assert.equal(matchesAnswer("", 0), false);
+});
+test("routes reject malformed steps, extra segments and unknown admin sections", () => {
+ assert.equal(readRoute("/learn/tree/predict/2").step, 2);
+ for (const path of [
+ "/learn/tree/predict/0",
+ "/learn/tree/predict/no",
+ "/learn/tree/concept/extra",
+ "/admin/invalid",
+ "/nope",
+ ])
+ assert.equal(readRoute(path).page, "not-found");
+ assert.equal(readRoute("/records/abc").recordId, "abc");
+ assert.equal(readRoute("/learn/tree/code?record=abc").recordId, "abc");
+});
diff --git a/apps/server/test/web-static.test.ts b/apps/server/test/web-static.test.ts
new file mode 100644
index 0000000..d8a81f0
--- /dev/null
+++ b/apps/server/test/web-static.test.ts
@@ -0,0 +1,58 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import Fastify from "fastify";
+import { mkdtemp, writeFile, rm } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { registerWeb } from "../src/web-static.js";
+test("direct page navigation serves the app while missing API and assets remain 404", async () => {
+ const root = await mkdtemp(join(tmpdir(), "algostep-web-"));
+ const app = Fastify();
+ try {
+ await writeFile(
+ join(root, "index.html"),
+ "
Algostep",
+ );
+ await registerWeb(app, root);
+ for (const path of [
+ "/learn/bfs/concept",
+ "/learn/tree/predict/3",
+ "/learn/fft/code?record=123",
+ "/records/abc",
+ "/support/abc",
+ "/admin/finance",
+ ]) {
+ const r = await app.inject({
+ url: path,
+ headers: { accept: "text/html" },
+ });
+ assert.equal(r.statusCode, 200, path);
+ assert.match(r.body, /Algostep/);
+ }
+ for (const path of [
+ "/api",
+ "/api/missing",
+ "/missing.js",
+ "/learning/missing.gif",
+ ])
+ assert.equal(
+ (await app.inject({ url: path, headers: { accept: "text/html" } }))
+ .statusCode,
+ 404,
+ path,
+ );
+ assert.equal(
+ (
+ await app.inject({
+ method: "POST",
+ url: "/learn/bfs/concept",
+ headers: { accept: "text/html" },
+ })
+ ).statusCode,
+ 404,
+ );
+ } finally {
+ await app.close();
+ await rm(root, { recursive: true, force: true });
+ }
+});
diff --git a/apps/web/public/learning/advanced-dp-dark.gif b/apps/web/public/learning/advanced-dp-dark.gif
new file mode 100644
index 0000000..d113d23
Binary files /dev/null and b/apps/web/public/learning/advanced-dp-dark.gif differ
diff --git a/apps/web/public/learning/advanced-dp-dark.png b/apps/web/public/learning/advanced-dp-dark.png
new file mode 100644
index 0000000..a027822
Binary files /dev/null and b/apps/web/public/learning/advanced-dp-dark.png differ
diff --git a/apps/web/public/learning/advanced-dp-light.gif b/apps/web/public/learning/advanced-dp-light.gif
new file mode 100644
index 0000000..bf44c5b
Binary files /dev/null and b/apps/web/public/learning/advanced-dp-light.gif differ
diff --git a/apps/web/public/learning/advanced-dp-light.png b/apps/web/public/learning/advanced-dp-light.png
new file mode 100644
index 0000000..4cb1b6a
Binary files /dev/null and b/apps/web/public/learning/advanced-dp-light.png differ
diff --git a/apps/web/public/learning/array-dark.gif b/apps/web/public/learning/array-dark.gif
new file mode 100644
index 0000000..4bc1e10
Binary files /dev/null and b/apps/web/public/learning/array-dark.gif differ
diff --git a/apps/web/public/learning/array-dark.png b/apps/web/public/learning/array-dark.png
new file mode 100644
index 0000000..a7d6980
Binary files /dev/null and b/apps/web/public/learning/array-dark.png differ
diff --git a/apps/web/public/learning/array-light.gif b/apps/web/public/learning/array-light.gif
new file mode 100644
index 0000000..3a2d760
Binary files /dev/null and b/apps/web/public/learning/array-light.gif differ
diff --git a/apps/web/public/learning/array-light.png b/apps/web/public/learning/array-light.png
new file mode 100644
index 0000000..d9685a4
Binary files /dev/null and b/apps/web/public/learning/array-light.png differ
diff --git a/apps/web/public/learning/backtracking-dark.gif b/apps/web/public/learning/backtracking-dark.gif
new file mode 100644
index 0000000..8acbb52
Binary files /dev/null and b/apps/web/public/learning/backtracking-dark.gif differ
diff --git a/apps/web/public/learning/backtracking-dark.png b/apps/web/public/learning/backtracking-dark.png
new file mode 100644
index 0000000..8d701b8
Binary files /dev/null and b/apps/web/public/learning/backtracking-dark.png differ
diff --git a/apps/web/public/learning/backtracking-light.gif b/apps/web/public/learning/backtracking-light.gif
new file mode 100644
index 0000000..2ea1914
Binary files /dev/null and b/apps/web/public/learning/backtracking-light.gif differ
diff --git a/apps/web/public/learning/backtracking-light.png b/apps/web/public/learning/backtracking-light.png
new file mode 100644
index 0000000..7e140cd
Binary files /dev/null and b/apps/web/public/learning/backtracking-light.png differ
diff --git a/apps/web/public/learning/balanced-tree-dark.gif b/apps/web/public/learning/balanced-tree-dark.gif
new file mode 100644
index 0000000..af59965
Binary files /dev/null and b/apps/web/public/learning/balanced-tree-dark.gif differ
diff --git a/apps/web/public/learning/balanced-tree-dark.png b/apps/web/public/learning/balanced-tree-dark.png
new file mode 100644
index 0000000..c52bb3d
Binary files /dev/null and b/apps/web/public/learning/balanced-tree-dark.png differ
diff --git a/apps/web/public/learning/balanced-tree-light.gif b/apps/web/public/learning/balanced-tree-light.gif
new file mode 100644
index 0000000..3a9d62f
Binary files /dev/null and b/apps/web/public/learning/balanced-tree-light.gif differ
diff --git a/apps/web/public/learning/balanced-tree-light.png b/apps/web/public/learning/balanced-tree-light.png
new file mode 100644
index 0000000..ee07bc3
Binary files /dev/null and b/apps/web/public/learning/balanced-tree-light.png differ
diff --git a/apps/web/public/learning/bellman-ford-dark.gif b/apps/web/public/learning/bellman-ford-dark.gif
new file mode 100644
index 0000000..3c01887
Binary files /dev/null and b/apps/web/public/learning/bellman-ford-dark.gif differ
diff --git a/apps/web/public/learning/bellman-ford-dark.png b/apps/web/public/learning/bellman-ford-dark.png
new file mode 100644
index 0000000..a20fa5a
Binary files /dev/null and b/apps/web/public/learning/bellman-ford-dark.png differ
diff --git a/apps/web/public/learning/bellman-ford-light.gif b/apps/web/public/learning/bellman-ford-light.gif
new file mode 100644
index 0000000..440b925
Binary files /dev/null and b/apps/web/public/learning/bellman-ford-light.gif differ
diff --git a/apps/web/public/learning/bellman-ford-light.png b/apps/web/public/learning/bellman-ford-light.png
new file mode 100644
index 0000000..fbd50ef
Binary files /dev/null and b/apps/web/public/learning/bellman-ford-light.png differ
diff --git a/apps/web/public/learning/bfs-dark.gif b/apps/web/public/learning/bfs-dark.gif
new file mode 100644
index 0000000..703e684
Binary files /dev/null and b/apps/web/public/learning/bfs-dark.gif differ
diff --git a/apps/web/public/learning/bfs-dark.png b/apps/web/public/learning/bfs-dark.png
new file mode 100644
index 0000000..bf9b075
Binary files /dev/null and b/apps/web/public/learning/bfs-dark.png differ
diff --git a/apps/web/public/learning/bfs-light.gif b/apps/web/public/learning/bfs-light.gif
new file mode 100644
index 0000000..5af2a6c
Binary files /dev/null and b/apps/web/public/learning/bfs-light.gif differ
diff --git a/apps/web/public/learning/bfs-light.png b/apps/web/public/learning/bfs-light.png
new file mode 100644
index 0000000..d7bf1de
Binary files /dev/null and b/apps/web/public/learning/bfs-light.png differ
diff --git a/apps/web/public/learning/binary-search-dark.gif b/apps/web/public/learning/binary-search-dark.gif
new file mode 100644
index 0000000..9e4b0fb
Binary files /dev/null and b/apps/web/public/learning/binary-search-dark.gif differ
diff --git a/apps/web/public/learning/binary-search-dark.png b/apps/web/public/learning/binary-search-dark.png
new file mode 100644
index 0000000..c891cd4
Binary files /dev/null and b/apps/web/public/learning/binary-search-dark.png differ
diff --git a/apps/web/public/learning/binary-search-light.gif b/apps/web/public/learning/binary-search-light.gif
new file mode 100644
index 0000000..094e7e6
Binary files /dev/null and b/apps/web/public/learning/binary-search-light.gif differ
diff --git a/apps/web/public/learning/binary-search-light.png b/apps/web/public/learning/binary-search-light.png
new file mode 100644
index 0000000..cff5258
Binary files /dev/null and b/apps/web/public/learning/binary-search-light.png differ
diff --git a/apps/web/public/learning/brute-force-dark.gif b/apps/web/public/learning/brute-force-dark.gif
new file mode 100644
index 0000000..d1a308d
Binary files /dev/null and b/apps/web/public/learning/brute-force-dark.gif differ
diff --git a/apps/web/public/learning/brute-force-dark.png b/apps/web/public/learning/brute-force-dark.png
new file mode 100644
index 0000000..a9a2f9d
Binary files /dev/null and b/apps/web/public/learning/brute-force-dark.png differ
diff --git a/apps/web/public/learning/brute-force-light.gif b/apps/web/public/learning/brute-force-light.gif
new file mode 100644
index 0000000..7ef5c78
Binary files /dev/null and b/apps/web/public/learning/brute-force-light.gif differ
diff --git a/apps/web/public/learning/brute-force-light.png b/apps/web/public/learning/brute-force-light.png
new file mode 100644
index 0000000..2720641
Binary files /dev/null and b/apps/web/public/learning/brute-force-light.png differ
diff --git a/apps/web/public/learning/bst-dark.gif b/apps/web/public/learning/bst-dark.gif
new file mode 100644
index 0000000..2263970
Binary files /dev/null and b/apps/web/public/learning/bst-dark.gif differ
diff --git a/apps/web/public/learning/bst-dark.png b/apps/web/public/learning/bst-dark.png
new file mode 100644
index 0000000..83a5b14
Binary files /dev/null and b/apps/web/public/learning/bst-dark.png differ
diff --git a/apps/web/public/learning/bst-light.gif b/apps/web/public/learning/bst-light.gif
new file mode 100644
index 0000000..276bb27
Binary files /dev/null and b/apps/web/public/learning/bst-light.gif differ
diff --git a/apps/web/public/learning/bst-light.png b/apps/web/public/learning/bst-light.png
new file mode 100644
index 0000000..82dd728
Binary files /dev/null and b/apps/web/public/learning/bst-light.png differ
diff --git a/apps/web/public/learning/bubble-sort-dark.gif b/apps/web/public/learning/bubble-sort-dark.gif
new file mode 100644
index 0000000..dde586d
Binary files /dev/null and b/apps/web/public/learning/bubble-sort-dark.gif differ
diff --git a/apps/web/public/learning/bubble-sort-dark.png b/apps/web/public/learning/bubble-sort-dark.png
new file mode 100644
index 0000000..4db216a
Binary files /dev/null and b/apps/web/public/learning/bubble-sort-dark.png differ
diff --git a/apps/web/public/learning/bubble-sort-light.gif b/apps/web/public/learning/bubble-sort-light.gif
new file mode 100644
index 0000000..41ec8bc
Binary files /dev/null and b/apps/web/public/learning/bubble-sort-light.gif differ
diff --git a/apps/web/public/learning/bubble-sort-light.png b/apps/web/public/learning/bubble-sort-light.png
new file mode 100644
index 0000000..ecf6841
Binary files /dev/null and b/apps/web/public/learning/bubble-sort-light.png differ
diff --git a/apps/web/public/learning/dfs-dark.gif b/apps/web/public/learning/dfs-dark.gif
new file mode 100644
index 0000000..d70fcc4
Binary files /dev/null and b/apps/web/public/learning/dfs-dark.gif differ
diff --git a/apps/web/public/learning/dfs-dark.png b/apps/web/public/learning/dfs-dark.png
new file mode 100644
index 0000000..bf9b075
Binary files /dev/null and b/apps/web/public/learning/dfs-dark.png differ
diff --git a/apps/web/public/learning/dfs-light.gif b/apps/web/public/learning/dfs-light.gif
new file mode 100644
index 0000000..25c7fcd
Binary files /dev/null and b/apps/web/public/learning/dfs-light.gif differ
diff --git a/apps/web/public/learning/dfs-light.png b/apps/web/public/learning/dfs-light.png
new file mode 100644
index 0000000..d7bf1de
Binary files /dev/null and b/apps/web/public/learning/dfs-light.png differ
diff --git a/apps/web/public/learning/dijkstra-dark.gif b/apps/web/public/learning/dijkstra-dark.gif
new file mode 100644
index 0000000..d8ee2de
Binary files /dev/null and b/apps/web/public/learning/dijkstra-dark.gif differ
diff --git a/apps/web/public/learning/dijkstra-dark.png b/apps/web/public/learning/dijkstra-dark.png
new file mode 100644
index 0000000..6df81ca
Binary files /dev/null and b/apps/web/public/learning/dijkstra-dark.png differ
diff --git a/apps/web/public/learning/dijkstra-light.gif b/apps/web/public/learning/dijkstra-light.gif
new file mode 100644
index 0000000..40a2955
Binary files /dev/null and b/apps/web/public/learning/dijkstra-light.gif differ
diff --git a/apps/web/public/learning/dijkstra-light.png b/apps/web/public/learning/dijkstra-light.png
new file mode 100644
index 0000000..b03ff05
Binary files /dev/null and b/apps/web/public/learning/dijkstra-light.png differ
diff --git a/apps/web/public/learning/dp-dark.gif b/apps/web/public/learning/dp-dark.gif
new file mode 100644
index 0000000..3095ee9
Binary files /dev/null and b/apps/web/public/learning/dp-dark.gif differ
diff --git a/apps/web/public/learning/dp-dark.png b/apps/web/public/learning/dp-dark.png
new file mode 100644
index 0000000..cad3af1
Binary files /dev/null and b/apps/web/public/learning/dp-dark.png differ
diff --git a/apps/web/public/learning/dp-light.gif b/apps/web/public/learning/dp-light.gif
new file mode 100644
index 0000000..11f74ce
Binary files /dev/null and b/apps/web/public/learning/dp-light.gif differ
diff --git a/apps/web/public/learning/dp-light.png b/apps/web/public/learning/dp-light.png
new file mode 100644
index 0000000..fd1d981
Binary files /dev/null and b/apps/web/public/learning/dp-light.png differ
diff --git a/apps/web/public/learning/fenwick-dark.gif b/apps/web/public/learning/fenwick-dark.gif
new file mode 100644
index 0000000..f029db0
Binary files /dev/null and b/apps/web/public/learning/fenwick-dark.gif differ
diff --git a/apps/web/public/learning/fenwick-dark.png b/apps/web/public/learning/fenwick-dark.png
new file mode 100644
index 0000000..38ab9e7
Binary files /dev/null and b/apps/web/public/learning/fenwick-dark.png differ
diff --git a/apps/web/public/learning/fenwick-light.gif b/apps/web/public/learning/fenwick-light.gif
new file mode 100644
index 0000000..ca06176
Binary files /dev/null and b/apps/web/public/learning/fenwick-light.gif differ
diff --git a/apps/web/public/learning/fenwick-light.png b/apps/web/public/learning/fenwick-light.png
new file mode 100644
index 0000000..95e3fc1
Binary files /dev/null and b/apps/web/public/learning/fenwick-light.png differ
diff --git a/apps/web/public/learning/fft-dark.gif b/apps/web/public/learning/fft-dark.gif
new file mode 100644
index 0000000..8f4defb
Binary files /dev/null and b/apps/web/public/learning/fft-dark.gif differ
diff --git a/apps/web/public/learning/fft-dark.png b/apps/web/public/learning/fft-dark.png
new file mode 100644
index 0000000..1a7e711
Binary files /dev/null and b/apps/web/public/learning/fft-dark.png differ
diff --git a/apps/web/public/learning/fft-light.gif b/apps/web/public/learning/fft-light.gif
new file mode 100644
index 0000000..d57f81a
Binary files /dev/null and b/apps/web/public/learning/fft-light.gif differ
diff --git a/apps/web/public/learning/fft-light.png b/apps/web/public/learning/fft-light.png
new file mode 100644
index 0000000..cb0820b
Binary files /dev/null and b/apps/web/public/learning/fft-light.png differ
diff --git a/apps/web/public/learning/flow-dark.gif b/apps/web/public/learning/flow-dark.gif
new file mode 100644
index 0000000..13cda12
Binary files /dev/null and b/apps/web/public/learning/flow-dark.gif differ
diff --git a/apps/web/public/learning/flow-dark.png b/apps/web/public/learning/flow-dark.png
new file mode 100644
index 0000000..31576fc
Binary files /dev/null and b/apps/web/public/learning/flow-dark.png differ
diff --git a/apps/web/public/learning/flow-light.gif b/apps/web/public/learning/flow-light.gif
new file mode 100644
index 0000000..0ad4342
Binary files /dev/null and b/apps/web/public/learning/flow-light.gif differ
diff --git a/apps/web/public/learning/flow-light.png b/apps/web/public/learning/flow-light.png
new file mode 100644
index 0000000..54ed649
Binary files /dev/null and b/apps/web/public/learning/flow-light.png differ
diff --git a/apps/web/public/learning/floyd-warshall-dark.gif b/apps/web/public/learning/floyd-warshall-dark.gif
new file mode 100644
index 0000000..abc7359
Binary files /dev/null and b/apps/web/public/learning/floyd-warshall-dark.gif differ
diff --git a/apps/web/public/learning/floyd-warshall-dark.png b/apps/web/public/learning/floyd-warshall-dark.png
new file mode 100644
index 0000000..fb9a473
Binary files /dev/null and b/apps/web/public/learning/floyd-warshall-dark.png differ
diff --git a/apps/web/public/learning/floyd-warshall-light.gif b/apps/web/public/learning/floyd-warshall-light.gif
new file mode 100644
index 0000000..6ee4a5b
Binary files /dev/null and b/apps/web/public/learning/floyd-warshall-light.gif differ
diff --git a/apps/web/public/learning/floyd-warshall-light.png b/apps/web/public/learning/floyd-warshall-light.png
new file mode 100644
index 0000000..b196ffa
Binary files /dev/null and b/apps/web/public/learning/floyd-warshall-light.png differ
diff --git a/apps/web/public/learning/geometry-dark.gif b/apps/web/public/learning/geometry-dark.gif
new file mode 100644
index 0000000..f50dd26
Binary files /dev/null and b/apps/web/public/learning/geometry-dark.gif differ
diff --git a/apps/web/public/learning/geometry-dark.png b/apps/web/public/learning/geometry-dark.png
new file mode 100644
index 0000000..3dc1bab
Binary files /dev/null and b/apps/web/public/learning/geometry-dark.png differ
diff --git a/apps/web/public/learning/geometry-light.gif b/apps/web/public/learning/geometry-light.gif
new file mode 100644
index 0000000..4f29e10
Binary files /dev/null and b/apps/web/public/learning/geometry-light.gif differ
diff --git a/apps/web/public/learning/geometry-light.png b/apps/web/public/learning/geometry-light.png
new file mode 100644
index 0000000..4695935
Binary files /dev/null and b/apps/web/public/learning/geometry-light.png differ
diff --git a/apps/web/public/learning/graph-list-dark.gif b/apps/web/public/learning/graph-list-dark.gif
new file mode 100644
index 0000000..703e684
Binary files /dev/null and b/apps/web/public/learning/graph-list-dark.gif differ
diff --git a/apps/web/public/learning/graph-list-dark.png b/apps/web/public/learning/graph-list-dark.png
new file mode 100644
index 0000000..bf9b075
Binary files /dev/null and b/apps/web/public/learning/graph-list-dark.png differ
diff --git a/apps/web/public/learning/graph-list-light.gif b/apps/web/public/learning/graph-list-light.gif
new file mode 100644
index 0000000..5af2a6c
Binary files /dev/null and b/apps/web/public/learning/graph-list-light.gif differ
diff --git a/apps/web/public/learning/graph-list-light.png b/apps/web/public/learning/graph-list-light.png
new file mode 100644
index 0000000..d7bf1de
Binary files /dev/null and b/apps/web/public/learning/graph-list-light.png differ
diff --git a/apps/web/public/learning/graph-matrix-dark.gif b/apps/web/public/learning/graph-matrix-dark.gif
new file mode 100644
index 0000000..703e684
Binary files /dev/null and b/apps/web/public/learning/graph-matrix-dark.gif differ
diff --git a/apps/web/public/learning/graph-matrix-dark.png b/apps/web/public/learning/graph-matrix-dark.png
new file mode 100644
index 0000000..bf9b075
Binary files /dev/null and b/apps/web/public/learning/graph-matrix-dark.png differ
diff --git a/apps/web/public/learning/graph-matrix-light.gif b/apps/web/public/learning/graph-matrix-light.gif
new file mode 100644
index 0000000..5af2a6c
Binary files /dev/null and b/apps/web/public/learning/graph-matrix-light.gif differ
diff --git a/apps/web/public/learning/graph-matrix-light.png b/apps/web/public/learning/graph-matrix-light.png
new file mode 100644
index 0000000..d7bf1de
Binary files /dev/null and b/apps/web/public/learning/graph-matrix-light.png differ
diff --git a/apps/web/public/learning/greedy-dark.gif b/apps/web/public/learning/greedy-dark.gif
new file mode 100644
index 0000000..537f9c8
Binary files /dev/null and b/apps/web/public/learning/greedy-dark.gif differ
diff --git a/apps/web/public/learning/greedy-dark.png b/apps/web/public/learning/greedy-dark.png
new file mode 100644
index 0000000..70b3c13
Binary files /dev/null and b/apps/web/public/learning/greedy-dark.png differ
diff --git a/apps/web/public/learning/greedy-light.gif b/apps/web/public/learning/greedy-light.gif
new file mode 100644
index 0000000..cd254b4
Binary files /dev/null and b/apps/web/public/learning/greedy-light.gif differ
diff --git a/apps/web/public/learning/greedy-light.png b/apps/web/public/learning/greedy-light.png
new file mode 100644
index 0000000..0d7c990
Binary files /dev/null and b/apps/web/public/learning/greedy-light.png differ
diff --git a/apps/web/public/learning/hash-dark.gif b/apps/web/public/learning/hash-dark.gif
new file mode 100644
index 0000000..46700c5
Binary files /dev/null and b/apps/web/public/learning/hash-dark.gif differ
diff --git a/apps/web/public/learning/hash-dark.png b/apps/web/public/learning/hash-dark.png
new file mode 100644
index 0000000..0aa9898
Binary files /dev/null and b/apps/web/public/learning/hash-dark.png differ
diff --git a/apps/web/public/learning/hash-light.gif b/apps/web/public/learning/hash-light.gif
new file mode 100644
index 0000000..b9287a0
Binary files /dev/null and b/apps/web/public/learning/hash-light.gif differ
diff --git a/apps/web/public/learning/hash-light.png b/apps/web/public/learning/hash-light.png
new file mode 100644
index 0000000..077f7dc
Binary files /dev/null and b/apps/web/public/learning/hash-light.png differ
diff --git a/apps/web/public/learning/heap-dark.gif b/apps/web/public/learning/heap-dark.gif
new file mode 100644
index 0000000..dfc302d
Binary files /dev/null and b/apps/web/public/learning/heap-dark.gif differ
diff --git a/apps/web/public/learning/heap-dark.png b/apps/web/public/learning/heap-dark.png
new file mode 100644
index 0000000..da8fd8a
Binary files /dev/null and b/apps/web/public/learning/heap-dark.png differ
diff --git a/apps/web/public/learning/heap-light.gif b/apps/web/public/learning/heap-light.gif
new file mode 100644
index 0000000..7b59fc9
Binary files /dev/null and b/apps/web/public/learning/heap-light.gif differ
diff --git a/apps/web/public/learning/heap-light.png b/apps/web/public/learning/heap-light.png
new file mode 100644
index 0000000..a9b0240
Binary files /dev/null and b/apps/web/public/learning/heap-light.png differ
diff --git a/apps/web/public/learning/insertion-sort-dark.gif b/apps/web/public/learning/insertion-sort-dark.gif
new file mode 100644
index 0000000..31684f2
Binary files /dev/null and b/apps/web/public/learning/insertion-sort-dark.gif differ
diff --git a/apps/web/public/learning/insertion-sort-dark.png b/apps/web/public/learning/insertion-sort-dark.png
new file mode 100644
index 0000000..4db216a
Binary files /dev/null and b/apps/web/public/learning/insertion-sort-dark.png differ
diff --git a/apps/web/public/learning/insertion-sort-light.gif b/apps/web/public/learning/insertion-sort-light.gif
new file mode 100644
index 0000000..ed3b73b
Binary files /dev/null and b/apps/web/public/learning/insertion-sort-light.gif differ
diff --git a/apps/web/public/learning/insertion-sort-light.png b/apps/web/public/learning/insertion-sort-light.png
new file mode 100644
index 0000000..ecf6841
Binary files /dev/null and b/apps/web/public/learning/insertion-sort-light.png differ
diff --git a/apps/web/public/learning/kmp-dark.gif b/apps/web/public/learning/kmp-dark.gif
new file mode 100644
index 0000000..d458751
Binary files /dev/null and b/apps/web/public/learning/kmp-dark.gif differ
diff --git a/apps/web/public/learning/kmp-dark.png b/apps/web/public/learning/kmp-dark.png
new file mode 100644
index 0000000..e415c5e
Binary files /dev/null and b/apps/web/public/learning/kmp-dark.png differ
diff --git a/apps/web/public/learning/kmp-light.gif b/apps/web/public/learning/kmp-light.gif
new file mode 100644
index 0000000..403a28c
Binary files /dev/null and b/apps/web/public/learning/kmp-light.gif differ
diff --git a/apps/web/public/learning/kmp-light.png b/apps/web/public/learning/kmp-light.png
new file mode 100644
index 0000000..23f18ed
Binary files /dev/null and b/apps/web/public/learning/kmp-light.png differ
diff --git a/apps/web/public/learning/lca-dark.gif b/apps/web/public/learning/lca-dark.gif
new file mode 100644
index 0000000..af2d8c7
Binary files /dev/null and b/apps/web/public/learning/lca-dark.gif differ
diff --git a/apps/web/public/learning/lca-dark.png b/apps/web/public/learning/lca-dark.png
new file mode 100644
index 0000000..bbc679e
Binary files /dev/null and b/apps/web/public/learning/lca-dark.png differ
diff --git a/apps/web/public/learning/lca-light.gif b/apps/web/public/learning/lca-light.gif
new file mode 100644
index 0000000..b035cda
Binary files /dev/null and b/apps/web/public/learning/lca-light.gif differ
diff --git a/apps/web/public/learning/lca-light.png b/apps/web/public/learning/lca-light.png
new file mode 100644
index 0000000..ffece60
Binary files /dev/null and b/apps/web/public/learning/lca-light.png differ
diff --git a/apps/web/public/learning/linear-search-dark.gif b/apps/web/public/learning/linear-search-dark.gif
new file mode 100644
index 0000000..b738a3a
Binary files /dev/null and b/apps/web/public/learning/linear-search-dark.gif differ
diff --git a/apps/web/public/learning/linear-search-dark.png b/apps/web/public/learning/linear-search-dark.png
new file mode 100644
index 0000000..0f956ac
Binary files /dev/null and b/apps/web/public/learning/linear-search-dark.png differ
diff --git a/apps/web/public/learning/linear-search-light.gif b/apps/web/public/learning/linear-search-light.gif
new file mode 100644
index 0000000..4fac049
Binary files /dev/null and b/apps/web/public/learning/linear-search-light.gif differ
diff --git a/apps/web/public/learning/linear-search-light.png b/apps/web/public/learning/linear-search-light.png
new file mode 100644
index 0000000..d55f4bd
Binary files /dev/null and b/apps/web/public/learning/linear-search-light.png differ
diff --git a/apps/web/public/learning/linked-list-dark.gif b/apps/web/public/learning/linked-list-dark.gif
new file mode 100644
index 0000000..cadc580
Binary files /dev/null and b/apps/web/public/learning/linked-list-dark.gif differ
diff --git a/apps/web/public/learning/linked-list-dark.png b/apps/web/public/learning/linked-list-dark.png
new file mode 100644
index 0000000..83a5b14
Binary files /dev/null and b/apps/web/public/learning/linked-list-dark.png differ
diff --git a/apps/web/public/learning/linked-list-light.gif b/apps/web/public/learning/linked-list-light.gif
new file mode 100644
index 0000000..5533ccf
Binary files /dev/null and b/apps/web/public/learning/linked-list-light.gif differ
diff --git a/apps/web/public/learning/linked-list-light.png b/apps/web/public/learning/linked-list-light.png
new file mode 100644
index 0000000..82dd728
Binary files /dev/null and b/apps/web/public/learning/linked-list-light.png differ
diff --git a/apps/web/public/learning/memoization-dark.gif b/apps/web/public/learning/memoization-dark.gif
new file mode 100644
index 0000000..dce5b61
Binary files /dev/null and b/apps/web/public/learning/memoization-dark.gif differ
diff --git a/apps/web/public/learning/memoization-dark.png b/apps/web/public/learning/memoization-dark.png
new file mode 100644
index 0000000..9f9d618
Binary files /dev/null and b/apps/web/public/learning/memoization-dark.png differ
diff --git a/apps/web/public/learning/memoization-light.gif b/apps/web/public/learning/memoization-light.gif
new file mode 100644
index 0000000..ed3cc8e
Binary files /dev/null and b/apps/web/public/learning/memoization-light.gif differ
diff --git a/apps/web/public/learning/memoization-light.png b/apps/web/public/learning/memoization-light.png
new file mode 100644
index 0000000..f890017
Binary files /dev/null and b/apps/web/public/learning/memoization-light.png differ
diff --git a/apps/web/public/learning/merge-sort-dark.gif b/apps/web/public/learning/merge-sort-dark.gif
new file mode 100644
index 0000000..aa94030
Binary files /dev/null and b/apps/web/public/learning/merge-sort-dark.gif differ
diff --git a/apps/web/public/learning/merge-sort-dark.png b/apps/web/public/learning/merge-sort-dark.png
new file mode 100644
index 0000000..2df953d
Binary files /dev/null and b/apps/web/public/learning/merge-sort-dark.png differ
diff --git a/apps/web/public/learning/merge-sort-light.gif b/apps/web/public/learning/merge-sort-light.gif
new file mode 100644
index 0000000..000d940
Binary files /dev/null and b/apps/web/public/learning/merge-sort-light.gif differ
diff --git a/apps/web/public/learning/merge-sort-light.png b/apps/web/public/learning/merge-sort-light.png
new file mode 100644
index 0000000..9759130
Binary files /dev/null and b/apps/web/public/learning/merge-sort-light.png differ
diff --git a/apps/web/public/learning/mst-dark.gif b/apps/web/public/learning/mst-dark.gif
new file mode 100644
index 0000000..adda8e1
Binary files /dev/null and b/apps/web/public/learning/mst-dark.gif differ
diff --git a/apps/web/public/learning/mst-dark.png b/apps/web/public/learning/mst-dark.png
new file mode 100644
index 0000000..5d725cb
Binary files /dev/null and b/apps/web/public/learning/mst-dark.png differ
diff --git a/apps/web/public/learning/mst-light.gif b/apps/web/public/learning/mst-light.gif
new file mode 100644
index 0000000..4d1741e
Binary files /dev/null and b/apps/web/public/learning/mst-light.gif differ
diff --git a/apps/web/public/learning/mst-light.png b/apps/web/public/learning/mst-light.png
new file mode 100644
index 0000000..b54bf39
Binary files /dev/null and b/apps/web/public/learning/mst-light.png differ
diff --git a/apps/web/public/learning/prefix-sum-dark.gif b/apps/web/public/learning/prefix-sum-dark.gif
new file mode 100644
index 0000000..661f09e
Binary files /dev/null and b/apps/web/public/learning/prefix-sum-dark.gif differ
diff --git a/apps/web/public/learning/prefix-sum-dark.png b/apps/web/public/learning/prefix-sum-dark.png
new file mode 100644
index 0000000..0aa9898
Binary files /dev/null and b/apps/web/public/learning/prefix-sum-dark.png differ
diff --git a/apps/web/public/learning/prefix-sum-light.gif b/apps/web/public/learning/prefix-sum-light.gif
new file mode 100644
index 0000000..3c2416f
Binary files /dev/null and b/apps/web/public/learning/prefix-sum-light.gif differ
diff --git a/apps/web/public/learning/prefix-sum-light.png b/apps/web/public/learning/prefix-sum-light.png
new file mode 100644
index 0000000..077f7dc
Binary files /dev/null and b/apps/web/public/learning/prefix-sum-light.png differ
diff --git a/apps/web/public/learning/queue-dark.gif b/apps/web/public/learning/queue-dark.gif
new file mode 100644
index 0000000..504251d
Binary files /dev/null and b/apps/web/public/learning/queue-dark.gif differ
diff --git a/apps/web/public/learning/queue-dark.png b/apps/web/public/learning/queue-dark.png
new file mode 100644
index 0000000..a7d6980
Binary files /dev/null and b/apps/web/public/learning/queue-dark.png differ
diff --git a/apps/web/public/learning/queue-light.gif b/apps/web/public/learning/queue-light.gif
new file mode 100644
index 0000000..2c2c2ea
Binary files /dev/null and b/apps/web/public/learning/queue-light.gif differ
diff --git a/apps/web/public/learning/queue-light.png b/apps/web/public/learning/queue-light.png
new file mode 100644
index 0000000..d9685a4
Binary files /dev/null and b/apps/web/public/learning/queue-light.png differ
diff --git a/apps/web/public/learning/quick-sort-dark.gif b/apps/web/public/learning/quick-sort-dark.gif
new file mode 100644
index 0000000..396a590
Binary files /dev/null and b/apps/web/public/learning/quick-sort-dark.gif differ
diff --git a/apps/web/public/learning/quick-sort-dark.png b/apps/web/public/learning/quick-sort-dark.png
new file mode 100644
index 0000000..0b7a60c
Binary files /dev/null and b/apps/web/public/learning/quick-sort-dark.png differ
diff --git a/apps/web/public/learning/quick-sort-light.gif b/apps/web/public/learning/quick-sort-light.gif
new file mode 100644
index 0000000..a104c99
Binary files /dev/null and b/apps/web/public/learning/quick-sort-light.gif differ
diff --git a/apps/web/public/learning/quick-sort-light.png b/apps/web/public/learning/quick-sort-light.png
new file mode 100644
index 0000000..25f8099
Binary files /dev/null and b/apps/web/public/learning/quick-sort-light.png differ
diff --git a/apps/web/public/learning/recursion-dark.gif b/apps/web/public/learning/recursion-dark.gif
new file mode 100644
index 0000000..b00a6f3
Binary files /dev/null and b/apps/web/public/learning/recursion-dark.gif differ
diff --git a/apps/web/public/learning/recursion-dark.png b/apps/web/public/learning/recursion-dark.png
new file mode 100644
index 0000000..6bd85ac
Binary files /dev/null and b/apps/web/public/learning/recursion-dark.png differ
diff --git a/apps/web/public/learning/recursion-light.gif b/apps/web/public/learning/recursion-light.gif
new file mode 100644
index 0000000..12ecfc1
Binary files /dev/null and b/apps/web/public/learning/recursion-light.gif differ
diff --git a/apps/web/public/learning/recursion-light.png b/apps/web/public/learning/recursion-light.png
new file mode 100644
index 0000000..21f9b09
Binary files /dev/null and b/apps/web/public/learning/recursion-light.png differ
diff --git a/apps/web/public/learning/scc-dark.gif b/apps/web/public/learning/scc-dark.gif
new file mode 100644
index 0000000..00a6812
Binary files /dev/null and b/apps/web/public/learning/scc-dark.gif differ
diff --git a/apps/web/public/learning/scc-dark.png b/apps/web/public/learning/scc-dark.png
new file mode 100644
index 0000000..d547e37
Binary files /dev/null and b/apps/web/public/learning/scc-dark.png differ
diff --git a/apps/web/public/learning/scc-light.gif b/apps/web/public/learning/scc-light.gif
new file mode 100644
index 0000000..66df03a
Binary files /dev/null and b/apps/web/public/learning/scc-light.gif differ
diff --git a/apps/web/public/learning/scc-light.png b/apps/web/public/learning/scc-light.png
new file mode 100644
index 0000000..c50e57d
Binary files /dev/null and b/apps/web/public/learning/scc-light.png differ
diff --git a/apps/web/public/learning/segment-tree-dark.gif b/apps/web/public/learning/segment-tree-dark.gif
new file mode 100644
index 0000000..b33b9db
Binary files /dev/null and b/apps/web/public/learning/segment-tree-dark.gif differ
diff --git a/apps/web/public/learning/segment-tree-dark.png b/apps/web/public/learning/segment-tree-dark.png
new file mode 100644
index 0000000..9bec426
Binary files /dev/null and b/apps/web/public/learning/segment-tree-dark.png differ
diff --git a/apps/web/public/learning/segment-tree-light.gif b/apps/web/public/learning/segment-tree-light.gif
new file mode 100644
index 0000000..3ba18cc
Binary files /dev/null and b/apps/web/public/learning/segment-tree-light.gif differ
diff --git a/apps/web/public/learning/segment-tree-light.png b/apps/web/public/learning/segment-tree-light.png
new file mode 100644
index 0000000..2793d77
Binary files /dev/null and b/apps/web/public/learning/segment-tree-light.png differ
diff --git a/apps/web/public/learning/selection-sort-dark.gif b/apps/web/public/learning/selection-sort-dark.gif
new file mode 100644
index 0000000..02e7279
Binary files /dev/null and b/apps/web/public/learning/selection-sort-dark.gif differ
diff --git a/apps/web/public/learning/selection-sort-dark.png b/apps/web/public/learning/selection-sort-dark.png
new file mode 100644
index 0000000..570bbee
Binary files /dev/null and b/apps/web/public/learning/selection-sort-dark.png differ
diff --git a/apps/web/public/learning/selection-sort-light.gif b/apps/web/public/learning/selection-sort-light.gif
new file mode 100644
index 0000000..a21cd27
Binary files /dev/null and b/apps/web/public/learning/selection-sort-light.gif differ
diff --git a/apps/web/public/learning/selection-sort-light.png b/apps/web/public/learning/selection-sort-light.png
new file mode 100644
index 0000000..90bd160
Binary files /dev/null and b/apps/web/public/learning/selection-sort-light.png differ
diff --git a/apps/web/public/learning/sliding-window-dark.gif b/apps/web/public/learning/sliding-window-dark.gif
new file mode 100644
index 0000000..0bb59d8
Binary files /dev/null and b/apps/web/public/learning/sliding-window-dark.gif differ
diff --git a/apps/web/public/learning/sliding-window-dark.png b/apps/web/public/learning/sliding-window-dark.png
new file mode 100644
index 0000000..a2c0647
Binary files /dev/null and b/apps/web/public/learning/sliding-window-dark.png differ
diff --git a/apps/web/public/learning/sliding-window-light.gif b/apps/web/public/learning/sliding-window-light.gif
new file mode 100644
index 0000000..01a1cf9
Binary files /dev/null and b/apps/web/public/learning/sliding-window-light.gif differ
diff --git a/apps/web/public/learning/sliding-window-light.png b/apps/web/public/learning/sliding-window-light.png
new file mode 100644
index 0000000..7004d48
Binary files /dev/null and b/apps/web/public/learning/sliding-window-light.png differ
diff --git a/apps/web/public/learning/sorting-dark.gif b/apps/web/public/learning/sorting-dark.gif
new file mode 100644
index 0000000..dde586d
Binary files /dev/null and b/apps/web/public/learning/sorting-dark.gif differ
diff --git a/apps/web/public/learning/sorting-dark.png b/apps/web/public/learning/sorting-dark.png
new file mode 100644
index 0000000..4db216a
Binary files /dev/null and b/apps/web/public/learning/sorting-dark.png differ
diff --git a/apps/web/public/learning/sorting-light.gif b/apps/web/public/learning/sorting-light.gif
new file mode 100644
index 0000000..41ec8bc
Binary files /dev/null and b/apps/web/public/learning/sorting-light.gif differ
diff --git a/apps/web/public/learning/sorting-light.png b/apps/web/public/learning/sorting-light.png
new file mode 100644
index 0000000..ecf6841
Binary files /dev/null and b/apps/web/public/learning/sorting-light.png differ
diff --git a/apps/web/public/learning/stack-dark.gif b/apps/web/public/learning/stack-dark.gif
new file mode 100644
index 0000000..4bc1e10
Binary files /dev/null and b/apps/web/public/learning/stack-dark.gif differ
diff --git a/apps/web/public/learning/stack-dark.png b/apps/web/public/learning/stack-dark.png
new file mode 100644
index 0000000..a7d6980
Binary files /dev/null and b/apps/web/public/learning/stack-dark.png differ
diff --git a/apps/web/public/learning/stack-light.gif b/apps/web/public/learning/stack-light.gif
new file mode 100644
index 0000000..3a2d760
Binary files /dev/null and b/apps/web/public/learning/stack-light.gif differ
diff --git a/apps/web/public/learning/stack-light.png b/apps/web/public/learning/stack-light.png
new file mode 100644
index 0000000..d9685a4
Binary files /dev/null and b/apps/web/public/learning/stack-light.png differ
diff --git a/apps/web/public/learning/tabulation-dark.gif b/apps/web/public/learning/tabulation-dark.gif
new file mode 100644
index 0000000..3095ee9
Binary files /dev/null and b/apps/web/public/learning/tabulation-dark.gif differ
diff --git a/apps/web/public/learning/tabulation-dark.png b/apps/web/public/learning/tabulation-dark.png
new file mode 100644
index 0000000..cad3af1
Binary files /dev/null and b/apps/web/public/learning/tabulation-dark.png differ
diff --git a/apps/web/public/learning/tabulation-light.gif b/apps/web/public/learning/tabulation-light.gif
new file mode 100644
index 0000000..11f74ce
Binary files /dev/null and b/apps/web/public/learning/tabulation-light.gif differ
diff --git a/apps/web/public/learning/tabulation-light.png b/apps/web/public/learning/tabulation-light.png
new file mode 100644
index 0000000..fd1d981
Binary files /dev/null and b/apps/web/public/learning/tabulation-light.png differ
diff --git a/apps/web/public/learning/topological-sort-dark.gif b/apps/web/public/learning/topological-sort-dark.gif
new file mode 100644
index 0000000..59cf852
Binary files /dev/null and b/apps/web/public/learning/topological-sort-dark.gif differ
diff --git a/apps/web/public/learning/topological-sort-dark.png b/apps/web/public/learning/topological-sort-dark.png
new file mode 100644
index 0000000..ced50d5
Binary files /dev/null and b/apps/web/public/learning/topological-sort-dark.png differ
diff --git a/apps/web/public/learning/topological-sort-light.gif b/apps/web/public/learning/topological-sort-light.gif
new file mode 100644
index 0000000..f70d6bd
Binary files /dev/null and b/apps/web/public/learning/topological-sort-light.gif differ
diff --git a/apps/web/public/learning/topological-sort-light.png b/apps/web/public/learning/topological-sort-light.png
new file mode 100644
index 0000000..9b3f844
Binary files /dev/null and b/apps/web/public/learning/topological-sort-light.png differ
diff --git a/apps/web/public/learning/tree-dark.gif b/apps/web/public/learning/tree-dark.gif
new file mode 100644
index 0000000..a750be8
Binary files /dev/null and b/apps/web/public/learning/tree-dark.gif differ
diff --git a/apps/web/public/learning/tree-dark.png b/apps/web/public/learning/tree-dark.png
new file mode 100644
index 0000000..3093f87
Binary files /dev/null and b/apps/web/public/learning/tree-dark.png differ
diff --git a/apps/web/public/learning/tree-light.gif b/apps/web/public/learning/tree-light.gif
new file mode 100644
index 0000000..b91b31c
Binary files /dev/null and b/apps/web/public/learning/tree-light.gif differ
diff --git a/apps/web/public/learning/tree-light.png b/apps/web/public/learning/tree-light.png
new file mode 100644
index 0000000..3b070d5
Binary files /dev/null and b/apps/web/public/learning/tree-light.png differ
diff --git a/apps/web/public/learning/trie-dark.gif b/apps/web/public/learning/trie-dark.gif
new file mode 100644
index 0000000..206271e
Binary files /dev/null and b/apps/web/public/learning/trie-dark.gif differ
diff --git a/apps/web/public/learning/trie-dark.png b/apps/web/public/learning/trie-dark.png
new file mode 100644
index 0000000..517d599
Binary files /dev/null and b/apps/web/public/learning/trie-dark.png differ
diff --git a/apps/web/public/learning/trie-light.gif b/apps/web/public/learning/trie-light.gif
new file mode 100644
index 0000000..f4c85bd
Binary files /dev/null and b/apps/web/public/learning/trie-light.gif differ
diff --git a/apps/web/public/learning/trie-light.png b/apps/web/public/learning/trie-light.png
new file mode 100644
index 0000000..c2da289
Binary files /dev/null and b/apps/web/public/learning/trie-light.png differ
diff --git a/apps/web/public/learning/two-pointer-dark.gif b/apps/web/public/learning/two-pointer-dark.gif
new file mode 100644
index 0000000..f985526
Binary files /dev/null and b/apps/web/public/learning/two-pointer-dark.gif differ
diff --git a/apps/web/public/learning/two-pointer-dark.png b/apps/web/public/learning/two-pointer-dark.png
new file mode 100644
index 0000000..7bcf64e
Binary files /dev/null and b/apps/web/public/learning/two-pointer-dark.png differ
diff --git a/apps/web/public/learning/two-pointer-light.gif b/apps/web/public/learning/two-pointer-light.gif
new file mode 100644
index 0000000..8195565
Binary files /dev/null and b/apps/web/public/learning/two-pointer-light.gif differ
diff --git a/apps/web/public/learning/two-pointer-light.png b/apps/web/public/learning/two-pointer-light.png
new file mode 100644
index 0000000..e268917
Binary files /dev/null and b/apps/web/public/learning/two-pointer-light.png differ
diff --git a/apps/web/public/learning/union-find-dark.gif b/apps/web/public/learning/union-find-dark.gif
new file mode 100644
index 0000000..f69e143
Binary files /dev/null and b/apps/web/public/learning/union-find-dark.gif differ
diff --git a/apps/web/public/learning/union-find-dark.png b/apps/web/public/learning/union-find-dark.png
new file mode 100644
index 0000000..7c13764
Binary files /dev/null and b/apps/web/public/learning/union-find-dark.png differ
diff --git a/apps/web/public/learning/union-find-light.gif b/apps/web/public/learning/union-find-light.gif
new file mode 100644
index 0000000..d587ca5
Binary files /dev/null and b/apps/web/public/learning/union-find-light.gif differ
diff --git a/apps/web/public/learning/union-find-light.png b/apps/web/public/learning/union-find-light.png
new file mode 100644
index 0000000..85477d3
Binary files /dev/null and b/apps/web/public/learning/union-find-light.png differ
diff --git a/apps/web/src/AdminDashboard.tsx b/apps/web/src/AdminDashboard.tsx
index 86fb23f..009936d 100644
--- a/apps/web/src/AdminDashboard.tsx
+++ b/apps/web/src/AdminDashboard.tsx
@@ -25,9 +25,14 @@ const eventLabels: Record = {
COSTS_IMPORTED: "비용 가져오기",
RECORD_DELETED: "학습 기록 삭제",
};
-export function AdminDashboard() {
- const [tab, setTab] = useState("overview"),
- [from, setFrom] = useState(today().slice(0, 7) + "-01"),
+export function AdminDashboard({
+ section: tab,
+ onNavigate: setTab,
+}: {
+ section: string;
+ onNavigate: (section: string) => void;
+}) {
+ const [from, setFrom] = useState(today().slice(0, 7) + "-01"),
[to, setTo] = useState(today()),
[grain, setGrain] = useState("day"),
[query, setQuery] = useState(`from=${from}&to=${to}&grain=day`),
diff --git a/apps/web/src/DiagramCanvas.tsx b/apps/web/src/DiagramCanvas.tsx
index 8afca4f..9289115 100644
--- a/apps/web/src/DiagramCanvas.tsx
+++ b/apps/web/src/DiagramCanvas.tsx
@@ -59,12 +59,14 @@ export function DiagramCanvas({
nodes,
edges = [],
label,
- responsiveFit = false,
+ responsiveFit = true,
+ onSelect,
}: {
nodes: DiagramNode[];
edges?: DiagramEdge[];
label: string;
responsiveFit?: boolean;
+ onSelect?: (id: string) => void;
}) {
const [moved, setMoved] = useState>(
{},
@@ -75,6 +77,7 @@ export function DiagramCanvas({
svg = useRef(null),
viewport = useRef(null);
const marker = useId().replace(/:/g, "");
+ const pointerStart = useRef({ x: 0, y: 0 });
const positioned = nodes.map((n, i) => {
const defaultPos =
layout === "circle"
@@ -286,10 +289,22 @@ export function DiagramCanvas({
className="diagram-node"
role="button"
tabIndex={0}
- aria-label={`${n.label}${n.detail ? " · " + n.detail : ""} 이동`}
+ aria-label={`${n.label}${n.detail ? " · " + n.detail : ""} ${onSelect ? "선택" : "이동"}`}
+ aria-pressed={onSelect ? n.state === "active" : undefined}
+ onClick={(e) => {
+ if (
+ onSelect &&
+ Math.hypot(
+ e.clientX - pointerStart.current.x,
+ e.clientY - pointerStart.current.y,
+ ) < 6
+ )
+ onSelect(n.id);
+ }}
transform={`translate(${n.x} ${n.y})`}
data-node-id={n.id}
onPointerDown={(e) => {
+ pointerStart.current = { x: e.clientX, y: e.clientY };
const p = point(e);
if (!p) return;
e.currentTarget.setPointerCapture(e.pointerId);
@@ -323,6 +338,11 @@ export function DiagramCanvas({
drag.current = null;
}}
onKeyDown={(e) => {
+ if (onSelect && (e.key === "Enter" || e.key === " ")) {
+ e.preventDefault();
+ onSelect(n.id);
+ return;
+ }
const delta: Record = {
ArrowLeft: [-12, 0],
ArrowRight: [12, 0],
@@ -361,9 +381,16 @@ export function DiagramCanvas({
className={`node ${n.state ?? ""}`}
/>
) : (
-
+
)}
-
+
{n.label.length > 22 ? n.label.slice(0, 21) + "…" : n.label}
{n.detail && (
diff --git a/apps/web/src/LearningFlow.tsx b/apps/web/src/LearningFlow.tsx
new file mode 100644
index 0000000..60ee047
--- /dev/null
+++ b/apps/web/src/LearningFlow.tsx
@@ -0,0 +1,405 @@
+import React, { useEffect, useMemo, useState } from "react";
+import {
+ checkpoints,
+ matchesAnswer,
+ type LearningFrame,
+} from "./prediction-model.js";
+import { Structure } from "./visualization.js";
+import { ObjectGraph } from "./ObjectGraph.js";
+import {
+ DiagramCanvas,
+ graphPositions,
+ type DiagramNode,
+ type DiagramEdge,
+} from "./DiagramCanvas.js";
+import { go } from "./router.js";
+import { track } from "./analytics.js";
+
+export function LearningSteps({
+ topic,
+ stage,
+ mobile,
+}: {
+ topic: string;
+ stage: number;
+ mobile: boolean;
+}) {
+ return (
+
+ );
+}
+export function ExampleAnimation({
+ topic,
+ title,
+ theme,
+}: {
+ topic: string;
+ title: string;
+ theme: string;
+}) {
+ const [playing, setPlaying] = useState(
+ () => !matchMedia("(prefers-reduced-motion: reduce)").matches,
+ );
+ const [version, setVersion] = useState(0);
+ const mode = theme === "dark" ? "dark" : "light";
+ return (
+
+ 움직임으로 살펴보기
+ 예시 입력에서 상태가 바뀌는 과정을 살펴보세요.
+
+
+
+
+
+
+ 자동 재생을 끄면 첫 장면을 표시해요. 아래 단계별 예제에서 각 상태를
+ 자세히 확인할 수 있어요.
+
+
+ );
+}
+function StateDiagram({
+ frame,
+ topic,
+}: {
+ frame: LearningFrame;
+ topic: string;
+}) {
+ return frame.advanced ? (
+ <>
+
+
+
+
+ {frame.advanced.rows.map((r, i) => (
+
+ {r.map((c, j) => (
+ | {c} |
+ ))}
+
+ ))}
+
+
+
+ >
+ ) : (
+ <>
+
+
+
+ {Object.entries(frame.core!.vars)
+ .filter(
+ ([k, v]) =>
+ ![
+ "a",
+ "graph",
+ "matrix",
+ "values",
+ "head",
+ "root",
+ "buckets",
+ ].includes(k) &&
+ (!v || typeof v !== "object" || Array.isArray(v)),
+ )
+ .map(([k, v]) => (
+
+ - {k}
+ - {JSON.stringify(v)}
+
+ ))}
+
+ >
+ );
+}
+export function Prediction({
+ topic,
+ step,
+ userId,
+ mobile,
+}: {
+ topic: any;
+ step: number;
+ userId: string;
+ mobile: boolean;
+}) {
+ const questions = useMemo(() => checkpoints(topic.id), [topic.id]);
+ const q = questions[step - 1];
+ const storageKey = `prediction-v1:${userId}:${topic.id}:${step}`;
+ const [state, setState] = useState<{
+ answer: string;
+ result: "correct" | "wrong" | null;
+ }>(() => {
+ try {
+ return (
+ JSON.parse(sessionStorage.getItem(storageKey) ?? "null") ?? {
+ answer: "",
+ result: null,
+ }
+ );
+ } catch {
+ return { answer: "", result: null };
+ }
+ });
+ useEffect(() => {
+ try {
+ sessionStorage.setItem(storageKey, JSON.stringify(state));
+ } catch {}
+ }, [state, storageKey]);
+ useEffect(() => {
+ track("QUIZ_VIEWED", { topic: topic.id, index: step - 1 });
+ }, [topic.id, step]);
+ if (!q)
+ return (
+
+ 존재하지 않는 학습 단계예요
+
+
+ );
+ const label = q.next.advanced
+ ? q.field === "active"
+ ? "강조할 노드 ID 목록 (ID 오름차순)"
+ : `계산 표 ${Number(q.field.split(".")[1]) + 1}행`
+ : ({
+ a: "배열 / 자료구조의 값",
+ current: "다음 방문 정점",
+ index: "다음 방문 노드의 인덱스",
+ mid: "다음 비교 인덱스",
+ answer: "반환할 인덱스",
+ order: "방문 순서",
+ queue: "대기열",
+ stack: "호출 스택",
+ }[q.field] ?? q.field);
+ const change = (answer: string) => setState({ answer, result: null });
+ let answerNodes: DiagramNode[] = [],
+ answerEdges: DiagramEdge[] = [];
+ const vars = q.current.core?.vars ?? {};
+ if (q.field === "current" && vars.graph) {
+ answerNodes = graphPositions(vars.graph).map((p, i) => ({
+ ...p,
+ id: String(i),
+ label: String(i),
+ shape: "circle",
+ state: state.answer === String(i) ? "active" : "",
+ }));
+ answerEdges = vars.graph.flatMap((row: number[], i: number) =>
+ row.filter((j) => i < j).map((j) => ({ from: String(i), to: String(j) })),
+ );
+ } else if (topic.id === "tree" && q.choices) {
+ answerNodes = q.choices.map((c, i) => {
+ const depth = Math.floor(Math.log2(i + 1));
+ return {
+ id: String(i),
+ label: c.label.split(" · ")[0],
+ x: 80 + ((i - (2 ** depth - 1) + 0.5) * 600) / 2 ** depth,
+ y: 60 + depth * 100,
+ shape: "circle",
+ state: state.answer === String(i) ? "active" : "",
+ };
+ });
+ answerEdges = answerNodes
+ .slice(1)
+ .map((n) => ({
+ from: String(Math.floor((Number(n.id) - 1) / 2)),
+ to: n.id,
+ }));
+ }
+ const correct =
+ state.result === "correct" && matchesAnswer(state.answer, q.answer);
+ return (
+ <>
+
+
{topic.title} · 다음 상태를 예측해요
+
현재 상태를 관찰하고 다음 단계의 값을 직접 구성하세요.
+
+
+
+
+ 현재 상태 · {step} / {questions.length}
+
+ {q.current.note}
+
+ 다음 단계에서 바뀔 {label}을 예측해 보세요.
+
+
+ 내가 만드는 다음 상태
+ {label}은 어떻게 될까요?
+ {q.field === "active" && (
+
+ {q.current.advanced?.nodes
+ .map((n) => `${n.id} = ${n.label}`)
+ .join(" / ")}
+
+ )}
+ {answerNodes.length > 0 ? (
+
+ ) : q.choices ? (
+
+ {q.choices.map((c) => (
+
+ ))}
+
+ ) : (
+
+ )}
+
+ 내 답: {state.answer || "아직 선택하지 않았어요."}
+
+
+
+ {correct
+ ? "정답이에요!"
+ : state.result === "wrong"
+ ? "다시 생각해 보세요."
+ : "선택한 상태를 확인해 보세요."}
+
+
+ {correct
+ ? q.next.note
+ : state.result === "wrong"
+ ? "현재 값과 알고리즘의 처리 순서를 확인하고 답을 수정하세요."
+ : "정답 확인 전에는 다음 상태와 해설을 공개하지 않아요."}
+
+
+
+ {correct && (
+
+ 다음 상태 확인
+
+
+ )}
+
+
+
+
+
+
+
+
+ {step === questions.length &&
+ correct &&
+ (mobile ? (
+
+ ) : (
+
+ ))}
+
+ >
+ );
+}
diff --git a/apps/web/src/SupportPanel.tsx b/apps/web/src/SupportPanel.tsx
index 249eacb..32d93d6 100644
--- a/apps/web/src/SupportPanel.tsx
+++ b/apps/web/src/SupportPanel.tsx
@@ -1,5 +1,6 @@
import React, { useEffect, useRef, useState } from "react";
import { api, post } from "./api.js";
+import { go } from "./router.js";
export const ticketLabels: Record = {
OPEN: "접수",
IN_PROGRESS: "처리 중",
@@ -36,7 +37,13 @@ export function Pager({
);
}
-export function SupportPanel({ admin = false }: { admin?: boolean }) {
+export function SupportPanel({
+ admin = false,
+ inquiryId = "",
+}: {
+ admin?: boolean;
+ inquiryId?: string;
+}) {
const [data, setData] = useState(null),
[page, setPage] = useState(1),
[status, setStatus] = useState(""),
@@ -53,9 +60,13 @@ export function SupportPanel({ admin = false }: { admin?: boolean }) {
let current = true;
setData(null);
setError("");
- api(`${endpoint}?page=${page}${status ? "&status=" + status : ""}`)
+ api(
+ inquiryId
+ ? `/support/${inquiryId}`
+ : `${endpoint}?page=${page}${status ? "&status=" + status : ""}`,
+ )
.then((d) => {
- if (current) setData(d);
+ if (current) setData(inquiryId ? { rows: [d], hasMore: false } : d);
})
.catch((e) => {
if (current) setError(e.message);
@@ -63,9 +74,14 @@ export function SupportPanel({ admin = false }: { admin?: boolean }) {
return () => {
current = false;
};
- }, [endpoint, page, status, revision]);
+ }, [endpoint, page, status, revision, inquiryId]);
return (
+ {inquiryId && (
+
+ )}
@@ -92,7 +108,7 @@ export function SupportPanel({ admin = false }: { admin?: boolean }) {
{notice}
)}
- {!admin && (
+ {!admin && !inquiryId && (
@@ -1018,107 +1199,19 @@ function App() {
window.scrollTo(0, 0);
}}
>
- 이해했어요. 퀴즈 풀기
+ 다음 상태 예측하기
>
)}
{actualPage === "practice" && topic && (
- <>
-
-
-
- {learningCategory(topic)} · 이해 확인
-
-
{topic.title} 이해 확인
-
퀴즈로 배운 내용을 확인하세요.
-
-
-
-
- {!mobile && (
-
-
코드로 적용해 볼 준비가 됐다면 다음 화면으로 이동하세요.
-
-
- )}
- >
- )}
- {actualPage === "challenges" && topic && !mobile && (
- <>
-
-
-
- {learningCategory(topic)} · 코드 연습
-
-
{topic.title}
-
-
- 03 · 적용하기
- 이해했다면, 직접 풀어볼까요?
- 문제를 선택하고 배운 개념을 코드로 구현해 보세요.
- {relatedProblems.map((p) => (
-
- {p.title}
- {p.statement}
-
- 공개 테스트 {p.tests.length}개 · Python / C++ / Java
-
-
-
- ))}
- {relatedProblems.length === 0 && (
- <>
-
- 이 개념의 코드 작성 문제는 아직 준비 중이에요. 예제와 퀴즈로
- 학습하거나 다른 문제를 살펴볼 수 있어요.
-
-
- >
- )}
-
- >
+
)}
{actualPage === "problems" && (
<>
@@ -1168,7 +1261,7 @@ function App() {
>
)}
- {actualPage === "workspace" && record && problem && (
+ {actualPage === "workspace" && workspaceReady && record && problem && (
<>
@@ -1508,35 +1601,54 @@ function App() {
KST 매월 1일 초기화 · 이월 없음 · 추가 구매는 가격 확정 후
제공
- {record.reviews?.map((r: any) => (
-
- {r.status}
-
- {new Date(r.created_at).toLocaleString("ko-KR")}
-
- {r.status === "FAILED" && (
-
- 리뷰를 완료하지 못했어요. 이용량은 차감되지 않았어요.
-
- )}
- {r.result && (
- <>
- 논리 오류
- {r.result.logicalErrors}
- 효율 개선
-
- {r.result.efficiencyImprovements}
+ {route.reviewId &&
+ !record.reviews?.some(
+ (r: any) => r.id === route.reviewId,
+ ) &&
리뷰를 찾을 수 없어요.
}
+ {record.reviews
+ ?.filter(
+ (r: any) => !route.reviewId || r.id === route.reviewId,
+ )
+ .map((r: any) => (
+
+
+ {r.status}
+
+ {new Date(r.created_at).toLocaleString("ko-KR")}
+
+ {r.status === "FAILED" && (
+
+ 리뷰를 완료하지 못했어요. 이용량은 차감되지
+ 않았어요.
- 대안 코드
- {r.result.alternativeCode}
- >
- )}
-
- 리뷰 당시 코드 · {labels[r.language]}
- {r.source}
-
-
- ))}
+ )}
+ {r.result && (
+ <>
+ 논리 오류
+ {r.result.logicalErrors}
+ 효율 개선
+
+ {r.result.efficiencyImprovements}
+
+ 대안 코드
+ {r.result.alternativeCode}
+ >
+ )}
+
+
+ 리뷰 당시 코드 · {labels[r.language]}
+
+ {r.source}
+
+
+ ))}
>
)}
@@ -1627,7 +1739,6 @@ function App() {
);
}
-function Structure({ vars, topic }: { vars: any; topic: string }) {
+export function Structure({ vars, topic }: { vars: any; topic: string }) {
const matrix = topic === "graph-matrix" ? vars.matrix : vars.buckets;
if (Array.isArray(matrix))
return (
@@ -266,7 +266,7 @@ function Structure({ vars, topic }: { vars: any; topic: string }) {
/>
{topic === "tree" ? "중위 순회" : "힙 배열"}{" "}
- {JSON.stringify(vars.order ?? values)}
+ {JSON.stringify(vars.order ?? (topic === "tree" ? [] : values))}
{vars.values.length > 31 && (
처음 31개 노드를 표시해요. 전체 값은 변수 보기에서 확인하세요.
diff --git a/docs/learning-flow-v2.md b/docs/learning-flow-v2.md
new file mode 100644
index 0000000..cebcc69
--- /dev/null
+++ b/docs/learning-flow-v2.md
@@ -0,0 +1,25 @@
+# 주소 기반 3단계 학습
+
+Figma 기준: [08 · Learning flow v2](https://www.figma.com/design/lPDjWigwlNvT2oAjAjxoyx?node-id=77-5616).
+
+| 화면 | 주소 | 동작 |
+| --- | --- | --- |
+| 학습 목록 | `/`, `/learn` | 종류별 47개 개념 |
+| 1. 개념 | `/learn/:topicId/concept` | 개념·용도, 라이트/다크 GIF, 상세 단계별 예제 |
+| 2. 예측 | `/learn/:topicId/predict/:step` | 현재 상태와 다음 상태의 핵심 값/위치/계산 표를 비교하고 답 제출 |
+| 3. 코드 | `/learn/:topicId/code` | 해당 문제의 기존 기록 재사용 또는 첫 기록 자동 생성 |
+| 문제 | `/problems`, `/problems/:problemId` | 목록 및 바로 코드 작성 |
+| 기록 | `/records`, `/records/:recordId` | 저장된 코드·실행·리뷰 |
+| 리뷰 상세 | `/records/:recordId/reviews/:reviewId` | 리뷰 시점의 코드와 결과 |
+| 문의 | `/support`, `/support/:inquiryId` | 본인 문의 목록 및 상세 |
+| 관리자 | `/admin`, `/admin/:section` | overview / finance / users / support / events |
+
+HTML 진입점과 React 앱은 공유하되 브라우저 History API와 URL로 화면을 구분한다. 서버는 HTML 페이지 직접 진입을 index.html로 연결하며 존재하지 않는 API와 자산은 404로 유지한다. 로그인 전 주소를 보존하고, 알려지지 않은 화면에는 복귀 CTA를 제공한다.
+
+예측은 기존 알고리즘의 결정적 계산 결과를 사용한다. 47개 개념에 452개 체크포인트가 있으며 LLM을 사용하지 않는다. 이진 탐색은 배열 위치, 그래프·트리는 정점 선택, 나머지는 다음 계산 값·순서·표의 행을 입력한다. 배치 이동 자체는 답으로 취급하지 않는다. 틀린 답은 다음 단계 이동을 막으며 수정 후 재시도할 수 있다. 답과 피드백은 사용자·개념·단계별 sessionStorage에 저장하여 같은 탭의 새로고침과 이전/다음 이동에 복원한다. 브라우저를 닫은 뒤의 진도 동기화는 제공하지 않는다.
+
+코드 기록 ID는 URL에 보존한다. 새로고침이나 뒤로가기로 기록을 추가 생성하지 않으며 화면 이동 시 편집 내용을 저장한다. 직접 코드 주소로 접근할 수도 있다. 모바일 웹과 Android는 개념·예측만 제공하고 코드 주소 접근으로 기록을 만들지 않는다.
+
+GIF는 `scripts/export-learning-frames.ts`로 실제 예제 계산을 내보내고 `scripts/render-learning-gifs.py`로 생성한다. 47개 × 2테마의 GIF와 첫 장면 PNG가 약 5MB이며 선택한 개념의 파일만 브라우저에서 요청한다. GIF 자동 재생을 끄면 첫 장면 PNG를 표시한다. 프레임 단위 일시정지·앞뒤 이동은 아래 상세 예제를 사용한다. OS 동작 줄이기 설정에서는 PNG로 시작한다.
+
+`PREDICTION_CHECKED` 이벤트는 개념 ID·단계·정오답만 수집하며 기존 GA4/Amplitude 전송 경로를 사용한다. 작성한 답·코드·문의 내용은 이 이벤트에 포함하지 않는다. 브라우저가 판정한 학습 이벤트이므로 시험 성적이나 결제 근거로 사용하지 않는다.
diff --git a/scripts/export-learning-frames.ts b/scripts/export-learning-frames.ts
new file mode 100644
index 0000000..2078d85
--- /dev/null
+++ b/scripts/export-learning-frames.ts
@@ -0,0 +1,28 @@
+import { writeFileSync, mkdirSync } from "node:fs";
+import { conceptUses } from "../apps/web/src/concept-uses.js";
+import {
+ learningFrames,
+ checkpoints,
+} from "../apps/web/src/prediction-model.js";
+mkdirSync("tmp/learning-v2", { recursive: true });
+const data = Object.keys(conceptUses).map((topic) => ({
+ topic,
+ frames: learningFrames(topic),
+ checkpoints: checkpoints(topic).length,
+}));
+if (data.some((x) => !x.frames.length || !x.checkpoints))
+ throw new Error(
+ JSON.stringify(
+ data
+ .filter((x) => !x.frames.length || !x.checkpoints)
+ .map((x) => ({
+ topic: x.topic,
+ frames: x.frames.length,
+ checks: x.checkpoints,
+ })),
+ ),
+ );
+writeFileSync("tmp/learning-v2/frames.json", JSON.stringify(data));
+console.log(
+ `${data.length} topics, ${data.reduce((sum, x) => sum + x.checkpoints, 0)} prediction checkpoints`,
+);
diff --git a/scripts/render-learning-gifs.py b/scripts/render-learning-gifs.py
new file mode 100644
index 0000000..c81a9d3
--- /dev/null
+++ b/scripts/render-learning-gifs.py
@@ -0,0 +1,68 @@
+"""Render deterministic algorithm traces exported by export-learning-frames.ts.
+Requires Pillow. No user code or external image input is involved.
+"""
+import json, math
+from pathlib import Path
+from PIL import Image, ImageDraw, ImageFont
+
+root=Path(__file__).resolve().parents[1]
+data=json.loads((root/'tmp/learning-v2/frames.json').read_text(encoding='utf-8'))
+out=root/'apps/web/public/learning';out.mkdir(parents=True,exist_ok=True)
+font=ImageFont.truetype('C:/Windows/Fonts/malgun.ttf',20)
+small=ImageFont.truetype('C:/Windows/Fonts/malgun.ttf',16)
+def wrap(s,n=48):
+ return '\n'.join(str(s)[i:i+n] for i in range(0,len(str(s)),n))
+for item in data:
+ for mode in ['light','dark']:
+ bg,fg,line,accent,tint,on=('#ffffff','#18213a','#dce1ec','#3159dd','#eaf0ff','#ffffff') if mode=='light' else ('#192133','#edf1fa','#35435d','#9bb4ff','#22365f','#101522')
+ images=[]
+ # Keep every state: playback and manual examples come from the same computation.
+ for index,frame in enumerate(item['frames']):
+ im=Image.new('RGB',(960,600),bg);d=ImageDraw.Draw(im)
+ d.text((28,18),f'{index+1} / {len(item["frames"])}',font=small,fill=accent)
+ d.multiline_text((28,46),wrap(frame['note']),font=font,fill=fg,spacing=5)
+ nodes=[];edges=[];rows=[]
+ if frame.get('advanced'):
+ a=frame['advanced'];nodes=a['nodes'];edges=a['edges'];rows=a['rows']
+ else:
+ v=frame['core']['vars'];a=v.get('values',v.get('a',[]))
+ if 'graph' in v:
+ n=len(v['graph'])
+ nodes=[dict(id=str(i),label=str(i),x=400+240*math.cos(i*2*math.pi/n-math.pi/2),y=230+180*math.sin(i*2*math.pi/n-math.pi/2),shape='circle',state='active' if i==v.get('current') else '') for i in range(n)]
+ edges=[dict(**{'from':str(i),'to':str(j)}) for i,row in enumerate(v['graph']) for j in row if i
{
+ await login(page);
+ await page.goto("/learn/binary-search/concept");
+ await expect(
+ page.getByRole("heading", { name: "기본 개념", exact: true }),
+ ).toBeVisible();
+ await expect(page.locator(".example-animation img")).toHaveJSProperty(
+ "naturalWidth",
+ 960,
+ );
+ await expect(
+ page.getByRole("heading", { name: "내가 만드는 다음 상태" }),
+ ).toHaveCount(0);
+ await page
+ .getByRole("button", { name: "다음 상태 예측하기", exact: true })
+ .click();
+ await expect(page).toHaveURL(/\/learn\/binary-search\/predict\/1$/);
+ await expect(
+ page.getByRole("button", { name: "정답 확인", exact: true }),
+ ).toBeDisabled();
+ await page.getByRole("button", { name: "2 · 인덱스 0", exact: true }).click();
+ await page.getByRole("button", { name: "정답 확인", exact: true }).click();
+ await expect(
+ page.getByText("다시 생각해 보세요.", { exact: true }),
+ ).toBeVisible();
+ await expect(
+ page.getByRole("button", { name: "다음 단계", exact: true }),
+ ).toBeDisabled();
+ await page
+ .getByRole("button", { name: "34 · 인덱스 5", exact: true })
+ .click();
+ await page.getByRole("button", { name: "정답 확인", exact: true }).click();
+ await expect(page.getByText("정답이에요!", { exact: true })).toBeVisible();
+ await page.getByRole("button", { name: "다음 단계", exact: true }).click();
+ await expect(page).toHaveURL(/predict\/2$/);
+ await page.goBack();
+ await expect(page).toHaveURL(/predict\/1$/);
+ await expect(page.getByText("정답이에요!", { exact: true })).toBeVisible();
+ await page.reload();
+ await expect(page.getByText("정답이에요!", { exact: true })).toBeVisible();
+ await page.goForward();
+ await expect(page).toHaveURL(/predict\/2$/);
+ await page.getByRole("link", { name: "03 코드 작성" }).click();
+ await expect(page.getByText("main.py", { exact: true })).toBeVisible();
+ await expect(page).toHaveURL(/\/learn\/binary-search\/code\?record=/);
+ const url = page.url();
+ const before = (await (await page.request.get("/api/records")).json()).records
+ .length;
+ page.once("dialog", (dialog) => dialog.accept());
+ await page.getByRole("button", { name: "권장 코드 불러오기" }).click();
+ await page.goBack();
+ await expect(page).toHaveURL(/predict\/2$/);
+ await page.goForward();
+ await expect(page.getByText("main.py", { exact: true })).toBeVisible();
+ const saved = await (
+ await page.request.get(
+ `/api/records/${new URL(page.url()).searchParams.get("record")}`,
+ )
+ ).json();
+ expect(saved.source).toContain("while left <= right");
+ await page.reload();
+ await expect(page.getByText("main.py", { exact: true })).toBeVisible();
+ expect(page.url()).toBe(url);
+ expect(
+ (await (await page.request.get("/api/records")).json()).records.length,
+ ).toBe(before);
+ await page.getByRole("link", { name: "01 개념 이해" }).click();
+ await page.goBack();
+ await expect(page.getByText("main.py", { exact: true })).toBeVisible();
+});
+test("graph and tree nodes accept mouse and keyboard answers; advanced state fields can be checked", async ({
+ page,
+}) => {
+ await login(page);
+ for (const topic of [
+ "bfs",
+ "tree",
+ "lca",
+ "geometry",
+ "fft",
+ "segment-tree",
+ ]) {
+ const q = checkpoints(topic)[0];
+ await page.goto(`/learn/${topic}/predict/1`);
+ if (["bfs", "tree"].includes(topic)) {
+ const node = page.locator(
+ `[aria-label="다음 상태 선택"] .diagram-node[data-node-id="${q.answer}"]`,
+ );
+ await node.focus();
+ await node.press("Enter");
+ } else
+ await page
+ .getByRole("textbox", { name: "다음 상태의 값" })
+ .fill(answerText(q.answer));
+ await page.getByRole("button", { name: "정답 확인", exact: true }).click();
+ await expect(page.getByText("정답이에요!", { exact: true })).toBeVisible();
+ for (const width of [1440, 412]) {
+ await page.setViewportSize({ width, height: 1000 });
+ expect(
+ await page.evaluate(
+ () => document.documentElement.scrollWidth <= innerWidth,
+ ),
+ ).toBe(true);
+ }
+ await page.screenshot({
+ path: `test-results/prediction-${topic}.png`,
+ fullPage: true,
+ });
+ }
+});
+test("all prediction steps are solvable without leaking next-state explanations; mobile never creates a code record", async ({
+ page,
+}) => {
+ await login(page);
+ await page.setViewportSize({ width: 412, height: 900 });
+ const questions = checkpoints("binary-search");
+ await page.goto("/learn/binary-search/predict/1");
+ for (let i = 0; i < questions.length; i++) {
+ const q = questions[i];
+ await page
+ .getByRole("button", {
+ name: q.choices!.find((c) => c.value === q.answer)!.label,
+ exact: true,
+ })
+ .click();
+ await page.getByRole("button", { name: "정답 확인", exact: true }).click();
+ await expect(page.getByText("정답이에요!", { exact: true })).toBeVisible();
+ if (i < questions.length - 1)
+ await page
+ .getByRole("button", { name: "다음 단계", exact: true })
+ .click();
+ }
+ await expect(
+ page.getByRole("button", { name: "학습 완료 · 목록으로" }),
+ ).toBeVisible();
+ await expect(page.getByRole("link", { name: "03 코드 작성" })).toHaveCount(0);
+ const before = (await (await page.request.get("/api/records")).json()).records
+ .length;
+ await page.goto("/learn/binary-search/code");
+ await expect(
+ page.getByRole("heading", { name: "코드 작성은 PC 웹에서 이용해 주세요" }),
+ ).toBeVisible();
+ expect(
+ (await (await page.request.get("/api/records")).json()).records.length,
+ ).toBe(before);
+});
+test("unknown paths and invalid topics have recovery; authentication preserves deep links", async ({
+ page,
+}) => {
+ await page.goto("/learn/bfs/predict/2");
+ await expect(
+ page.getByText("Google 로그인이 아직 연결되지 않았어요."),
+ ).toBeVisible();
+ await login(page);
+ await page.reload();
+ await expect(
+ page.getByRole("heading", { name: /현재 상태 · 2/ }),
+ ).toBeVisible();
+ for (const path of [
+ "/missing",
+ "/learn/missing/concept",
+ "/learn/bfs/predict/0",
+ ]) {
+ await page.goto(path);
+ await expect(
+ page.getByRole("heading", { name: "페이지를 찾을 수 없어요" }),
+ ).toBeVisible();
+ }
+ await page.goto("/learn/bfs/predict/999");
+ await expect(
+ page.getByRole("heading", { name: "존재하지 않는 학습 단계예요" }),
+ ).toBeVisible();
+});
diff --git a/tests/browser/platform.spec.ts b/tests/browser/platform.spec.ts
index ca8dca2..4498be6 100644
--- a/tests/browser/platform.spec.ts
+++ b/tests/browser/platform.spec.ts
@@ -155,19 +155,6 @@ test("expanded quizzes and movable diagrams retain positions across steps", asyn
await authenticate(page);
await page.goto("/");
await page.getByRole("button", { name: /너비 우선 탐색/ }).click();
- await expect(page.getByText(/문항 1 \/ 3/)).toHaveCount(0);
- await page
- .getByRole("button", { name: "이해했어요. 퀴즈 풀기", exact: true })
- .click();
- await expect(page.getByText(/문항 1 \/ 3/)).toBeVisible();
- await page.getByRole("button", { name: "다음 문항", exact: true }).click();
- await expect(page.getByText(/0의 이웃이/)).toBeVisible();
- await page.getByRole("button", { name: "1 [0,1,2,3]", exact: true }).click();
- await page.getByRole("button", { name: "정답 확인", exact: true }).click();
- await expect(page.getByText("정답이에요!", { exact: true })).toBeVisible();
- await page
- .getByRole("button", { name: "개념과 예제로 돌아가기", exact: true })
- .click();
await page.getByLabel("예제 선택", { exact: true }).selectOption("1");
const node = page.locator('.diagram-node[data-node-id="0"]').first();
await node.scrollIntoViewIfNeeded();
@@ -222,124 +209,11 @@ test("login gate, theme and setup state", async ({ page }) => {
fullPage: true,
});
});
-test("learning, quizzes and graph/tree explanatory diagrams", async ({
- page,
-}) => {
- await authenticate(page);
- await page.goto("/");
- await expect(
- page.getByRole("heading", { name: "개념별 학습" }),
- ).toBeVisible();
- await page.getByRole("button", { name: /너비 우선 탐색/ }).click();
- await expect(
- page.getByRole("img", { name: "그래프의 현재 방문 상태" }),
- ).toBeVisible();
- await page.getByRole("button", { name: "다음 단계" }).click();
- await page
- .getByRole("button", { name: "이해했어요. 퀴즈 풀기", exact: true })
- .click();
- await page.getByRole("button", { name: "2 큐에 넣을 때" }).click();
- await page.getByRole("button", { name: "정답 확인" }).click();
- await expect(page.getByText("정답이에요!")).toBeVisible();
- await page.screenshot({
- path: "test-results/graph-lesson.png",
- fullPage: true,
- });
- await page
- .getByRole("button", { name: "개념과 예제로 돌아가기", exact: true })
- .click();
- await page.getByRole("button", { name: "학습 목록" }).click();
- await page.getByRole("button", { name: /이진 트리 순회/ }).click();
- await expect(
- page.getByRole("img", { name: "이진 트리와 현재 노드" }),
- ).toBeVisible();
-});
test("workspace autosaves and queues a real request without fabricated execution", async ({
page,
}) => {
await authenticate(page);
- await page.goto("/");
- await page.getByRole("button", { name: "문제 풀기", exact: true }).click();
- const before = (await (await page.request.get("/api/records")).json()).records
- .length;
- await page
- .getByRole("article")
- .filter({ hasText: "정렬된 배열에서 값 찾기" })
- .getByRole("button", { name: "개념과 예제 보기" })
- .click();
- await expect(
- page.getByRole("heading", { name: "기본 개념", exact: true }),
- ).toBeVisible();
- await expect(
- page.getByRole("heading", { name: "개념을 한 단계씩", exact: true }),
- ).toBeVisible();
- await expect(page.getByText("main.py", { exact: true })).toHaveCount(0);
- expect(
- (await (await page.request.get("/api/records")).json()).records.length,
- ).toBe(before);
- const concept = await page.locator(".lesson-layout .prose").boundingBox(),
- example = await page.locator(".lesson-example-column").boundingBox();
- expect(concept!.x + concept!.width).toBeLessThan(example!.x);
- expect(Math.abs(concept!.y - example!.y)).toBeLessThan(2);
- await page.screenshot({
- path: "test-results/learning-before-coding.png",
- fullPage: true,
- });
- await page
- .getByRole("button", { name: "이해했어요. 퀴즈 풀기", exact: true })
- .click();
- await expect(
- page.getByRole("heading", { name: "기본 개념", exact: true }),
- ).toHaveCount(0);
- await expect(page.getByLabel("예제 선택", { exact: true })).toHaveCount(0);
- await expect(
- page.getByRole("heading", { name: "이진 탐색 이해 확인", exact: true }),
- ).toBeVisible();
- expect(
- (await (await page.request.get("/api/records")).json()).records.length,
- ).toBe(before);
- await page.screenshot({
- path: "test-results/separate-practice-screen.png",
- fullPage: true,
- });
- await expect(
- page.getByRole("heading", { name: "잠깐, 이해했나요?", exact: true }),
- ).toBeVisible();
- await expect(
- page.getByRole("heading", {
- name: "이해했다면, 직접 풀어볼까요?",
- exact: true,
- }),
- ).toHaveCount(0);
- await expect(
- page.getByRole("button", { name: "코드로 풀기", exact: true }),
- ).toHaveCount(0);
- await page
- .getByRole("button", { name: "직접 풀어보기", exact: true })
- .click();
- await expect(
- page.getByRole("heading", { name: "잠깐, 이해했나요?", exact: true }),
- ).toHaveCount(0);
- await expect(
- page.getByRole("heading", {
- name: "이해했다면, 직접 풀어볼까요?",
- exact: true,
- }),
- ).toBeVisible();
- await page.screenshot({
- path: "test-results/separate-coding-screen.png",
- fullPage: true,
- });
- await page
- .getByRole("button", { name: "이해 확인으로 돌아가기", exact: true })
- .click();
- await expect(
- page.getByRole("heading", { name: "잠깐, 이해했나요?", exact: true }),
- ).toBeVisible();
- await page
- .getByRole("button", { name: "직접 풀어보기", exact: true })
- .click();
- await page.getByRole("button", { name: "코드로 풀기", exact: true }).click();
+ await page.goto("/learn/binary-search/code");
await expect(page.getByText("main.py", { exact: true })).toBeVisible();
page.once("dialog", (dialog) => dialog.accept());
await page.getByRole("button", { name: "권장 코드 불러오기" }).click();
@@ -397,30 +271,9 @@ test("home groups all topics by type without priority labels and supports search
await expect(
page.getByRole("button", { name: "다른 문제 살펴보기" }),
).toHaveCount(0);
- await page
- .getByRole("button", { name: "이해했어요. 퀴즈 풀기", exact: true })
- .click();
- await page
- .getByRole("button", { name: "직접 풀어보기", exact: true })
- .click();
- await expect(
- page.getByRole("button", { name: "다른 문제 살펴보기" }),
- ).toHaveCount(0);
- await expect(
- page.getByText("FFT로 다항식 곱하기", { exact: true }),
- ).toBeVisible();
- await expect(
- page.getByRole("button", { name: "코드로 풀기", exact: true }),
- ).toBeVisible();
- await expect(
- page.getByRole("button", { name: "이해했어요. 퀴즈 풀기" }),
- ).toHaveCount(0);
- await page
- .getByRole("button", { name: "이해 확인으로 돌아가기", exact: true })
- .click();
- await page
- .getByRole("button", { name: "개념과 예제로 돌아가기", exact: true })
- .click();
+ await page.getByRole("link", { name: "03 코드 작성" }).click();
+ await expect(page.getByText("main.py", { exact: true })).toBeVisible();
+ await page.getByRole("link", { name: "01 개념 이해" }).click();
await page.setViewportSize({ width: 390, height: 844 });
await expect(
page.getByRole("region", { name: "개념에서 문제로" }),
@@ -438,9 +291,9 @@ test("home groups all topics by type without priority labels and supports search
fullPage: true,
});
await page
- .getByRole("button", { name: "이해했어요. 퀴즈 풀기", exact: true })
+ .getByRole("button", { name: "다음 상태 예측하기", exact: true })
.click();
- await expect(page.getByText(/문항 1 \/ 3/)).toBeVisible();
+ await expect(page.getByRole("heading", { name: /현재 상태/ })).toBeVisible();
await expect(
page.getByRole("button", { name: "코드로 풀기", exact: true }),
).toHaveCount(0);
@@ -477,20 +330,16 @@ test("every concept includes a concrete purpose and application before the quiz
page.getByRole("heading", { name: "잠깐, 이해했나요?", exact: true }),
).toHaveCount(0);
await page
- .getByRole("button", { name: "이해했어요. 퀴즈 풀기", exact: true })
- .click();
- await page
- .getByRole("button", { name: "직접 풀어보기", exact: true })
+ .getByRole("button", { name: "다음 상태 예측하기", exact: true })
.click();
await expect(
- page.getByRole("button", { name: "코드로 풀기", exact: true }),
+ page.getByRole("heading", { name: /현재 상태/ }),
).toBeVisible();
- await expect(page.getByText(/연습 문제를 준비 중/)).toHaveCount(0);
- await page
- .getByRole("button", { name: "이해 확인으로 돌아가기", exact: true })
- .click();
+ await expect(
+ page.getByRole("link", { name: "03 코드 작성" }),
+ ).toHaveAttribute("href", `/learn/${topic.id}/code`);
await page
- .getByRole("button", { name: "개념과 예제로 돌아가기", exact: true })
+ .getByRole("button", { name: "개념 설명으로", exact: true })
.click();
await page.getByRole("button", { name: "학습 목록", exact: true }).click();
}
@@ -523,7 +372,7 @@ test("mobile offers lessons and quizzes, not coding navigation", async ({
).toBeVisible();
await mobileNav.getByRole("button", { name: "퀴즈", exact: true }).click();
await expect(
- page.getByRole("heading", { name: "잠깐, 이해했나요?", exact: true }),
+ page.getByRole("heading", { name: /다음 상태를 예측해요/ }),
).toBeVisible();
await mobileNav.getByRole("button", { name: "홈", exact: true }).click();
await expect(
diff --git a/tests/e2e-server.ts b/tests/e2e-server.ts
index 8919fb3..edd16de 100644
--- a/tests/e2e-server.ts
+++ b/tests/e2e-server.ts
@@ -12,6 +12,7 @@ const app = await createApp({
sessionSecret: "e2e-only",
runnerToken: "e2e-runner-secret",
llmEnabled: false,
+ testRateLimit: 10000,
adminEmails: ["admin@example.test"],
verifyGoogle: async (token) => {
if (token === "integration-admin-token")