v0.4
This commit is contained in:
@@ -1,3 +1,7 @@
|
||||
node_modules/
|
||||
colony.json
|
||||
colony.json.tmp
|
||||
decks.json
|
||||
decks.json.tmp
|
||||
data/
|
||||
assets/deck-images/
|
||||
|
||||
+13
@@ -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"]
|
||||
@@ -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 & 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>
|
||||
@@ -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
|
||||
@@ -517,6 +517,13 @@
|
||||
background: url('assets/card-frame.png') center / cover no-repeat;
|
||||
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 */
|
||||
.card-inner {
|
||||
position: absolute;
|
||||
@@ -675,9 +682,19 @@
|
||||
<a href="games.html">Sessions</a>
|
||||
<a x-show="game" x-text="game && game.title" style="color:var(--paper);pointer-events:none;"></a>
|
||||
</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
|
||||
x-show="game && cards.length"
|
||||
x-show="game && (cards.length || customDecks.length)"
|
||||
class="edit-toggle locked"
|
||||
style="border-color:rgba(184,138,67,.35)"
|
||||
@click="drawCard()"
|
||||
@@ -718,6 +735,15 @@
|
||||
<div class="game-header-actions" x-show="editMode">
|
||||
<button class="btn sm" @click="openEditGame()">Edit Session Details</button>
|
||||
</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>
|
||||
|
||||
@@ -825,8 +851,11 @@
|
||||
</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-env" x-show="item.env">
|
||||
<img :src="'assets/' + item.env + '.jpg'" :alt="item.title" loading="lazy" />
|
||||
<div class="entry-env" x-show="item.env || deckCache[item.id]">
|
||||
<img
|
||||
:src="item.env ? 'assets/' + item.env + '.jpg' : 'assets/deck-images/' + deckCache[item.id]"
|
||||
:alt="item.title" loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="entry-actions" x-show="editMode && !item.isAnchor">
|
||||
@@ -866,8 +895,11 @@
|
||||
</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-env" x-show="item.env">
|
||||
<img :src="'assets/' + item.env + '.jpg'" :alt="item.title" loading="lazy" />
|
||||
<div class="entry-env" x-show="item.env || deckCache[item.id]">
|
||||
<img
|
||||
:src="item.env ? 'assets/' + item.env + '.jpg' : 'assets/deck-images/' + deckCache[item.id]"
|
||||
:alt="item.title" loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="entry-actions" x-show="editMode">
|
||||
@@ -910,8 +942,11 @@
|
||||
<a class="kw-usage" :href="'#entry-' + ref.id" x-text="ref.title"></a>
|
||||
</template>
|
||||
</div>
|
||||
<div class="entry-env" x-show="item.env">
|
||||
<img :src="'assets/' + item.env + '.jpg'" :alt="item.title" loading="lazy" />
|
||||
<div class="entry-env" x-show="item.env || deckCache[item.id]">
|
||||
<img
|
||||
:src="item.env ? 'assets/' + item.env + '.jpg' : 'assets/deck-images/' + deckCache[item.id]"
|
||||
:alt="item.title" loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="entry-actions" x-show="editMode">
|
||||
@@ -967,6 +1002,9 @@
|
||||
<!-- Card modal -->
|
||||
<div class="card-overlay" x-show="showCard" @click.self="showCard = false" @keydown.escape.window="showCard = false">
|
||||
<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-content">
|
||||
<div class="card-type-label" x-text="drawnCard && drawnCard.type === 'question' ? 'Question' : 'Action'"></div>
|
||||
@@ -1104,6 +1142,20 @@
|
||||
</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="modal-actions">
|
||||
<button class="btn ghost" @click="closeModal()">Cancel</button>
|
||||
@@ -1361,6 +1413,11 @@
|
||||
saving: false,
|
||||
tlItems: [],
|
||||
_tlRows: { top: 1, bot: 1 },
|
||||
// Image decks
|
||||
deckImages: [],
|
||||
deckCache: {},
|
||||
customDecks: [], // full deck objects with cards
|
||||
activeDeckId: '', // '' = default cards.json
|
||||
// Live updates
|
||||
liveUpdate: false,
|
||||
// Card draw
|
||||
@@ -1398,6 +1455,7 @@
|
||||
return
|
||||
}
|
||||
this.loadCards()
|
||||
this.loadDecks()
|
||||
this.connectLive()
|
||||
const ok = await this.loadGame()
|
||||
if (!ok) { this.loading = false; return }
|
||||
@@ -1416,6 +1474,7 @@
|
||||
if (!r.ok) { this.error = 'Session not found'; return false }
|
||||
this.game = await r.json()
|
||||
document.title = this.game.title + ' — Orison Reach'
|
||||
if (this.game.deck_id) this.activeDeckId = this.game.deck_id
|
||||
return true
|
||||
} catch {
|
||||
this.error = 'Network error'
|
||||
@@ -1429,6 +1488,54 @@
|
||||
this.items = r.ok ? await r.json() : []
|
||||
} catch { this.items = [] }
|
||||
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() {
|
||||
@@ -1456,6 +1563,35 @@
|
||||
},
|
||||
|
||||
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
|
||||
const hasCast = this.cast().length > 0
|
||||
const hasKeyword = this.allKeywords().length > 0
|
||||
@@ -1992,8 +2128,17 @@
|
||||
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() {
|
||||
this.activeForm = Object.assign({}, this.game)
|
||||
this.activeForm = Object.assign({ deck_id: '' }, this.game)
|
||||
this.formError = null
|
||||
this.modalType = 'editGame'
|
||||
this.showModal = true
|
||||
@@ -2074,6 +2219,7 @@
|
||||
if (!r.ok) { this.formError = data.error || 'Failed to save'; return }
|
||||
this.game = data
|
||||
document.title = data.title + ' — Orison Reach'
|
||||
if (data.deck_id !== undefined) this.activeDeckId = data.deck_id
|
||||
this.tlItems = this.computeLayout()
|
||||
this.closeModal()
|
||||
} catch { this.formError = 'Network error' }
|
||||
|
||||
+21
-2
@@ -344,6 +344,19 @@
|
||||
</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="modal-actions">
|
||||
@@ -360,6 +373,7 @@
|
||||
function gamesApp() {
|
||||
return {
|
||||
games: [],
|
||||
decks: [],
|
||||
loading: true,
|
||||
showModal: false,
|
||||
saving: false,
|
||||
@@ -367,7 +381,11 @@
|
||||
form: {},
|
||||
|
||||
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() {
|
||||
@@ -386,7 +404,8 @@
|
||||
title: '', description: '', mood: 'light',
|
||||
timeline_start: '', timeline_end: '', password: '',
|
||||
start_title: '', start_description: '', start_mood: '',
|
||||
end_title: '', end_description: '', end_mood: ''
|
||||
end_title: '', end_description: '', end_mood: '',
|
||||
deck_id: ''
|
||||
}
|
||||
this.formError = null
|
||||
this.showModal = true
|
||||
|
||||
+2
-1
@@ -237,7 +237,8 @@
|
||||
<a href="#engineering">Engineering Project</a>
|
||||
<a href="#labour">Labour Project</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>
|
||||
</nav>
|
||||
|
||||
|
||||
@@ -6,6 +6,26 @@ const path = require('path')
|
||||
|
||||
const app = express()
|
||||
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>>
|
||||
const sseClients = new Map()
|
||||
@@ -67,10 +87,18 @@ function publicGame(g) {
|
||||
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 || '',
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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) ---
|
||||
app.get('/api/games/:slug/live', (req, res) => {
|
||||
res.writeHead(200, {
|
||||
@@ -150,6 +178,7 @@ app.put('/api/games/:slug', requirePassword, (req, res) => {
|
||||
game.end_title = end_title || ''
|
||||
game.end_description = end_description || ''
|
||||
game.end_mood = end_mood || ''
|
||||
game.deck_id = req.body.deck_id || ''
|
||||
saveDB(req.db); broadcast(req.params.slug)
|
||||
res.json(publicGame(game))
|
||||
})
|
||||
@@ -258,4 +287,167 @@ app.delete('/api/games/:slug/keywords/:id', requirePassword, (req, res) => {
|
||||
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}`))
|
||||
|
||||
Reference in New Issue
Block a user