Add JSON import and export
This commit is contained in:
@@ -101,6 +101,17 @@ Features:
|
|||||||
- Delete a slot
|
- Delete a slot
|
||||||
- Automatic draft saving for the current unsaved work
|
- Automatic draft saving for the current unsaved work
|
||||||
|
|
||||||
|
## Import and export
|
||||||
|
|
||||||
|
The current build can be backed up or shared as JSON:
|
||||||
|
|
||||||
|
- Generate JSON in the text box and copy it.
|
||||||
|
- Download the current build as a `.json` file.
|
||||||
|
- Import by pasting JSON into the text box.
|
||||||
|
- Import by uploading a `.json` file.
|
||||||
|
|
||||||
|
Imports accept both the app's wrapped export format and the raw build object. Imported builds are normalized, rendered immediately, and autosaved as the current draft.
|
||||||
|
|
||||||
## Running locally
|
## Running locally
|
||||||
|
|
||||||
Open `index.html` in a browser.
|
Open `index.html` in a browser.
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ const PROFILES = {
|
|||||||
|
|
||||||
const STORAGE_KEY = "forgeMasterOptimizer.v1";
|
const STORAGE_KEY = "forgeMasterOptimizer.v1";
|
||||||
const DRAFT_KEY = "forgeMasterOptimizer.draft.v1";
|
const DRAFT_KEY = "forgeMasterOptimizer.draft.v1";
|
||||||
|
const EXPORT_VERSION = 1;
|
||||||
const slots = [
|
const slots = [
|
||||||
...Array.from({ length: 8 }, (_, i) => ({ type: "item", title: `Item ${i + 1}` })),
|
...Array.from({ length: 8 }, (_, i) => ({ type: "item", title: `Item ${i + 1}` })),
|
||||||
{ type: "mount", title: "Mount" },
|
{ type: "mount", title: "Mount" },
|
||||||
@@ -358,6 +359,100 @@ function setStatus(message, danger = false) {
|
|||||||
node.style.color = danger ? "var(--danger)" : "var(--good)";
|
node.style.color = danger ? "var(--danger)" : "var(--good)";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setImportExportStatus(message, danger = false) {
|
||||||
|
const node = $("#importExportStatus");
|
||||||
|
node.textContent = message;
|
||||||
|
node.style.color = danger ? "var(--danger)" : "var(--good)";
|
||||||
|
}
|
||||||
|
|
||||||
|
function createExportPayload() {
|
||||||
|
return {
|
||||||
|
app: "forge-master-strategy-optimizer",
|
||||||
|
version: EXPORT_VERSION,
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
build: normalizeState(state),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createExportJson() {
|
||||||
|
return JSON.stringify(createExportPayload(), null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateExportJson() {
|
||||||
|
const json = createExportJson();
|
||||||
|
const textarea = $("#jsonTransfer");
|
||||||
|
textarea.value = json;
|
||||||
|
textarea.focus();
|
||||||
|
textarea.select();
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(json);
|
||||||
|
setImportExportStatus("Export JSON generated and copied to clipboard.");
|
||||||
|
} catch {
|
||||||
|
setImportExportStatus("Export JSON generated. Copy it from the text box or download it as a file.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadExportJson() {
|
||||||
|
const json = createExportJson();
|
||||||
|
$("#jsonTransfer").value = json;
|
||||||
|
const blob = new Blob([json], { type: "application/json" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = `forge-master-build-${new Date().toISOString().slice(0, 10)}.json`;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
setImportExportStatus("Downloaded current build as JSON.");
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseImportedState(jsonText) {
|
||||||
|
if (!jsonText.trim()) throw new Error("Paste JSON first or upload a JSON file.");
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(jsonText);
|
||||||
|
} catch {
|
||||||
|
throw new Error("Invalid JSON. Check that the full export was copied.");
|
||||||
|
}
|
||||||
|
const build = parsed?.build || parsed;
|
||||||
|
if (!build || typeof build !== "object" || !Array.isArray(build.pieces)) {
|
||||||
|
throw new Error("JSON does not look like a Forge Master build export.");
|
||||||
|
}
|
||||||
|
return normalizeState(build);
|
||||||
|
}
|
||||||
|
|
||||||
|
function importJsonText(jsonText, sourceLabel = "JSON") {
|
||||||
|
try {
|
||||||
|
state = parseImportedState(jsonText);
|
||||||
|
render();
|
||||||
|
setImportExportStatus(`Imported ${sourceLabel}. Draft autosaved.`);
|
||||||
|
} catch (error) {
|
||||||
|
setImportExportStatus(error.message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function importPastedJson() {
|
||||||
|
importJsonText($("#jsonTransfer").value, "pasted JSON");
|
||||||
|
}
|
||||||
|
|
||||||
|
function importUploadedJson(event) {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.addEventListener("load", () => {
|
||||||
|
const text = String(reader.result || "");
|
||||||
|
$("#jsonTransfer").value = text;
|
||||||
|
importJsonText(text, file.name);
|
||||||
|
event.target.value = "";
|
||||||
|
});
|
||||||
|
reader.addEventListener("error", () => {
|
||||||
|
setImportExportStatus("Could not read the selected file.", true);
|
||||||
|
event.target.value = "";
|
||||||
|
});
|
||||||
|
reader.readAsText(file);
|
||||||
|
}
|
||||||
|
|
||||||
function bindEvents() {
|
function bindEvents() {
|
||||||
$("#profileSelect").addEventListener("change", (event) => {
|
$("#profileSelect").addEventListener("change", (event) => {
|
||||||
state.profile = event.target.value;
|
state.profile = event.target.value;
|
||||||
@@ -381,6 +476,10 @@ function bindEvents() {
|
|||||||
$("#saveSlot").addEventListener("click", saveNamedSlot);
|
$("#saveSlot").addEventListener("click", saveNamedSlot);
|
||||||
$("#loadSlot").addEventListener("click", loadNamedSlot);
|
$("#loadSlot").addEventListener("click", loadNamedSlot);
|
||||||
$("#deleteSlot").addEventListener("click", deleteNamedSlot);
|
$("#deleteSlot").addEventListener("click", deleteNamedSlot);
|
||||||
|
$("#copyExport").addEventListener("click", generateExportJson);
|
||||||
|
$("#downloadExport").addEventListener("click", downloadExportJson);
|
||||||
|
$("#importJson").addEventListener("click", importPastedJson);
|
||||||
|
$("#importFile").addEventListener("change", importUploadedJson);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleGearInput(event) {
|
function handleGearInput(event) {
|
||||||
|
|||||||
+18
@@ -47,6 +47,24 @@
|
|||||||
<button id="resetBuild" type="button" class="secondary">Reset current build</button>
|
<button id="resetBuild" type="button" class="secondary">Reset current build</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="card import-export" aria-labelledby="import-export-heading">
|
||||||
|
<div>
|
||||||
|
<h2 id="import-export-heading">Import / Export</h2>
|
||||||
|
<p>Back up or share the current build as JSON. Paste JSON directly or upload a saved file to import.</p>
|
||||||
|
</div>
|
||||||
|
<div class="io-actions">
|
||||||
|
<button id="copyExport" type="button">Generate / Copy JSON</button>
|
||||||
|
<button id="downloadExport" type="button" class="secondary">Download JSON</button>
|
||||||
|
<label class="file-button secondary">
|
||||||
|
Upload JSON
|
||||||
|
<input id="importFile" type="file" accept="application/json,.json" />
|
||||||
|
</label>
|
||||||
|
<button id="importJson" type="button">Import pasted JSON</button>
|
||||||
|
</div>
|
||||||
|
<textarea id="jsonTransfer" rows="8" spellcheck="false" placeholder="Generated export JSON appears here. You can also paste JSON here and click Import pasted JSON."></textarea>
|
||||||
|
<p id="importExportStatus" class="status" role="status"></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="summary-grid" aria-label="Build summary">
|
<section class="summary-grid" aria-label="Build summary">
|
||||||
<article class="metric"><span>Total damage base</span><strong id="totalDamage">0</strong></article>
|
<article class="metric"><span>Total damage base</span><strong id="totalDamage">0</strong></article>
|
||||||
<article class="metric"><span>Total health base</span><strong id="totalHealth">0</strong></article>
|
<article class="metric"><span>Total health base</span><strong id="totalHealth">0</strong></article>
|
||||||
|
|||||||
+28
-2
@@ -26,11 +26,11 @@ body {
|
|||||||
var(--bg);
|
var(--bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
button, input, select {
|
button, input, select, textarea {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
button, input, select {
|
button, input, select, textarea {
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 0.75rem;
|
border-radius: 0.75rem;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
@@ -48,6 +48,7 @@ button {
|
|||||||
button:hover { filter: brightness(1.1); }
|
button:hover { filter: brightness(1.1); }
|
||||||
button.secondary { background: var(--panel-2); }
|
button.secondary { background: var(--panel-2); }
|
||||||
button.danger { border-color: rgba(251, 113, 133, 0.5); background: rgba(251, 113, 133, 0.16); }
|
button.danger { border-color: rgba(251, 113, 133, 0.5); background: rgba(251, 113, 133, 0.16); }
|
||||||
|
textarea { width: 100%; min-height: 9rem; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
||||||
|
|
||||||
label {
|
label {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -104,6 +105,31 @@ h3 { margin-bottom: 0; }
|
|||||||
}
|
}
|
||||||
.controls label { min-width: 220px; }
|
.controls label { min-width: 220px; }
|
||||||
|
|
||||||
|
.import-export {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.9rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.import-export p { color: var(--muted); margin-bottom: 0; }
|
||||||
|
.io-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.file-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--panel-2);
|
||||||
|
padding: 0.7rem 0.85rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.file-button input { display: none; }
|
||||||
|
|
||||||
.summary-grid, .results, .gear-grid {
|
.summary-grid, .results, .gear-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
|
|||||||
Reference in New Issue
Block a user