Edit any note and its images from the admin page

Every note gets an Edit form built from its note type's schema: image
fields show a thumbnail with a pick from the vault or an upload, link
fields offer note names, and lists of rows can be added to, removed and
reordered. A new Site pages view lists each page of the site with its
text and images and the notes that fill it. Image and link fields now
carry a JSON Schema format so a form knows to offer a picker.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
This commit is contained in:
Claude
2026-10-18 14:19:38 +00:00
parent 5077aeacfd
commit 0f78505e99
10 changed files with 781 additions and 10 deletions
+61
View File
@@ -17,6 +17,8 @@ import { layout, type Flash } from "./layout.ts";
import * as pages from "./pages.ts";
import * as inbox from "./inbox.ts";
import * as posts from "./posts.ts";
import * as editor from "./editor.ts";
import { sitePagesView } from "./sitepages.ts";
import { suggestionChange, suggestionsPanel } from "./suggestions.ts";
const css = readFileSync(new URL("../public/admin.css", import.meta.url), "utf8");
@@ -127,6 +129,24 @@ export function createAdminServer(api: ContentApi, opts: { now?: () => Date } =
if (note.type !== "post") return new Redirect(pages.noteHref(note.path));
return editorPage(posts.draftFromNote(note));
}
case "/site": {
const all = await Promise.all(["page", "site-settings", "store-settings", "status-kinds", "skills", "education"].map((t) => api.fullNotes(t)));
const singles = new Map(all.slice(1).flatMap((l) => (l[0] ? [[l[0].type, l[0]] as const] : [])));
return { title: "Site pages", body: sitePagesView(all[0]!, singles) };
}
case "/edit": {
const note = await api.note(required(q, "path"));
if (note.type === "post") return new Redirect(posts.editHref(note.path));
return noteEditorPage(editor.draftFromNote(note));
}
case "/edit/new": {
const type = required(q, "type");
if (type === "post") return new Redirect("/posts/new");
if (!noteTypeNames.includes(type as never)) throw new ApiError(404, `There is no note type ${type}.`, null);
const data: Record<string, unknown> = {};
if (type === "page" && q.get("page")) data.page = q.get("page");
return noteEditorPage({ type, title: q.get("title") ?? "", publish: false, data, body: "" });
}
case "/images": {
const folder = q.get("folder") ?? "";
return { title: "Images", body: posts.imagesPage(await api.attachments(), folder === "-" ? "" : folder) };
@@ -156,6 +176,45 @@ export function createAdminServer(api: ContentApi, opts: { now?: () => Date } =
return { title: d.title || "Write a post", body: posts.postEditor(d, { images, notes: names, showPreview }) };
}
async function noteEditorPage(d: editor.EditorDraft, reasons: string[] = []): Promise<Page> {
const [images, notes] = await Promise.all([api.attachments(), api.notes({ limit: 5000 })]);
const names = [...new Set(notes.filter((n) => n.type !== "status").map((n) => n.title))].sort((a, b) => a.localeCompare(b));
const body = editor.noteEditor(d, { images, notes: names });
return {
title: d.title || "New note",
body: reasons.length ? html`<div class="flash err" role="alert">Not saved.<ul>${reasons.map((r) => html`<li>${r}</li>`)}</ul></div>${body}` : body,
};
}
/** Save the note editor: upload chosen images, then reorder a row or save. */
async function saveNote(f: Form, files: Upload[]): Promise<Page | Redirect> {
const type = required(f, "type");
if (!noteTypeNames.includes(type as never)) throw new ApiError(400, `There is no note type ${type}.`, null);
const path = f.get("path") || undefined;
const uploaded = new Map<string, string[]>();
for (const u of files.filter((x) => x.field.startsWith("upload."))) {
const key = u.field.slice("upload.".length);
uploaded.set(key, [...(uploaded.get(key) ?? []), await api.upload(editor.uploadFolder(type), u.name, u.bytes)]);
}
let values = editor.formData(type, f, uploaded);
const title = (f.get("title") ?? "").trim();
const body = (f.get("body") ?? "").replace(/\r\n/g, "\n");
const draft = (): editor.EditorDraft => ({ type, path, title, publish: values.publish === true, data: values, body });
const move = f.get("move");
if (move) {
values = editor.moveRow(values, move);
return noteEditorPage(draft());
}
if (!title) return noteEditorPage(draft(), ["A note needs a title."]);
const fields = editor.changeFields(values, !!path);
const text = editor.hasBody(type) ? body : undefined;
const o = path
? await api.apply({ op: "update", path, fields, body: text })
: await api.apply({ op: "create", data: { type, title, ...fields }, body: text });
if (o.status === "rejected") return noteEditorPage(draft(), o.reasons.map((r) => r.message));
return after(editor.editHref(o.path), o, "Saved. The site shows it within a minute.");
}
/** Save the post form: upload its images, then preview, save a draft or publish. */
async function savePost(f: Form, files: Upload[]): Promise<Page | Redirect> {
const d = posts.draftFromForm(f);
@@ -185,6 +244,8 @@ export function createAdminServer(api: ContentApi, opts: { now?: () => Date } =
switch (path) {
case "/posts/save":
return savePost(f, files);
case "/edit/save":
return saveNote(f, files);
case "/images/upload": {
const folder = f.get("folder") || "Posts";
const added: string[] = [];