Reference sheet: labelled fields, and a picture picker that shows the bin as thumbnails

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-11-10 01:02:23 +02:00
co-authored by Claude Sonnet 5.5
parent 34db2b331d
commit 1faeb30000
2 changed files with 35 additions and 21 deletions
+11 -4
View File
@@ -48,10 +48,17 @@ test("it is hidden when the server has no refsheet module, and the button opens
assert.match(button.textContent, /Reference sheet/);
button.click();
await new Promise((r) => setTimeout(r, 20));
const picks = [...document.querySelectorAll("select")].filter((s) => /Picture for slot/.test(s.getAttribute("aria-label") || ""));
assert.equal(picks.length, 6);
assert.deepEqual([...picks[0].options].map((o) => o.textContent), ["Empty", "front.png", "Upload a picture…"]); // a video is not offered
picks[0].value = "a"; picks[0].dispatchEvent(new Event("change", { bubbles: true }));
const buttons = (text) => [...document.querySelectorAll("button")].filter((b) => b.textContent.includes(text));
assert.equal(buttons("Choose picture…").length, 6);
assert.ok(document.body.textContent.includes("Rows") && document.body.textContent.includes("Columns") && document.body.textContent.includes("Slot format")); // every control has a visible label
buttons("Choose picture…")[0].click();
await new Promise((r) => setTimeout(r, 20));
const tiles = [...document.querySelectorAll("[role=option]")];
assert.deepEqual(tiles.map((t) => t.textContent), ["front.png"]); // the bin's pictures only: a video is not offered
assert.ok(tiles[0].querySelector("img").getAttribute("src").endsWith("/a/thumb"));
tiles[0].click();
await new Promise((r) => setTimeout(r, 20));
assert.equal(buttons("Change: front.png").length, 1);
const make = [...document.querySelectorAll("button")].find((b) => b.textContent === "Make sheet");
make.click();
await new Promise((r) => setTimeout(r, 20));
+24 -17
View File
@@ -4,7 +4,6 @@ import { composer as c } from "../../composer/composer.js";
import { learn } from "../../shell/bin.js";
import { DEFAULTS, FORMATS, MAX_SLOTS, problem, request, resize } from "./sheet.js";
const UPLOAD = "__upload";
const thumb = (id) => `/fun/api/media/${encodeURIComponent(id)}/thumb`;
export default {
@@ -24,29 +23,37 @@ export default {
const note = c.text.sm({ text: "" });
let make;
const choose = async (i, value) => {
if (value === UPLOAD) {
const file = await app.dom.pickFile("image/*");
if (file) {
const got = await app.api.uploadMedia([file]);
if (got.problems.length) c.toast.warn({ text: got.problems[0] });
if (got.media[0]) { await reload(); state.slots[i].picture = got.media[0].id; app.say("bin"); }
}
} else state.slots[i].picture = value;
drawGrid(); status();
const upload = async (i) => {
const file = await app.dom.pickFile("image/*");
if (!file) return;
const got = await app.api.uploadMedia([file]);
if (got.problems.length) c.toast.warn({ text: got.problems[0] });
if (got.media[0]) { await reload(); state.slots[i].picture = got.media[0].id; app.say("bin"); }
};
// The bin itself, as pictures with their names: one click chooses, Escape or the close button keeps what was there.
let picking = null;
const choose = (i) => {
if (picking) return;
const done = (fn) => { picking.close("done"); Promise.resolve(fn && fn()).then(() => { drawGrid(); status(); }); };
const items = pictures.map((m) => ({ id: m.id, label: m.name, thumb: thumb(m.id) }));
const gallery = c.gallery.adaptive({ id: "refsheet-pick", items, selection: state.slots[i].picture ? [state.slots[i].picture] : [], empty: "The media bin has no pictures yet. Upload one.",
onActivate: (it) => done(() => { state.slots[i].picture = it.id; }) });
picking = c.modal.stacked({ title: `Picture for slot ${i + 1}`, size: "lg", onClose: () => { picking = null; }, body: gallery,
actions: [c.button.md({ label: "Upload a picture…", onClick: () => done(() => upload(i)) }),
c.button.md({ label: "Leave empty", tone: "ghost", onClick: () => done(() => { state.slots[i].picture = ""; }) })] });
};
const cell = (slot, i) => {
const named = pictures.find((m) => m.id === slot.picture);
const card = c.captioned.cell({ src: slot.picture ? thumb(slot.picture) : "", title: `Slot ${i + 1}`, caption: slot.label, label: `Slot ${i + 1}`, onChange: (text) => { slot.label = text; } });
const pick = c.select.sm({ label: `Picture for slot ${i + 1}`, value: named ? named.id : "", onChange: (v) => choose(i, v),
options: [{ value: "", label: "Empty" }, ...pictures.map((m) => ({ value: m.id, label: m.name })), { value: UPLOAD, label: "Upload a picture…" }] });
const card = c.captioned.cell({ src: slot.picture ? thumb(slot.picture) : "", title: `Slot ${i + 1}`, badge: slot.picture && !named ? "picture gone" : "", tone: "warn", caption: slot.label, label: `Slot ${i + 1}`,
onOpen: () => choose(i), onChange: (text) => { slot.label = text; } });
const pick = c.button.sm({ label: named ? `Change: ${named.name}` : "Choose picture…", tone: "ghost", onClick: () => choose(i) });
return c.region.stack({ gap: "xs", children: [card, pick] });
};
const drawGrid = () => grid.set([c.grid.cards({ children: state.slots.map(cell) })]);
const status = () => { note.setText(problem(state)); make && make.setDisabled(!!problem(state)); };
const regrid = () => { state.slots = resize(state.slots, state.rows, state.cols); drawGrid(); status(); };
const num = (key, label, max) => c.number.md({ label, value: state[key], min: 1, max, step: 1, precision: 0, onChange: (v) => { state[key] = Math.max(1, Math.min(max, Math.round(v) || 1)); regrid(); } });
const num = (key, label, max) => c.field.default({ label, hint: `1 to ${max}`, control: c.number.md({ label, value: state[key], min: 1, max, step: 1, precision: 0, onChange: (v) => { state[key] = Math.max(1, Math.min(max, Math.round(v) || 1)); regrid(); } }) });
make = c.button.md({ label: "Make sheet", tone: "primary", onClick: async () => {
make.setBusy(true);
try {
@@ -58,11 +65,11 @@ export default {
} catch (err) { c.toast.warn({ text: `Could not make the sheet: ${err.message}` }); } finally { make.setBusy(false); }
} });
win = c.modal.generic({ title: "Reference sheet", subtitle: "Pictures in a grid on white, each labelled below. Each picture is cropped from the middle to fit its slot.", size: "xl",
onClose: () => { win = null; },
onClose: () => { win = null; if (picking) picking.close("gone"); },
body: c.region.stack({ gap: "md", children: [
c.field.default({ label: "Title (top left corner, optional)", control: c.input.md({ value: state.title, label: "Title", onCommit: (v) => { state.title = v; } }) }),
c.region.stack({ gap: "sm", children: [num("rows", "Rows", 12), num("cols", "Columns", 12),
c.select.md({ label: "Slot format", value: state.format, options: FORMATS.map((f) => ({ value: f, label: f })), onChange: (v) => { state.format = v; } })] }),
c.field.default({ label: "Slot format", hint: "width : height of every picture slot", control: c.select.md({ label: "Slot format", value: state.format, options: FORMATS.map((f) => ({ value: f, label: f })), onChange: (v) => { state.format = v; } }) })] }),
note, grid] }),
actions: [make] });
drawGrid(); status();