Form view: structured key/value editor (keys read-only, values editable; side-by-side desktop, stacked mobile) + testable strings-format.js

This commit is contained in:
FuelBoard Contributor
2026-08-13 14:09:19 +01:00
parent 4b3f7365cf
commit f06cd833c0
4 changed files with 343 additions and 36 deletions
+189 -35
View File
@@ -20,8 +20,15 @@
padding: 8px 10px; border-radius: 8px; font-size: 13px; margin-bottom: 12px;
}
#dirty.ok { background: #12331c; border-color: #1f7a3d; color: #7ee2a0; }
.toolbar { margin-bottom: 12px; }
.seg { display: flex; gap: 6px; margin-bottom: 10px; }
.seg-btn {
flex: 1; padding: 9px 0; border-radius: 10px; font-size: 14px; font-weight: 600;
background: #0d0f12; color: #8b93a1; border: 1px solid #2a313a; cursor: pointer;
}
.seg-btn.on { background: #12331c; border-color: #1f7a3d; color: #7ee2a0; }
select {
width: 100%; margin-bottom: 12px; padding: 10px 12px; font-size: 15px;
width: 100%; margin-bottom: 10px; padding: 10px 12px; font-size: 15px;
background: #0d0f12; color: #e6e8eb; border: 1px solid #2a313a;
border-radius: 10px; -webkit-appearance: none; appearance: none;
}
@@ -30,6 +37,36 @@
background: #0d0f12; color: #d7dce2; border: 1px solid #2a313a;
border-radius: 10px; padding: 12px; font: 13px/1.5 ui-monospace, Menlo, monospace;
}
#notice {
display: none; background: #3a2b10; border: 1px solid #8a6410; color: #ffd479;
padding: 8px 10px; border-radius: 8px; font-size: 13px; margin-bottom: 12px;
}
/* Form view ------------------------------------------------------------ */
.form { display: flex; flex-direction: column; }
.section {
font-size: 12px; font-weight: 700; letter-spacing: .07em; text-transform: uppercase;
color: #5fd68a; padding: 14px 2px 5px; margin-top: 8px;
border-bottom: 1px solid #1f7a3d33;
}
.comment { color: #5a6472; font-size: 12px; padding: 8px 2px; font-style: italic; }
.entry {
display: grid; grid-template-columns: minmax(0, 42%) minmax(0, 58%);
gap: 10px; align-items: start; padding: 7px 0; border-bottom: 1px solid #161a20;
}
.entry .k {
font: 12.5px/1.45 ui-monospace, Menlo, monospace; color: #9aa4b2;
word-break: break-word; padding-top: 8px; user-select: text;
}
.entry .v input {
width: 100%; background: #0d0f12; color: #e6e8eb; border: 1px solid #2a313a;
border-radius: 8px; padding: 7px 9px; font-size: 14px;
}
.entry .v input:focus { border-color: #1f7a3d; outline: none; }
@media (max-width: 640px) {
/* Mobile: key on top, value below */
.entry { grid-template-columns: 1fr; gap: 3px; }
.entry .k { padding-top: 0; font-size: 11.5px; }
}
.row { display: flex; gap: 10px; margin: 12px 0; flex-wrap: wrap; }
button {
flex: 1; min-width: 140px; padding: 14px 16px; border: 0; border-radius: 12px;
@@ -56,10 +93,18 @@
<h1>⛽ FuelBoard Copy Editor</h1>
<div class="meta"><code id="repo"></code> · <span id="head"></span></div>
<div id="dirty"></div>
<select id="jump" disabled>
<option value="">Jump to section…</option>
</select>
<textarea id="src" spellcheck="false" placeholder="Loading Localizable.strings…"></textarea>
<div class="toolbar">
<div class="seg">
<button id="btnForm" class="seg-btn on" type="button">Form</button>
<button id="btnRaw" class="seg-btn" type="button">Raw</button>
</div>
<select id="jump" disabled>
<option value="">Jump to section…</option>
</select>
</div>
<div id="notice"></div>
<div id="form" class="form"></div>
<textarea id="src" hidden spellcheck="false" placeholder="Loading Localizable.strings…"></textarea>
<div class="row">
<button id="save">Save</button>
<button id="saveBuild">Save &amp; Build</button>
@@ -67,6 +112,7 @@
<div id="status"></div>
<div id="result"></div>
<script src="strings-format.js"></script>
<script>
const KEY = "fbEditorToken";
// Accept the token from the URL (?key=…): a bookmarked link survives storage
@@ -76,6 +122,13 @@ const urlKey = new URLSearchParams(location.search).get("key");
let token = urlKey || localStorage.getItem(KEY) || "";
if (urlKey) localStorage.setItem(KEY, urlKey);
let pollTimer = null;
let blocks = []; // parsed .strings blocks (StringsFormat)
let sections = []; // [{name, line}] from the server
let mode = "form"; // "form" | "raw"
let localDirty = false; // unsaved edits in this page session
let serverDirty = []; // git dirty files from the server
const $ = id => document.getElementById(id);
async function api(path, opts = {}) {
const headers = { "X-Key": token, ...(opts.headers || {}) };
@@ -89,59 +142,149 @@ async function api(path, opts = {}) {
return res;
}
// ---- views ---------------------------------------------------------------
function renderForm() {
const el = $("form");
el.innerHTML = "";
const sec = new Map(sections.map(s => [s.line, s.name]));
let line = 1;
for (const b of blocks) {
const start = line;
line += b.raw.split("\n").length;
if (b.type === "blank") continue;
if (b.type === "comment") {
const div = document.createElement("div");
if (sec.has(start)) {
div.className = "section";
div.dataset.sectionLine = start;
div.textContent = (b.raw.trim().match(/^\/\* (.+) \*\/$/) || [])[1] || b.raw.trim();
} else {
div.className = "comment";
div.textContent = b.raw;
}
el.appendChild(div);
continue;
}
if (b.type === "entry") {
const row = document.createElement("div");
row.className = "entry";
const k = document.createElement("div");
k.className = "k";
k.textContent = b.key; // read-only by design
const v = document.createElement("div");
v.className = "v";
const input = document.createElement("input");
input.type = "text";
input.spellcheck = false;
input.autocapitalize = "off";
input.autocorrect = "off";
input.value = b.value;
input.addEventListener("input", () => {
b.value = input.value;
localDirty = true;
updateBanner();
});
v.appendChild(input);
row.appendChild(k);
row.appendChild(v);
el.appendChild(row);
continue;
}
// Unparseable data line — never let the form clobber a file we don't
// understand; drop to Raw editing with a notice.
$("notice").textContent = "Line not understood (see below) — switched to Raw view:\n" + b.raw.slice(0, 120);
$("notice").style.display = "block";
switchMode("raw");
return;
}
$("notice").style.display = "none";
}
function switchMode(m) {
mode = m;
$("btnForm").classList.toggle("on", m === "form");
$("btnRaw").classList.toggle("on", m === "raw");
$("form").hidden = m !== "form";
$("src").hidden = m !== "raw";
if (m === "form") {
blocks = StringsFormat.parseStrings($("src").value);
renderForm();
} else {
$("src").value = StringsFormat.serializeStrings(blocks);
}
}
// ---- load / save ----------------------------------------------------------
async function load() {
try {
const res = await api("/file");
const data = await res.json();
document.getElementById("src").value = data.content;
document.getElementById("repo").textContent = data.repo;
document.getElementById("head").textContent = data.head;
const jump = document.getElementById("jump");
$("src").value = data.content;
blocks = StringsFormat.parseStrings(data.content);
sections = data.sections || [];
$("repo").textContent = data.repo;
$("head").textContent = data.head;
const jump = $("jump");
jump.innerHTML = '<option value="">Jump to section…</option>' +
(data.sections || []).map(s => `<option value="${s.line}">${s.name}</option>`).join("");
jump.disabled = !(data.sections || []).length;
const dirty = document.getElementById("dirty");
if (data.dirty.length) {
dirty.style.display = "block";
dirty.classList.remove("ok");
dirty.textContent = "⚠ working tree has uncommitted changes: " + data.dirty.join(", ");
} else {
dirty.style.display = "block";
dirty.classList.add("ok");
dirty.textContent = "✓ working tree clean — safe to edit";
}
sections.map(s => `<option value="${s.line}">${s.name}</option>`).join("");
jump.disabled = !sections.length;
serverDirty = data.dirty || [];
localDirty = false;
updateBanner();
renderForm();
} catch (e) { statusBox("error loading file: " + e, "fail"); }
}
function updateBanner() {
const dirty = $("dirty");
dirty.style.display = "block";
if (localDirty) {
dirty.classList.remove("ok");
dirty.textContent = "⚠ unsaved edits — press Save (or Save & Build)";
} else if (serverDirty.length) {
dirty.classList.remove("ok");
dirty.textContent = "⚠ working tree has uncommitted changes: " + serverDirty.join(", ");
} else {
dirty.classList.add("ok");
dirty.textContent = "✓ working tree clean — safe to edit";
}
}
function currentContent() {
return mode === "form" ? StringsFormat.serializeStrings(blocks) : $("src").value;
}
function statusBox(text, cls) {
const s = document.getElementById("status");
const s = $("status");
s.style.display = "block";
s.className = cls || "";
s.textContent = text;
}
async function saveOnly() {
const res = await api("/save", { method: "POST", body: document.getElementById("src").value });
const res = await api("/save", { method: "POST", body: currentContent() });
const data = await res.json();
if (data.lint_ok) {
statusBox("✓ saved — " + data.lint, "done");
await load(); // refresh clean/dirty + re-parse
} else {
statusBox("✗ saved but INVALID — " + data.lint, "fail");
}
}
async function saveAndBuild() {
const saveBtn = document.getElementById("save");
const buildBtn = document.getElementById("saveBuild");
const saveBtn = $("save");
const buildBtn = $("saveBuild");
saveBtn.disabled = buildBtn.disabled = true;
document.getElementById("result").style.display = "none";
$("result").style.display = "none";
statusBox("saving + starting build…");
let res = await api("/save-build", { method: "POST", body: document.getElementById("src").value });
let res = await api("/save-build", { method: "POST", body: currentContent() });
let data = await res.json();
if (!data.job_id) { statusBox("✗ " + JSON.stringify(data), "fail"); enable(); return; }
// Poll the job until it finishes.
pollTimer = setInterval(async () => {
try {
const r = await api("/job/" + data.job_id);
@@ -149,7 +292,7 @@ async function saveAndBuild() {
statusBox(j.lines.join("\n") + (j.running ? "\n…building…" : ""), j.success ? "done" : "fail");
if (j.done) {
clearInterval(pollTimer);
const result = document.getElementById("result");
const result = $("result");
if (j.success) {
result.className = "ok";
result.innerHTML = "✓ Build OK — <a href=\"http://192.168.1.131:8765/FuelBoard.ipa\">FuelBoard.ipa</a> " +
@@ -160,23 +303,34 @@ async function saveAndBuild() {
}
result.style.display = "block";
enable();
await load();
}
} catch (e) { clearInterval(pollTimer); statusBox("poll error: " + e, "fail"); enable(); }
}, 2000);
}
function enable() {
document.getElementById("save").disabled = false;
document.getElementById("saveBuild").disabled = false;
$("save").disabled = false;
$("saveBuild").disabled = false;
}
document.getElementById("save").addEventListener("click", saveOnly);
document.getElementById("saveBuild").addEventListener("click", saveAndBuild);
document.getElementById("jump").addEventListener("change", (e) => {
// ---- events ----------------------------------------------------------------
$("save").addEventListener("click", saveOnly);
$("saveBuild").addEventListener("click", saveAndBuild);
$("btnForm").addEventListener("click", () => switchMode("form"));
$("btnRaw").addEventListener("click", () => switchMode("raw"));
$("src").addEventListener("input", () => { localDirty = true; updateBanner(); });
$("jump").addEventListener("change", (e) => {
const line = parseInt(e.target.value, 10);
e.target.value = ""; // act as a "go" control, not a persistent selection
if (!line) return;
const ta = document.getElementById("src");
if (mode === "form") {
const h = document.querySelector(`[data-section-line="${line}"]`);
if (h) h.scrollIntoView({ behavior: "smooth", block: "start" });
return;
}
const ta = $("src");
const ls = ta.value.split("\n");
let offset = 0;
for (let i = 0; i < line - 1; i++) offset += ls[i].length + 1;