350 lines
12 KiB
HTML
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 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,"&").replace(/</g,"<").replace(/>/g,">");
|
|
}
|
|
|
|
setInterval(loadDecks, 5000);
|
|
loadDecks();
|
|
</script>
|
|
</body>
|
|
</html> |