From 1c0422a02550c79b50ab4b2417905fc36a94577c Mon Sep 17 00:00:00 2001 From: type-two Date: Fri, 17 Jul 2026 17:42:19 +1000 Subject: [PATCH] =?UTF-8?q?lane=20G=20M16:=20pathsStore=20=E2=80=94=20save?= =?UTF-8?q?/load/delete=20server-side=20camera=20paths=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- frontend/src/pathsStore.js | 121 ++++++++++++++++++++++++++++++++++--- 1 file changed, 114 insertions(+), 7 deletions(-) diff --git a/frontend/src/pathsStore.js b/frontend/src/pathsStore.js index 7f8b2a6..e43cbc7 100644 --- a/frontend/src/pathsStore.js +++ b/frontend/src/pathsStore.js @@ -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: }), POST /api/paths {name, json:} // (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(); + }, };