mirror of
https://github.com/Patiun/com.gregkilmer.www.git
synced 2026-11-08 04:08:24 +00:00
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:
@@ -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>`;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ export interface Flash {
|
||||
|
||||
const nav: [string, string][] = [
|
||||
["/", "Overview"],
|
||||
["/site", "Site pages"],
|
||||
["/review", "Review"],
|
||||
["/inbox", "Inbox"],
|
||||
["/notes", "Notes"],
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
Reference in New Issue
Block a user