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
+37 -1
View File
@@ -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();
});
});