syntaxai/tdd.md · main · src / b51_render_admin.ts

b51_render_admin.ts 164 lines · 5835 bytes raw
// 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,
  });