handzon-core 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +74 -0
- package/src/collections.ts +150 -0
- package/src/components/Footer.astro +85 -0
- package/src/components/Navbar.astro +74 -0
- package/src/components/Progress.tsx +36 -0
- package/src/components/Sidebar.astro +162 -0
- package/src/components/StepNav.astro +107 -0
- package/src/components/ai/ByokSetup.tsx +90 -0
- package/src/components/ai/ChatButton.tsx +30 -0
- package/src/components/ai/ChatPanel.tsx +244 -0
- package/src/components/auth/SignInButton.astro +41 -0
- package/src/components/auth/UserMenu.astro +79 -0
- package/src/components/auth/UserMenu.tsx +136 -0
- package/src/components/home/FilterBar.tsx +152 -0
- package/src/components/home/Hero.astro +60 -0
- package/src/components/home/Pagination.tsx +89 -0
- package/src/components/home/ResumeRail.tsx +50 -0
- package/src/components/home/TutorialCard.astro +185 -0
- package/src/components/mdx/Callout.astro +77 -0
- package/src/components/mdx/Checkpoint.astro +14 -0
- package/src/components/mdx/Checkpoint.tsx +49 -0
- package/src/components/mdx/Diff.astro +6 -0
- package/src/components/mdx/Diff.tsx +100 -0
- package/src/components/mdx/Download.astro +37 -0
- package/src/components/mdx/Embed.astro +56 -0
- package/src/components/mdx/File.astro +28 -0
- package/src/components/mdx/FileTree.astro +6 -0
- package/src/components/mdx/FileTree.tsx +71 -0
- package/src/components/mdx/Hint.astro +51 -0
- package/src/components/mdx/Mermaid.astro +6 -0
- package/src/components/mdx/Mermaid.tsx +47 -0
- package/src/components/mdx/Playground.astro +6 -0
- package/src/components/mdx/Playground.tsx +34 -0
- package/src/components/mdx/Quiz.astro +6 -0
- package/src/components/mdx/Quiz.tsx +102 -0
- package/src/components/mdx/Recap.astro +65 -0
- package/src/components/mdx/Reveal.astro +7 -0
- package/src/components/mdx/Reveal.tsx +25 -0
- package/src/components/mdx/Step.astro +12 -0
- package/src/components/mdx/Steps.astro +40 -0
- package/src/components/mdx/Tab.astro +22 -0
- package/src/components/mdx/Tabs.astro +67 -0
- package/src/components/mdx/Terminal.astro +6 -0
- package/src/components/mdx/Terminal.tsx +47 -0
- package/src/index.ts +55 -0
- package/src/layouts/BaseLayout.astro +112 -0
- package/src/layouts/TutorialLayout.astro +218 -0
- package/src/lib/ai/client.ts +92 -0
- package/src/lib/ai/context.ts +97 -0
- package/src/lib/content.ts +73 -0
- package/src/lib/mdx-components.ts +47 -0
- package/src/lib/progress/local.ts +89 -0
- package/src/lib/progress/remote.ts +199 -0
- package/src/lib/progress/types.ts +63 -0
- package/src/lib/progress/useProgress.ts +117 -0
- package/src/lib/rehype-mermaid-passthrough.ts +31 -0
- package/src/pages/Home.astro +408 -0
- package/src/pages/TutorialLanding.astro +324 -0
- package/src/pages/TutorialStep.astro +67 -0
- package/src/pages/paths.ts +36 -0
- package/src/server/auth/config.ts +102 -0
- package/src/server/auth/schema.ts +66 -0
- package/src/server/auth/session.ts +27 -0
- package/src/server/auth.ts +127 -0
- package/src/server/db/client.ts +14 -0
- package/src/server/db/migrate.ts +29 -0
- package/src/server/db/schema.ts +65 -0
- package/src/server/handlers/healthz.ts +6 -0
- package/src/server/handlers/progress.ts +90 -0
- package/src/server/handlers/tutorialStats.ts +67 -0
- package/src/server/http.ts +33 -0
- package/src/types/ai.ts +17 -0
- package/styles/base.css +127 -0
- package/styles/components/a11y.css +12 -0
- package/styles/components/byok.css +50 -0
- package/styles/components/chat.css +304 -0
- package/styles/components/checkpoint.css +49 -0
- package/styles/components/diff.css +44 -0
- package/styles/components/expressive-code.css +61 -0
- package/styles/components/filetree.css +68 -0
- package/styles/components/mermaid.css +19 -0
- package/styles/components/modal.css +25 -0
- package/styles/components/progress.css +19 -0
- package/styles/components/quiz.css +101 -0
- package/styles/components/reveal.css +25 -0
- package/styles/components/tabs.css +60 -0
- package/styles/components/terminal.css +55 -0
- package/styles/components.css +28 -0
- package/styles/global.css +15 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { AstroCookieSetOptions, AstroCookies } from "astro";
|
|
2
|
+
import { and, eq, isNull } from "drizzle-orm";
|
|
3
|
+
import { getAuthedUser } from "./auth/session.ts";
|
|
4
|
+
import { getDb } from "./db/client.ts";
|
|
5
|
+
import { learners, progressEntries } from "./db/schema.ts";
|
|
6
|
+
|
|
7
|
+
const COOKIE = "tt-device";
|
|
8
|
+
const ONE_YEAR = 60 * 60 * 24 * 365;
|
|
9
|
+
|
|
10
|
+
const COOKIE_OPTS: AstroCookieSetOptions = {
|
|
11
|
+
httpOnly: true,
|
|
12
|
+
// Only flip on `secure` in production — Astro's dev server is plain
|
|
13
|
+
// HTTP and `secure: true` silently drops the cookie there.
|
|
14
|
+
secure: import.meta.env.PROD,
|
|
15
|
+
sameSite: "lax",
|
|
16
|
+
path: "/",
|
|
17
|
+
maxAge: ONE_YEAR,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function randomDeviceId(): string {
|
|
21
|
+
// 32-char hex (128 bits) — plenty for an unpredictable, opaque id.
|
|
22
|
+
const bytes = new Uint8Array(16);
|
|
23
|
+
crypto.getRandomValues(bytes);
|
|
24
|
+
return Array.from(bytes)
|
|
25
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
26
|
+
.join("");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Resolve the current learner row.
|
|
31
|
+
*
|
|
32
|
+
* - No session + no cookie → mint a device learner + cookie (anonymous).
|
|
33
|
+
* - No session + cookie → return the existing device learner.
|
|
34
|
+
* - Session present → return the user-linked learner. If a device
|
|
35
|
+
* cookie also exists and points to an orphan
|
|
36
|
+
* (user_id NULL), re-key its progress to the
|
|
37
|
+
* user learner in a single transaction and
|
|
38
|
+
* drop the device cookie. One-time claim.
|
|
39
|
+
*
|
|
40
|
+
* `request` is required so we can read the Auth.js session. Endpoints
|
|
41
|
+
* that don't currently have it (older internal callers) can pass `null`
|
|
42
|
+
* to keep the anonymous-only path.
|
|
43
|
+
*/
|
|
44
|
+
export async function getOrCreateLearner(
|
|
45
|
+
cookies: AstroCookies,
|
|
46
|
+
request: Request | null,
|
|
47
|
+
): Promise<{ id: string; deviceId: string | null }> {
|
|
48
|
+
const db = getDb();
|
|
49
|
+
const authed = request ? await getAuthedUser(request) : null;
|
|
50
|
+
|
|
51
|
+
if (authed) {
|
|
52
|
+
const userLearner = await findOrCreateUserLearner(db, authed.userId);
|
|
53
|
+
const deviceId = cookies.get(COOKIE)?.value;
|
|
54
|
+
if (deviceId) {
|
|
55
|
+
await maybeClaimDeviceProgress(db, deviceId, userLearner.id);
|
|
56
|
+
cookies.delete(COOKIE, { path: "/" });
|
|
57
|
+
}
|
|
58
|
+
return { id: userLearner.id, deviceId: null };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Anonymous path — unchanged from pre-auth behaviour.
|
|
62
|
+
let deviceId = cookies.get(COOKIE)?.value;
|
|
63
|
+
if (deviceId) {
|
|
64
|
+
const found = await db
|
|
65
|
+
.select()
|
|
66
|
+
.from(learners)
|
|
67
|
+
.where(eq(learners.deviceId, deviceId))
|
|
68
|
+
.limit(1);
|
|
69
|
+
if (found[0]) return { id: found[0].id, deviceId };
|
|
70
|
+
}
|
|
71
|
+
deviceId = randomDeviceId();
|
|
72
|
+
const [created] = await db.insert(learners).values({ deviceId }).returning();
|
|
73
|
+
cookies.set(COOKIE, deviceId, COOKIE_OPTS);
|
|
74
|
+
return { id: created!.id, deviceId };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function findOrCreateUserLearner(
|
|
78
|
+
db: ReturnType<typeof getDb>,
|
|
79
|
+
userId: string,
|
|
80
|
+
): Promise<{ id: string }> {
|
|
81
|
+
const existing = await db
|
|
82
|
+
.select({ id: learners.id })
|
|
83
|
+
.from(learners)
|
|
84
|
+
.where(eq(learners.userId, userId))
|
|
85
|
+
.limit(1);
|
|
86
|
+
if (existing[0]) return { id: existing[0].id };
|
|
87
|
+
const [created] = await db.insert(learners).values({ userId }).returning({ id: learners.id });
|
|
88
|
+
return { id: created!.id };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* If `deviceId` points at an orphan (user_id NULL) learner with progress,
|
|
93
|
+
* re-parent its progress rows to `userLearnerId` and delete the orphan.
|
|
94
|
+
* Idempotent: subsequent sign-ins with the same device cookie no-op.
|
|
95
|
+
*/
|
|
96
|
+
async function maybeClaimDeviceProgress(
|
|
97
|
+
db: ReturnType<typeof getDb>,
|
|
98
|
+
deviceId: string,
|
|
99
|
+
userLearnerId: string,
|
|
100
|
+
): Promise<void> {
|
|
101
|
+
const orphans = await db
|
|
102
|
+
.select({ id: learners.id })
|
|
103
|
+
.from(learners)
|
|
104
|
+
.where(and(eq(learners.deviceId, deviceId), isNull(learners.userId)))
|
|
105
|
+
.limit(1);
|
|
106
|
+
const orphan = orphans[0];
|
|
107
|
+
if (!orphan || orphan.id === userLearnerId) return;
|
|
108
|
+
|
|
109
|
+
await db.transaction(async (tx) => {
|
|
110
|
+
// Move progress rows. ON CONFLICT: keep the row with the most
|
|
111
|
+
// recent updated_at — if the user already has progress on this
|
|
112
|
+
// (kind, scope, key), prefer whichever was touched last.
|
|
113
|
+
await tx.execute(/* sql */ `
|
|
114
|
+
INSERT INTO "progress_entries" ("learner_id","kind","scope","key","value","updated_at")
|
|
115
|
+
SELECT '${userLearnerId}'::uuid, "kind","scope","key","value","updated_at"
|
|
116
|
+
FROM "progress_entries"
|
|
117
|
+
WHERE "learner_id" = '${orphan.id}'::uuid
|
|
118
|
+
ON CONFLICT ("learner_id","kind","scope","key")
|
|
119
|
+
DO UPDATE SET
|
|
120
|
+
"value" = CASE WHEN EXCLUDED."updated_at" > "progress_entries"."updated_at"
|
|
121
|
+
THEN EXCLUDED."value" ELSE "progress_entries"."value" END,
|
|
122
|
+
"updated_at" = GREATEST(EXCLUDED."updated_at", "progress_entries"."updated_at");
|
|
123
|
+
`);
|
|
124
|
+
await tx.delete(progressEntries).where(eq(progressEntries.learnerId, orphan.id));
|
|
125
|
+
await tx.delete(learners).where(eq(learners.id, orphan.id));
|
|
126
|
+
});
|
|
127
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { drizzle } from "drizzle-orm/postgres-js";
|
|
2
|
+
import postgres from "postgres";
|
|
3
|
+
import * as schema from "./schema";
|
|
4
|
+
|
|
5
|
+
let _client: ReturnType<typeof drizzle> | null = null;
|
|
6
|
+
|
|
7
|
+
export function getDb() {
|
|
8
|
+
if (_client) return _client;
|
|
9
|
+
const url = process.env.DATABASE_URL;
|
|
10
|
+
if (!url) throw new Error("DATABASE_URL is not set.");
|
|
11
|
+
const sql = postgres(url, { max: 5, idle_timeout: 20 });
|
|
12
|
+
_client = drizzle(sql, { schema });
|
|
13
|
+
return _client;
|
|
14
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drizzle migration runner used by the scaffold's `db:migrate` script
|
|
3
|
+
* (and the Tier 2 Blueprint's `preDeployCommand`). Keeping it inside
|
|
4
|
+
* the framework lets the scaffold's `package.json` stay free of a
|
|
5
|
+
* direct `drizzle-orm` dependency — the previous setup imported
|
|
6
|
+
* `drizzle-orm/postgres-js/migrator` from the scaffold itself, which
|
|
7
|
+
* only resolved when pnpm's `shamefully-hoist=true` was on the
|
|
8
|
+
* workspace `.npmrc`. Render's strict install rejected it.
|
|
9
|
+
*
|
|
10
|
+
* Usage (scaffold side):
|
|
11
|
+
*
|
|
12
|
+
* import { runMigrations } from "handzon-core/server/db/migrate";
|
|
13
|
+
* runMigrations("./drizzle")
|
|
14
|
+
* .then(() => process.exit(0))
|
|
15
|
+
* .catch((e) => { console.error(e); process.exit(1); });
|
|
16
|
+
*/
|
|
17
|
+
import { migrate } from "drizzle-orm/postgres-js/migrator";
|
|
18
|
+
import { getDb } from "./client.ts";
|
|
19
|
+
|
|
20
|
+
export async function runMigrations(migrationsFolder: string): Promise<void> {
|
|
21
|
+
if (!process.env.DATABASE_URL) {
|
|
22
|
+
console.log("DATABASE_URL not set — skipping migrations (Tier 1 build).");
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
const db = getDb();
|
|
26
|
+
console.log("Running migrations…");
|
|
27
|
+
await migrate(db, { migrationsFolder });
|
|
28
|
+
console.log("Migrations complete.");
|
|
29
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { sql } from "drizzle-orm";
|
|
2
|
+
import {
|
|
3
|
+
index,
|
|
4
|
+
jsonb,
|
|
5
|
+
pgTable,
|
|
6
|
+
primaryKey,
|
|
7
|
+
text,
|
|
8
|
+
timestamp,
|
|
9
|
+
uniqueIndex,
|
|
10
|
+
uuid,
|
|
11
|
+
} from "drizzle-orm/pg-core";
|
|
12
|
+
import { users } from "../auth/schema.ts";
|
|
13
|
+
|
|
14
|
+
// Re-export the Auth.js tables so consumers (and drizzle-kit) see one
|
|
15
|
+
// schema barrel.
|
|
16
|
+
export { users, accounts, sessions, verificationTokens } from "../auth/schema.ts";
|
|
17
|
+
|
|
18
|
+
export const learners = pgTable(
|
|
19
|
+
"learners",
|
|
20
|
+
{
|
|
21
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
22
|
+
// Nullable now — signed-in learners don't need a device cookie.
|
|
23
|
+
deviceId: text("device_id"),
|
|
24
|
+
// Optional FK to the Auth.js user. Set on first sign-in via the
|
|
25
|
+
// claim transaction; null for anonymous learners.
|
|
26
|
+
userId: uuid("user_id").references(() => users.id, { onDelete: "set null" }),
|
|
27
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
28
|
+
},
|
|
29
|
+
(table) => ({
|
|
30
|
+
// Partial uniques: only enforced when the column is non-null. Lets
|
|
31
|
+
// multiple signed-in learners coexist without device ids and avoids
|
|
32
|
+
// the historical `device_id NOT NULL UNIQUE` constraint blocking
|
|
33
|
+
// user-only rows.
|
|
34
|
+
deviceIdUnique: uniqueIndex("learners_device_id_unique")
|
|
35
|
+
.on(table.deviceId)
|
|
36
|
+
.where(sql`${table.deviceId} IS NOT NULL`),
|
|
37
|
+
userIdUnique: uniqueIndex("learners_user_id_unique")
|
|
38
|
+
.on(table.userId)
|
|
39
|
+
.where(sql`${table.userId} IS NOT NULL`),
|
|
40
|
+
}),
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
export const progressEntries = pgTable(
|
|
44
|
+
"progress_entries",
|
|
45
|
+
{
|
|
46
|
+
learnerId: uuid("learner_id")
|
|
47
|
+
.notNull()
|
|
48
|
+
.references(() => learners.id, { onDelete: "cascade" }),
|
|
49
|
+
// 'step' | 'checkpoint' | 'quiz' | 'pref' | 'lastVisited' | 'tutorial'
|
|
50
|
+
// The 'tutorial' kind keys aggregate popularity events. Two keys
|
|
51
|
+
// matter for cross-learner counts: 'started' (first step view per
|
|
52
|
+
// learner) and 'completed' (all steps in the tutorial complete).
|
|
53
|
+
// The composite PK guarantees each learner is counted at most once
|
|
54
|
+
// per (slug, key), so a plain COUNT(*) gives unique-learner totals.
|
|
55
|
+
kind: text("kind").notNull(),
|
|
56
|
+
scope: text("scope").notNull(), // tutorial slug or 'global'
|
|
57
|
+
key: text("key").notNull(),
|
|
58
|
+
value: jsonb("value").notNull(),
|
|
59
|
+
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
|
60
|
+
},
|
|
61
|
+
(table) => ({
|
|
62
|
+
pk: primaryKey({ columns: [table.learnerId, table.kind, table.scope, table.key] }),
|
|
63
|
+
byLearner: index("progress_by_learner").on(table.learnerId, table.scope),
|
|
64
|
+
}),
|
|
65
|
+
);
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { APIRoute } from "astro";
|
|
2
|
+
import { json } from "../http.ts";
|
|
3
|
+
|
|
4
|
+
// Hit by Render's healthCheckPath on both tiers. Both tiers are SSR
|
|
5
|
+
// node services in this template, so the response is computed at runtime.
|
|
6
|
+
export const GET: APIRoute = () => json({ status: "ok" });
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { APIRoute } from "astro";
|
|
2
|
+
import { eq, sql } from "drizzle-orm";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { getOrCreateLearner } from "../auth.ts";
|
|
5
|
+
import { getDb } from "../db/client.ts";
|
|
6
|
+
import { progressEntries } from "../db/schema.ts";
|
|
7
|
+
import { isSameOrigin, json } from "../http.ts";
|
|
8
|
+
|
|
9
|
+
const MAX_BODY_BYTES = 32 * 1024;
|
|
10
|
+
const MAX_ENTRIES = 200;
|
|
11
|
+
|
|
12
|
+
const ProgressEntrySchema = z.object({
|
|
13
|
+
kind: z.enum(["step", "checkpoint", "quiz", "pref", "lastVisited", "tutorial"]),
|
|
14
|
+
scope: z.string().min(1).max(128),
|
|
15
|
+
key: z.string().min(1).max(128),
|
|
16
|
+
value: z.unknown(),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const ProgressBodySchema = z.array(ProgressEntrySchema).max(MAX_ENTRIES);
|
|
20
|
+
|
|
21
|
+
// Tier 1: no Postgres — returns an empty list (the frontend uses the
|
|
22
|
+
// local store and never reads this in that mode). Tier 2: hits Postgres.
|
|
23
|
+
export const GET: APIRoute = async ({ cookies, request }) => {
|
|
24
|
+
if (!process.env.DATABASE_URL) {
|
|
25
|
+
return json({ entries: [] });
|
|
26
|
+
}
|
|
27
|
+
const learner = await getOrCreateLearner(cookies, request);
|
|
28
|
+
const db = getDb();
|
|
29
|
+
const rows = await db
|
|
30
|
+
.select()
|
|
31
|
+
.from(progressEntries)
|
|
32
|
+
.where(eq(progressEntries.learnerId, learner.id));
|
|
33
|
+
return json({ entries: rows });
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export const POST: APIRoute = async ({ cookies, request }) => {
|
|
37
|
+
if (!isSameOrigin(request)) {
|
|
38
|
+
return json({ error: "Cross-origin write rejected." }, { status: 403 });
|
|
39
|
+
}
|
|
40
|
+
if (!process.env.DATABASE_URL) {
|
|
41
|
+
return json({ written: 0 });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const lengthHeader = request.headers.get("content-length");
|
|
45
|
+
if (lengthHeader && Number(lengthHeader) > MAX_BODY_BYTES) {
|
|
46
|
+
return json({ error: "Payload too large." }, { status: 413 });
|
|
47
|
+
}
|
|
48
|
+
const raw = await request.text();
|
|
49
|
+
if (raw.length > MAX_BODY_BYTES) {
|
|
50
|
+
return json({ error: "Payload too large." }, { status: 413 });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let parsed: z.infer<typeof ProgressBodySchema>;
|
|
54
|
+
try {
|
|
55
|
+
parsed = ProgressBodySchema.parse(JSON.parse(raw));
|
|
56
|
+
} catch (e) {
|
|
57
|
+
return json({ error: e instanceof Error ? e.message : "Invalid JSON." }, { status: 400 });
|
|
58
|
+
}
|
|
59
|
+
if (parsed.length === 0) return json({ written: 0 });
|
|
60
|
+
|
|
61
|
+
const learner = await getOrCreateLearner(cookies, request);
|
|
62
|
+
const db = getDb();
|
|
63
|
+
const now = new Date();
|
|
64
|
+
const rows = parsed.map((b) => ({
|
|
65
|
+
learnerId: learner.id,
|
|
66
|
+
kind: b.kind,
|
|
67
|
+
scope: b.scope,
|
|
68
|
+
key: b.key,
|
|
69
|
+
value: b.value,
|
|
70
|
+
updatedAt: now,
|
|
71
|
+
}));
|
|
72
|
+
await db
|
|
73
|
+
.insert(progressEntries)
|
|
74
|
+
.values(rows)
|
|
75
|
+
.onConflictDoUpdate({
|
|
76
|
+
target: [
|
|
77
|
+
progressEntries.learnerId,
|
|
78
|
+
progressEntries.kind,
|
|
79
|
+
progressEntries.scope,
|
|
80
|
+
progressEntries.key,
|
|
81
|
+
],
|
|
82
|
+
set: {
|
|
83
|
+
// `excluded` is the row Postgres would have inserted — without
|
|
84
|
+
// this the SET was a no-op (`value = progress_entries.value`).
|
|
85
|
+
value: sql`excluded.value`,
|
|
86
|
+
updatedAt: sql`excluded.updated_at`,
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
return json({ written: rows.length });
|
|
90
|
+
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { APIRoute } from "astro";
|
|
2
|
+
import { eq, sql } from "drizzle-orm";
|
|
3
|
+
import { getDb } from "../db/client.ts";
|
|
4
|
+
import { progressEntries } from "../db/schema.ts";
|
|
5
|
+
import { json } from "../http.ts";
|
|
6
|
+
|
|
7
|
+
export interface TutorialStat {
|
|
8
|
+
slug: string;
|
|
9
|
+
started: number;
|
|
10
|
+
completed: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface CacheEntry {
|
|
14
|
+
expiresAt: number;
|
|
15
|
+
payload: { stats: TutorialStat[] };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const CACHE_TTL_MS = 60_000;
|
|
19
|
+
let cache: CacheEntry | null = null;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Returns one row per tutorial slug with cross-learner started /
|
|
23
|
+
* completed counts. Tier 1 (no DATABASE_URL) returns an empty array so
|
|
24
|
+
* card hydration is a no-op and the build stays static.
|
|
25
|
+
*
|
|
26
|
+
* Each (learner, slug, key) is a single row thanks to the composite PK
|
|
27
|
+
* on `progress_entries`, so `COUNT(*)` is a unique-learner count — no
|
|
28
|
+
* extra `DISTINCT` needed.
|
|
29
|
+
*/
|
|
30
|
+
export const GET: APIRoute = async () => {
|
|
31
|
+
if (!process.env.DATABASE_URL) {
|
|
32
|
+
return json({ stats: [] satisfies TutorialStat[] }, {
|
|
33
|
+
headers: { "Cache-Control": "public, max-age=60" },
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
const now = Date.now();
|
|
37
|
+
if (cache && cache.expiresAt > now) {
|
|
38
|
+
return json(cache.payload, {
|
|
39
|
+
headers: { "Cache-Control": "public, max-age=60" },
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const db = getDb();
|
|
44
|
+
const rows = await db
|
|
45
|
+
.select({
|
|
46
|
+
scope: progressEntries.scope,
|
|
47
|
+
key: progressEntries.key,
|
|
48
|
+
count: sql<number>`count(*)::int`.as("count"),
|
|
49
|
+
})
|
|
50
|
+
.from(progressEntries)
|
|
51
|
+
.where(eq(progressEntries.kind, "tutorial"))
|
|
52
|
+
.groupBy(progressEntries.scope, progressEntries.key);
|
|
53
|
+
|
|
54
|
+
const bySlug = new Map<string, TutorialStat>();
|
|
55
|
+
for (const r of rows) {
|
|
56
|
+
const entry = bySlug.get(r.scope) ?? { slug: r.scope, started: 0, completed: 0 };
|
|
57
|
+
if (r.key === "started") entry.started = Number(r.count);
|
|
58
|
+
else if (r.key === "completed") entry.completed = Number(r.count);
|
|
59
|
+
bySlug.set(r.scope, entry);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const payload = { stats: Array.from(bySlug.values()) };
|
|
63
|
+
cache = { expiresAt: now + CACHE_TTL_MS, payload };
|
|
64
|
+
return json(payload, {
|
|
65
|
+
headers: { "Cache-Control": "public, max-age=60" },
|
|
66
|
+
});
|
|
67
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared HTTP helpers for API routes. Centralises the JSON response shape
|
|
3
|
+
* and the same-origin guard so routes don't reinvent either.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export function json(body: unknown, init: ResponseInit = {}): Response {
|
|
7
|
+
const headers = new Headers(init.headers);
|
|
8
|
+
headers.set("Content-Type", "application/json");
|
|
9
|
+
return new Response(JSON.stringify(body), { ...init, headers });
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Reject cross-origin writes. SameSite=Lax on the device cookie already
|
|
14
|
+
* blocks most CSRF, but `Sec-Fetch-Site` is the modern, browser-supplied
|
|
15
|
+
* check — pair them for defense in depth. Same-origin and direct
|
|
16
|
+
* navigations are allowed; `none` (typed URL, bookmark) is allowed for GET
|
|
17
|
+
* only, which API mutating routes don't expose.
|
|
18
|
+
*/
|
|
19
|
+
export function isSameOrigin(request: Request): boolean {
|
|
20
|
+
const site = request.headers.get("sec-fetch-site");
|
|
21
|
+
if (site === "same-origin") return true;
|
|
22
|
+
if (site === "none") return true;
|
|
23
|
+
if (site) return false;
|
|
24
|
+
// Older browsers without Sec-Fetch-Site: fall back to Origin/Referer.
|
|
25
|
+
const origin = request.headers.get("origin");
|
|
26
|
+
if (!origin) return true;
|
|
27
|
+
const host = request.headers.get("host");
|
|
28
|
+
try {
|
|
29
|
+
return new URL(origin).host === host;
|
|
30
|
+
} catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
package/src/types/ai.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface AiConfig {
|
|
2
|
+
enabled: boolean;
|
|
3
|
+
name: string;
|
|
4
|
+
tagline?: string;
|
|
5
|
+
greeting?: string;
|
|
6
|
+
avatar?: string;
|
|
7
|
+
persona?: string;
|
|
8
|
+
provider: "anthropic" | "openai" | "google" | "openai-compatible";
|
|
9
|
+
model: string;
|
|
10
|
+
byok: "required" | "optional" | "disabled";
|
|
11
|
+
tone: "socratic" | "direct" | "encouraging";
|
|
12
|
+
contextBudgetTokens: number;
|
|
13
|
+
includeFutureSteps: boolean;
|
|
14
|
+
tools: { suggestPlaygroundEdit: boolean };
|
|
15
|
+
disabledSkills?: string[];
|
|
16
|
+
allowedDomains?: string[];
|
|
17
|
+
}
|
package/styles/base.css
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
@import "@fontsource-variable/geist";
|
|
2
|
+
@import "@fontsource-variable/geist-mono";
|
|
3
|
+
|
|
4
|
+
*,
|
|
5
|
+
*::before,
|
|
6
|
+
*::after {
|
|
7
|
+
box-sizing: border-box;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
html {
|
|
11
|
+
background: var(--color-bg);
|
|
12
|
+
color: var(--color-fg);
|
|
13
|
+
font-family: var(--font-sans);
|
|
14
|
+
-webkit-font-smoothing: antialiased;
|
|
15
|
+
text-rendering: optimizeLegibility;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
body {
|
|
19
|
+
margin: 0;
|
|
20
|
+
min-height: 100dvh;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
a {
|
|
24
|
+
color: var(--color-accent);
|
|
25
|
+
text-decoration: underline;
|
|
26
|
+
text-underline-offset: 2px;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
a:hover {
|
|
30
|
+
color: var(--color-fg);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
:focus-visible {
|
|
34
|
+
outline: var(--border-default, 2px) solid var(--color-accent);
|
|
35
|
+
outline-offset: 2px;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/* Tutorial prose — consistent type scale, single weight family.
|
|
39
|
+
* Same weight (700) across every heading; size + tracking handles the
|
|
40
|
+
* hierarchy so it doesn't look "h1 thin / h2 super heavy".
|
|
41
|
+
*/
|
|
42
|
+
.prose {
|
|
43
|
+
font-family: var(--font-sans);
|
|
44
|
+
line-height: 1.65;
|
|
45
|
+
font-size: 1rem;
|
|
46
|
+
max-width: 72ch;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.prose h1,
|
|
50
|
+
.prose h2,
|
|
51
|
+
.prose h3,
|
|
52
|
+
.prose h4,
|
|
53
|
+
.prose h5,
|
|
54
|
+
.prose h6 {
|
|
55
|
+
font-weight: 700;
|
|
56
|
+
letter-spacing: -0.015em;
|
|
57
|
+
line-height: 1.2;
|
|
58
|
+
margin-top: 2rem;
|
|
59
|
+
margin-bottom: 0.6rem;
|
|
60
|
+
color: var(--color-fg);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.prose h1 { font-size: 1.875rem; letter-spacing: -0.02em; }
|
|
64
|
+
.prose h2 {
|
|
65
|
+
font-size: 1.4rem;
|
|
66
|
+
padding-top: 1.25rem;
|
|
67
|
+
border-top: var(--border-default) solid var(--color-border);
|
|
68
|
+
}
|
|
69
|
+
.prose h3 { font-size: 1.15rem; }
|
|
70
|
+
.prose h4 { font-size: 1rem; }
|
|
71
|
+
|
|
72
|
+
.prose p { margin: 0.75em 0; }
|
|
73
|
+
|
|
74
|
+
.prose ul,
|
|
75
|
+
.prose ol {
|
|
76
|
+
padding-left: 1.5rem;
|
|
77
|
+
margin: 0.75em 0;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.prose li { margin: 0.25em 0; }
|
|
81
|
+
|
|
82
|
+
.prose code:not(pre code),
|
|
83
|
+
.tut-tab-panel code:not(pre code) {
|
|
84
|
+
font-family: var(--font-mono);
|
|
85
|
+
background: var(--color-surface-2);
|
|
86
|
+
padding: 0.15em 0.45em;
|
|
87
|
+
font-size: 0.875em;
|
|
88
|
+
color: var(--color-accent);
|
|
89
|
+
border-radius: 0;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.prose blockquote {
|
|
93
|
+
border-left: var(--border-thick, 3px) solid var(--color-accent);
|
|
94
|
+
padding-left: 1rem;
|
|
95
|
+
margin: 1em 0;
|
|
96
|
+
color: var(--color-muted);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
.prose hr {
|
|
100
|
+
border: none;
|
|
101
|
+
border-top: var(--border-default, 2px) solid var(--color-border);
|
|
102
|
+
margin: 2em 0;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.prose img {
|
|
106
|
+
max-width: 100%;
|
|
107
|
+
height: auto;
|
|
108
|
+
border-radius: 0;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
.prose table {
|
|
112
|
+
width: 100%;
|
|
113
|
+
border-collapse: collapse;
|
|
114
|
+
margin: 1em 0;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.prose th,
|
|
118
|
+
.prose td {
|
|
119
|
+
border: var(--border-default, 2px) solid var(--color-border);
|
|
120
|
+
padding: 0.5em 0.75em;
|
|
121
|
+
text-align: left;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.prose th {
|
|
125
|
+
background: var(--color-surface);
|
|
126
|
+
font-weight: 700;
|
|
127
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/* BYOK setup */
|
|
2
|
+
.byok-panel {
|
|
3
|
+
position: fixed;
|
|
4
|
+
top: 50%;
|
|
5
|
+
left: 50%;
|
|
6
|
+
transform: translate(-50%, -50%);
|
|
7
|
+
width: min(440px, 92vw);
|
|
8
|
+
background: var(--color-surface);
|
|
9
|
+
border: var(--border-default) solid var(--color-border);
|
|
10
|
+
padding: 1.5rem;
|
|
11
|
+
z-index: 70;
|
|
12
|
+
border-radius: 0;
|
|
13
|
+
}
|
|
14
|
+
.byok-panel h2 { margin: 0 0 0.5rem; font-size: 1.25rem; }
|
|
15
|
+
.byok-desc { color: var(--color-muted); font-size: 0.9em; margin: 0.25rem 0 1rem; }
|
|
16
|
+
.byok-link { font-size: 0.85em; margin: 0 0 1rem; }
|
|
17
|
+
.byok-field { display: grid; gap: 0.35rem; margin-bottom: 1rem; }
|
|
18
|
+
.byok-field span {
|
|
19
|
+
font-family: var(--font-mono);
|
|
20
|
+
font-size: 0.72em;
|
|
21
|
+
text-transform: uppercase;
|
|
22
|
+
letter-spacing: 0.06em;
|
|
23
|
+
color: var(--color-muted);
|
|
24
|
+
}
|
|
25
|
+
.byok-field input {
|
|
26
|
+
background: var(--color-bg);
|
|
27
|
+
border: var(--border-default) solid var(--color-border);
|
|
28
|
+
color: var(--color-fg);
|
|
29
|
+
padding: 0.55rem 0.75rem;
|
|
30
|
+
font: inherit;
|
|
31
|
+
border-radius: 0;
|
|
32
|
+
}
|
|
33
|
+
.byok-actions { display: flex; justify-content: flex-end; }
|
|
34
|
+
.byok-actions button {
|
|
35
|
+
background: var(--color-accent);
|
|
36
|
+
color: var(--color-accent-fg);
|
|
37
|
+
border: 0;
|
|
38
|
+
padding: 0.55rem 1rem;
|
|
39
|
+
font-weight: 600;
|
|
40
|
+
cursor: pointer;
|
|
41
|
+
border-radius: 0;
|
|
42
|
+
}
|
|
43
|
+
.byok-actions button:disabled { opacity: 0.4; cursor: not-allowed; }
|
|
44
|
+
.byok-disclaimer {
|
|
45
|
+
margin: 0;
|
|
46
|
+
padding-top: 1rem;
|
|
47
|
+
border-top: var(--border-default) solid var(--color-border);
|
|
48
|
+
color: var(--color-muted);
|
|
49
|
+
font-size: 0.8em;
|
|
50
|
+
}
|