mirror of
https://github.com/Patiun/com.gregkilmer.www.git
synced 2026-11-08 04:08:24 +00:00
Merge pull request #9 from Patiun/claude/patiun-rebrand-gjoo9f
Edit any note and its images from the admin page
This commit is contained in:
+4
-2
@@ -16,10 +16,12 @@ the vault so Obsidian shows them too. Its changes are recorded with the author
|
||||
| Page | What it does |
|
||||
| --- | --- |
|
||||
| Overview | Live and total notes, what waits for review, folder problems, the service's last scan and last error, a Scan now button, each sync job's last run and last success, and Library suggestions: series moved to Reading in PersonalReader with no Library item, each with Add to the Library (title, medium, AniList id, state now, published) and Dismiss. |
|
||||
| Site pages | Each page of the site with its Page text note (lede, photos, portrait) as thumbnails, **Edit page** or **Add page text**, and links to the notes that fill the page (crafts, games, decks, product lines, settings). |
|
||||
| Review | Held changes with the rules' reasons and a line diff against the live version. Accept puts it live (and into the vault, for API changes). Reject keeps the live version; for an edit made in Obsidian, "Reject and put the live version back" also rewrites the file. Earlier decisions are listed by status. |
|
||||
| Notes, Drafts | Browse and search every note, or only the unpublished ones. |
|
||||
| A note | A preview of the body, its fields, what links to it, Publish or Unpublish, and its history. Any earlier version can be compared with the live one and rolled back. |
|
||||
| Notes, Drafts | Browse and search every note, or only the unpublished ones. Each row has Edit; filtered to one type, the list also offers to add a note of that type. |
|
||||
| A note | A preview of the body, its fields, what links to it, Edit, Publish or Unpublish, and its history. Any earlier version can be compared with the live one and rolled back. |
|
||||
| Posts | Every blog post, drafts first, and **Write a post**: title, topic, kind of post, publish date, summary, a cover picked from the vault or uploaded, the pages it is about, Feature it, case study details, and the Markdown body. Preview shows it rendered with its images; Save draft and Publish write the note to `Posts/`. Images added to the post are uploaded to `_attachments/Posts` and appended as `![[file]]` lines. |
|
||||
| Edit | The form for any note (posts open their own editor). It is built from the note type's schema in `@gk/schema`, so it offers exactly the fields the content service accepts: image fields (`format: vault-image`) show a thumbnail, a pick from the vault and an upload into the type's `_attachments` folder; link fields offer note names; lists of rows (photos, specs, prices, socials) can be added to, removed and moved up or down. An emptied field is removed from the note, an unticked optional box is left out, and fields the form cannot show (a list inside a row) stay as they are. The title is the file name, so it is renamed in Obsidian. |
|
||||
| Images | Everything in `_attachments`, newest first, by folder, with which notes use each one. Upload several at once into a folder, copy an image's `![[file]]` line, and delete images no note uses. |
|
||||
| Capture | A status for the Now log, or a Library item with the option to post a status about it in the same step. |
|
||||
| Store | Orders open or closed per category, with the reopen date and turnaround, and open slots or stock per product line. |
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
*
|
||||
* Each field carries `title` and `description` metadata. The JSON Schema
|
||||
* export keeps them, so the MCP server, the admin page and any later editor
|
||||
* can render a form or explain a field without a second definition.
|
||||
* can render a form or explain a field without a second definition. Image
|
||||
* and link fields also carry a `format` (`vault-image`, `vault-link`,
|
||||
* `vault-links`) so a form knows to offer a picker.
|
||||
*/
|
||||
import { z } from "zod";
|
||||
import { normalizeWikilinkInput, wikilinkTarget } from "./names.ts";
|
||||
@@ -30,7 +32,7 @@ export const wikilink = (meta: Meta) =>
|
||||
normalizeWikilinkInput,
|
||||
z.string().refine((v) => wikilinkTarget(v) !== null, "Write a link as [[Note name]]"),
|
||||
)
|
||||
.meta(meta);
|
||||
.meta({ format: "vault-link", ...meta });
|
||||
|
||||
/** A list of links to other notes. */
|
||||
export const wikilinks = (meta: Meta) =>
|
||||
@@ -39,7 +41,7 @@ export const wikilinks = (meta: Meta) =>
|
||||
(v) => (Array.isArray(v) ? v.map(normalizeWikilinkInput) : v),
|
||||
z.array(z.string().refine((v) => wikilinkTarget(v) !== null, "Write each link as [[Note name]]")),
|
||||
)
|
||||
.meta(meta);
|
||||
.meta({ format: "vault-links", ...meta });
|
||||
|
||||
/** An image: a vault attachment as `[[file.jpg]]`, or a full https URL. */
|
||||
export const image = (meta: Meta) =>
|
||||
@@ -53,7 +55,7 @@ export const image = (meta: Meta) =>
|
||||
"Use a vault image as [[file.jpg]] or a full https URL",
|
||||
),
|
||||
)
|
||||
.meta(meta);
|
||||
.meta({ format: "vault-image", ...meta });
|
||||
|
||||
/** One short line of text. */
|
||||
export const line = (meta: Meta) => z.string().trim().min(1).meta(meta);
|
||||
|
||||
@@ -110,3 +110,31 @@ details summary { cursor: pointer; color: var(--muted); margin-top: 10px; }
|
||||
form.inline { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; font-size: 13px; }
|
||||
form.inline button { min-height: 30px; padding: 2px 12px; font-size: 13px; }
|
||||
.preview img { max-height: 480px; object-fit: contain; }
|
||||
|
||||
/* Note editor */
|
||||
.note-form .field { margin-bottom: 14px; }
|
||||
.note-form fieldset { border: 1px solid var(--line); border-radius: 8px; padding: 10px 12px 12px; margin: 0 0 14px; min-width: 0; }
|
||||
.note-form legend { padding: 0 6px; font-weight: 600; }
|
||||
.note-form .label { font-weight: 600; }
|
||||
.image-pick { display: grid; grid-template-columns: 120px minmax(0, 1fr); gap: 12px; align-items: start; }
|
||||
.image-pick img, .image-pick .no-image { width: 120px; aspect-ratio: 4 / 3; object-fit: cover; border-radius: 8px; border: 1px solid var(--line); display: block; }
|
||||
.image-pick .no-image { display: grid; place-items: center; color: var(--muted); font-size: 0.85rem; }
|
||||
.image-pick .sub { margin-top: 8px; font-weight: 400; color: var(--muted); }
|
||||
.side .image-pick { grid-template-columns: 1fr; }
|
||||
.side .image-pick img, .side .image-pick .no-image { width: 100%; aspect-ratio: 16 / 9; }
|
||||
.row-item { border-top: 1px solid var(--line); padding: 10px 0; }
|
||||
.row-item:first-of-type { border-top: 0; }
|
||||
.row-item.blank { opacity: 0.85; }
|
||||
.row-fields { display: grid; gap: 0 12px; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); }
|
||||
.row-fields .field:has(.image-pick) { grid-column: 1 / -1; }
|
||||
.row-tools { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-top: 4px; }
|
||||
.row-tools button { padding: 4px 10px; font-size: 0.85rem; }
|
||||
|
||||
/* Site pages */
|
||||
.site-pages { list-style: none; padding: 0; margin: 0; display: grid; gap: 16px; }
|
||||
.site-pages h2 { margin: 0; font-size: 1.1rem; }
|
||||
.page-images { list-style: none; padding: 0; margin: 8px 0; display: flex; flex-wrap: wrap; gap: 10px; }
|
||||
.page-images li { width: 120px; font-size: 0.8rem; color: var(--muted); }
|
||||
.page-images img { width: 120px; aspect-ratio: 4 / 3; object-fit: cover; border-radius: 8px; border: 1px solid var(--line); display: block; margin-bottom: 4px; }
|
||||
.feeds { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
|
||||
.pill-link { border: 1px solid var(--line); border-radius: 999px; padding: 2px 10px; font-size: 0.85rem; }
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -69,7 +69,7 @@ const post = async (path: string, form: Record<string, string>, headers: Record<
|
||||
headers: { "content-type": "application/x-www-form-urlencoded", "sec-fetch-site": "same-origin", ...headers },
|
||||
body: new URLSearchParams(form).toString(),
|
||||
});
|
||||
return { status: res.status, location: res.headers.get("location") ?? "" };
|
||||
return { status: res.status, location: res.headers.get("location") ?? "", text: await res.text() };
|
||||
};
|
||||
|
||||
describe("admin page", () => {
|
||||
@@ -149,4 +149,40 @@ describe("admin page", () => {
|
||||
expect(page.text).toContain("personalreader");
|
||||
expect(page.text).toContain("Suwayomi did not answer");
|
||||
});
|
||||
|
||||
it("edits a page's text and images from Site pages", async () => {
|
||||
const site = await get("/site");
|
||||
expect(site.text).toContain("Add page text");
|
||||
expect((await get("/edit/new?type=page&page=%2Fabout&title=About")).text).toContain('value="/about"');
|
||||
|
||||
// A tiny PNG: the content service checks the file's first bytes.
|
||||
const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0]);
|
||||
const fd = new FormData();
|
||||
for (const [k, v] of Object.entries({ type: "page", title: "About", page: "/about", lede: "Maker of things", "has.publish": "1", publish: "1", "photos.0.file": "", "photos.0.alt": "" })) fd.append(k, v);
|
||||
fd.append("upload.portrait", new Blob([png], { type: "image/png" }), "Greg Portrait.png");
|
||||
const res = await fetch(base + "/edit/save", { method: "POST", redirect: "manual", headers: { "sec-fetch-site": "same-origin" }, body: fd });
|
||||
expect(res.status).toBe(303);
|
||||
const note = r.store.list({ type: "page" }).find((n) => n.data.page === "/about")!;
|
||||
expect(note.data).toMatchObject({ lede: "Maker of things", portrait: "[[greg-portrait.png]]" });
|
||||
expect(note.publish).toBe(true);
|
||||
expect((await get("/site")).text).toContain("/attachments/greg-portrait.png");
|
||||
|
||||
// Clearing a field removes it; a reorder only redraws the form.
|
||||
await post("/edit/save", { type: "page", path: note.path, title: "About", page: "/about", lede: "", portrait: "greg-portrait.png", "has.publish": "1", publish: "1" });
|
||||
expect(r.store.get(note.path)!.data.lede).toBeUndefined();
|
||||
const moved = await post("/edit/save", {
|
||||
type: "page",
|
||||
path: note.path,
|
||||
title: "About",
|
||||
page: "/about",
|
||||
"photos.0.file": "a.jpg",
|
||||
"photos.0.alt": "A",
|
||||
"photos.1.file": "b.jpg",
|
||||
"photos.1.alt": "B",
|
||||
move: "photos:1:up",
|
||||
});
|
||||
expect(moved.status).toBe(200);
|
||||
expect(moved.text.indexOf('value="b.jpg"')).toBeLessThan(moved.text.indexOf('value="a.jpg"'));
|
||||
expect(r.store.get(note.path)!.data.photos).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { changeFields, formData, kindOf, moveRow, noteEditor, shapeOf } from "../src/editor.ts";
|
||||
|
||||
const form = (pairs: [string, string][]) => new URLSearchParams(pairs);
|
||||
|
||||
describe("note editor fields", () => {
|
||||
it("knows every field of every kind of page it edits", () => {
|
||||
const p = shapeOf("page").properties;
|
||||
expect(kindOf(p.portrait!)).toBe("image");
|
||||
expect(kindOf(p.cover!)).toBe("image");
|
||||
expect(kindOf(p.photos!)).toBe("rows");
|
||||
expect(kindOf(p.links!)).toBe("links");
|
||||
expect(kindOf(p.tags!)).toBe("lines");
|
||||
expect(kindOf(shapeOf("make").properties.photos!)).toBe("images");
|
||||
expect(kindOf(shapeOf("make").properties.date!)).toBe("date");
|
||||
expect(kindOf(shapeOf("experience").properties.start!)).toBe("month");
|
||||
expect(kindOf(shapeOf("product-line").properties.slots!)).toBe("group");
|
||||
expect(kindOf(shapeOf("craft").properties.status!)).toBe("enum");
|
||||
});
|
||||
|
||||
it("reads a Page text form back into note values", () => {
|
||||
const v = formData(
|
||||
"page",
|
||||
form([
|
||||
["page", "/about"],
|
||||
["lede", " Maker of things "],
|
||||
["portrait", "greg.jpg"],
|
||||
["photos.0.file", "kashi.jpg"],
|
||||
["photos.0.alt", "Kashi asleep"],
|
||||
["photos.0.caption", "Kashi"],
|
||||
["photos.1.file", "tea.jpg"],
|
||||
["photos.1.alt", "Tea"],
|
||||
["photos.1.remove", "1"],
|
||||
["photos.2.file", ""],
|
||||
["photos.2.alt", ""],
|
||||
["has.publish", "1"],
|
||||
["publish", "1"],
|
||||
["has.favorite", "1"],
|
||||
["links", "Resin\n[[Galaxy dice]]\n"],
|
||||
]),
|
||||
new Map([["cover", ["new-cover.jpg"]]]),
|
||||
);
|
||||
expect(v).toMatchObject({
|
||||
page: "/about",
|
||||
lede: "Maker of things",
|
||||
portrait: "[[greg.jpg]]",
|
||||
cover: "[[new-cover.jpg]]",
|
||||
photos: [{ file: "[[kashi.jpg]]", alt: "Kashi asleep", caption: "Kashi" }],
|
||||
publish: true,
|
||||
links: ["[[Resin]]", "[[Galaxy dice]]"],
|
||||
});
|
||||
expect(v.quickFacts).toBeUndefined();
|
||||
expect(v.favorite).toBeUndefined();
|
||||
});
|
||||
|
||||
it("adds uploaded photos to an image list and keeps https images", () => {
|
||||
const v = formData(
|
||||
"make",
|
||||
form([
|
||||
["photos.0", "https://example.com/a.jpg"],
|
||||
["photos.1", "b.jpg"],
|
||||
["photos.1.remove", "1"],
|
||||
]),
|
||||
new Map([["photos", ["c.jpg", "d.jpg"]]]),
|
||||
);
|
||||
expect(v.photos).toEqual(["https://example.com/a.jpg", "[[c.jpg]]", "[[d.jpg]]"]);
|
||||
});
|
||||
|
||||
it("clears emptied fields on an update and leaves them out on a create", () => {
|
||||
const v = { lede: "Hi", portrait: undefined, photos: undefined };
|
||||
expect(changeFields(v, true)).toEqual({ lede: "Hi", portrait: null, photos: null });
|
||||
expect(changeFields(v, false)).toEqual({ lede: "Hi" });
|
||||
});
|
||||
|
||||
it("moves a row up or down", () => {
|
||||
const v = { photos: ["a", "b", "c"] };
|
||||
expect(moveRow(v, "photos:2:up").photos).toEqual(["a", "c", "b"]);
|
||||
expect(moveRow(v, "photos:0:down").photos).toEqual(["b", "a", "c"]);
|
||||
expect(moveRow(v, "photos:0:up").photos).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
it("renders a picker with a thumbnail for each image", () => {
|
||||
const page = String(
|
||||
noteEditor(
|
||||
{ type: "page", path: "Pages/About.md", title: "About", publish: true, data: { page: "/about", portrait: "[[greg.jpg]]", photos: [{ file: "[[kashi.jpg]]", alt: "Kashi" }] }, body: "Hello" },
|
||||
{ images: [{ name: "greg.jpg", folder: "Pages", size: 1, modified: "", usedBy: [] }], notes: [] },
|
||||
),
|
||||
);
|
||||
expect(page).toContain('<img src="/attachments/greg.jpg"');
|
||||
expect(page).toContain('<img src="/attachments/kashi.jpg"');
|
||||
expect(page).toContain('name="upload.portrait"');
|
||||
expect(page).toContain('value="photos:0:down"');
|
||||
expect(page).toContain(">Hello</textarea>");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user