forked from bai/curriculum-project-hub
feat(hub): migrate database admin pages to SPA (database-admin)
- scaffold hub/database-admin as SvelteKit 2 + Svelte 5 static SPA
with aurora/glass visual style (paths.base='/database')
- add lib/{api,session,org}.ts + Aurora.svelte component
- add routes: root redirect, /admin login page, /dashboard (OWNER/ADMIN only)
- backend: replace server-rendered HTML routes with /database/config JSON endpoint
- add hub/src/database/static.ts to serve SPA under /database/*
- wire registerDatabaseSpa into plugin.ts
- exempt /database/* from silo rate-limit (same treatment as /admin/*)
- add database:dev + database:build npm scripts; update deploy scripts
- update hub/src/database/README.md
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Serves the database-admin SPA (built by SvelteKit via `database-admin/build/`)
|
||||
* and the SPA index fallback for client-side routes under `/database/*`.
|
||||
*
|
||||
* The SvelteKit project lives in `hub/database-admin/` and is built with
|
||||
* `paths.base = '/database'`, so its assets are emitted under `/database/_app/*`
|
||||
* (not the root `/_app/*` that admin-web owns — that keeps the two SPAs from
|
||||
* colliding). On disk the files still live at `build/_app/*`; this handler maps
|
||||
* the `/database`-prefixed URLs back to those files.
|
||||
*
|
||||
* Run `npm run build` in database-admin/ to produce the static output. In
|
||||
* development, `npm run dev` there proxies `/api`, `/auth`, and `/database` to
|
||||
* the Hub. Override the UI directory with `CPH_DATABASE_UI_DIR` if needed.
|
||||
*
|
||||
* Mirrors src/admin/static.ts.
|
||||
*/
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, extname, join, resolve as resolvePath } from "node:path";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
|
||||
const MIME: Record<string, string> = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
".mjs": "text/javascript; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".svg": "image/svg+xml",
|
||||
".ico": "image/x-icon",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
".json": "application/json; charset=utf-8",
|
||||
".txt": "text/plain; charset=utf-8",
|
||||
};
|
||||
|
||||
function resolveUiDir(): string {
|
||||
const override = process.env["CPH_DATABASE_UI_DIR"];
|
||||
if (override && override.trim() !== "") return resolvePath(override);
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
return resolvePath(join(here, "..", "..", "database-admin", "build"));
|
||||
}
|
||||
|
||||
export async function registerDatabaseSpa(app: FastifyInstance): Promise<void> {
|
||||
const uiDir = resolveUiDir();
|
||||
if (!existsSync(join(uiDir, "index.html"))) {
|
||||
app.log.warn(
|
||||
{ uiDir },
|
||||
"database-admin/build not found; /database SPA shell disabled. Run `npm run build` in database-admin/ to enable. /database/config and /database/dev-login remain functional.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const indexHtml = await readFile(join(uiDir, "index.html"), "utf8");
|
||||
|
||||
// SvelteKit build assets. base='/database' emits them at /database/_app/*,
|
||||
// but on disk they're under build/_app/*.
|
||||
app.get("/database/_app/*", async (request, reply) => {
|
||||
const rel = (request.params as { "*": string })["*"];
|
||||
const safe = rel.split("/").filter((p) => p !== ".." && p !== "").join("/");
|
||||
try {
|
||||
const buf = await readFile(join(uiDir, "_app", safe));
|
||||
const mime = MIME[extname(safe)] ?? "application/octet-stream";
|
||||
return reply.type(mime).send(buf);
|
||||
} catch {
|
||||
return reply.status(404).send({ error: { code: "not_found", message: "asset not found" } });
|
||||
}
|
||||
});
|
||||
|
||||
// Top-level static files emitted under the base path (favicon.svg, robots.txt).
|
||||
app.get("/database/favicon.svg", async (_request, reply) => {
|
||||
try {
|
||||
const buf = await readFile(join(uiDir, "favicon.svg"));
|
||||
return reply.type("image/svg+xml").send(buf);
|
||||
} catch {
|
||||
return reply.status(404).send();
|
||||
}
|
||||
});
|
||||
app.get("/database/robots.txt", async (_request, reply) => {
|
||||
try {
|
||||
const buf = await readFile(join(uiDir, "robots.txt"));
|
||||
return reply.type("text/plain; charset=utf-8").send(buf);
|
||||
} catch {
|
||||
return reply.status(404).send();
|
||||
}
|
||||
});
|
||||
|
||||
// SPA client-side route fallback. Concrete /database/* routes (/database/config,
|
||||
// /database/dev-login, and the asset routes above) are more specific, so
|
||||
// Fastify's router matches them before this wildcard. Everything else under
|
||||
// /database serves index.html so SvelteKit's client router can resolve the view.
|
||||
app.get("/database", async (_request, reply) => {
|
||||
return reply.type("text/html; charset=utf-8").send(indexHtml);
|
||||
});
|
||||
app.get("/database/*", async (_request, reply) => {
|
||||
return reply.type("text/html; charset=utf-8").send(indexHtml);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user