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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions apps/server/src/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 18 additions & 3 deletions apps/server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }>;
Expand Down Expand Up @@ -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),
);
Expand All @@ -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);
Expand Down Expand Up @@ -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()");
Expand Down Expand Up @@ -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(
Expand Down
6 changes: 6 additions & 0 deletions apps/server/src/event-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
Expand All @@ -100,6 +105,7 @@ export const clientEventTypes = [
"EXAMPLE_SELECTED",
"EXAMPLE_APPLIED",
"VISUALIZATION_CONTROL",
"PREDICTION_CHECKED",
"QUIZ_VIEWED",
"QUIZ_RETRIED",
"LANGUAGE_SELECTED",
Expand Down
8 changes: 2 additions & 6 deletions apps/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 &&
Expand Down Expand Up @@ -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;
Expand Down
19 changes: 19 additions & 0 deletions apps/server/src/web-static.ts
Original file line number Diff line number Diff line change
@@ -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: "페이지를 찾을 수 없어요." });
});
}
8 changes: 8 additions & 0 deletions apps/server/test/admin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
52 changes: 52 additions & 0 deletions apps/server/test/learning-flow.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
58 changes: 58 additions & 0 deletions apps/server/test/web-static.test.ts
Original file line number Diff line number Diff line change
@@ -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"),
"<!doctype html><title>Algostep</title>",
);
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, /<title>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 });
}
});
Binary file added apps/web/public/learning/advanced-dp-dark.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/web/public/learning/advanced-dp-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/web/public/learning/advanced-dp-light.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/web/public/learning/advanced-dp-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/web/public/learning/array-dark.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/web/public/learning/array-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/web/public/learning/array-light.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/web/public/learning/array-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/web/public/learning/backtracking-dark.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/web/public/learning/backtracking-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/web/public/learning/backtracking-light.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/web/public/learning/backtracking-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/web/public/learning/balanced-tree-dark.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/web/public/learning/balanced-tree-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/web/public/learning/balanced-tree-light.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/web/public/learning/balanced-tree-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/web/public/learning/bellman-ford-dark.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/web/public/learning/bellman-ford-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/web/public/learning/bellman-ford-light.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/web/public/learning/bellman-ford-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/web/public/learning/bfs-dark.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/web/public/learning/bfs-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/web/public/learning/bfs-light.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/web/public/learning/bfs-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/web/public/learning/binary-search-dark.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/web/public/learning/binary-search-dark.png
Binary file added apps/web/public/learning/binary-search-light.gif
Binary file added apps/web/public/learning/binary-search-light.png
Binary file added apps/web/public/learning/brute-force-dark.gif
Binary file added apps/web/public/learning/brute-force-dark.png
Binary file added apps/web/public/learning/brute-force-light.gif
Binary file added apps/web/public/learning/brute-force-light.png
Binary file added apps/web/public/learning/bst-dark.gif
Binary file added apps/web/public/learning/bst-dark.png
Binary file added apps/web/public/learning/bst-light.gif
Binary file added apps/web/public/learning/bst-light.png
Binary file added apps/web/public/learning/bubble-sort-dark.gif
Binary file added apps/web/public/learning/bubble-sort-dark.png
Binary file added apps/web/public/learning/bubble-sort-light.gif
Binary file added apps/web/public/learning/bubble-sort-light.png
Binary file added apps/web/public/learning/dfs-dark.gif
Binary file added apps/web/public/learning/dfs-dark.png
Binary file added apps/web/public/learning/dfs-light.gif
Binary file added apps/web/public/learning/dfs-light.png
Binary file added apps/web/public/learning/dijkstra-dark.gif
Binary file added apps/web/public/learning/dijkstra-dark.png
Binary file added apps/web/public/learning/dijkstra-light.gif
Binary file added apps/web/public/learning/dijkstra-light.png
Binary file added apps/web/public/learning/dp-dark.gif
Binary file added apps/web/public/learning/dp-dark.png
Binary file added apps/web/public/learning/dp-light.gif
Binary file added apps/web/public/learning/dp-light.png
Binary file added apps/web/public/learning/fenwick-dark.gif
Binary file added apps/web/public/learning/fenwick-dark.png
Binary file added apps/web/public/learning/fenwick-light.gif
Binary file added apps/web/public/learning/fenwick-light.png
Binary file added apps/web/public/learning/fft-dark.gif
Binary file added apps/web/public/learning/fft-dark.png
Binary file added apps/web/public/learning/fft-light.gif
Binary file added apps/web/public/learning/fft-light.png
Binary file added apps/web/public/learning/flow-dark.gif
Binary file added apps/web/public/learning/flow-dark.png
Binary file added apps/web/public/learning/flow-light.gif
Binary file added apps/web/public/learning/flow-light.png
Binary file added apps/web/public/learning/floyd-warshall-dark.png
Binary file added apps/web/public/learning/floyd-warshall-light.gif
Binary file added apps/web/public/learning/floyd-warshall-light.png
Binary file added apps/web/public/learning/geometry-dark.gif
Binary file added apps/web/public/learning/geometry-dark.png
Binary file added apps/web/public/learning/geometry-light.gif
Binary file added apps/web/public/learning/geometry-light.png
Binary file added apps/web/public/learning/graph-list-dark.gif
Binary file added apps/web/public/learning/graph-list-dark.png
Binary file added apps/web/public/learning/graph-list-light.gif
Binary file added apps/web/public/learning/graph-list-light.png
Binary file added apps/web/public/learning/graph-matrix-dark.gif
Binary file added apps/web/public/learning/graph-matrix-dark.png
Binary file added apps/web/public/learning/graph-matrix-light.png
Binary file added apps/web/public/learning/greedy-dark.gif
Binary file added apps/web/public/learning/greedy-dark.png
Binary file added apps/web/public/learning/greedy-light.gif
Binary file added apps/web/public/learning/greedy-light.png
Binary file added apps/web/public/learning/hash-dark.gif
Binary file added apps/web/public/learning/hash-dark.png
Binary file added apps/web/public/learning/hash-light.gif
Binary file added apps/web/public/learning/hash-light.png
Binary file added apps/web/public/learning/heap-dark.gif
Binary file added apps/web/public/learning/heap-dark.png
Binary file added apps/web/public/learning/heap-light.gif
Binary file added apps/web/public/learning/heap-light.png
Binary file added apps/web/public/learning/insertion-sort-dark.gif
Binary file added apps/web/public/learning/insertion-sort-dark.png
Binary file added apps/web/public/learning/insertion-sort-light.png
Binary file added apps/web/public/learning/kmp-dark.gif
Binary file added apps/web/public/learning/kmp-dark.png
Binary file added apps/web/public/learning/kmp-light.gif
Binary file added apps/web/public/learning/kmp-light.png
Binary file added apps/web/public/learning/lca-dark.gif
Binary file added apps/web/public/learning/lca-dark.png
Binary file added apps/web/public/learning/lca-light.gif
Binary file added apps/web/public/learning/lca-light.png
Binary file added apps/web/public/learning/linear-search-dark.gif
Binary file added apps/web/public/learning/linear-search-dark.png
Binary file added apps/web/public/learning/linear-search-light.gif
Binary file added apps/web/public/learning/linear-search-light.png
Binary file added apps/web/public/learning/linked-list-dark.gif
Binary file added apps/web/public/learning/linked-list-dark.png
Binary file added apps/web/public/learning/linked-list-light.gif
Binary file added apps/web/public/learning/linked-list-light.png
Binary file added apps/web/public/learning/memoization-dark.gif
Binary file added apps/web/public/learning/memoization-dark.png
Binary file added apps/web/public/learning/memoization-light.gif
Binary file added apps/web/public/learning/memoization-light.png
Binary file added apps/web/public/learning/merge-sort-dark.gif
Binary file added apps/web/public/learning/merge-sort-dark.png
Binary file added apps/web/public/learning/merge-sort-light.gif
Binary file added apps/web/public/learning/merge-sort-light.png
Binary file added apps/web/public/learning/mst-dark.gif
Binary file added apps/web/public/learning/mst-dark.png
Binary file added apps/web/public/learning/mst-light.gif
Binary file added apps/web/public/learning/mst-light.png
Binary file added apps/web/public/learning/prefix-sum-dark.gif
Binary file added apps/web/public/learning/prefix-sum-dark.png
Binary file added apps/web/public/learning/prefix-sum-light.gif
Binary file added apps/web/public/learning/prefix-sum-light.png
Binary file added apps/web/public/learning/queue-dark.gif
Binary file added apps/web/public/learning/queue-dark.png
Binary file added apps/web/public/learning/queue-light.gif
Binary file added apps/web/public/learning/queue-light.png
Binary file added apps/web/public/learning/quick-sort-dark.gif
Binary file added apps/web/public/learning/quick-sort-dark.png
Binary file added apps/web/public/learning/quick-sort-light.gif
Binary file added apps/web/public/learning/quick-sort-light.png
Binary file added apps/web/public/learning/recursion-dark.gif
Binary file added apps/web/public/learning/recursion-dark.png
Binary file added apps/web/public/learning/recursion-light.gif
Binary file added apps/web/public/learning/recursion-light.png
Binary file added apps/web/public/learning/scc-dark.gif
Binary file added apps/web/public/learning/scc-dark.png
Binary file added apps/web/public/learning/scc-light.gif
Binary file added apps/web/public/learning/scc-light.png
Binary file added apps/web/public/learning/segment-tree-dark.gif
Binary file added apps/web/public/learning/segment-tree-dark.png
Binary file added apps/web/public/learning/segment-tree-light.png
Binary file added apps/web/public/learning/selection-sort-dark.gif
Binary file added apps/web/public/learning/selection-sort-dark.png
Binary file added apps/web/public/learning/selection-sort-light.gif
Binary file added apps/web/public/learning/selection-sort-light.png
Binary file added apps/web/public/learning/sliding-window-dark.gif
Binary file added apps/web/public/learning/sliding-window-dark.png
Binary file added apps/web/public/learning/sliding-window-light.gif
Binary file added apps/web/public/learning/sliding-window-light.png
Binary file added apps/web/public/learning/sorting-dark.gif
Binary file added apps/web/public/learning/sorting-dark.png
Binary file added apps/web/public/learning/sorting-light.gif
Binary file added apps/web/public/learning/sorting-light.png
Binary file added apps/web/public/learning/stack-dark.gif
Binary file added apps/web/public/learning/stack-dark.png
Binary file added apps/web/public/learning/stack-light.gif
Binary file added apps/web/public/learning/stack-light.png
Binary file added apps/web/public/learning/tabulation-dark.gif
Binary file added apps/web/public/learning/tabulation-dark.png
Binary file added apps/web/public/learning/tabulation-light.gif
Binary file added apps/web/public/learning/tabulation-light.png
Binary file added apps/web/public/learning/tree-dark.gif
Binary file added apps/web/public/learning/tree-dark.png
Binary file added apps/web/public/learning/tree-light.gif
Binary file added apps/web/public/learning/tree-light.png
Binary file added apps/web/public/learning/trie-dark.gif
Binary file added apps/web/public/learning/trie-dark.png
Binary file added apps/web/public/learning/trie-light.gif
Binary file added apps/web/public/learning/trie-light.png
Binary file added apps/web/public/learning/two-pointer-dark.gif
Binary file added apps/web/public/learning/two-pointer-dark.png
Binary file added apps/web/public/learning/two-pointer-light.gif
Binary file added apps/web/public/learning/two-pointer-light.png
Binary file added apps/web/public/learning/union-find-dark.gif
Binary file added apps/web/public/learning/union-find-dark.png
Binary file added apps/web/public/learning/union-find-light.gif
Binary file added apps/web/public/learning/union-find-light.png
11 changes: 8 additions & 3 deletions apps/web/src/AdminDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,14 @@ const eventLabels: Record<string, string> = {
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`),
Expand Down
35 changes: 31 additions & 4 deletions apps/web/src/DiagramCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, { x: number; y: number }>>(
{},
Expand All @@ -75,6 +77,7 @@ export function DiagramCanvas({
svg = useRef<SVGSVGElement>(null),
viewport = useRef<HTMLDivElement>(null);
const marker = useId().replace(/:/g, "");
const pointerStart = useRef({ x: 0, y: 0 });
const positioned = nodes.map((n, i) => {
const defaultPos =
layout === "circle"
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<string, number[]> = {
ArrowLeft: [-12, 0],
ArrowRight: [12, 0],
Expand Down Expand Up @@ -361,9 +381,16 @@ export function DiagramCanvas({
className={`node ${n.state ?? ""}`}
/>
) : (
<circle r="26" className={`node ${n.state ?? ""}`} />
<circle
r={onSelect ? Math.max(26, 24 / zoom) : 26}
className={`node ${n.state ?? ""}`}
/>
)}
<text textAnchor="middle" y={n.detail ? -3 : 5}>
<text
textAnchor="middle"
y={n.detail ? -3 : 5}
style={onSelect ? { fontSize: 14 / zoom } : undefined}
>
{n.label.length > 22 ? n.label.slice(0, 21) + "…" : n.label}
</text>
{n.detail && (
Expand Down
Loading
Loading