Files
cardpuller/static/index.html
T
2026-05-09 08:45:23 -05:00

350 lines
12 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<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;
}
/* ── 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; }
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;
}
.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; }
.row { display: flex; gap: 6px; margin-top: 8px; }
.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: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;
}
.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; }
.progress-bar { height: 2px; background: var(--border); flex-shrink: 0; }
.progress-fill { height: 100%; background: var(--green); transition: width 0.5s ease; }
.log-header {
padding: 11px 16px; border-bottom: 1px solid var(--border);
display: flex; align-items: center; gap: 8px; flex-shrink: 0;
}
.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>
<div class="sidebar">
<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&#10;First card = commander"></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">
<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>
<script>
let activeDeck = null;
let sse = null;
let deckData = {};
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) {
const t = await r.text().catch(() => r.statusText);
throw new Error(t);
}
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 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}')">
<span class="deck-name">${esc(d.deck_name)}</span>
<span class="badge badge-${d.status}">${label}</span>
</div>`;
}).join("");
}
async function selectDeck(slug) {
if (sse) { sse.close(); sse = null; }
activeDeck = slug;
renderList();
document.getElementById("logHeader").style.display = "flex";
const logBody = document.getElementById("logBody");
logBody.innerHTML = "";
const d = deckData[slug];
if (!d) return;
updateHeader(d);
if (d.status === "running" || d.status === "queued") {
startSSE(slug);
} 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");
}
}
}
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("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";
}
function startSSE(slug) {
const es = new EventSource(`/decks/${slug}/stream`);
sse = es;
es.onmessage = async (e) => {
if (e.data === "__DONE__") {
es.close(); sse = 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");
}
}
return;
}
if (activeDeck === slug) appendLine(e.data);
// Refresh progress without a full reload
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 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;
}
async function submitDeck() {
const name = document.getElementById("deckName").value.trim();
const cards = document.getElementById("cardList").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;
}
const btn = document.getElementById("submitBtn");
btn.disabled = true;
try {
const r = await api("POST", "/decks", { deck_name: name, cards });
clearForm();
await loadDecks();
selectDeck(r.slug);
} catch (e) {
err.textContent = e.message;
err.style.display = "";
} finally {
btn.disabled = false;
}
}
function clearForm() {
document.getElementById("deckName").value = "";
document.getElementById("cardList").value = "";
document.getElementById("submitError").style.display = "none";
}
async function deleteDeck() {
if (!activeDeck) return;
const d = deckData[activeDeck];
if (!confirm(`Delete "${d?.deck_name}"?\nThis removes all downloaded card images.`)) return;
try {
await api("DELETE", `/decks/${activeDeck}`);
if (sse) { sse.close(); sse = null; }
activeDeck = null;
document.getElementById("logHeader").style.display = "none";
document.getElementById("logBody").innerHTML = '<div class="placeholder">Deck deleted.</div>';
document.getElementById("progressFill").style.width = "0";
await loadDecks();
} 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 esc(s) {
return s.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");
}
setInterval(loadDecks, 5000);
loadDecks();
</script>
</body>
</html>