syntaxai/tdd.md · main · src / b51_render_admin.ts
// c51 — UI: shells for the admin sxdoc editor.
//
// Three views: list (GET /admin), edit form (GET /admin/edit/...), and
// auth walls for non-admin viewers. Body builders return HTML strings;
// the c21 handler wraps them in htmlResponse.
//
// Fase 2a: raw-HTML textarea editor. Fase 2b adds the block editor on
// top — the textarea stays as the underlying form field, and the
// block-editor JS will hydrate it into a typed UI. So the form shape
// here is forward-compatible with the block editor that lands next.
import { escape, renderPage } from "./b51_render_layout.ts";
import type { SxDocumentSummary } from "./a31_sxdoc.ts";
import type { SxDocument } from "./a31_sxdoc.ts";
import { sxToHtml } from "./b51_render_sxdoc.ts";
export const renderAdminList = async (documents: SxDocumentSummary[]): Promise<string> => {
const pages = documents.filter((d) => d.type === "page");
const posts = documents.filter((d) => d.type === "post");
const body = `# admin
[+ new document](/admin/new)
## pages (${pages.length})
${pages.length === 0 ? "_no pages yet — migrate or create one._" : adminTable(pages)}
## posts (${posts.length})
${posts.length === 0 ? "_no posts yet — migrate or create one._" : adminTable(posts)}
[← back to home](/)
`;
return renderPage({
title: "admin — tdd.md",
bodyMarkdown: body,
noindex: true,
});
};
const adminTable = (rows: SxDocumentSummary[]): string => {
const lines = rows.map((r) =>
`| [${escape(r.title)}](/admin/edit/${r.type}/${r.slug}) | \`${escape(r.slug)}\` | ${r.status} | ${r.primaryTag ?? "—"} |`,
);
return `| title | slug | status | tag |
|---|---|---|---|
${lines.join("\n")}`;
};
export interface AdminEditViewModel {
mode: "new" | "edit";
title: string;
slug: string;
type: "page" | "post";
// SxDocument is the canonical input — server projects it to HTML for
// the textarea and embeds the JSON for the client editor's hydration.
doc: SxDocument;
status: "published" | "draft";
primaryTag: string | null;
error?: string;
}
// Embed JSON safely inside <script type="application/json">: replace
// any "<" so a stray "</script>" in user content can't break out of the
// script tag. JSON.parse handles "<" identically to "<".
const safeJsonForScript = (value: unknown): string =>
JSON.stringify(value).replace(/</g, "\\u003c");
export const renderAdminEdit = async (vm: AdminEditViewModel): Promise<string> => {
const action = vm.mode === "new" ? "/admin/new" : `/admin/edit/${vm.type}/${vm.slug}`;
const heading = vm.mode === "new" ? "new document" : "edit document";
const submitLabel = vm.mode === "new" ? "Create" : "Save";
const html = sxToHtml(vm.doc);
const docJson = safeJsonForScript(vm.doc);
const errorBlock = vm.error
? `<p class="admin-error">${escape(vm.error)}</p>`
: "";
// Delete button uses a separate form to avoid posting the entire edit
// payload to the delete endpoint. confirm() catches accidental clicks.
const deleteForm = vm.mode === "edit"
? `<form method="POST" action="/admin/delete/${vm.type}/${vm.slug}" onsubmit="return confirm('Delete \\'${escape(vm.title)}\\'?');" style="display:inline">
<button type="submit" class="admin-delete">Delete</button>
</form>`
: "";
const form = `<form method="POST" action="${escape(action)}" class="admin-form">
${errorBlock}
<label class="admin-field">
<span>Title</span>
<input type="text" name="title" value="${escape(vm.title)}" required>
</label>
<label class="admin-field">
<span>Slug</span>
<input type="text" name="slug" value="${escape(vm.slug)}" placeholder="about, company/about, docs/spec/grammar" pattern="[a-z0-9_\-]+(?:/[a-z0-9_\-]+)*" required>
</label>
<div class="admin-row">
<label class="admin-field">
<span>Type</span>
<select name="type">
<option value="page"${vm.type === "page" ? " selected" : ""}>page</option>
<option value="post"${vm.type === "post" ? " selected" : ""}>post</option>
</select>
</label>
<label class="admin-field">
<span>Status</span>
<select name="status">
<option value="published"${vm.status === "published" ? " selected" : ""}>published</option>
<option value="draft"${vm.status === "draft" ? " selected" : ""}>draft</option>
</select>
</label>
<label class="admin-field">
<span>Primary tag</span>
<input type="text" name="primary_tag" value="${escape(vm.primaryTag ?? "")}" placeholder="optional">
</label>
</div>
<label class="admin-field">
<span>HTML body</span>
<textarea name="html" rows="24" required>${escape(html)}</textarea>
</label>
<div class="admin-actions">
<button type="submit">${submitLabel}</button>
<a href="/admin" class="admin-cancel">Cancel</a>
</div>
</form>
${deleteForm}
<script type="application/json" id="sxdoc-initial">${docJson}</script>
<script type="module" src="/admin/assets/blockeditor.js"></script>`;
const title = vm.mode === "new"
? "new — admin — tdd.md"
: `${vm.title} — admin — tdd.md`;
return renderPage({
title,
bodyHtml: `<h1>${heading}</h1>${form}`,
noindex: true,
});
};
export const renderAdminLoginWall = async (): Promise<string> =>
renderPage({
title: "admin — sign in — tdd.md",
bodyMarkdown: `# admin
> Sign in with GitHub to access the admin UI.
[ sign in with github → ](/auth/github/start)
[← back to home](/)`,
noindex: true,
});
export const renderAdminNonAdminWall = async (viewer: string): Promise<string> =>
renderPage({
title: "admin — not authorized — tdd.md",
bodyMarkdown: `# not authorized
> You are signed in as \`${escape(viewer)}\`, but the admin UI is reserved for the site admin.
[← back to home](/) · [your agent](/agents/${escape(viewer)})`,
noindex: true,
});