Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
83 lines
5.6 KiB
JavaScript
83 lines
5.6 KiB
JavaScript
// Reference sheet: pick a grid, a picture and a label for each slot, and get one white sheet saved to the media bin.
|
|
// Pictures come from the bin (or are uploaded into it first); each is cut to the slot's shape on the server.
|
|
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 thumb = (id) => `/fun/api/media/${encodeURIComponent(id)}/thumb`;
|
|
|
|
export default {
|
|
id: "refsheet",
|
|
mount: "assets.actions",
|
|
needs: ["api", "dom"],
|
|
modules: ["refsheet"],
|
|
setup({ host, app }) {
|
|
let win = null;
|
|
const open = async () => {
|
|
if (win) return;
|
|
let pictures = [];
|
|
const reload = async () => { const all = (await app.api.media()).media || []; learn(all); pictures = all.filter((m) => m.kind === "image"); };
|
|
try { await reload(); } catch (err) { return c.toast.warn({ text: `Could not read the media bin: ${err.message}` }); }
|
|
const state = { ...DEFAULTS, slots: resize([], DEFAULTS.rows, DEFAULTS.cols) };
|
|
const grid = c.region.stack({ gap: "sm" });
|
|
const note = c.text.sm({ text: "" });
|
|
let make;
|
|
|
|
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("media"); }
|
|
};
|
|
// 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}`, 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.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 {
|
|
const out = await app.api.refsheetMake(request(state));
|
|
await reload(); app.say("media");
|
|
c.toast.good({ text: `Saved “${out.media.name}” to the media bin.` });
|
|
if (out.missing.length) c.toast.warn({ text: `Left empty (picture gone or not readable): ${out.missing.join(", ")}.` });
|
|
win.close("done");
|
|
} 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; 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.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();
|
|
};
|
|
|
|
const button = c.button.sm({ label: "Reference sheet…", tone: "ghost", title: `A labelled grid of pictures on white, saved to the media bin (up to ${MAX_SLOTS} slots)`, onClick: open });
|
|
host.append(button.node);
|
|
return () => { if (win) win.close("gone"); button.destroy(); };
|
|
},
|
|
};
|