Files
colony-archive/deck-editor.html
T
2026-08-03 19:04:07 +02:00

586 lines
31 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Image Decks — Orison Reach</title>
<style>
:root {
--void:#171513;--void-soft:#211e1a;--panel:#2a2520;--paper:#e8dcc4;
--ink:#2c231c;--text:#e9dfca;--muted:#a89d89;--brass:#b88a43;
--rust:#94452f;--moss:#70794e;--oxygen:#86a5a4;--line:rgba(232,220,196,.18);
--serif:Georgia,"Times New Roman",serif;
--sans:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;
--mono:"SFMono-Regular",Consolas,"Liberation Mono",monospace;
}
*{box-sizing:border-box;}
body{margin:0;color:var(--text);
background:radial-gradient(circle at 12% 8%,rgba(134,165,164,.08),transparent 26rem),
linear-gradient(rgba(255,255,255,.018) 1px,transparent 1px),
linear-gradient(90deg,rgba(255,255,255,.014) 1px,transparent 1px),var(--void);
background-size:auto,28px 28px,28px 28px,auto;
font-family:var(--sans);line-height:1.7;}
body::before{content:"";position:fixed;inset:0;pointer-events:none;opacity:.16;
background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.8' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.16'/%3E%3C/svg%3E");
mix-blend-mode:soft-light;z-index:10;}
a{color:inherit;}
.shell{width:min(1180px,calc(100% - 36px));margin:0 auto;}
nav{position:sticky;top:0;z-index:5;border-bottom:1px solid var(--line);
background:rgba(23,21,19,.86);backdrop-filter:blur(14px);}
nav .shell{display:flex;gap:1.6rem;align-items:center;}
nav a{padding:1rem 0;text-decoration:none;white-space:nowrap;
font:650 .72rem/1 var(--mono);letter-spacing:.14em;text-transform:uppercase;color:var(--muted);}
nav a:hover,nav a.active{color:var(--paper);}
main{padding:4rem 0 7rem;}
h2{font-family:var(--serif);font-size:clamp(2.2rem,5vw,4.2rem);font-weight:600;
color:var(--paper);letter-spacing:-.02em;line-height:1.05;margin:0;}
.eyebrow{color:var(--brass);font:700 .74rem/1.2 var(--mono);letter-spacing:.22em;text-transform:uppercase;margin-bottom:.4rem;}
.btn{display:inline-flex;align-items:center;gap:.4rem;padding:.55rem 1.1rem;
border:1px solid var(--line);background:transparent;color:var(--brass);
font:700 .68rem/1 var(--mono);letter-spacing:.14em;text-transform:uppercase;
cursor:pointer;transition:background .15s,border-color .15s;white-space:nowrap;}
.btn:hover{background:rgba(184,138,67,.1);border-color:var(--brass);}
.btn.primary{background:var(--brass);color:var(--void);border-color:var(--brass);}
.btn.primary:hover{background:#cca055;}
.btn.sm{padding:.35rem .7rem;font-size:.62rem;}
.btn.danger{color:var(--rust);}
.btn.danger:hover{background:rgba(148,69,47,.15);border-color:var(--rust);}
.btn.ghost{color:var(--muted);}
.btn.ghost:hover{color:var(--text);}
.layout{display:grid;grid-template-columns:260px 1fr;gap:2.5rem;margin-top:3rem;align-items:start;}
@media(max-width:700px){.layout{grid-template-columns:1fr;}}
/* Deck list */
.deck-list{display:flex;flex-direction:column;gap:1px;}
.deck-row{padding:.9rem 1.1rem;background:var(--void-soft);border:1px solid var(--line);
cursor:pointer;transition:border-color .12s;}
.deck-row.active,.deck-row:hover{border-color:rgba(184,138,67,.4);}
.deck-row.active{background:rgba(184,138,67,.06);}
.deck-name{font:600 .95rem/1.2 var(--serif);color:var(--paper);}
.deck-meta{font:.62rem/1 var(--mono);color:var(--muted);margin-top:.2rem;}
/* Tabs */
.tabs{display:flex;gap:0;border-bottom:1px solid var(--line);margin-bottom:1.5rem;}
.tab{padding:.6rem 1.2rem;font:700 .68rem/1 var(--mono);letter-spacing:.12em;
text-transform:uppercase;color:var(--muted);cursor:pointer;border-bottom:2px solid transparent;
margin-bottom:-1px;transition:color .12s;}
.tab.active{color:var(--brass);border-bottom-color:var(--brass);}
.tab:hover{color:var(--text);}
/* Cards list */
.card-list{display:flex;flex-direction:column;gap:1px;}
.card-item{background:var(--void-soft);border:1px solid var(--line);}
.card-header{display:flex;align-items:center;justify-content:space-between;
padding:.8rem 1rem;cursor:pointer;gap:1rem;}
.card-header:hover{background:rgba(255,255,255,.02);}
.card-item-title{font:600 1rem/1.2 var(--serif);color:var(--paper);}
.card-item-type{font:.6rem/1 var(--mono);letter-spacing:.1em;text-transform:uppercase;
color:var(--muted);margin-top:.15rem;}
.card-body{padding:1rem;border-top:1px solid var(--line);}
/* Card form */
.form-group{margin-bottom:1rem;}
.form-label{display:block;font:700 .65rem/1 var(--mono);letter-spacing:.1em;
text-transform:uppercase;color:var(--brass);margin-bottom:.38rem;}
input[type=text],textarea,select{width:100%;background:var(--panel);
border:1px solid rgba(232,220,196,.2);color:var(--text);
padding:.6rem .8rem;font-family:var(--sans);font-size:.9rem;
appearance:none;-webkit-appearance:none;}
select option{background:var(--panel);}
textarea{resize:vertical;min-height:72px;}
input:focus,textarea:focus,select:focus{outline:none;border-color:var(--brass);}
.form-row{display:grid;grid-template-columns:1fr 1fr;gap:1rem;}
.check-row{display:flex;gap:1.2rem;flex-wrap:wrap;}
.check-label{display:flex;align-items:center;gap:.5rem;font:.8rem/1 var(--mono);color:var(--text);cursor:pointer;}
.check-label input{width:auto;}
/* Card images */
.card-img-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:.7rem;margin-top:.8rem;}
.card-img-wrap{position:relative;}
.card-img-wrap img{width:100%;aspect-ratio:4/3;object-fit:cover;display:block;border:1px solid var(--line);}
.card-img-del{position:absolute;top:.25rem;right:.25rem;background:rgba(23,21,19,.8);
border:none;color:var(--rust);font:.6rem/1 var(--mono);padding:.2rem .4rem;cursor:pointer;}
.card-img-del:hover{background:var(--rust);color:var(--paper);}
.card-img-upload{border:1px dashed rgba(184,138,67,.3);aspect-ratio:4/3;
display:flex;flex-direction:column;align-items:center;justify-content:center;
gap:.3rem;cursor:pointer;transition:border-color .12s;font:.65rem/1.3 var(--mono);
color:var(--muted);text-align:center;padding:.5rem;}
.card-img-upload:hover{border-color:rgba(184,138,67,.6);color:var(--brass);}
/* Pool images */
.img-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(170px,1fr));gap:1rem;}
.img-card{background:var(--void-soft);border:1px solid var(--line);overflow:hidden;}
.img-thumb{width:100%;aspect-ratio:4/3;object-fit:cover;display:block;}
.img-body{padding:.55rem .75rem .7rem;}
.img-tags{display:flex;flex-wrap:wrap;gap:.25rem;margin-bottom:.4rem;}
.tag{font:700 .57rem/1 var(--mono);letter-spacing:.08em;text-transform:uppercase;
padding:.18rem .45rem;background:rgba(184,138,67,.1);color:var(--brass);
border:1px solid rgba(184,138,67,.22);cursor:pointer;}
.tag:hover{background:rgba(148,69,47,.15);color:var(--rust);}
.tag-input{width:100%;background:var(--panel);border:1px solid rgba(232,220,196,.18);
color:var(--text);padding:.32rem .5rem;font:.75rem/1 var(--mono);outline:none;margin-bottom:.4rem;}
.tag-input:focus{border-color:var(--brass);}
/* Upload zone */
.upload-zone{border:2px dashed rgba(184,138,67,.28);padding:1.8rem;text-align:center;
cursor:pointer;transition:border-color .15s;margin-bottom:1.2rem;}
.upload-zone:hover{border-color:rgba(184,138,67,.6);}
.upload-label{font:.75rem/1.5 var(--mono);color:var(--muted);display:block;margin-top:.35rem;}
.empty{padding:2.5rem 0;color:var(--muted);font:italic 1rem/1.6 var(--serif);}
/* Modal */
.modal-overlay{position:fixed;inset:0;background:rgba(23,21,19,.88);
backdrop-filter:blur(6px);z-index:50;display:flex;align-items:center;
justify-content:center;padding:1rem;}
.modal{background:var(--void-soft);border:1px solid rgba(232,220,196,.3);
padding:1.8rem;width:min(480px,100%);max-height:90vh;overflow-y:auto;}
.modal-title{font:700 1.4rem/1.1 var(--serif);color:var(--paper);margin:0 0 1.3rem;}
.modal-actions{display:flex;justify-content:flex-end;gap:.7rem;margin-top:1.3rem;
padding-top:1rem;border-top:1px solid var(--line);}
.form-error{font:.72rem/1.4 var(--mono);color:var(--rust);margin-top:.35rem;}
footer{border-top:1px solid var(--line);padding:2.5rem 0 4rem;color:var(--muted);}
footer p{margin:0;font:.72rem/1.7 var(--mono);letter-spacing:.08em;text-transform:uppercase;}
[x-cloak]{display:none !important;}
</style>
</head>
<body x-data="deckApp()" x-cloak>
<!-- Login overlay -->
<div x-show="needsAuth" style="position:fixed;inset:0;background:rgba(23,21,19,.95);backdrop-filter:blur(8px);z-index:100;display:flex;align-items:center;justify-content:center;padding:1rem">
<div style="background:var(--void-soft);border:1px solid rgba(232,220,196,.3);padding:2rem;width:min(400px,100%)">
<div style="font:700 1.4rem/1.1 var(--serif);color:var(--paper);margin:0 0 1.4rem">Deck Editor Access</div>
<div class="form-group">
<label class="form-label">Admin Password</label>
<input type="password" x-model="loginPw" @keydown.enter="login()" placeholder="Enter admin password" style="width:100%;background:var(--panel);border:1px solid rgba(232,220,196,.2);color:var(--text);padding:.6rem .8rem;font-family:var(--sans);font-size:.9rem" />
</div>
<div x-show="loginErr" x-text="loginErr" style="font:.75rem/1.4 var(--mono);color:var(--rust);margin-bottom:.8rem"></div>
<button class="btn primary" @click="login()" :disabled="loginSaving" style="width:100%">
<span x-text="loginSaving ? 'Checking…' : 'Enter'"></span>
</button>
</div>
</div>
<nav>
<div class="shell">
<a href="index.html">Archive</a>
<a href="games.html">Sessions</a>
<a href="deck-editor.html" class="active">Image Decks</a>
<button x-show="adminToken && !needsAuth" class="btn sm ghost" @click="logout()" style="margin-left:auto">Log out</button>
</div>
</nav>
<main class="shell">
<div>
<div class="eyebrow">Archive // Card &amp; Image Library</div>
<div style="display:flex;align-items:flex-end;justify-content:space-between;gap:2rem;flex-wrap:wrap;margin-top:.4rem">
<h2>Decks</h2>
<button class="btn primary" @click="openNewDeck()">+ New Deck</button>
</div>
<p style="color:var(--muted);max-width:64ch;margin:.8rem 0 0;font-size:.9rem">
Create decks with custom cards and images. Images tagged with keywords auto-match events in any session.
</p>
</div>
<div class="layout">
<!-- Deck sidebar -->
<div>
<div style="font:700 .63rem/1 var(--mono);letter-spacing:.16em;text-transform:uppercase;color:var(--muted);margin-bottom:.7rem">Decks</div>
<div class="deck-list">
<div x-show="!decks.length" class="empty" style="padding:1rem 0">No decks yet.</div>
<template x-for="d in decks" :key="d.id">
<div class="deck-row" :class="sel?.id===d.id?'active':''" @click="sel=d">
<div class="deck-name" x-text="d.name"></div>
<div class="deck-meta">
<span x-text="(d.cards?.length||0)+' cards'"></span> ·
<span x-text="(d.images?.length||0)+' images'"></span>
</div>
</div>
</template>
</div>
</div>
<!-- Deck detail -->
<div>
<div x-show="!sel" class="empty">Select or create a deck.</div>
<div x-show="sel">
<!-- Deck header -->
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:1rem;flex-wrap:wrap;margin-bottom:1.5rem">
<div>
<div style="font:700 1.5rem/1.1 var(--serif);color:var(--paper)" x-text="sel?.name"></div>
<div style="font:.85rem/1.5 var(--sans);color:var(--muted);margin-top:.2rem" x-text="sel?.description||'No description.'"></div>
</div>
<div style="display:flex;gap:.5rem;flex-wrap:wrap">
<button class="btn sm ghost" @click="openEditDeck()">Edit</button>
<button class="btn sm danger" @click="deleteDeck()">Delete</button>
</div>
</div>
<!-- Tabs -->
<div class="tabs">
<div class="tab" :class="tab==='cards'?'active':''" @click="tab='cards'">
Cards (<span x-text="sel?.cards?.length||0"></span>)
</div>
<div class="tab" :class="tab==='images'?'active':''" @click="tab='images'">
Image Pool (<span x-text="sel?.images?.length||0"></span>)
</div>
</div>
<!-- ── CARDS TAB ─────────────────────────────────── -->
<div x-show="tab==='cards'">
<div style="display:flex;justify-content:flex-end;margin-bottom:1rem">
<button class="btn sm" @click="openNewCard()">+ Add Card</button>
</div>
<div x-show="!sel?.cards?.length" class="empty">No cards yet. Add the first one.</div>
<div class="card-list">
<template x-for="card in sel?.cards||[]" :key="card.id">
<div class="card-item">
<div class="card-header" @click="expandedCard = expandedCard===card.id ? null : card.id">
<div>
<div class="card-item-title" x-text="card.title"></div>
<div class="card-item-type" x-text="(card.type||'action').toUpperCase() + (card.images?.length ? ' · '+card.images.length+' image'+(card.images.length>1?'s':'') : '')"></div>
</div>
<div style="display:flex;gap:.4rem;flex-shrink:0">
<button class="btn sm ghost" @click.stop="openEditCard(card)">Edit</button>
<button class="btn sm danger" @click.stop="deleteCard(card)">Delete</button>
<span style="font:.7rem/1 var(--mono);color:var(--muted);margin-left:.2rem;line-height:2" x-text="expandedCard===card.id?'▲':'▼'"></span>
</div>
</div>
<!-- Expanded: show text + images -->
<div class="card-body" x-show="expandedCard===card.id">
<div style="font:.72rem/1 var(--mono);letter-spacing:.1em;text-transform:uppercase;color:var(--brass);margin-bottom:.5rem">A —</div>
<div style="font:.9rem/1.6 var(--sans);color:var(--text);margin-bottom:.8rem" x-text="card.a"></div>
<div style="font:.72rem/1 var(--mono);letter-spacing:.1em;text-transform:uppercase;color:var(--brass);margin-bottom:.5rem">B —</div>
<div style="font:.9rem/1.6 var(--sans);color:var(--text);margin-bottom:1rem" x-text="card.b"></div>
<!-- Card images -->
<div style="font:.63rem/1 var(--mono);letter-spacing:.12em;text-transform:uppercase;color:var(--muted);margin-bottom:.5rem">Card Images</div>
<div class="card-img-grid">
<template x-for="img in card.images||[]" :key="img.id">
<div class="card-img-wrap">
<img :src="'assets/deck-images/'+img.filename" loading="lazy" />
<button class="card-img-del" @click="deleteCardImage(card, img)">✕</button>
</div>
</template>
<div class="card-img-upload" @click="triggerCardImageUpload(card)">
<span>+</span>
<span>Upload image</span>
</div>
</div>
<input type="file" :id="'ci-'+card.id" accept="image/*" multiple style="display:none"
@change="handleCardImages(card, $event)" />
</div>
</div>
</template>
</div>
</div>
<!-- ── IMAGE POOL TAB ────────────────────────────── -->
<div x-show="tab==='images'">
<div class="upload-zone" @click="$refs.poolInput.click()" @dragover.prevent @drop.prevent="handlePoolDrop($event)">
<div style="font:700 .7rem/1 var(--mono);letter-spacing:.14em;text-transform:uppercase;color:var(--brass)">
Drop images or click to upload
</div>
<span class="upload-label">These images auto-match events by keyword. Tag each one after upload.</span>
<div x-show="uploading" style="font:.72rem/1 var(--mono);color:var(--brass);margin-top:.4rem" x-text="'Uploading '+uploadProg+'…'"></div>
</div>
<input type="file" x-ref="poolInput" multiple accept="image/*" style="display:none" @change="handlePoolFiles($event)" />
<div x-show="!sel?.images?.length" class="empty">No images in pool.</div>
<div class="img-grid">
<template x-for="img in sel?.images||[]" :key="img.id">
<div class="img-card">
<img class="img-thumb" :src="'assets/deck-images/'+img.filename" loading="lazy" />
<div class="img-body">
<div class="img-tags">
<template x-for="kw in img.keywords" :key="kw">
<span class="tag" @click="removePoolKw(img,kw)" :title="'Remove '+kw" x-text="kw"></span>
</template>
</div>
<input class="tag-input" type="text" placeholder="add keyword + Enter"
@keydown.enter.prevent="addPoolKw(img,$event.target);$event.target.value=''"
@keydown.comma.prevent="addPoolKw(img,$event.target);$event.target.value=''" />
<button class="btn sm danger" @click="deletePoolImage(img)">Delete</button>
</div>
</div>
</template>
</div>
</div>
</div><!-- /sel -->
</div>
</div><!-- /layout -->
</main>
<footer>
<div class="shell" style="display:flex;justify-content:space-between;gap:2rem;flex-wrap:wrap">
<p>Orison Reach Public Archive<br />Civilian access edition</p>
<p>Knowledge builds tomorrow<br />Work keeps it standing</p>
</div>
</footer>
<!-- Deck modal -->
<div class="modal-overlay" x-show="showDeckModal" @click.self="showDeckModal=false" @keydown.escape.window="showDeckModal=false">
<div class="modal">
<div class="modal-title" x-text="editDeckId?'Edit Deck':'New Deck'"></div>
<div class="form-group">
<label class="form-label">Name *</label>
<input type="text" x-model="deckForm.name" />
</div>
<div class="form-group">
<label class="form-label">Description</label>
<textarea x-model="deckForm.description"></textarea>
</div>
<div class="form-error" x-show="deckErr" x-text="deckErr"></div>
<div class="modal-actions">
<button class="btn ghost" @click="showDeckModal=false">Cancel</button>
<button class="btn primary" @click="saveDeck()" :disabled="saving" x-text="saving?'Saving…':(editDeckId?'Save':'Create')"></button>
</div>
</div>
</div>
<!-- Card modal -->
<div class="modal-overlay" x-show="showCardModal" @click.self="showCardModal=false" @keydown.escape.window="showCardModal=false">
<div class="modal">
<div class="modal-title" x-text="editCardId?'Edit Card':'New Card'"></div>
<div class="form-group">
<label class="form-label">Title *</label>
<input type="text" x-model="cardForm.title" placeholder="e.g. The Discovery" />
</div>
<div class="form-group">
<label class="form-label">Type</label>
<div style="display:flex;gap:1.2rem">
<label class="check-label"><input type="radio" x-model="cardForm.type" value="action" /><span>Action</span></label>
<label class="check-label"><input type="radio" x-model="cardForm.type" value="question" /><span>Question</span></label>
</div>
</div>
<div class="form-group" x-show="cardForm.type==='question'">
<label class="form-label">Question</label>
<textarea x-model="cardForm.question" placeholder="What does this ask the players?" style="min-height:60px"></textarea>
</div>
<div class="form-group">
<label class="form-label">Option A *</label>
<textarea x-model="cardForm.a" placeholder="First choice or instruction…"></textarea>
</div>
<div class="form-group">
<label class="form-label">Option B *</label>
<textarea x-model="cardForm.b" placeholder="Second choice or instruction…"></textarea>
</div>
<div class="form-group">
<label class="form-label">Only draw when…</label>
<div class="check-row">
<label class="check-label"><input type="checkbox" :checked="cardForm.requires.includes('cast')" @change="toggleReq('cast',$event)" /><span>Cast exists</span></label>
<label class="check-label"><input type="checkbox" :checked="cardForm.requires.includes('keyword')" @change="toggleReq('keyword',$event)" /><span>Keywords exist</span></label>
<label class="check-label"><input type="checkbox" :checked="cardForm.requires.includes('project')" @change="toggleReq('project',$event)" /><span>Projects exist</span></label>
</div>
</div>
<div class="form-error" x-show="cardErr" x-text="cardErr"></div>
<div class="modal-actions">
<button class="btn ghost" @click="showCardModal=false">Cancel</button>
<button class="btn primary" @click="saveCard()" :disabled="saving" x-text="saving?'Saving…':(editCardId?'Save':'Add Card')"></button>
</div>
</div>
</div>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.3/dist/cdn.min.js"></script>
<script>
function deckApp() {
return {
adminToken: localStorage.getItem('deckAdminToken') || '',
needsAuth: false,
loginPw: '', loginErr: null, loginSaving: false,
decks: [], sel: null, tab: 'cards',
expandedCard: null,
showDeckModal: false, editDeckId: null,
deckForm: { name: '', description: '' }, deckErr: null,
showCardModal: false, editCardId: null,
cardForm: { title: '', type: 'action', question: '', a: '', b: '', requires: [] },
cardErr: null,
saving: false, uploading: false, uploadProg: '',
async init() {
// Check if auth is required
const r = await fetch('/api/admin/login', { method: 'POST',
headers: this.adminHeaders(), body: JSON.stringify({password:''}) })
if (r.status === 401) {
// Server has a password set — check our stored token
const check = await fetch('/api/decks', { headers: {'x-admin-token': this.adminToken} })
this.needsAuth = check.status === 401
}
if (!this.needsAuth) await this.load()
},
async login() {
this.loginSaving = true; this.loginErr = null
const r = await fetch('/api/admin/login', { method: 'POST',
headers: this.adminHeaders(), body: JSON.stringify({password: this.loginPw}) })
const d = await r.json()
if (!r.ok) { this.loginErr = 'Wrong password'; this.loginSaving = false; return }
this.adminToken = d.token
localStorage.setItem('deckAdminToken', d.token)
this.needsAuth = false; this.loginSaving = false
await this.load()
},
logout() {
this.adminToken = ''; localStorage.removeItem('deckAdminToken'); this.needsAuth = true
},
adminHeaders() {
return { 'Content-Type': 'application/json', 'x-admin-token': this.adminToken }
},
async load() {
const r = await fetch('/api/decks')
this.decks = r.ok ? await r.json() : []
if (this.sel) this.sel = this.decks.find(d => d.id === this.sel.id) || null
},
// ── Deck CRUD ────────────────────────────────────────
openNewDeck() {
this.editDeckId = null; this.deckForm = { name: '', description: '' }
this.deckErr = null; this.showDeckModal = true
},
openEditDeck() {
if (!this.sel) return
this.editDeckId = this.sel.id
this.deckForm = { name: this.sel.name, description: this.sel.description }
this.deckErr = null; this.showDeckModal = true
},
async saveDeck() {
if (!this.deckForm.name) { this.deckErr = 'Name required'; return }
this.saving = true
try {
const url = this.editDeckId ? '/api/decks/'+this.editDeckId : '/api/decks'
const r = await fetch(url, { method: this.editDeckId?'PUT':'POST',
headers: this.adminHeaders(), body: JSON.stringify(this.deckForm) })
const d = await r.json()
if (!r.ok) { this.deckErr = d.error; return }
await this.load()
if (!this.editDeckId) this.sel = this.decks.find(x => x.id === d.id)
this.showDeckModal = false
} finally { this.saving = false }
},
async deleteDeck() {
if (!this.sel || !confirm(`Delete "${this.sel.name}"?`)) return
await fetch('/api/decks/'+this.sel.id, { method: 'DELETE', headers: this.adminHeaders() })
this.sel = null; await this.load()
},
// ── Card CRUD ────────────────────────────────────────
openNewCard() {
this.editCardId = null
this.cardForm = { title: '', type: 'action', question: '', a: '', b: '', requires: [] }
this.cardErr = null; this.showCardModal = true
},
openEditCard(card) {
this.editCardId = card.id
this.cardForm = { title: card.title, type: card.type||'action',
question: card.question||'', a: card.a, b: card.b, requires: [...(card.requires||[])] }
this.cardErr = null; this.showCardModal = true
},
toggleReq(val, e) {
if (e.target.checked) { if (!this.cardForm.requires.includes(val)) this.cardForm.requires.push(val) }
else this.cardForm.requires = this.cardForm.requires.filter(r => r !== val)
},
async saveCard() {
if (!this.cardForm.title || !this.cardForm.a || !this.cardForm.b) {
this.cardErr = 'Title, A and B required'; return }
this.saving = true
try {
const url = this.editCardId
? `/api/decks/${this.sel.id}/cards/${this.editCardId}`
: `/api/decks/${this.sel.id}/cards`
const r = await fetch(url, { method: this.editCardId?'PUT':'POST',
headers: this.adminHeaders(), body: JSON.stringify(this.cardForm) })
const d = await r.json()
if (!r.ok) { this.cardErr = d.error; return }
await this.load()
this.showCardModal = false
} finally { this.saving = false }
},
async deleteCard(card) {
if (!confirm(`Delete "${card.title}"?`)) return
await fetch(`/api/decks/${this.sel.id}/cards/${card.id}`, { method: 'DELETE', headers: this.adminHeaders() })
await this.load()
},
// ── Card images ──────────────────────────────────────
triggerCardImageUpload(card) {
document.getElementById('ci-'+card.id)?.click()
},
async handleCardImages(card, e) {
const files = [...e.target.files]
for (const file of files) {
const base64 = await this.toBase64(file)
await fetch(`/api/decks/${this.sel.id}/cards/${card.id}/images`, {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ filename: file.name, data: base64, keywords: [] })
})
}
e.target.value = ''
await this.load()
},
async deleteCardImage(card, img) {
if (!confirm('Delete image?')) return
await fetch(`/api/decks/${this.sel.id}/cards/${card.id}/images/${img.id}`, { method: 'DELETE', headers: this.adminHeaders() })
await this.load()
},
// ── Pool images ──────────────────────────────────────
handlePoolDrop(e) {
const files = [...e.dataTransfer.files].filter(f => f.type.startsWith('image/'))
if (files.length) this.uploadPool(files)
},
handlePoolFiles(e) {
const files = [...e.target.files]
if (files.length) this.uploadPool(files)
e.target.value = ''
},
async uploadPool(files) {
this.uploading = true
for (let i = 0; i < files.length; i++) {
this.uploadProg = `${i+1}/${files.length}`
const base64 = await this.toBase64(files[i])
await fetch(`/api/decks/${this.sel.id}/images`, {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ filename: files[i].name, data: base64, keywords: [] })
})
}
this.uploading = false; await this.load()
},
async addPoolKw(img, input) {
const kw = input.value.trim().toLowerCase().replace(/[,\s]+$/,'')
if (!kw || img.keywords.includes(kw)) return
await fetch(`/api/decks/${this.sel.id}/images/${img.id}`, {
method: 'PUT', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ keywords: [...img.keywords, kw] })
})
await this.load()
},
async removePoolKw(img, kw) {
await fetch(`/api/decks/${this.sel.id}/images/${img.id}`, {
method: 'PUT', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ keywords: img.keywords.filter(k => k !== kw) })
})
await this.load()
},
async deletePoolImage(img) {
if (!confirm('Delete image?')) return
await fetch(`/api/decks/${this.sel.id}/images/${img.id}`, { method: 'DELETE', headers: this.adminHeaders() })
await this.load()
},
toBase64(file) {
return new Promise(res => {
const r = new FileReader()
r.onload = e => res(e.target.result.split(',')[1])
r.readAsDataURL(file)
})
}
}
}
</script>
</body>
</html>