This commit is contained in:
2026-08-03 19:04:07 +02:00
parent 40bb3452cb
commit 78b9061b4b
8 changed files with 985 additions and 12 deletions
+4
View File
@@ -1,3 +1,7 @@
node_modules/ node_modules/
colony.json colony.json
colony.json.tmp colony.json.tmp
decks.json
decks.json.tmp
data/
assets/deck-images/
+13
View File
@@ -0,0 +1,13 @@
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN mkdir -p assets/deck-images
EXPOSE 3000
ENV NODE_ENV=production
CMD ["node", "server.js"]
+585
View File
@@ -0,0 +1,585 @@
<!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>
+13
View File
@@ -0,0 +1,13 @@
services:
colony:
build: .
ports:
- "3000:3000"
environment:
- PORT=3000
- ADMIN_PASSWORD=changeme
volumes:
- ./data/colony.json:/app/colony.json
- ./data/decks.json:/app/decks.json
- ./data/deck-images:/app/assets/deck-images
restart: unless-stopped
+155 -9
View File
@@ -517,6 +517,13 @@
background: url('assets/card-frame.png') center / cover no-repeat; background: url('assets/card-frame.png') center / cover no-repeat;
flex-shrink: 0; flex-shrink: 0;
} }
.card-image-bg {
position: absolute;
inset: 16% 13% 13%;
object-fit: cover;
opacity: .35;
pointer-events: none;
}
/* Inner content area sits on the lined paper, inside the ornate border */ /* Inner content area sits on the lined paper, inside the ornate border */
.card-inner { .card-inner {
position: absolute; position: absolute;
@@ -675,9 +682,19 @@
<a href="games.html">Sessions</a> <a href="games.html">Sessions</a>
<a x-show="game" x-text="game && game.title" style="color:var(--paper);pointer-events:none;"></a> <a x-show="game" x-text="game && game.title" style="color:var(--paper);pointer-events:none;"></a>
</div> </div>
<div style="display:flex;align-items:center;gap:.8rem"> <div style="display:flex;align-items:center;gap:.6rem">
<select x-show="game && (cards.length || customDecks.length)"
x-model="activeDeckId"
style="background:transparent;border:1px solid var(--line);color:var(--muted);
font:650 .65rem/1 var(--mono);letter-spacing:.1em;text-transform:uppercase;
padding:.45rem .6rem;cursor:pointer;appearance:none">
<option value="">Default Cards</option>
<template x-for="d in customDecks" :key="d.id">
<option :value="d.id" x-text="d.name" style="background:var(--panel)"></option>
</template>
</select>
<button <button
x-show="game && cards.length" x-show="game && (cards.length || customDecks.length)"
class="edit-toggle locked" class="edit-toggle locked"
style="border-color:rgba(184,138,67,.35)" style="border-color:rgba(184,138,67,.35)"
@click="drawCard()" @click="drawCard()"
@@ -718,6 +735,15 @@
<div class="game-header-actions" x-show="editMode"> <div class="game-header-actions" x-show="editMode">
<button class="btn sm" @click="openEditGame()">Edit Session Details</button> <button class="btn sm" @click="openEditGame()">Edit Session Details</button>
</div> </div>
<div class="game-meta-row" x-show="customDecks.length" style="margin-top:1rem;gap:.6rem">
<span style="font:700 .65rem/1 var(--mono);letter-spacing:.12em;text-transform:uppercase;color:var(--muted)">Deck</span>
<select x-model="activeDeckId" @change="saveDeckToGame()" style="background:var(--panel);border:1px solid var(--line);color:var(--text);font:700 .65rem/1 var(--mono);letter-spacing:.1em;text-transform:uppercase;padding:.35rem .7rem;cursor:pointer;appearance:none">
<option value="">Default Cards</option>
<template x-for="d in customDecks" :key="d.id">
<option :value="d.id" x-text="d.name"></option>
</template>
</select>
</div>
</div> </div>
</div> </div>
@@ -825,8 +851,11 @@
</div> </div>
<div class="entry-title" x-text="item.title"></div> <div class="entry-title" x-text="item.title"></div>
<div class="entry-desc" x-show="item.description" x-html="renderDesc(item.description)"></div> <div class="entry-desc" x-show="item.description" x-html="renderDesc(item.description)"></div>
<div class="entry-env" x-show="item.env"> <div class="entry-env" x-show="item.env || deckCache[item.id]">
<img :src="'assets/' + item.env + '.jpg'" :alt="item.title" loading="lazy" /> <img
:src="item.env ? 'assets/' + item.env + '.jpg' : 'assets/deck-images/' + deckCache[item.id]"
:alt="item.title" loading="lazy"
/>
</div> </div>
</div> </div>
<div class="entry-actions" x-show="editMode && !item.isAnchor"> <div class="entry-actions" x-show="editMode && !item.isAnchor">
@@ -866,8 +895,11 @@
</div> </div>
<div class="entry-title" x-text="item.title"></div> <div class="entry-title" x-text="item.title"></div>
<div class="entry-desc" x-show="item.description" x-html="renderDesc(item.description)"></div> <div class="entry-desc" x-show="item.description" x-html="renderDesc(item.description)"></div>
<div class="entry-env" x-show="item.env"> <div class="entry-env" x-show="item.env || deckCache[item.id]">
<img :src="'assets/' + item.env + '.jpg'" :alt="item.title" loading="lazy" /> <img
:src="item.env ? 'assets/' + item.env + '.jpg' : 'assets/deck-images/' + deckCache[item.id]"
:alt="item.title" loading="lazy"
/>
</div> </div>
</div> </div>
<div class="entry-actions" x-show="editMode"> <div class="entry-actions" x-show="editMode">
@@ -910,8 +942,11 @@
<a class="kw-usage" :href="'#entry-' + ref.id" x-text="ref.title"></a> <a class="kw-usage" :href="'#entry-' + ref.id" x-text="ref.title"></a>
</template> </template>
</div> </div>
<div class="entry-env" x-show="item.env"> <div class="entry-env" x-show="item.env || deckCache[item.id]">
<img :src="'assets/' + item.env + '.jpg'" :alt="item.title" loading="lazy" /> <img
:src="item.env ? 'assets/' + item.env + '.jpg' : 'assets/deck-images/' + deckCache[item.id]"
:alt="item.title" loading="lazy"
/>
</div> </div>
</div> </div>
<div class="entry-actions" x-show="editMode"> <div class="entry-actions" x-show="editMode">
@@ -967,6 +1002,9 @@
<!-- Card modal --> <!-- Card modal -->
<div class="card-overlay" x-show="showCard" @click.self="showCard = false" @keydown.escape.window="showCard = false"> <div class="card-overlay" x-show="showCard" @click.self="showCard = false" @keydown.escape.window="showCard = false">
<div class="card-frame" x-show="drawnCard"> <div class="card-frame" x-show="drawnCard">
<!-- Custom card image shown as subtle background within the inner frame -->
<img x-show="drawnCard?.cardImage" class="card-image-bg"
:src="'assets/deck-images/' + drawnCard?.cardImage" alt="" />
<div class="card-inner"> <div class="card-inner">
<div class="card-content"> <div class="card-content">
<div class="card-type-label" x-text="drawnCard && drawnCard.type === 'question' ? 'Question' : 'Action'"></div> <div class="card-type-label" x-text="drawnCard && drawnCard.type === 'question' ? 'Question' : 'Action'"></div>
@@ -1104,6 +1142,20 @@
</div> </div>
</div> </div>
</div> </div>
<div class="form-section">
<div class="form-section-title">Card Deck</div>
<div class="form-group">
<label class="form-label">Default deck for this session</label>
<select x-model="activeForm.deck_id">
<option value="">Default Cards (built-in)</option>
<template x-for="d in customDecks" :key="d.id">
<option :value="d.id" x-text="d.name"></option>
</template>
</select>
<div class="form-hint">Players will draw from this deck by default. Can be overridden per-session in the nav.</div>
</div>
</div>
<div class="form-error" x-show="formError" x-text="formError"></div> <div class="form-error" x-show="formError" x-text="formError"></div>
<div class="modal-actions"> <div class="modal-actions">
<button class="btn ghost" @click="closeModal()">Cancel</button> <button class="btn ghost" @click="closeModal()">Cancel</button>
@@ -1361,6 +1413,11 @@
saving: false, saving: false,
tlItems: [], tlItems: [],
_tlRows: { top: 1, bot: 1 }, _tlRows: { top: 1, bot: 1 },
// Image decks
deckImages: [],
deckCache: {},
customDecks: [], // full deck objects with cards
activeDeckId: '', // '' = default cards.json
// Live updates // Live updates
liveUpdate: false, liveUpdate: false,
// Card draw // Card draw
@@ -1398,6 +1455,7 @@
return return
} }
this.loadCards() this.loadCards()
this.loadDecks()
this.connectLive() this.connectLive()
const ok = await this.loadGame() const ok = await this.loadGame()
if (!ok) { this.loading = false; return } if (!ok) { this.loading = false; return }
@@ -1416,6 +1474,7 @@
if (!r.ok) { this.error = 'Session not found'; return false } if (!r.ok) { this.error = 'Session not found'; return false }
this.game = await r.json() this.game = await r.json()
document.title = this.game.title + ' — Orison Reach' document.title = this.game.title + ' — Orison Reach'
if (this.game.deck_id) this.activeDeckId = this.game.deck_id
return true return true
} catch { } catch {
this.error = 'Network error' this.error = 'Network error'
@@ -1429,6 +1488,54 @@
this.items = r.ok ? await r.json() : [] this.items = r.ok ? await r.json() : []
} catch { this.items = [] } } catch { this.items = [] }
this.tlItems = this.computeLayout() this.tlItems = this.computeLayout()
this.buildDeckCache()
},
async loadDecks() {
try {
const r = await fetch('/api/decks')
if (!r.ok) return
const decks = await r.json()
this.customDecks = decks
this.deckImages = decks.flatMap(d => (d.images || []).map(img => ({
filename: img.filename, keywords: img.keywords || []
})))
this.buildDeckCache()
} catch {}
},
buildDeckCache() {
if (!this.deckImages.length) return
const cache = {}
for (const item of this.items) {
if (!item.env) cache[item.id] = this.pickDeckImage(item)
}
this.deckCache = cache
},
// Deterministic seed from item ID so image never changes on re-render
idSeed(id) {
return id.split('').reduce((a, c) => (a * 31 + c.charCodeAt(0)) >>> 0, 0)
},
pickDeckImage(item) {
if (!this.deckImages.length) return null
const tags = new Set()
if (item.mood) tags.add(item.mood.toLowerCase())
if (item.discipline) tags.add(item.discipline.toLowerCase())
for (const m of (item.description || '').matchAll(/\*\*([^*\n]+)\*\*/g))
tags.add(m[1].trim().toLowerCase())
const scored = this.deckImages.map(img => ({
img,
score: img.keywords.filter(k => tags.has(k.toLowerCase())).length + 1
}))
const total = scored.reduce((s, x) => s + x.score, 0)
let pick = (this.idSeed(item.id) % 10000) / 10000 * total
for (const { img, score } of scored) {
pick -= score
if (pick <= 0) return img.filename
}
return scored[0].img.filename
}, },
connectLive() { connectLive() {
@@ -1456,6 +1563,35 @@
}, },
drawCard() { drawCard() {
// Custom deck selected?
if (this.activeDeckId) {
const deck = this.customDecks.find(d => d.id === this.activeDeckId)
const pool = deck?.cards || []
if (!pool.length) return
const hasCast = this.cast().length > 0
const hasKeyword = this.allKeywords().length > 0
const hasProject = this.projects().length > 0
const meets = c => {
if (!c.requires?.length) return true
return c.requires.every(r =>
r==='cast'?hasCast:r==='keyword'?hasKeyword:r==='project'?hasProject:true)
}
const eligible = pool.filter(meets)
const avail = eligible.filter(c => !this.recentCardIds.includes(c.id))
const src = avail.length ? avail : eligible
if (!src.length) return
const picked = src[Math.floor(Math.random() * src.length)]
const resolved = this.resolveDynamic(picked)
// Pick random card image if the card has images
if (picked.images?.length) {
const img = picked.images[Math.floor(Math.random() * picked.images.length)]
resolved.cardImage = img.filename
}
this.drawnCard = resolved
this.recentCardIds = [...this.recentCardIds.slice(-9), picked.id]
this.showCard = true
return
}
if (!this.cards.length) return if (!this.cards.length) return
const hasCast = this.cast().length > 0 const hasCast = this.cast().length > 0
const hasKeyword = this.allKeywords().length > 0 const hasKeyword = this.allKeywords().length > 0
@@ -1992,8 +2128,17 @@
this.showModal = true this.showModal = true
}, },
async saveDeckToGame() {
if (!this.game || !this.editMode) return
await fetch('/api/games/' + this.slug, {
method: 'PUT',
headers: this.authHeaders(),
body: JSON.stringify({ ...this.game, deck_id: this.activeDeckId })
})
},
openEditGame() { openEditGame() {
this.activeForm = Object.assign({}, this.game) this.activeForm = Object.assign({ deck_id: '' }, this.game)
this.formError = null this.formError = null
this.modalType = 'editGame' this.modalType = 'editGame'
this.showModal = true this.showModal = true
@@ -2074,6 +2219,7 @@
if (!r.ok) { this.formError = data.error || 'Failed to save'; return } if (!r.ok) { this.formError = data.error || 'Failed to save'; return }
this.game = data this.game = data
document.title = data.title + ' — Orison Reach' document.title = data.title + ' — Orison Reach'
if (data.deck_id !== undefined) this.activeDeckId = data.deck_id
this.tlItems = this.computeLayout() this.tlItems = this.computeLayout()
this.closeModal() this.closeModal()
} catch { this.formError = 'Network error' } } catch { this.formError = 'Network error' }
+21 -2
View File
@@ -344,6 +344,19 @@
</div> </div>
</div> </div>
<div x-show="decks.length" style="border-top:1px solid rgba(232,220,196,.18);margin-top:1.4rem;padding-top:1.2rem;">
<div style="font:700 .68rem/1 var(--mono);letter-spacing:.14em;text-transform:uppercase;color:var(--muted);margin-bottom:1rem;">Card Deck</div>
<div class="form-group">
<label class="form-label">Default deck</label>
<select x-model="form.deck_id">
<option value="">Default Cards (built-in)</option>
<template x-for="d in decks" :key="d.id">
<option :value="d.id" x-text="d.name"></option>
</template>
</select>
</div>
</div>
<div class="form-error" x-show="formError" x-text="formError"></div> <div class="form-error" x-show="formError" x-text="formError"></div>
<div class="modal-actions"> <div class="modal-actions">
@@ -360,6 +373,7 @@
function gamesApp() { function gamesApp() {
return { return {
games: [], games: [],
decks: [],
loading: true, loading: true,
showModal: false, showModal: false,
saving: false, saving: false,
@@ -367,7 +381,11 @@
form: {}, form: {},
async init() { async init() {
await this.loadGames() await Promise.all([this.loadGames(), this.loadDecks()])
},
async loadDecks() {
try { const r = await fetch('/api/decks'); this.decks = r.ok ? await r.json() : [] } catch {}
}, },
async loadGames() { async loadGames() {
@@ -386,7 +404,8 @@
title: '', description: '', mood: 'light', title: '', description: '', mood: 'light',
timeline_start: '', timeline_end: '', password: '', timeline_start: '', timeline_end: '', password: '',
start_title: '', start_description: '', start_mood: '', start_title: '', start_description: '', start_mood: '',
end_title: '', end_description: '', end_mood: '' end_title: '', end_description: '', end_mood: '',
deck_id: ''
} }
this.formError = null this.formError = null
this.showModal = true this.showModal = true
+2 -1
View File
@@ -237,7 +237,8 @@
<a href="#engineering">Engineering Project</a> <a href="#engineering">Engineering Project</a>
<a href="#labour">Labour Project</a> <a href="#labour">Labour Project</a>
<a href="#ledger">Survival Ledger</a> <a href="#ledger">Survival Ledger</a>
<a href="games.html" style="color:var(--brass);margin-left:auto">Game Sessions</a> <a href="games.html" style="margin-left:auto">Game Sessions</a>
<a href="deck-editor.html" style="color:var(--brass)">Image Decks</a>
</div> </div>
</nav> </nav>
+192
View File
@@ -6,6 +6,26 @@ const path = require('path')
const app = express() const app = express()
const PORT = process.env.PORT || 3000 const PORT = process.env.PORT || 3000
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || ''
// Stateless admin token: hmac(timestamp, secret). Valid 24h.
function makeAdminToken() {
const ts = Date.now().toString()
const sig = crypto.createHmac('sha256', 'colony-admin-' + ADMIN_PASSWORD).update(ts).digest('hex')
return ts + '.' + sig
}
function validAdminToken(token) {
if (!ADMIN_PASSWORD || !token) return !ADMIN_PASSWORD // if no password set, allow all
const [ts, sig] = (token || '').split('.')
if (!ts || !sig) return false
if (Date.now() - Number(ts) > 86_400_000) return false // expired
const expected = crypto.createHmac('sha256', 'colony-admin-' + ADMIN_PASSWORD).update(ts).digest('hex')
return sig === expected
}
function requireAdmin(req, res, next) {
if (validAdminToken(req.headers['x-admin-token'])) return next()
res.status(401).json({ error: 'Admin authentication required' })
}
// SSE clients: Map<slug, Set<res>> // SSE clients: Map<slug, Set<res>>
const sseClients = new Map() const sseClients = new Map()
@@ -67,10 +87,18 @@ function publicGame(g) {
timeline_start: g.timeline_start, timeline_end: g.timeline_end, timeline_start: g.timeline_start, timeline_end: g.timeline_end,
start_title: g.start_title || '', start_description: g.start_description || '', start_mood: g.start_mood || '', start_title: g.start_title || '', start_description: g.start_description || '', start_mood: g.start_mood || '',
end_title: g.end_title || '', end_description: g.end_description || '', end_mood: g.end_mood || '', end_title: g.end_title || '', end_description: g.end_description || '', end_mood: g.end_mood || '',
deck_id: g.deck_id || '',
created_at: g.created_at created_at: g.created_at
} }
} }
// --- Admin auth ---
app.post('/api/admin/login', (req, res) => {
if (!ADMIN_PASSWORD) return res.json({ token: 'no-auth-required' })
if (req.body.password !== ADMIN_PASSWORD) return res.status(401).json({ error: 'Wrong password' })
res.json({ token: makeAdminToken() })
})
// --- Live updates (SSE) --- // --- Live updates (SSE) ---
app.get('/api/games/:slug/live', (req, res) => { app.get('/api/games/:slug/live', (req, res) => {
res.writeHead(200, { res.writeHead(200, {
@@ -150,6 +178,7 @@ app.put('/api/games/:slug', requirePassword, (req, res) => {
game.end_title = end_title || '' game.end_title = end_title || ''
game.end_description = end_description || '' game.end_description = end_description || ''
game.end_mood = end_mood || '' game.end_mood = end_mood || ''
game.deck_id = req.body.deck_id || ''
saveDB(req.db); broadcast(req.params.slug) saveDB(req.db); broadcast(req.params.slug)
res.json(publicGame(game)) res.json(publicGame(game))
}) })
@@ -258,4 +287,167 @@ app.delete('/api/games/:slug/keywords/:id', requirePassword, (req, res) => {
res.json({ ok: true }) res.json({ ok: true })
}) })
// --- Decks (image libraries) ---
function loadDecks() {
try { return JSON.parse(fs.readFileSync(path.join(__dirname, 'decks.json'), 'utf8')) }
catch { return { decks: [] } }
}
function saveDecks(data) {
const tmp = path.join(__dirname, 'decks.json') + '.tmp'
fs.writeFileSync(tmp, JSON.stringify(data, null, 2))
fs.renameSync(tmp, path.join(__dirname, 'decks.json'))
}
app.get('/api/decks', (req, res) => {
res.json(loadDecks().decks)
})
app.post('/api/decks', requireAdmin, (req, res) => {
const { name, description } = req.body
if (!name) return res.status(400).json({ error: 'name required' })
const db = loadDecks()
const deck = { id: newId(), name, description: description || '', images: [], created_at: now() }
db.decks.push(deck)
saveDecks(db)
res.status(201).json(deck)
})
app.put('/api/decks/:id', requireAdmin, (req, res) => {
const db = loadDecks()
const deck = db.decks.find(d => d.id === req.params.id)
if (!deck) return res.status(404).json({ error: 'Deck not found' })
deck.name = req.body.name || deck.name
deck.description = req.body.description ?? deck.description
saveDecks(db)
res.json(deck)
})
app.delete('/api/decks/:id', requireAdmin, (req, res) => {
const db = loadDecks()
const idx = db.decks.findIndex(d => d.id === req.params.id)
if (idx === -1) return res.status(404).json({ error: 'Deck not found' })
// Delete image files
for (const img of db.decks[idx].images || []) {
try { fs.unlinkSync(path.join(__dirname, 'assets', 'deck-images', img.filename)) } catch {}
}
db.decks.splice(idx, 1)
saveDecks(db)
res.json({ ok: true })
})
// --- Cards within a deck ---
app.post('/api/decks/:id/cards', requireAdmin, (req, res) => {
const db = loadDecks()
const deck = db.decks.find(d => d.id === req.params.id)
if (!deck) return res.status(404).json({ error: 'Deck not found' })
const { title, type, question, a, b, requires } = req.body
if (!title || !a || !b) return res.status(400).json({ error: 'title, a, b required' })
const card = { id: newId(), title, type: type || 'action', question: question || '', a, b, requires: requires || [], images: [], created_at: now() }
deck.cards = deck.cards || []
deck.cards.push(card)
saveDecks(db)
res.status(201).json(card)
})
app.put('/api/decks/:deckId/cards/:cardId', requireAdmin, (req, res) => {
const db = loadDecks()
const deck = db.decks.find(d => d.id === req.params.deckId)
if (!deck) return res.status(404).json({ error: 'Deck not found' })
const card = (deck.cards || []).find(c => c.id === req.params.cardId)
if (!card) return res.status(404).json({ error: 'Card not found' })
const { title, type, question, a, b, requires } = req.body
Object.assign(card, { title: title ?? card.title, type: type ?? card.type, question: question ?? card.question, a: a ?? card.a, b: b ?? card.b, requires: requires ?? card.requires })
saveDecks(db)
res.json(card)
})
app.delete('/api/decks/:deckId/cards/:cardId', requireAdmin, (req, res) => {
const db = loadDecks()
const deck = db.decks.find(d => d.id === req.params.deckId)
if (!deck) return res.status(404).json({ error: 'Deck not found' })
const idx = (deck.cards || []).findIndex(c => c.id === req.params.cardId)
if (idx === -1) return res.status(404).json({ error: 'Card not found' })
for (const img of deck.cards[idx].images || []) {
try { fs.unlinkSync(path.join(__dirname, 'assets', 'deck-images', img.filename)) } catch {}
}
deck.cards.splice(idx, 1)
saveDecks(db)
res.json({ ok: true })
})
app.post('/api/decks/:deckId/cards/:cardId/images', requireAdmin, (req, res) => {
const db = loadDecks()
const deck = db.decks.find(d => d.id === req.params.deckId)
if (!deck) return res.status(404).json({ error: 'Deck not found' })
const card = (deck.cards || []).find(c => c.id === req.params.cardId)
if (!card) return res.status(404).json({ error: 'Card not found' })
const { filename, data, keywords } = req.body
if (!data) return res.status(400).json({ error: 'data required' })
const ext = path.extname(filename || '.jpg') || '.jpg'
const savedName = `deck-${newId()}${ext}`
fs.mkdirSync(path.join(__dirname, 'assets', 'deck-images'), { recursive: true })
fs.writeFileSync(path.join(__dirname, 'assets', 'deck-images', savedName), Buffer.from(data, 'base64'))
const img = { id: newId(), filename: savedName, keywords: keywords || [], created_at: now() }
card.images = card.images || []
card.images.push(img)
saveDecks(db)
res.status(201).json(img)
})
app.delete('/api/decks/:deckId/cards/:cardId/images/:imgId', requireAdmin, (req, res) => {
const db = loadDecks()
const deck = db.decks.find(d => d.id === req.params.deckId)
if (!deck) return res.status(404).json({ error: 'Deck not found' })
const card = (deck.cards || []).find(c => c.id === req.params.cardId)
if (!card) return res.status(404).json({ error: 'Card not found' })
const idx = (card.images || []).findIndex(i => i.id === req.params.imgId)
if (idx === -1) return res.status(404).json({ error: 'Image not found' })
try { fs.unlinkSync(path.join(__dirname, 'assets', 'deck-images', card.images[idx].filename)) } catch {}
card.images.splice(idx, 1)
saveDecks(db)
res.json({ ok: true })
})
// --- Pool images (for event auto-matching) ---
app.post('/api/decks/:id/images', requireAdmin, (req, res) => {
const db = loadDecks()
const deck = db.decks.find(d => d.id === req.params.id)
if (!deck) return res.status(404).json({ error: 'Deck not found' })
const { filename, data, keywords } = req.body
if (!data) return res.status(400).json({ error: 'data required' })
const ext = path.extname(filename || '.jpg') || '.jpg'
const savedName = `deck-${newId()}${ext}`
const imgPath = path.join(__dirname, 'assets', 'deck-images', savedName)
fs.mkdirSync(path.dirname(imgPath), { recursive: true })
fs.writeFileSync(imgPath, Buffer.from(data, 'base64'))
const img = { id: newId(), filename: savedName, keywords: keywords || [], created_at: now() }
deck.images = deck.images || []
deck.images.push(img)
saveDecks(db)
res.status(201).json(img)
})
app.put('/api/decks/:deckId/images/:imgId', requireAdmin, (req, res) => {
const db = loadDecks()
const deck = db.decks.find(d => d.id === req.params.deckId)
if (!deck) return res.status(404).json({ error: 'Deck not found' })
const img = deck.images.find(i => i.id === req.params.imgId)
if (!img) return res.status(404).json({ error: 'Image not found' })
img.keywords = req.body.keywords ?? img.keywords
saveDecks(db)
res.json(img)
})
app.delete('/api/decks/:deckId/images/:imgId', requireAdmin, (req, res) => {
const db = loadDecks()
const deck = db.decks.find(d => d.id === req.params.deckId)
if (!deck) return res.status(404).json({ error: 'Deck not found' })
const idx = deck.images.findIndex(i => i.id === req.params.imgId)
if (idx === -1) return res.status(404).json({ error: 'Image not found' })
try { fs.unlinkSync(path.join(__dirname, 'assets', 'deck-images', deck.images[idx].filename)) } catch {}
deck.images.splice(idx, 1)
saveDecks(db)
res.json({ ok: true })
})
app.listen(PORT, () => console.log(`Colony Archive running on http://localhost:${PORT}`)) app.listen(PORT, () => console.log(`Colony Archive running on http://localhost:${PORT}`))