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
+456
View File
@@ -0,0 +1,456 @@
/**
* The note editor: a form for any note type, made from the note type's
* schema so it accepts exactly what the content service accepts. Image
* fields get a picker (a thumbnail, the vault's images, an upload), link
* fields get the note names, and lists of rows (photos, specs, socials) can
* be added to, removed and reordered.
*
* The form round-trips through the note's own data shape: `formData` turns a
* posted form back into frontmatter values, and `noteEditor` renders from
* them, so a reorder or a failed save shows exactly what was typed.
*/
import { noteTypes, type NoteType } from "@gk/schema";
import { z } from "zod";
import type { Attachment, Note } from "./client.ts";
import { html, qs, type Html } from "./html.ts";
import { noteHref } from "./pages.ts";
/** One field as the JSON Schema export describes it. */
export interface Prop {
type?: string;
enum?: unknown[];
const?: unknown;
format?: string;
pattern?: string;
title?: string;
description?: string;
items?: Prop;
properties?: Record<string, Prop>;
required?: string[];
default?: unknown;
}
interface Shape {
properties: Record<string, Prop>;
required: string[];
}
/** Fields the service or the file name owns; the editor never sends them. */
const OWNED = new Set(["type", "title", "updated", "updatedBy", "created"]);
/** Shared fields, shown after the note type's own. */
const SHARED = ["publish", "favorite", "cover", "links", "tags", "slug", "agentEdits"];
const shapes = new Map<string, Shape>();
export function shapeOf(type: string): Shape {
let s = shapes.get(type);
if (!s) {
const def = noteTypes[type as NoteType];
if (!def) throw new Error(`Unknown note type ${type}`);
const js = z.toJSONSchema(def.schema, { io: "input", unrepresentable: "any" }) as { properties: Record<string, Prop>; required?: string[] };
s = { properties: js.properties, required: js.required ?? [] };
shapes.set(type, s);
}
return s;
}
/** Whether notes of this type have Markdown under the frontmatter. */
export const hasBody = (type: string) => noteTypes[type as NoteType]?.body != null;
export const editHref = (path: string) => `/edit${qs({ path })}`;
export const newHref = (type: string, extra: Record<string, string> = {}) => `/edit/new${qs({ type, ...extra })}`;
/** The `_attachments` folder an upload from this note type goes to. */
export function uploadFolder(type: string): string {
if (["craft", "station", "tool", "make"].includes(type)) return "Workshop";
if (["library-item", "board-game"].includes(type)) return "Library";
if (["tabletop-system", "campaign", "card-game", "deck"].includes(type)) return "Tabletop";
if (["product-line", "store-settings"].includes(type)) return "Store";
if (type === "post") return "Posts";
return "Pages";
}
// ------------------------------------------------------------ field kinds
type Kind = "bool" | "enum" | "number" | "date" | "month" | "url" | "image" | "link" | "text" | "lines" | "links" | "images" | "choices" | "rows" | "group" | "other";
const DATE = /^\^\\d\{4\}-\\d\{2\}-\\d\{2\}\$$/;
const MONTH = /^\^\\d\{4\}-\\d\{2\}\$$/;
export function kindOf(p: Prop, nested = false): Kind {
if (p.type === "boolean") return "bool";
if (p.type === "integer" || p.type === "number") return "number";
if (p.type === "string" || (!p.type && p.format)) {
if (p.enum) return "enum";
if (p.format === "vault-image") return "image";
if (p.format === "vault-link") return "link";
if (p.format === "uri") return "url";
if (p.pattern && DATE.test(p.pattern)) return "date";
if (p.pattern && MONTH.test(p.pattern)) return "month";
return "text";
}
if (p.type === "array" && p.items) {
const it = p.items;
if (p.format === "vault-links") return "links";
if (it.format === "vault-image") return "images";
if (it.type === "string" && it.enum) return "choices";
if (it.type === "string") return "lines";
// Rows of simple fields; a row inside a row is left to Obsidian.
if (!nested && it.type === "object" && Object.values(it.properties ?? {}).every((s) => !["rows", "group", "other", "images"].includes(kindOf(s, true)))) return "rows";
return "other";
}
if (p.type === "object" && !nested && Object.values(p.properties ?? {}).every((s) => ["bool", "enum", "number", "date", "month", "url", "text", "link", "image"].includes(kindOf(s, true)))) return "group";
return "other";
}
// ------------------------------------------------------------ values
/** `[[file.jpg]]` or `![[file.jpg|300]]` to `file.jpg`; a URL stays as it is. */
export const linkName = (v: unknown) =>
String(v ?? "")
.trim()
.replace(/^!?\[\[|\]\]$/g, "")
.split("|")[0]!
.trim();
const wrap = (v: string) => (/^https?:\/\//.test(v) ? v : `[[${v}]]`);
const str = (v: unknown) => (v === undefined || v === null ? "" : String(v));
// ------------------------------------------------------------ reading a form
export interface Upload {
field: string;
name: string;
bytes: Uint8Array<ArrayBuffer>;
}
/** Turn a posted field into a value, or undefined when it was left empty. */
function scalar(kind: Kind, raw: string | null): unknown {
const v = (raw ?? "").trim();
if (!v) return undefined;
if (kind === "number") {
const n = Number(v);
return Number.isFinite(n) ? n : v;
}
if (kind === "image" || kind === "link") return wrap(linkName(v));
return v;
}
function lines(raw: string | null, link: boolean): string[] {
return (raw ?? "")
.split("\n")
.map((l) => l.trim())
.filter(Boolean)
.map((l) => (link ? wrap(linkName(l)) : l));
}
/** Indexes of the rows the form posted for `name`, in their order on the page. */
function rowIndexes(f: URLSearchParams, name: string): number[] {
const seen = new Set<number>();
const re = new RegExp(`^${name.replace(/\./g, "\\.")}\\.(\\d+)(\\.|$)`);
for (const k of f.keys()) {
const m = re.exec(k);
if (m) seen.add(Number(m[1]));
}
return [...seen].sort((a, b) => a - b);
}
/**
* The note's values from a posted editor form. Uploaded images are saved
* first (by `save`) and arrive in `uploaded` keyed by the field that chose
* them. An empty field comes back as undefined.
*/
export function formData(type: string, f: URLSearchParams, uploaded: Map<string, string[]> = new Map()): Record<string, unknown> {
const shape = shapeOf(type);
const { properties } = shape;
const required = new Set(shape.required);
const out: Record<string, unknown> = {};
const field = (name: string, p: Prop, kind: Kind, required = false): unknown => {
const up = uploaded.get(name);
switch (kind) {
case "bool":
// An unticked optional box is left out rather than written as false.
if (!f.has(`has.${name}`)) return undefined;
return f.get(name) === "1" ? true : required || name === "publish" ? false : undefined;
case "image":
return up?.length ? wrap(up[0]!) : scalar(kind, f.get(name));
case "lines":
case "links": {
const l = lines(f.get(name), kind === "links");
return l.length ? l : undefined;
}
case "choices": {
const l = f.getAll(name).filter(Boolean);
return l.length ? l : undefined;
}
case "images": {
const l = rowIndexes(f, name)
.filter((i) => f.get(`${name}.${i}.remove`) !== "1")
.map((i) => scalar("image", f.get(`${name}.${i}`)))
.filter((v): v is string => typeof v === "string");
for (const u of up ?? []) l.push(wrap(u));
return l.length ? l : undefined;
}
case "rows": {
const subs = Object.entries(p.items?.properties ?? {});
const rows: Record<string, unknown>[] = [];
for (const i of rowIndexes(f, name)) {
if (f.get(`${name}.${i}.remove`) === "1") continue;
const row: Record<string, unknown> = {};
const req = new Set(p.items?.required ?? []);
for (const [k, s] of subs) {
const v = field(`${name}.${i}.${k}`, s, kindOf(s, true), req.has(k));
if (v !== undefined) row[k] = v;
}
// A row with nothing typed is the blank "add one" row.
if (Object.values(row).some((v) => v !== false)) rows.push(row);
}
return rows.length ? rows : undefined;
}
case "group": {
const g: Record<string, unknown> = {};
const req = new Set(p.required ?? []);
for (const [k, s] of Object.entries(p.properties ?? {})) {
const v = field(`${name}.${k}`, s, kindOf(s, true), req.has(k));
if (v !== undefined) g[k] = v;
}
return Object.keys(g).length ? g : undefined;
}
case "other":
return undefined;
default:
return scalar(kind, f.get(name));
}
};
for (const [key, p] of Object.entries(properties)) {
if (OWNED.has(key)) continue;
const kind = kindOf(p);
if (kind === "other") continue;
out[key] = field(key, p, kind, required.has(key));
}
return out;
}
/**
* The fields to send. On an update, a field left empty is sent as null so
* the content service drops it, and fields the form cannot edit are left
* out so the note keeps them.
*/
export function changeFields(values: Record<string, unknown>, update: boolean): Record<string, unknown> {
const fields: Record<string, unknown> = {};
for (const [k, v] of Object.entries(values)) {
if (v !== undefined) fields[k] = v;
else if (update) fields[k] = null;
}
return fields;
}
/** Move row `i` of `name` up or down in posted form values. */
export function moveRow(values: Record<string, unknown>, move: string): Record<string, unknown> {
const m = /^(.+):(\d+):(up|down)$/.exec(move);
if (!m) return values;
const [, key, at, dir] = m;
const list = values[key!];
if (!Array.isArray(list)) return values;
const i = Number(at);
const j = dir === "up" ? i - 1 : i + 1;
if (j < 0 || j >= list.length) return values;
const next = [...list];
[next[i], next[j]] = [next[j], next[i]];
return { ...values, [key!]: next };
}
// ------------------------------------------------------------ rendering
export interface EditorDraft {
type: string;
/** Set when editing a saved note. */
path?: string;
title: string;
publish: boolean;
data: Record<string, unknown>;
body: string;
}
export function draftFromNote(n: Note): EditorDraft {
return { type: n.type, path: n.path, title: n.title, publish: n.publish, data: n.data, body: n.body };
}
interface Ctx {
images: Attachment[];
notes: string[];
}
const isImage = (name: string) => /\.(png|jpe?g|webp|gif|avif|svg)$/i.test(name);
const imageSrc = (v: string) => (/^https:\/\//.test(v) ? v : `/attachments/${encodeURIComponent(v)}`);
const id = (name: string) => `e-${name.replace(/[^a-zA-Z0-9]+/g, "-")}`;
const ACCEPT = "image/png,image/jpeg,image/webp,image/gif,image/avif";
function labelFor(key: string, p: Prop) {
return p.title ?? key;
}
function hint(p: Prop): Html | string {
return p.description ? html`<p class="hint">${p.description}</p>` : "";
}
function imagePicker(name: string, value: string, ctx: Ctx, label: string): Html {
const current = linkName(value);
const known = ctx.images.some((a) => a.name === current);
return html`<div class="image-pick">
${current ? html`<img src="${imageSrc(current)}" alt="" loading="lazy">` : html`<div class="no-image">No image</div>`}
<div>
<label for="${id(name)}">${label}</label>
<select id="${id(name)}" name="${name}">
<option value="">None</option>
${current && !known ? html`<option value="${current}" selected>${current}</option>` : ""}
${ctx.images.map((a) => html`<option value="${a.name}" ${a.name === current ? html`selected` : ""}>${a.folder ? `${a.folder} / ` : ""}${a.name}</option>`)}
</select>
<label for="${id(name)}-up" class="sub">Or upload a new one</label>
<input id="${id(name)}-up" name="upload.${name}" type="file" accept="${ACCEPT}">
</div>
</div>`;
}
function input(name: string, kind: Kind, p: Prop, value: unknown, ctx: Ctx, label: string, required: boolean): Html {
const i = id(name);
const req = required ? html`required` : "";
switch (kind) {
case "bool":
return html`<input type="hidden" name="has.${name}" value="1"><label class="check"><input type="checkbox" name="${name}" value="1" ${value === true ? html`checked` : ""}> ${label}</label>`;
case "enum":
return html`<label for="${i}">${label}</label><select id="${i}" name="${name}" ${req}>
${required ? "" : html`<option value="">Not set</option>`}
${(p.enum ?? []).map((o) => html`<option ${String(o) === str(value) ? html`selected` : ""}>${String(o)}</option>`)}
</select>`;
case "number":
return html`<label for="${i}">${label}</label><input id="${i}" name="${name}" type="number" step="${p.type === "integer" ? "1" : "any"}" value="${str(value)}" ${req}>`;
case "date":
return html`<label for="${i}">${label}</label><input id="${i}" name="${name}" type="date" value="${str(value).slice(0, 10)}" ${req}>`;
case "month":
return html`<label for="${i}">${label}</label><input id="${i}" name="${name}" type="month" value="${str(value)}" ${req}>`;
case "url":
return html`<label for="${i}">${label}</label><input id="${i}" name="${name}" type="url" value="${str(value)}" ${req}>`;
case "link":
return html`<label for="${i}">${label}</label><input id="${i}" name="${name}" list="note-names" value="${linkName(value)}" placeholder="A note's name" ${req}>`;
case "image":
return imagePicker(name, str(value), ctx, label);
default:
return html`<label for="${i}">${label}</label><input id="${i}" name="${name}" value="${str(value)}" ${req}>`;
}
}
function fieldBlock(key: string, p: Prop, value: unknown, ctx: Ctx, required: boolean): Html {
const kind = kindOf(p);
const label = labelFor(key, p);
const i = id(key);
switch (kind) {
case "lines":
case "links": {
const list = Array.isArray(value) ? value.map((v) => (kind === "links" ? linkName(v) : str(v))) : [];
return html`<div class="field"><label for="${i}">${label}</label>
<textarea id="${i}" name="${key}" rows="${Math.max(3, list.length + 1)}" ${kind === "links" ? html`placeholder="One note name per line"` : html`placeholder="One per line"`}>${list.join("\n")}</textarea>
${hint(p)}</div>`;
}
case "choices": {
const set = new Set(Array.isArray(value) ? value.map(String) : []);
return html`<fieldset class="field"><legend>${label}</legend>${(p.items?.enum ?? []).map(
(o) => html`<label class="check"><input type="checkbox" name="${key}" value="${String(o)}" ${set.has(String(o)) ? html`checked` : ""}> ${String(o)}</label>`,
)}${hint(p)}</fieldset>`;
}
case "images": {
const list = Array.isArray(value) ? value.map(str) : [];
return html`<fieldset class="field rows"><legend>${label}</legend>${hint(p)}
${list.map(
(v, n) => html`<div class="row-item">
${imagePicker(`${key}.${n}`, v, ctx, `Image ${n + 1}`)}
${rowTools(key, n, list.length)}
</div>`,
)}
<label for="${i}-add">Add images</label>
<input id="${i}-add" name="upload.${key}" type="file" accept="${ACCEPT}" multiple>
</fieldset>`;
}
case "rows": {
const list = Array.isArray(value) ? (value as Record<string, unknown>[]) : [];
const subs = Object.entries(p.items?.properties ?? {});
const req = new Set(p.items?.required ?? []);
const row = (r: Record<string, unknown>, n: number, blank: boolean) => html`<div class="row-item ${blank ? "blank" : ""}">
${blank ? html`<p class="hint">Add one</p>` : ""}
<div class="row-fields">${subs.map(([k, s]) => {
const sk = kindOf(s, true);
const name = `${key}.${n}.${k}`;
if (sk === "lines") {
const l = Array.isArray(r[k]) ? (r[k] as unknown[]).map(str) : [];
return html`<div class="field"><label for="${id(name)}">${labelFor(k, s)}</label><textarea id="${id(name)}" name="${name}" rows="2" placeholder="One per line">${l.join("\n")}</textarea></div>`;
}
if (sk === "choices") return html`<div class="field">${labelFor(k, s)}: edit in Obsidian</div>`;
return html`<div class="field">${input(name, sk, s, r[k], ctx, labelFor(k, s), !blank && req.has(k))}</div>`;
})}</div>
${blank ? "" : rowTools(key, n, list.length)}
</div>`;
return html`<fieldset class="field rows"><legend>${label}</legend>${hint(p)}
${list.map((r, n) => row(r, n, false))}
${row({}, list.length, true)}
</fieldset>`;
}
case "group": {
const g = (value ?? {}) as Record<string, unknown>;
const req = new Set(p.required ?? []);
return html`<fieldset class="field"><legend>${label}</legend>${hint(p)}<div class="row-fields">${Object.entries(p.properties ?? {}).map(
([k, s]) => html`<div class="field">${input(`${key}.${k}`, kindOf(s, true), s, g[k], ctx, labelFor(k, s), req.has(k))}</div>`,
)}</div></fieldset>`;
}
case "other":
return value === undefined
? html``
: html`<div class="field"><span class="label">${label}</span><p class="hint">This field is edited in Obsidian.</p></div>`;
default:
return html`<div class="field">${input(key, kind, p, value, ctx, label, required)}${kind === "bool" ? "" : hint(p)}</div>`;
}
}
function rowTools(key: string, n: number, count: number): Html {
return html`<div class="row-tools">
<button type="submit" name="move" value="${key}:${n}:up" formnovalidate ${n === 0 ? html`disabled` : ""}>Move up</button>
<button type="submit" name="move" value="${key}:${n}:down" formnovalidate ${n === count - 1 ? html`disabled` : ""}>Move down</button>
<label class="check"><input type="checkbox" name="${key}.${n}.remove" value="1"> Remove</label>
</div>`;
}
export function noteEditor(d: EditorDraft, ctx: Ctx): Html {
const def = noteTypes[d.type as NoteType];
const { properties, required } = shapeOf(d.type);
const req = new Set(required);
const images = { ...ctx, images: ctx.images.filter((a) => isImage(a.name)) };
const own = Object.entries(properties).filter(([k]) => !OWNED.has(k) && !SHARED.includes(k));
const shared = SHARED.filter((k) => properties[k]).map((k) => [k, properties[k]!] as const);
return html`<h1>${d.path ? html`Edit ${d.title}` : html`New ${def.label.toLowerCase()}`}</h1>
<p class="lede">${def.description} ${d.path ? html`Saved as <code>${d.path}</code>. <a href="${noteHref(d.path)}">Note and history</a>` : ""}</p>
<form method="post" action="/edit/save" enctype="multipart/form-data" class="note-form">
<input type="hidden" name="type" value="${d.type}">
${d.path ? html`<input type="hidden" name="path" value="${d.path}">` : ""}
<div class="post-grid">
<div class="panel">
<div class="field"><label for="e-title">Title</label>
<input id="e-title" name="title" value="${d.title}" required ${d.path ? html`readonly aria-describedby="e-title-note"` : ""}>
${d.path ? html`<p class="hint" id="e-title-note">The title is the note's file name; rename it in Obsidian.</p>` : ""}</div>
${own.map(([k, p]) => fieldBlock(k, p, d.data[k], images, req.has(k)))}
${def.body !== null
? html`<div class="field"><label for="e-body">${d.type === "page" ? "Introduction" : "Body"}</label>
<textarea id="e-body" name="body" rows="14" class="body-text">${d.body}</textarea>
<p class="hint">${def.body} Markdown; embed a vault image with ![[file.jpg]].</p></div>`
: ""}
</div>
<div class="side">
<div class="panel">
<h2>On the site</h2>
${shared.map(([k, p]) => fieldBlock(k, p, k === "publish" ? d.publish : d.data[k], images, false))}
</div>
<div class="panel actions stack">
<button class="primary" name="action" value="save">Save</button>
${d.path ? html`<a href="${noteHref(d.path)}">Cancel</a>` : ""}
</div>
</div>
</div>
<datalist id="note-names">${ctx.notes.map((n) => html`<option value="${n}">`)}</datalist>
</form>`;
}
+1
View File
@@ -8,6 +8,7 @@ export interface Flash {
const nav: [string, string][] = [
["/", "Overview"],
["/site", "Site pages"],
["/review", "Review"],
["/inbox", "Inbox"],
["/notes", "Notes"],
+7 -3
View File
@@ -9,6 +9,8 @@ import { html, qs, raw, when, type Html } from "./html.ts";
import { renderMarkdown } from "./markdown.ts";
export const noteHref = (path: string) => `/note${qs({ path })}`;
/** The editor for a note: posts have their own. */
const editLink = (n: { path: string; type: string }) => (n.type === "post" ? `/posts/edit${qs({ path: n.path })}` : `/edit${qs({ path: n.path })}`);
const authorLabel: Record<string, string> = { greg: "You", agent: "An agent", admin: "Admin page" };
const who = (a: string) => authorLabel[a] ?? a;
@@ -134,9 +136,10 @@ export function notesList(p: { title: string; lede: string; action: string; note
)}</select></div>
<button>Show</button>
</form>
${p.type ? html`<p><a class="button" href="${p.type === "post" ? "/posts/new" : `/edit/new${qs({ type: p.type })}`}">Add a ${p.type} note</a></p>` : ""}
${p.notes.length
? html`<div class="table-wrap"><table><thead><tr><th>Title</th><th>Type</th><th>State</th><th>Changed</th></tr></thead><tbody>
${p.notes.map((n) => html`<tr><td><a href="${noteHref(n.path)}">${n.title}</a><div class="muted mono">${n.path}</div></td><td>${n.type}</td><td>${publishPill(n.publish)}</td><td>${when(n.acceptedAt)}</td></tr>`)}
? html`<div class="table-wrap"><table><thead><tr><th>Title</th><th>Type</th><th>State</th><th>Changed</th><th></th></tr></thead><tbody>
${p.notes.map((n) => html`<tr><td><a href="${noteHref(n.path)}">${n.title}</a><div class="muted mono">${n.path}</div></td><td>${n.type}</td><td>${publishPill(n.publish)}</td><td>${when(n.acceptedAt)}</td><td><a href="${editLink(n)}">Edit</a></td></tr>`)}
</tbody></table></div>`
: html`<p class="muted">No notes match.</p>`}`;
}
@@ -150,10 +153,11 @@ export function noteView(n: Note, history: Revision[]): Html {
<p class="lede">${publishPill(n.publish)} ${n.type} · <span class="mono">${n.path}</span> · last changed by ${who(n.author).toLowerCase()} ${when(n.acceptedAt)}</p>
${n.pending ? html`<div class="flash">A change to this note is waiting: <a href="/review/${n.pending.id}">review it</a>.</div>` : ""}
<div class="actions">
<a class="button primary" href="${editLink(n)}">Edit</a>
<form method="post" action="/note/publish" class="inline">
<input type="hidden" name="path" value="${n.path}">
<input type="hidden" name="publish" value="${n.publish ? "0" : "1"}">
<button class="${n.publish ? "" : "primary"}">${n.publish ? "Unpublish" : "Publish"}</button>
<button>${n.publish ? "Unpublish" : "Publish"}</button>
</form>
</div>
<h2>Preview</h2>
+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[] = [];
+86
View File
@@ -0,0 +1,86 @@
/**
* The Site pages view: each page of the public site, the Page text note
* behind it (its lede, introduction and photos) and the kinds of notes that
* fill it, so changing what a page shows starts from the page itself.
*/
import { noteTypes, type NoteType } from "@gk/schema";
import type { FullNote } from "./client.ts";
import { editHref, linkName, newHref } from "./editor.ts";
import { html, qs, type Html } from "./html.ts";
export interface SitePage {
page: string;
name: string;
/** Note types whose notes appear on the page. */
feeds: NoteType[];
}
export const sitePages: SitePage[] = [
{ page: "/", name: "Landing", feeds: ["site-settings"] },
{ page: "/about", name: "About", feeds: [] },
{ page: "/now", name: "Now", feeds: ["status", "status-kinds"] },
{ page: "/work", name: "Work", feeds: ["post", "experience"] },
{ page: "/resume", name: "Resume", feeds: ["experience", "skills", "education", "site-settings"] },
{ page: "/workshop", name: "Workshop", feeds: ["craft", "make", "station", "tool"] },
{ page: "/library", name: "Library", feeds: ["library-item"] },
{ page: "/library/shelf", name: "Shelf", feeds: ["board-game"] },
{ page: "/tabletop", name: "Tabletop", feeds: ["tabletop-system", "campaign", "card-game", "deck"] },
{ page: "/blog", name: "Blog", feeds: ["post"] },
{ page: "/store", name: "Store", feeds: ["product-line", "store-settings"] },
{ page: "/inquiries", name: "Inquiries", feeds: ["site-settings"] },
{ page: "/404", name: "Not found", feeds: [] },
];
const imageSrc = (v: string) => (/^https:\/\//.test(v) ? v : `/attachments/${encodeURIComponent(v)}`);
/** Every image a Page text note shows: portrait, cover and photos. */
export function pageImages(n: FullNote): { label: string; file: string }[] {
const d = n.data;
const out: { label: string; file: string }[] = [];
if (d.portrait) out.push({ label: "Portrait", file: linkName(d.portrait) });
if (d.cover) out.push({ label: "Cover", file: linkName(d.cover) });
if (Array.isArray(d.photos))
for (const p of d.photos as { file?: unknown; caption?: unknown; alt?: unknown }[])
if (p?.file) out.push({ label: String(p.caption ?? p.alt ?? "Photo"), file: linkName(p.file) });
return out;
}
const singletons = new Set<string>(["site-settings", "store-settings", "status-kinds", "skills", "education"]);
function feedLink(type: NoteType, singles: Map<string, FullNote>): Html {
const def = noteTypes[type];
const one = singles.get(type);
if (singletons.has(type)) {
return one
? html`<a class="pill-link" href="${editHref(one.path)}">${def.label}</a>`
: html`<a class="pill-link" href="${newHref(type)}">${def.label} (add)</a>`;
}
return html`<a class="pill-link" href="/notes${qs({ type })}">${def.label}</a>`;
}
export function sitePagesView(pageNotes: FullNote[], singles: Map<string, FullNote>): Html {
const byPage = new Map(pageNotes.map((n) => [String(n.data.page ?? ""), n]));
return html`<h1>Site pages</h1>
<p class="lede">Every page of the site with its text and images. Edit a page's lede, introduction and photos here; the lists on a page come from the notes linked under it.</p>
<ul class="site-pages">${sitePages.map((p) => {
const n = byPage.get(p.page);
const images = n ? pageImages(n) : [];
return html`<li class="panel">
<div class="head-row">
<h2>${p.name} <code>${p.page}</code></h2>
${n
? html`<a class="button primary" href="${editHref(n.path)}">Edit page</a>`
: html`<a class="button" href="${newHref("page", { page: p.page, title: p.name })}">Add page text</a>`}
</div>
${n
? html`<p>${n.data.lede ? String(n.data.lede) : html`<span class="muted">No lede yet.</span>`} ${n.publish ? "" : html`<span class="pill draft">Draft</span>`}</p>`
: html`<p class="muted">No Page text note yet, so the page uses its built-in wording.</p>`}
${images.length
? html`<ul class="page-images">${images.map(
(i) => html`<li><img src="${imageSrc(i.file)}" alt="" loading="lazy"><span>${i.label}</span></li>`,
)}</ul>`
: ""}
${p.feeds.length ? html`<p class="feeds"><span class="muted">Also on this page:</span> ${p.feeds.map((t) => feedLink(t, singles))}</p>` : ""}
</li>`;
})}</ul>`;
}