Able to be edited, other cleanup
This commit is contained in:
+395
-193
@@ -2,52 +2,26 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Card Server</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--bg: #0f0f0f;
|
||||
--surface: #161616;
|
||||
--border: #252525;
|
||||
--text: #d8d8d8;
|
||||
--muted: #555;
|
||||
--green: #4caf50;
|
||||
--red: #e57373;
|
||||
--yellow: #ffd54f;
|
||||
--blue: #64b5f6;
|
||||
--purple: #ce93d8;
|
||||
}
|
||||
body {
|
||||
background: var(--bg); color: var(--text);
|
||||
font-family: 'Courier New', monospace; font-size: 13px;
|
||||
display: flex; height: 100vh; overflow: hidden;
|
||||
--bg: #0f0f0f; --border: #252525; --text: #d8d8d8; --muted: #555;
|
||||
--green: #4caf50; --red: #e57373; --yellow: #ffd54f; --blue: #64b5f6; --gold: #ffc107;
|
||||
}
|
||||
body { background: var(--bg); color: var(--text); font-family: 'Courier New', monospace; font-size: 13px; display: flex; height: 100vh; overflow: hidden; }
|
||||
|
||||
/* ── Sidebar ── */
|
||||
.sidebar {
|
||||
width: 310px; min-width: 310px;
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex; flex-direction: column; overflow: hidden;
|
||||
}
|
||||
.section { padding: 14px; border-bottom: 1px solid var(--border); }
|
||||
.section-label {
|
||||
font-size: 10px; text-transform: uppercase; letter-spacing: 1.5px;
|
||||
color: var(--muted); margin-bottom: 10px;
|
||||
}
|
||||
input, textarea {
|
||||
width: 100%; background: #1c1c1c; border: 1px solid var(--border);
|
||||
color: var(--text); padding: 7px 9px; font-family: inherit; font-size: 13px;
|
||||
border-radius: 2px; outline: none;
|
||||
}
|
||||
input { margin-bottom: 7px; }
|
||||
/* Sidebar */
|
||||
.sidebar { width: 280px; min-width: 280px; border-right: 1px solid var(--border); display: flex; flex-direction: column; overflow: hidden; }
|
||||
.section { padding: 12px; border-bottom: 1px solid var(--border); }
|
||||
.section-label { font-size: 10px; text-transform: uppercase; letter-spacing: 1.5px; color: var(--muted); margin-bottom: 8px; }
|
||||
input, textarea { width: 100%; background: #1c1c1c; border: 1px solid var(--border); color: var(--text); padding: 6px 8px; font-family: inherit; font-size: 12px; border-radius: 2px; outline: none; }
|
||||
input { margin-bottom: 6px; }
|
||||
input:focus, textarea:focus { border-color: #3a3a3a; }
|
||||
textarea { min-height: 150px; resize: vertical; }
|
||||
|
||||
.btn {
|
||||
background: #1c1c1c; border: 1px solid var(--border); color: var(--text);
|
||||
padding: 6px 11px; font-family: inherit; font-size: 12px;
|
||||
cursor: pointer; border-radius: 2px; white-space: nowrap;
|
||||
}
|
||||
textarea { resize: vertical; }
|
||||
#cardList { min-height: 120px; }
|
||||
.btn { background: #1c1c1c; border: 1px solid var(--border); color: var(--text); padding: 5px 10px; font-family: inherit; font-size: 12px; cursor: pointer; border-radius: 2px; white-space: nowrap; }
|
||||
.btn:hover { background: #222; border-color: #3a3a3a; }
|
||||
.btn:disabled { opacity: 0.4; cursor: default; }
|
||||
.btn-green { color: var(--green); border-color: #1e3a1e; }
|
||||
@@ -56,53 +30,94 @@ textarea { min-height: 150px; resize: vertical; }
|
||||
.btn-red:hover { background: #1e1414; }
|
||||
.btn-blue { color: var(--blue); border-color: #1e2e3a; }
|
||||
.btn-blue:hover { background: #141c22; }
|
||||
|
||||
.row { display: flex; gap: 6px; margin-top: 8px; }
|
||||
|
||||
.btn-gold { color: var(--gold); border-color: #3a2e00; }
|
||||
.btn-gold:hover { background: #1e1800; }
|
||||
.row { display: flex; gap: 6px; margin-top: 7px; flex-wrap: wrap; }
|
||||
.error-text { color: var(--red); font-size: 11px; margin-top: 5px; }
|
||||
.deck-list { flex: 1; overflow-y: auto; }
|
||||
.deck-row {
|
||||
padding: 10px 14px; border-bottom: 1px solid var(--border);
|
||||
cursor: pointer; display: flex; align-items: center; gap: 8px;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.deck-row { padding: 9px 12px; border-bottom: 1px solid var(--border); cursor: pointer; display: flex; align-items: center; gap: 8px; }
|
||||
.deck-row:hover { background: #181818; }
|
||||
.deck-row.active { background: #1a1a24; border-left: 2px solid #3a3a6a; }
|
||||
.deck-name { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.badge {
|
||||
font-size: 10px; padding: 2px 6px; border-radius: 2px; white-space: nowrap;
|
||||
}
|
||||
.deck-name { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 12px; }
|
||||
.badge { font-size: 10px; padding: 2px 5px; border-radius: 2px; white-space: nowrap; }
|
||||
.badge-complete { color: var(--green); background: #141e14; }
|
||||
.badge-running { color: var(--yellow); background: #1e1a0e; }
|
||||
.badge-queued { color: var(--blue); background: #101622; }
|
||||
.badge-failed { color: var(--red); background: #1e1010; }
|
||||
|
||||
/* ── Main panel ── */
|
||||
.panel { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.badge-queued { color: var(--blue); background: #101622; }
|
||||
.badge-failed { color: var(--red); background: #1e1010; }
|
||||
|
||||
/* Main panel */
|
||||
.panel { flex: 1; display: flex; flex-direction: column; overflow: hidden; min-width: 0; }
|
||||
.progress-bar { height: 2px; background: var(--border); flex-shrink: 0; }
|
||||
.progress-fill { height: 100%; background: var(--green); transition: width 0.5s ease; }
|
||||
.progress-fill { height: 100%; background: var(--green); transition: width 0.4s ease; }
|
||||
.deck-header { padding: 9px 14px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 7px; flex-shrink: 0; flex-wrap: wrap; }
|
||||
.deck-header-title { flex: 1; font-weight: bold; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 80px; }
|
||||
.deck-header-meta { font-size: 11px; color: var(--muted); }
|
||||
.price-tag { font-size: 11px; color: var(--green); }
|
||||
|
||||
.log-header {
|
||||
padding: 11px 16px; border-bottom: 1px solid var(--border);
|
||||
display: flex; align-items: center; gap: 8px; flex-shrink: 0;
|
||||
/* Card area */
|
||||
.card-area { flex: 1; overflow-y: auto; padding: 12px 14px; }
|
||||
.card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); gap: 8px; }
|
||||
.card-tile { position: relative; border-radius: 4px; overflow: hidden; background: #1a1a1a; border: 1px solid var(--border); aspect-ratio: 63/88; cursor: pointer; }
|
||||
.card-tile img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.card-tile.missing { display: flex; align-items: center; justify-content: center; text-align: center; padding: 6px; font-size: 10px; color: var(--red); border-color: #3a1e1e; cursor: default; }
|
||||
.card-tile.is-commander { border: 2px solid var(--gold); }
|
||||
.card-tile.marked-remove { opacity: 0.35; border-color: var(--red); }
|
||||
.card-label { position: absolute; bottom: 0; left: 0; right: 0; background: rgba(0,0,0,0.82); color: #fff; font-size: 9px; padding: 3px 5px; opacity: 0; transition: opacity 0.15s; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.card-tile:hover .card-label { opacity: 1; }
|
||||
.commander-crown { position: absolute; top: 3px; left: 3px; font-size: 13px; line-height: 1; pointer-events: none; text-shadow: 0 1px 3px rgba(0,0,0,0.9); }
|
||||
.card-overlay-btn { position: absolute; display: none; border: none; cursor: pointer; font-size: 13px; line-height: 1; padding: 3px 5px; border-radius: 2px; }
|
||||
.btn-remove-card { top: 3px; right: 3px; background: rgba(180,0,0,0.8); color: #fff; }
|
||||
.btn-remove-card:hover { background: rgba(220,0,0,0.95); }
|
||||
.btn-set-commander { top: 3px; left: 3px; background: rgba(0,0,0,0.75); color: var(--gold); }
|
||||
.btn-set-commander:hover { background: rgba(40,30,0,0.95); }
|
||||
.edit-mode .card-tile:not(.missing) .card-overlay-btn { display: block; }
|
||||
.edit-mode .card-tile.is-commander .btn-set-commander { display: none; }
|
||||
.placeholder { color: #333; padding: 40px 20px; text-align: center; line-height: 2; }
|
||||
|
||||
/* Edit add section */
|
||||
.edit-add-section { margin-top: 16px; border-top: 1px solid var(--border); padding-top: 14px; }
|
||||
.search-box { position: relative; }
|
||||
.search-results { background: #141414; border: 1px solid var(--border); border-top: none; border-radius: 0 0 2px 2px; max-height: 280px; overflow-y: auto; display: none; }
|
||||
.search-results.open { display: block; }
|
||||
.search-result { display: flex; align-items: center; gap: 8px; padding: 6px 8px; cursor: pointer; border-bottom: 1px solid #1a1a1a; }
|
||||
.search-result:hover { background: #1c1c1c; }
|
||||
.search-result img { width: 32px; height: 45px; object-fit: cover; border-radius: 2px; flex-shrink: 0; }
|
||||
.search-result-info { flex: 1; min-width: 0; }
|
||||
.search-result-name { font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.search-result-meta { font-size: 10px; color: var(--muted); }
|
||||
.search-result-add { font-size: 10px; color: var(--green); padding: 2px 6px; background: #141e14; border: 1px solid #1e3a1e; border-radius: 2px; white-space: nowrap; flex-shrink: 0; }
|
||||
.search-result-add:hover { background: #1a2a1a; }
|
||||
#bulkAddArea { min-height: 70px; margin-top: 6px; }
|
||||
|
||||
/* Log */
|
||||
.log-footer { flex-shrink: 0; border-top: 1px solid var(--border); }
|
||||
.log-toggle { padding: 6px 14px; cursor: pointer; display: flex; align-items: center; gap: 8px; font-size: 11px; color: var(--muted); user-select: none; }
|
||||
.log-toggle:hover { background: #181818; color: var(--text); }
|
||||
.log-arrow { font-size: 9px; transition: transform 0.2s; display: inline-block; }
|
||||
.log-arrow.open { transform: rotate(90deg); }
|
||||
.log-fail { color: var(--red); }
|
||||
.log-content { display: none; max-height: 160px; overflow-y: auto; padding: 7px 14px; font-size: 11px; line-height: 1.7; border-top: 1px solid var(--border); background: #0c0c0c; }
|
||||
.log-content.open { display: block; }
|
||||
.ll { color: var(--muted); white-space: pre-wrap; word-break: break-all; }
|
||||
.ll.ok { color: var(--green); }
|
||||
.ll.fail { color: var(--red); }
|
||||
.ll.copy { color: var(--blue); }
|
||||
.ll.skip { color: #333; }
|
||||
|
||||
/* Lightbox */
|
||||
.lightbox { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.92); z-index: 200; align-items: center; justify-content: center; cursor: zoom-out; }
|
||||
.lightbox.open { display: flex; }
|
||||
.lightbox img { max-height: 88vh; max-width: 92vw; border-radius: 6px; box-shadow: 0 8px 40px rgba(0,0,0,0.8); pointer-events: none; }
|
||||
|
||||
/* Mobile */
|
||||
@media (max-width: 620px) {
|
||||
body { flex-direction: column; height: auto; min-height: 100vh; overflow: auto; }
|
||||
.sidebar { width: 100%; min-width: unset; border-right: none; border-bottom: 1px solid var(--border); max-height: none; }
|
||||
.deck-list { max-height: 200px; }
|
||||
.panel { min-height: 60vh; overflow: visible; }
|
||||
.card-area { overflow: visible; }
|
||||
.log-content { max-height: 120px; }
|
||||
}
|
||||
.log-title { flex: 1; font-weight: bold; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.log-body {
|
||||
flex: 1; overflow-y: auto; padding: 14px 16px;
|
||||
line-height: 1.75; font-size: 12px;
|
||||
}
|
||||
|
||||
.log-line { color: var(--muted); white-space: pre-wrap; word-break: break-all; }
|
||||
.log-line.ok { color: var(--green); }
|
||||
.log-line.fail { color: var(--red); }
|
||||
.log-line.copy { color: var(--blue); }
|
||||
.log-line.skip { color: #3a3a3a; }
|
||||
.log-line.done { color: var(--text); font-weight: bold; }
|
||||
|
||||
.placeholder { color: #333; padding: 32px; text-align: center; line-height: 2; }
|
||||
.error-text { color: var(--red); font-size: 11px; margin-top: 6px; }
|
||||
|
||||
.hint { font-size: 11px; color: var(--muted); margin-top: 8px; line-height: 1.6; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -111,57 +126,59 @@ textarea { min-height: 150px; resize: vertical; }
|
||||
<div class="section">
|
||||
<div class="section-label">New Deck</div>
|
||||
<input id="deckName" placeholder="Deck name" />
|
||||
<textarea id="cardList" placeholder="Paste card list — one per line First card = commander"></textarea>
|
||||
<input id="deckCommander" placeholder="Commander (defaults to first card)" />
|
||||
<textarea id="cardList" placeholder="Paste card list — one per line"></textarea>
|
||||
<div class="row">
|
||||
<button class="btn btn-green" id="submitBtn" onclick="submitDeck()" style="flex:1">Submit</button>
|
||||
<button class="btn" onclick="clearForm()">Clear</button>
|
||||
</div>
|
||||
<div id="submitError" class="error-text" style="display:none"></div>
|
||||
</div>
|
||||
|
||||
<div class="section" style="padding-bottom:8px; border-bottom:none;">
|
||||
<div class="section-label">Decks</div>
|
||||
</div>
|
||||
<div class="deck-list" id="deckList">
|
||||
<div class="placeholder">No decks yet.</div>
|
||||
<div class="deck-list" id="deckList"><div class="placeholder">No decks yet.</div></div>
|
||||
</div>
|
||||
|
||||
<div class="panel" id="mainPanel">
|
||||
<div class="progress-bar"><div class="progress-fill" id="progressFill" style="width:0"></div></div>
|
||||
<div class="deck-header" id="deckHeader" style="display:none">
|
||||
<span class="deck-header-title" id="headerTitle"></span>
|
||||
<span class="deck-header-meta" id="headerMeta"></span>
|
||||
<span class="price-tag" id="priceTag"></span>
|
||||
<button class="btn btn-gold" id="editBtn" onclick="enterEditMode()" style="display:none">Edit</button>
|
||||
<button class="btn btn-green" id="saveBtn" onclick="saveEdit()" style="display:none">Save</button>
|
||||
<button class="btn" id="cancelBtn" onclick="exitEditMode(true)" style="display:none">Cancel</button>
|
||||
<button class="btn btn-blue" id="dlBtn" onclick="downloadTTS()" style="display:none">↓ TTS</button>
|
||||
<button class="btn btn-red" id="delBtn" onclick="deleteDeck()" style="display:none">Delete</button>
|
||||
</div>
|
||||
<div class="card-area" id="cardArea"><div class="placeholder">Select a deck or submit a new one.</div></div>
|
||||
<div class="log-footer" id="logFooter" style="display:none">
|
||||
<div class="log-toggle" onclick="toggleLog()">
|
||||
<span class="log-arrow" id="logArrow">►</span>
|
||||
<span id="logLabel">Log</span>
|
||||
</div>
|
||||
<div class="log-content" id="logContent"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="progress-bar"><div class="progress-fill" id="progressFill" style="width:0"></div></div>
|
||||
|
||||
<div class="log-header" id="logHeader" style="display:none">
|
||||
<span class="log-title" id="logTitle"></span>
|
||||
<button class="btn btn-blue" id="dlBtn" onclick="downloadTTS()" style="display:none">↓ Full Save</button>
|
||||
<button class="btn btn-blue" id="dlObjBtn" onclick="downloadObject()" style="display:none">↓ Saved Object</button>
|
||||
<button class="btn btn-red" id="delBtn" onclick="deleteDeck()" style="display:none">Delete</button>
|
||||
</div>
|
||||
|
||||
<div class="log-body" id="logBody">
|
||||
<div class="placeholder">
|
||||
Submit a new deck or select one from the list.<br>
|
||||
<span style="font-size:11px; color:#2a2a2a">
|
||||
TTS save → Documents/My Games/Tabletop Simulator/Saves/
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lightbox" id="lightbox" onclick="closeLightbox()">
|
||||
<img id="lightboxImg" />
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let activeDeck = null;
|
||||
let sse = null;
|
||||
let deckData = {};
|
||||
let activeDeck = null, sse = null, cardPollTimer = null;
|
||||
let deckData = {}, currentCards = [];
|
||||
let editMode = false, editRemovals = new Set(), editCommander = null;
|
||||
let searchTimer = null;
|
||||
|
||||
async function api(method, path, body) {
|
||||
const r = await fetch(path, {
|
||||
method,
|
||||
headers: body ? { "Content-Type": "application/json" } : {},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
headers: body ? {"Content-Type":"application/json"} : {},
|
||||
body: body ? JSON.stringify(body) : undefined
|
||||
});
|
||||
if (!r.ok) {
|
||||
const t = await r.text().catch(() => r.statusText);
|
||||
throw new Error(t);
|
||||
}
|
||||
if (!r.ok) throw new Error(await r.text().catch(() => r.statusText));
|
||||
if (r.status === 204) return null;
|
||||
return r.json();
|
||||
}
|
||||
@@ -179,16 +196,12 @@ async function loadDecks() {
|
||||
function renderList() {
|
||||
const el = document.getElementById("deckList");
|
||||
const slugs = Object.keys(deckData);
|
||||
if (!slugs.length) {
|
||||
el.innerHTML = '<div class="placeholder">No decks yet.</div>';
|
||||
return;
|
||||
}
|
||||
if (!slugs.length) { el.innerHTML = '<div class="placeholder">No decks yet.</div>'; return; }
|
||||
el.innerHTML = slugs.map(slug => {
|
||||
const d = deckData[slug];
|
||||
const active = slug === activeDeck ? " active" : "";
|
||||
const pct = d.total > 0 ? Math.round(d.done / d.total * 100) : 0;
|
||||
const label = d.status === "running" ? `${pct}%` : d.status;
|
||||
return `<div class="deck-row${active}" onclick="selectDeck('${slug}')">
|
||||
const label = d.status === "running" ? pct + "%" : d.status;
|
||||
return `<div class="deck-row${slug === activeDeck ? " active" : ""}" onclick="selectDeck('${slug}')">
|
||||
<span class="deck-name">${esc(d.deck_name)}</span>
|
||||
<span class="badge badge-${d.status}">${label}</span>
|
||||
</div>`;
|
||||
@@ -196,65 +209,237 @@ function renderList() {
|
||||
}
|
||||
|
||||
async function selectDeck(slug) {
|
||||
if (editMode) exitEditMode(true);
|
||||
if (sse) { sse.close(); sse = null; }
|
||||
if (cardPollTimer) { clearInterval(cardPollTimer); cardPollTimer = null; }
|
||||
activeDeck = slug;
|
||||
renderList();
|
||||
|
||||
document.getElementById("logHeader").style.display = "flex";
|
||||
const logBody = document.getElementById("logBody");
|
||||
logBody.innerHTML = "";
|
||||
|
||||
const d = deckData[slug];
|
||||
if (!d) return;
|
||||
document.getElementById("deckHeader").style.display = "flex";
|
||||
document.getElementById("logFooter").style.display = "";
|
||||
document.getElementById("logContent").innerHTML = "";
|
||||
document.getElementById("logContent").classList.remove("open");
|
||||
document.getElementById("logArrow").classList.remove("open");
|
||||
updateHeader(d);
|
||||
|
||||
document.getElementById("cardArea").innerHTML = "";
|
||||
await refreshCards(slug);
|
||||
if (d.status === "running" || d.status === "queued") {
|
||||
startSSE(slug);
|
||||
cardPollTimer = setInterval(() => refreshCards(slug), 3000);
|
||||
} else {
|
||||
try {
|
||||
const detail = await api("GET", `/decks/${slug}`);
|
||||
detail.log.forEach(line => appendLine(line));
|
||||
if (d.status === "complete") {
|
||||
appendLine("Ready to download:", "done");
|
||||
appendLine(" Full Save → Saves/ (load as new game)", "done");
|
||||
appendLine(" Saved Object → Saves/Saved Objects/ (spawn into running game via Objects → Saved Objects)", "done");
|
||||
}
|
||||
} catch (e) {
|
||||
appendLine("Failed to load log: " + e.message, "fail");
|
||||
}
|
||||
try { const det = await api("GET", `/decks/${slug}`); renderLog(det.log); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
function updateHeader(d) {
|
||||
document.getElementById("logTitle").textContent = d.deck_name;
|
||||
const pct = d.total > 0 ? (d.done / d.total * 100) : (d.status === "complete" ? 100 : 0);
|
||||
document.getElementById("headerTitle").textContent = d.deck_name;
|
||||
const pct = d.total > 0 ? d.done / d.total * 100 : (d.status === "complete" ? 100 : 0);
|
||||
document.getElementById("progressFill").style.width = pct + "%";
|
||||
document.getElementById("dlBtn").style.display = d.status === "complete" ? "" : "none";
|
||||
document.getElementById("dlObjBtn").style.display = d.status === "complete" ? "" : "none";
|
||||
document.getElementById("delBtn").style.display = (d.status === "complete" || d.status === "failed") ? "" : "none";
|
||||
document.getElementById("headerMeta").textContent = d.total > 0 ? `${d.done}/${d.total}` : "";
|
||||
document.getElementById("priceTag").textContent = d.price_usd > 0 ? `$${d.price_usd.toFixed(2)}` : "";
|
||||
const complete = d.status === "complete";
|
||||
const terminal = complete || d.status === "failed";
|
||||
document.getElementById("dlBtn").style.display = complete && !editMode ? "" : "none";
|
||||
document.getElementById("delBtn").style.display = terminal && !editMode ? "" : "none";
|
||||
document.getElementById("editBtn").style.display = terminal && !editMode ? "" : "none";
|
||||
document.getElementById("saveBtn").style.display = editMode ? "" : "none";
|
||||
document.getElementById("cancelBtn").style.display = editMode ? "" : "none";
|
||||
}
|
||||
|
||||
async function refreshCards(slug) {
|
||||
if (activeDeck !== slug) return;
|
||||
try {
|
||||
const cards = await api("GET", `/decks/${slug}/cards`);
|
||||
currentCards = cards;
|
||||
renderCards(slug, cards);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function renderCards(slug, cards) {
|
||||
const area = document.getElementById("cardArea");
|
||||
if (!cards.length) { area.innerHTML = '<div class="placeholder">No cards yet.</div>'; return; }
|
||||
|
||||
let grid = area.querySelector(".card-grid");
|
||||
if (!grid) {
|
||||
area.innerHTML = "";
|
||||
grid = document.createElement("div");
|
||||
grid.className = "card-grid" + (editMode ? " edit-mode" : "");
|
||||
area.appendChild(grid);
|
||||
} else {
|
||||
grid.className = "card-grid" + (editMode ? " edit-mode" : "");
|
||||
}
|
||||
|
||||
const commander = editCommander || (deckData[slug] && deckData[slug].commander) || "";
|
||||
|
||||
// Sync tiles
|
||||
const existing = Array.from(grid.querySelectorAll(".card-tile, .card-tile.missing"));
|
||||
cards.forEach((card, i) => {
|
||||
let tile = existing[i];
|
||||
const isCommander = card.name === commander;
|
||||
const isRemoved = editRemovals.has(card.id);
|
||||
const tileKey = card.filename + "|" + card.exists + "|" + isCommander + "|" + isRemoved + "|" + editMode;
|
||||
|
||||
if (!tile) { tile = document.createElement("div"); grid.appendChild(tile); tile.dataset.key = ""; }
|
||||
|
||||
if (tile.dataset.key === tileKey) return;
|
||||
tile.dataset.key = tileKey;
|
||||
|
||||
if (card.exists) {
|
||||
const imgUrl = `/cards/${slug}/${card.filename}`;
|
||||
tile.className = "card-tile" + (isCommander ? " is-commander" : "") + (isRemoved ? " marked-remove" : "");
|
||||
tile.innerHTML = `
|
||||
<img src="${imgUrl}" loading="lazy" alt="${esc(card.name)}" onclick="showLightbox('${imgUrl}')">
|
||||
<span class="card-label">${isCommander ? "♛ " : ""}${esc(card.name)}</span>
|
||||
${isCommander ? `<span class="commander-crown">♛</span>` : ""}
|
||||
<button class="card-overlay-btn btn-remove-card" onclick="toggleRemove(${card.id})">✕</button>
|
||||
${!isCommander ? `<button class="card-overlay-btn btn-set-commander" onclick="setCommander('${esc(card.name)}')">♛</button>` : ""}
|
||||
`;
|
||||
} else {
|
||||
tile.className = "card-tile missing" + (isRemoved ? " marked-remove" : "");
|
||||
tile.innerHTML = `
|
||||
${esc(card.name)}
|
||||
<button class="card-overlay-btn btn-remove-card" onclick="toggleRemove(${card.id})">✕</button>
|
||||
`;
|
||||
}
|
||||
});
|
||||
|
||||
while (grid.children.length > cards.length) grid.removeChild(grid.lastChild);
|
||||
|
||||
// Edit add section
|
||||
let addSection = area.querySelector(".edit-add-section");
|
||||
if (editMode) {
|
||||
if (!addSection) {
|
||||
addSection = document.createElement("div");
|
||||
addSection.className = "edit-add-section";
|
||||
addSection.innerHTML = `
|
||||
<div class="section-label">Search to Add</div>
|
||||
<div class="search-box">
|
||||
<input id="searchInput" placeholder="Card name..." oninput="onSearchInput(this.value)" autocomplete="off" />
|
||||
<div class="search-results" id="searchResults"></div>
|
||||
</div>
|
||||
<div class="section-label" style="margin-top:12px">Bulk Add (one per line)</div>
|
||||
<textarea id="bulkAddArea" placeholder="Paste card names..."></textarea>
|
||||
<div class="row"><button class="btn btn-green" onclick="saveEdit()" style="margin-top:4px">Save Changes</button></div>
|
||||
`;
|
||||
area.appendChild(addSection);
|
||||
}
|
||||
} else if (addSection) {
|
||||
addSection.remove();
|
||||
}
|
||||
}
|
||||
|
||||
function toggleRemove(cardId) {
|
||||
if (!editMode) return;
|
||||
if (editRemovals.has(cardId)) {
|
||||
editRemovals.delete(cardId);
|
||||
} else {
|
||||
editRemovals.add(cardId);
|
||||
}
|
||||
renderCards(activeDeck, currentCards);
|
||||
}
|
||||
|
||||
function setCommander(name) {
|
||||
if (!editMode) return;
|
||||
editCommander = name;
|
||||
renderCards(activeDeck, currentCards);
|
||||
}
|
||||
|
||||
function enterEditMode() {
|
||||
editMode = true;
|
||||
editRemovals = new Set();
|
||||
editCommander = null;
|
||||
renderCards(activeDeck, currentCards);
|
||||
if (activeDeck && deckData[activeDeck]) updateHeader(deckData[activeDeck]);
|
||||
}
|
||||
|
||||
function exitEditMode(cancel = false) {
|
||||
editMode = false;
|
||||
editRemovals = new Set();
|
||||
editCommander = null;
|
||||
searchTimer && clearTimeout(searchTimer);
|
||||
renderCards(activeDeck, currentCards);
|
||||
if (activeDeck && deckData[activeDeck]) updateHeader(deckData[activeDeck]);
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
if (!activeDeck || !editMode) return;
|
||||
const bulk = document.getElementById("bulkAddArea");
|
||||
const addNames = bulk ? bulk.value.split("\n").map(s => s.trim()).filter(Boolean) : [];
|
||||
const deck = deckData[activeDeck];
|
||||
const body = {
|
||||
deck_name: deck.deck_name,
|
||||
commander: editCommander || deck.commander,
|
||||
remove: [...editRemovals],
|
||||
add: addNames,
|
||||
};
|
||||
try {
|
||||
document.getElementById("saveBtn").disabled = true;
|
||||
await api("PATCH", `/decks/${activeDeck}`, body);
|
||||
exitEditMode(false);
|
||||
await loadDecks();
|
||||
await selectDeck(activeDeck);
|
||||
} catch (e) {
|
||||
alert(e.message);
|
||||
} finally {
|
||||
document.getElementById("saveBtn").disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onSearchInput(val) {
|
||||
clearTimeout(searchTimer);
|
||||
const res = document.getElementById("searchResults");
|
||||
if (!res) return;
|
||||
if (!val || val.length < 2) { res.classList.remove("open"); res.innerHTML = ""; return; }
|
||||
searchTimer = setTimeout(() => doSearch(val), 350);
|
||||
}
|
||||
|
||||
async function doSearch(q) {
|
||||
const res = document.getElementById("searchResults");
|
||||
if (!res) return;
|
||||
try {
|
||||
const results = await api("GET", `/search?q=${encodeURIComponent(q)}`);
|
||||
if (!results.length) { res.innerHTML = `<div class="search-result" style="color:var(--muted)">No results</div>`; res.classList.add("open"); return; }
|
||||
res.innerHTML = results.map(r => `
|
||||
<div class="search-result">
|
||||
${r.image ? `<img src="${esc(r.image)}" loading="lazy">` : `<div style="width:32px;height:45px;background:#222;border-radius:2px"></div>`}
|
||||
<div class="search-result-info">
|
||||
<div class="search-result-name">${esc(r.name)}</div>
|
||||
<div class="search-result-meta">${esc(r.set_name)}${r.price ? ` · $${r.price}` : ""}</div>
|
||||
</div>
|
||||
<button class="search-result-add" onclick="addFromSearch('${esc(r.name).replace(/'/g,"\\'")}')">+ Add</button>
|
||||
</div>
|
||||
`).join("");
|
||||
res.classList.add("open");
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function addFromSearch(name) {
|
||||
const bulk = document.getElementById("bulkAddArea");
|
||||
if (!bulk) return;
|
||||
bulk.value = (bulk.value ? bulk.value.trimEnd() + "\n" : "") + name;
|
||||
const res = document.getElementById("searchResults");
|
||||
if (res) { res.classList.remove("open"); res.innerHTML = ""; }
|
||||
const inp = document.getElementById("searchInput");
|
||||
if (inp) inp.value = "";
|
||||
}
|
||||
|
||||
function startSSE(slug) {
|
||||
const es = new EventSource(`/decks/${slug}/stream`);
|
||||
sse = es;
|
||||
|
||||
es.onmessage = async (e) => {
|
||||
if (e.data === "__DONE__") {
|
||||
es.close(); sse = null;
|
||||
if (cardPollTimer) { clearInterval(cardPollTimer); cardPollTimer = null; }
|
||||
await loadDecks();
|
||||
const d = deckData[slug];
|
||||
if (activeDeck === slug && d) {
|
||||
updateHeader(d);
|
||||
if (d.status === "complete") {
|
||||
appendLine("Ready to download:", "done");
|
||||
appendLine(" Full Save → Saves/ (load as new game)", "done");
|
||||
appendLine(" Saved Object → Saves/Saved Objects/ (spawn into running game via Objects → Saved Objects)", "done");
|
||||
}
|
||||
if (activeDeck === slug) {
|
||||
await refreshCards(slug);
|
||||
try { const det = await api("GET", `/decks/${slug}`); renderLog(det.log); } catch {}
|
||||
if (deckData[slug]) updateHeader(deckData[slug]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (activeDeck === slug) appendLine(e.data);
|
||||
// Refresh progress without a full reload
|
||||
if (!e.data.startsWith(" ")) appendLogLine(e.data);
|
||||
try {
|
||||
const list = await api("GET", "/decks");
|
||||
list.forEach(d => { deckData[d.slug] = d; });
|
||||
@@ -262,89 +447,106 @@ function startSSE(slug) {
|
||||
renderList();
|
||||
} catch {}
|
||||
};
|
||||
|
||||
es.onerror = () => { es.close(); sse = null; };
|
||||
}
|
||||
|
||||
function appendLine(line, cls = null) {
|
||||
const el = document.getElementById("logBody");
|
||||
const div = document.createElement("div");
|
||||
div.className = "log-line " + (cls ?? (
|
||||
line.includes("— OK") ? "ok" :
|
||||
line.includes("— FAIL") ? "fail" :
|
||||
line.includes("— COPY") ? "copy" :
|
||||
line.includes("— SKIP") ? "skip" :
|
||||
line.includes("Complete.") ? "done" : ""
|
||||
));
|
||||
div.textContent = line;
|
||||
el.appendChild(div);
|
||||
el.scrollTop = el.scrollHeight;
|
||||
function lineClass(l) {
|
||||
return l.includes("— OK") ? "ok" : l.includes("— FAIL") ? "fail" : l.includes("— COPY") ? "copy" : l.includes("— SKIP") ? "skip" : "";
|
||||
}
|
||||
|
||||
function renderLog(lines) {
|
||||
const el = document.getElementById("logContent");
|
||||
el.innerHTML = lines.map(l => `<div class="ll ${lineClass(l)}">${esc(l)}</div>`).join("");
|
||||
updateLogLabel(lines);
|
||||
}
|
||||
|
||||
function appendLogLine(line) {
|
||||
const el = document.getElementById("logContent");
|
||||
const div = document.createElement("div");
|
||||
div.className = "ll " + lineClass(line);
|
||||
div.textContent = line;
|
||||
el.appendChild(div);
|
||||
if (el.classList.contains("open")) el.scrollTop = el.scrollHeight;
|
||||
updateLogLabel(Array.from(el.querySelectorAll(".ll")).map(d => d.textContent));
|
||||
}
|
||||
|
||||
function updateLogLabel(lines) {
|
||||
const fails = lines.filter(l => l.includes("— FAIL")).length;
|
||||
document.getElementById("logLabel").innerHTML = fails > 0
|
||||
? `Log · <span class="log-fail">${fails} failed</span>` : "Log";
|
||||
}
|
||||
|
||||
function toggleLog() {
|
||||
const c = document.getElementById("logContent");
|
||||
const a = document.getElementById("logArrow");
|
||||
const open = c.classList.toggle("open");
|
||||
a.classList.toggle("open", open);
|
||||
if (open) c.scrollTop = c.scrollHeight;
|
||||
}
|
||||
|
||||
function showLightbox(url) {
|
||||
document.getElementById("lightboxImg").src = url;
|
||||
document.getElementById("lightbox").classList.add("open");
|
||||
}
|
||||
function closeLightbox() { document.getElementById("lightbox").classList.remove("open"); }
|
||||
document.addEventListener("keydown", e => { if (e.key === "Escape") closeLightbox(); });
|
||||
|
||||
async function submitDeck() {
|
||||
const name = document.getElementById("deckName").value.trim();
|
||||
const name = document.getElementById("deckName").value.trim();
|
||||
const cards = document.getElementById("cardList").value.trim();
|
||||
const err = document.getElementById("submitError");
|
||||
const commander = document.getElementById("deckCommander").value.trim();
|
||||
const err = document.getElementById("submitError");
|
||||
err.style.display = "none";
|
||||
|
||||
if (!name || !cards) {
|
||||
err.textContent = "Deck name and card list are both required.";
|
||||
err.style.display = "";
|
||||
return;
|
||||
}
|
||||
|
||||
if (!name || !cards) { err.textContent = "Need a name and card list."; err.style.display = ""; return; }
|
||||
const btn = document.getElementById("submitBtn");
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const r = await api("POST", "/decks", { deck_name: name, cards });
|
||||
const r = await api("POST", "/decks", { deck_name: name, cards, commander });
|
||||
clearForm();
|
||||
await loadDecks();
|
||||
selectDeck(r.slug);
|
||||
} catch (e) {
|
||||
err.textContent = e.message;
|
||||
err.style.display = "";
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
} finally { btn.disabled = false; }
|
||||
}
|
||||
|
||||
function clearForm() {
|
||||
document.getElementById("deckName").value = "";
|
||||
document.getElementById("cardList").value = "";
|
||||
["deckName","deckCommander","cardList"].forEach(id => document.getElementById(id).value = "");
|
||||
document.getElementById("submitError").style.display = "none";
|
||||
}
|
||||
|
||||
// Auto-fill commander from first card line
|
||||
document.getElementById("cardList").addEventListener("input", function() {
|
||||
const cmdField = document.getElementById("deckCommander");
|
||||
if (cmdField.value) return;
|
||||
const first = this.value.split("\n").map(s => s.trim()).find(Boolean);
|
||||
if (first) cmdField.placeholder = `Commander (e.g. ${first})`;
|
||||
});
|
||||
|
||||
async function deleteDeck() {
|
||||
if (!activeDeck) return;
|
||||
const d = deckData[activeDeck];
|
||||
if (!confirm(`Delete "${d?.deck_name}"?\nThis removes all downloaded card images.`)) return;
|
||||
if (!confirm(`Delete "${deckData[activeDeck]?.deck_name}"? This removes all downloaded images.`)) return;
|
||||
try {
|
||||
await api("DELETE", `/decks/${activeDeck}`);
|
||||
if (sse) { sse.close(); sse = null; }
|
||||
if (cardPollTimer) { clearInterval(cardPollTimer); cardPollTimer = null; }
|
||||
activeDeck = null;
|
||||
document.getElementById("logHeader").style.display = "none";
|
||||
document.getElementById("logBody").innerHTML = '<div class="placeholder">Deck deleted.</div>';
|
||||
["deckHeader","logFooter"].forEach(id => document.getElementById(id).style.display = "none");
|
||||
document.getElementById("progressFill").style.width = "0";
|
||||
document.getElementById("cardArea").innerHTML = '<div class="placeholder">Deck deleted.</div>';
|
||||
await loadDecks();
|
||||
} catch (e) {
|
||||
alert(e.message);
|
||||
}
|
||||
} catch (e) { alert(e.message); }
|
||||
}
|
||||
|
||||
function downloadTTS() {
|
||||
if (activeDeck) window.location.href = `/decks/${activeDeck}/tts`;
|
||||
}
|
||||
|
||||
function downloadObject() {
|
||||
if (activeDeck) window.location.href = `/decks/${activeDeck}/object`;
|
||||
}
|
||||
function downloadTTS() { if (activeDeck) window.location.href = `/decks/${activeDeck}/tts`; }
|
||||
|
||||
function esc(s) {
|
||||
return s.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">");
|
||||
return String(s).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");
|
||||
}
|
||||
|
||||
setInterval(loadDecks, 5000);
|
||||
loadDecks();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user