diff --git a/.gitignore b/.gitignore index a222bd3..52b8fa8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ node_modules/ colony.json colony.json.tmp +decks.json +decks.json.tmp +data/ +assets/deck-images/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e6b65b3 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/deck-editor.html b/deck-editor.html new file mode 100644 index 0000000..b166d81 --- /dev/null +++ b/deck-editor.html @@ -0,0 +1,585 @@ + + + + + + Image Decks — Orison Reach + + + + +
+
+
Deck Editor Access
+
+ + +
+
+ +
+
+ + + +
+
+
Archive // Card & Image Library
+
+

Decks

+ +
+

+ Create decks with custom cards and images. Images tagged with keywords auto-match events in any session. +

+
+ +
+ +
+
Decks
+
+
No decks yet.
+ +
+
+ + +
+
Select or create a deck.
+
+ +
+
+
+
+
+
+ + +
+
+ + +
+
+ Cards () +
+
+ Image Pool () +
+
+ + +
+
+ +
+
No cards yet. Add the first one.
+
+ +
+
+ + +
+
+
+ Drop images or click to upload +
+ These images auto-match events by keyword. Tag each one after upload. +
+
+ + +
No images in pool.
+
+ +
+
+ +
+
+
+
+ + + + + + + + + + + + + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3c52be8 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/game.html b/game.html index 9001ab6..8bd1300 100644 --- a/game.html +++ b/game.html @@ -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 @@ Sessions -
+
+
+
+ Deck + +
@@ -825,8 +851,11 @@
-
- +
+
@@ -866,8 +895,11 @@
-
- +
+
@@ -910,8 +942,11 @@
-
- +
+
@@ -967,6 +1002,9 @@
+ +
@@ -1104,6 +1142,20 @@
+
+
Card Deck
+
+ + +
Players will draw from this deck by default. Can be overridden per-session in the nav.
+
+
+
+
+
Card Deck
+
+ + +
+
+
diff --git a/server.js b/server.js index 15f1679..031814b 100644 --- a/server.js +++ b/server.js @@ -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> 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}`))