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:
@@ -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