617 lines
28 KiB
HTML
617 lines
28 KiB
HTML
<!DOCTYPE html>
|
|
<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; --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: 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 { 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; }
|
|
.btn-green:hover { background: #141e14; }
|
|
.btn-red { color: var(--red); border-color: #3a1e1e; }
|
|
.btn-red:hover { background: #1e1414; }
|
|
.btn-blue { color: var(--blue); border-color: #1e2e3a; }
|
|
.btn-blue:hover { background: #141c22; }
|
|
.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: 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; 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; min-width: 0; }
|
|
.progress-bar { height: 2px; background: var(--border); flex-shrink: 0; }
|
|
.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); }
|
|
|
|
/* 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); }
|
|
.token-toggle { display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--muted); margin-bottom: 6px; cursor: pointer; user-select: none; }
|
|
.token-toggle input { width: auto; margin: 0; }
|
|
.dfc-badge { position: absolute; bottom: 3px; left: 3px; background: rgba(0,0,0,0.78); color: var(--blue); font-size: 10px; line-height: 1; padding: 3px 5px; border-radius: 2px; cursor: pointer; z-index: 2; }
|
|
.dfc-badge:hover { background: rgba(20,28,40,0.95); }
|
|
.card-price { position: absolute; bottom: 0; right: 0; background: rgba(0,0,0,0.75); color: var(--green); font-size: 9px; padding: 2px 4px; line-height: 1.4; pointer-events: none; }
|
|
.card-price.free { color: var(--muted); }
|
|
.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; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<div class="sidebar">
|
|
<div class="section">
|
|
<div class="section-label">New Deck</div>
|
|
<input id="deckName" placeholder="Deck name" />
|
|
<label class="token-toggle"><input type="checkbox" id="isTokens" onchange="onTokenToggle()" /> Token bundle (no commander)</label>
|
|
<div id="commanderWrap"><input id="deckCommander" placeholder="Commander (defaults to first card)" /></div>
|
|
<textarea id="cardList" placeholder="One per line — card name or Scryfall card URL"></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>
|
|
</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" id="sortBtn" onclick="toggleSort()" style="display:none">Sort: Default</button>
|
|
<button class="btn btn-blue" id="dlBtn" onclick="copyTTSUrl()" style="display:none">Copy URL</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="lightbox" id="lightbox" onclick="closeLightbox()">
|
|
<img id="lightboxImg" />
|
|
</div>
|
|
|
|
<script>
|
|
let activeDeck = null, sse = null, cardPollTimer = null;
|
|
let deckData = {}, currentCards = [];
|
|
let editMode = false, editRemovals = new Set(), editCommander = null;
|
|
let searchTimer = null;
|
|
let sortOrder = "default"; // "default" | "cost-desc" | "cost-asc"
|
|
|
|
function toggleSort() {
|
|
sortOrder = sortOrder === "default" ? "cost-desc" : sortOrder === "cost-desc" ? "cost-asc" : "default";
|
|
const labels = { "default": "Sort: Default", "cost-desc": "Sort: $ High", "cost-asc": "Sort: $ Low" };
|
|
document.getElementById("sortBtn").textContent = labels[sortOrder];
|
|
if (activeDeck) renderCards(activeDeck, currentCards);
|
|
}
|
|
|
|
function sortedCards(cards) {
|
|
if (sortOrder === "default") return cards;
|
|
const sorted = [...cards];
|
|
sorted.sort((a, b) => sortOrder === "cost-desc"
|
|
? (b.price_usd || 0) - (a.price_usd || 0)
|
|
: (a.price_usd || 0) - (b.price_usd || 0));
|
|
return sorted;
|
|
}
|
|
|
|
async function api(method, path, body) {
|
|
const r = await fetch(path, {
|
|
method,
|
|
headers: body ? {"Content-Type":"application/json"} : {},
|
|
body: body ? JSON.stringify(body) : undefined
|
|
});
|
|
if (!r.ok) throw new Error(await r.text().catch(() => r.statusText));
|
|
if (r.status === 204) return null;
|
|
return r.json();
|
|
}
|
|
|
|
async function loadDecks() {
|
|
try {
|
|
const list = await api("GET", "/decks");
|
|
deckData = {};
|
|
list.forEach(d => deckData[d.slug] = d);
|
|
renderList();
|
|
if (activeDeck && deckData[activeDeck]) updateHeader(deckData[activeDeck]);
|
|
} catch {}
|
|
}
|
|
|
|
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; }
|
|
el.innerHTML = slugs.map(slug => {
|
|
const d = deckData[slug];
|
|
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${slug === activeDeck ? " active" : ""}" onclick="selectDeck('${slug}')">
|
|
<span class="deck-name">${esc(d.deck_name)}</span>
|
|
<span class="badge badge-${d.status}">${label}</span>
|
|
</div>`;
|
|
}).join("");
|
|
}
|
|
|
|
async function selectDeck(slug) {
|
|
if (editMode) exitEditMode(true);
|
|
if (sse) { sse.close(); sse = null; }
|
|
if (cardPollTimer) { clearInterval(cardPollTimer); cardPollTimer = null; }
|
|
activeDeck = slug;
|
|
sortOrder = "default";
|
|
document.getElementById("sortBtn").textContent = "Sort: Default";
|
|
renderList();
|
|
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 det = await api("GET", `/decks/${slug}`); renderLog(det.log); } catch {}
|
|
}
|
|
}
|
|
|
|
function updateHeader(d) {
|
|
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("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";
|
|
document.getElementById("sortBtn").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");
|
|
const displayCards = sortedCards(cards);
|
|
if (!displayCards.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 isBundle = !(deckData[slug] && deckData[slug].commander);
|
|
const commander = isBundle ? "" : (editCommander || deckData[slug].commander || "");
|
|
|
|
// Sync tiles
|
|
const existing = Array.from(grid.querySelectorAll(".card-tile, .card-tile.missing"));
|
|
displayCards.forEach((card, i) => {
|
|
let tile = existing[i];
|
|
const isCommander = !isBundle && card.name === commander;
|
|
const isRemoved = editRemovals.has(card.id);
|
|
const label = cardLabel(card);
|
|
const priceStr = card.price_usd > 0 ? `$${card.price_usd.toFixed(2)}` : null;
|
|
const tileKey = [card.filename, card.exists, card.back_exists, isCommander, isRemoved, editMode, card.fetch_status, label, (card.price_usd || 0)].join("|");
|
|
|
|
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(label)}" onclick="showLightbox('${imgUrl}')">
|
|
<span class="card-label">${isCommander ? "♛ " : ""}${esc(label)}</span>
|
|
${isCommander ? `<span class="commander-crown">♛</span>` : ""}
|
|
${card.back_exists ? `<div class="dfc-badge" title="flip side" onclick="event.stopPropagation();showLightbox('/cards/${slug}/${esc(card.back_filename)}')">⇄</div>` : ""}
|
|
${priceStr ? `<span class="card-price">${priceStr}</span>` : `<span class="card-price free">$0</span>`}
|
|
<button class="card-overlay-btn btn-remove-card" onclick="toggleRemove(${card.id})">✕</button>
|
|
${!isCommander && !isBundle ? `<button class="card-overlay-btn btn-set-commander" onclick="setCommander('${esc(card.name).replace(/'/g,"\\'")}')">♛</button>` : ""}
|
|
`;
|
|
} else {
|
|
const hint = card.fetch_status === "failed" ? " — failed" : " …";
|
|
tile.className = "card-tile missing" + (isRemoved ? " marked-remove" : "");
|
|
tile.innerHTML = `
|
|
${esc(label)}${hint}
|
|
<button class="card-overlay-btn btn-remove-card" onclick="toggleRemove(${card.id})">✕</button>
|
|
`;
|
|
}
|
|
});
|
|
|
|
while (grid.children.length > displayCards.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="Card names or Scryfall card URLs — one per line"></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: deck.commander ? (editCommander || deck.commander) : null,
|
|
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();
|
|
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 (!e.data.startsWith(" ")) appendLogLine(e.data);
|
|
try {
|
|
const list = await api("GET", "/decks");
|
|
list.forEach(d => { deckData[d.slug] = d; });
|
|
if (activeDeck === slug && deckData[slug]) updateHeader(deckData[slug]);
|
|
renderList();
|
|
} catch {}
|
|
};
|
|
es.onerror = () => { es.close(); sse = null; };
|
|
}
|
|
|
|
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 cards = document.getElementById("cardList").value.trim();
|
|
const isTokens = document.getElementById("isTokens").checked;
|
|
const commander = document.getElementById("deckCommander").value.trim();
|
|
const err = document.getElementById("submitError");
|
|
err.style.display = "none";
|
|
if (!name) { err.textContent = "Need a name."; err.style.display = ""; return; }
|
|
if (!isTokens && !cards) { err.textContent = "Need a card list (or check Token bundle)."; err.style.display = ""; return; }
|
|
const btn = document.getElementById("submitBtn");
|
|
btn.disabled = true;
|
|
try {
|
|
const r = await api("POST", "/decks", { deck_name: name, cards, commander, is_tokens: isTokens });
|
|
clearForm();
|
|
await loadDecks();
|
|
selectDeck(r.slug);
|
|
} catch (e) {
|
|
err.textContent = e.message;
|
|
err.style.display = "";
|
|
} finally { btn.disabled = false; }
|
|
}
|
|
|
|
function onTokenToggle() {
|
|
const on = document.getElementById("isTokens").checked;
|
|
document.getElementById("commanderWrap").style.display = on ? "none" : "";
|
|
}
|
|
|
|
function clearForm() {
|
|
["deckName","deckCommander","cardList"].forEach(id => document.getElementById(id).value = "");
|
|
document.getElementById("isTokens").checked = false;
|
|
onTokenToggle();
|
|
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;
|
|
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;
|
|
["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); }
|
|
}
|
|
|
|
async function copyTTSUrl() {
|
|
if (!activeDeck) return;
|
|
const url = `${window.location.origin}/decks/${activeDeck}/tts.json`;
|
|
try {
|
|
await navigator.clipboard.writeText(url);
|
|
const btn = document.getElementById("dlBtn");
|
|
const orig = btn.textContent;
|
|
btn.textContent = "Copied!";
|
|
setTimeout(() => btn.textContent = orig, 1500);
|
|
} catch {
|
|
prompt("Copy this URL:", url);
|
|
}
|
|
}
|
|
|
|
function esc(s) {
|
|
return String(s).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");
|
|
}
|
|
|
|
function cardLabel(card) {
|
|
if (card.name) return card.name;
|
|
if (card.scry_url) {
|
|
const tail = (card.scry_url.split("/card/")[1] || "").replace(/\/$/, "").split("/");
|
|
if (tail.length >= 2) return `${tail[0]} · ${tail[1]}`;
|
|
}
|
|
return "card";
|
|
}
|
|
|
|
setInterval(loadDecks, 5000);
|
|
loadDecks();
|
|
</script>
|
|
</body>
|
|
</html> |