lane G M16: pathsStore — save/load/delete server-side camera paths UI

Unhides #btn-path-save / #path-load-select / #btn-path-del; save prompts a name and
POSTs camPath.toJSON(), load fetches + camPath.fromJSON, delete confirms; dropdown
refreshes after every mutation. Capsule mode: write buttons hide via write-ui, the
load dropdown keeps working against baked api/paths (hides itself if unavailable).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-17 17:42:19 +10:00
parent 0314daec1a
commit 1c0422a025

View File

@ -1,4 +1,4 @@
// Server-side camera paths UI (spec M16, lane G). STUB — lane G fills THIS FILE ONLY.
// Server-side camera paths UI (spec M16, lane G).
//
// Pre-wired by foundation2: the hidden controls in index.html — #btn-path-save (💾),
// #path-load-select (dropdown), #btn-path-del (🗑) — this module's import + init() call in
@ -6,13 +6,120 @@
// GET /api/paths/{id} ({..., json: <camPath JSON>}), POST /api/paths {name, json:<text>}
// (422 on invalid), DELETE /api/paths/{id}.
//
// When implementing: unhide the controls, set `ready = true`. Save = prompt for a name,
// POST camPath.toJSON(); load = fetch the selected path and camPath.fromJSON(json); delete =
// DELETE the selected id. Refresh the dropdown after every mutation. #btn-path-save and
// #btn-path-del already carry the `write-ui` class, so capsule mode (contract #5) hides
// them for free — the load dropdown may stay if you bake paths in, else hide it too.
// Save = prompt for a name, POST camPath.toJSON(); load = fetch the selected path and
// camPath.fromJSON(json); delete = DELETE the selected id; the dropdown refreshes after
// every mutation. #btn-path-save / #btn-path-del carry `write-ui`, so capsule mode
// (contract #5) hides them for free; the load dropdown stays — the capsule bakes
// api/paths + api/paths/{id}, so loading keeps working zero-backend. If the listing
// fetch fails in a capsule (older bundle without baked paths), the dropdown hides too.
import { state } from "./state.js";
import { camPath } from "./camPath.js";
const PLACEHOLDER = "— saved paths —";
function el(id) {
return document.getElementById(id);
}
async function api(path, options) {
const resp = await fetch(state.apiBase + path, options);
if (!resp.ok) throw new Error(`${path} -> HTTP ${resp.status}`);
return resp.json();
}
export const pathsStore = {
ready: false,
init() {}, // ponytail: stub — lane G replaces the body
init() {
const saveBtn = el("btn-path-save");
const select = el("path-load-select");
const delBtn = el("btn-path-del");
if (!saveBtn || !select || !delBtn) return;
saveBtn.addEventListener("click", () => this.save());
delBtn.addEventListener("click", () => this.remove());
select.addEventListener("change", () => this.load());
saveBtn.style.display = "";
select.style.display = "";
delBtn.style.display = "";
this.ready = true;
this.refresh(); // async initial fill of the dropdown
},
/** Rebuild the dropdown from GET /api/paths, keeping the selection when it survives. */
async refresh(selectId = null) {
const select = el("path-load-select");
const keep = selectId != null ? String(selectId) : select.value;
let paths;
try {
paths = await api("/api/paths");
} catch (err) {
console.warn("[festival4d] saved-paths listing unavailable", err);
if (state.capsule) select.style.display = "none"; // baked bundle without paths
return;
}
select.innerHTML = "";
const ph = document.createElement("option");
ph.value = "";
ph.textContent = PLACEHOLDER;
select.appendChild(ph);
for (const p of paths) {
const opt = document.createElement("option");
opt.value = String(p.id);
opt.textContent = p.name;
opt.title = p.created_at ?? "";
select.appendChild(opt);
}
select.value = [...select.options].some((o) => o.value === keep) ? keep : "";
},
/** 💾 Save the current camera path under a prompted name. */
async save() {
if (!camPath.keyframes.length) {
window.alert("No keyframes to save — press K (or ⏺+) to add some first.");
return;
}
const name = window.prompt("Name this camera path:", `path ${new Date().toLocaleString()}`);
if (!name || !name.trim()) return;
try {
const created = await api("/api/paths", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: name.trim(), json: camPath.toJSON() }),
});
await this.refresh(created.id); // leave the new path selected
} catch (err) {
console.error("[festival4d] saving camera path failed", err);
}
},
/** Load the selected saved path into camPath. */
async load() {
const id = el("path-load-select").value;
if (!id) return;
try {
const data = await api(`/api/paths/${id}`);
camPath.fromJSON(data.json);
} catch (err) {
console.error("[festival4d] loading camera path failed", err);
this.refresh(); // it may have been deleted elsewhere — resync the dropdown
}
},
/** 🗑 Delete the selected saved path. */
async remove() {
const select = el("path-load-select");
const id = select.value;
if (!id) return;
const name = select.options[select.selectedIndex]?.textContent ?? id;
if (!window.confirm(`Delete saved path "${name}"?`)) return;
try {
await api(`/api/paths/${id}`, { method: "DELETE" });
} catch (err) {
console.error("[festival4d] deleting camera path failed", err);
}
await this.refresh();
},
};