This commit is contained in:
2026-08-03 16:31:47 +02:00
parent 50c04d56b2
commit 40bb3452cb
14 changed files with 68 additions and 8 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 53 KiB

+30
View File
@@ -989,6 +989,16 @@
</div>
</div>
<!-- Live update toast -->
<div x-show="liveUpdate"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 transform translate-y-2"
x-transition:leave="transition ease-in duration-300"
x-transition:leave-end="opacity-0"
style="position:fixed;bottom:1.5rem;right:1.5rem;z-index:400;background:var(--panel);border:1px solid rgba(112,121,78,.5);color:var(--moss);font:700 .62rem/1 var(--mono);letter-spacing:.16em;text-transform:uppercase;padding:.55rem 1rem;pointer-events:none">
Chronicle updated
</div>
<!-- Timeline click chooser — dismiss on outside click via transparent backdrop -->
<div x-show="tlChooser.show"
style="position:fixed;inset:0;z-index:199"
@@ -1351,6 +1361,8 @@
saving: false,
tlItems: [],
_tlRows: { top: 1, bot: 1 },
// Live updates
liveUpdate: false,
// Card draw
cards: [],
drawnCard: null,
@@ -1386,6 +1398,7 @@
return
}
this.loadCards()
this.connectLive()
const ok = await this.loadGame()
if (!ok) { this.loading = false; return }
await Promise.all([this.loadItems(), this.loadKeywords()])
@@ -1418,6 +1431,23 @@
this.tlItems = this.computeLayout()
},
connectLive() {
if (!this.slug) return
const es = new EventSource('/api/games/' + this.slug + '/live')
es.onmessage = async (e) => {
if (e.data === 'connected') return
// Don't reload while a modal is open — pick it up when they close
if (this.showModal) return
await Promise.all([this.loadGame(), this.loadItems(), this.loadKeywords()])
this.liveUpdate = true
setTimeout(() => this.liveUpdate = false, 2500)
}
es.onerror = () => {
es.close()
setTimeout(() => this.connectLive(), 5000)
}
},
async loadCards() {
try {
const r = await fetch('/assets/cards.json')
+38 -8
View File
@@ -6,6 +6,18 @@ const path = require('path')
const app = express()
const PORT = process.env.PORT || 3000
// SSE clients: Map<slug, Set<res>>
const sseClients = new Map()
function broadcast(slug) {
const bucket = sseClients.get(slug)
if (!bucket) return
const msg = `data: update\n\n`
for (const res of bucket) {
try { res.write(msg) } catch {}
}
}
const DB_PATH = path.join(__dirname, 'colony.json')
function loadDB() {
@@ -59,6 +71,24 @@ function publicGame(g) {
}
}
// --- Live updates (SSE) ---
app.get('/api/games/:slug/live', (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no',
})
res.write('data: connected\n\n')
const slug = req.params.slug
if (!sseClients.has(slug)) sseClients.set(slug, new Set())
sseClients.get(slug).add(res)
req.on('close', () => {
sseClients.get(slug)?.delete(res)
if (sseClients.get(slug)?.size === 0) sseClients.delete(slug)
})
})
// --- Games ---
app.get('/api/games', (req, res) => {
const db = loadDB()
@@ -120,7 +150,7 @@ app.put('/api/games/:slug', requirePassword, (req, res) => {
game.end_title = end_title || ''
game.end_description = end_description || ''
game.end_mood = end_mood || ''
saveDB(req.db)
saveDB(req.db); broadcast(req.params.slug)
res.json(publicGame(game))
})
@@ -159,7 +189,7 @@ app.post('/api/games/:slug/items', requirePassword, (req, res) => {
}
req.game.items = req.game.items || []
req.game.items.push(item)
saveDB(req.db)
saveDB(req.db); broadcast(req.params.slug)
res.status(201).json(item)
})
@@ -171,7 +201,7 @@ app.put('/api/games/:slug/items/:id', requirePassword, (req, res) => {
if (!title || !date) return res.status(400).json({ error: 'title, date required' })
const { env, env_layout } = req.body
items[idx] = { ...items[idx], title, description: description || '', mood: mood || null, date, end_date: end_date || null, status: status || null, discipline: discipline || null, env: env || null, env_layout: env_layout || null }
saveDB(req.db)
saveDB(req.db); broadcast(req.params.slug)
res.json(items[idx])
})
@@ -180,7 +210,7 @@ app.delete('/api/games/:slug/items/:id', requirePassword, (req, res) => {
const idx = items.findIndex(i => i.id === req.params.id)
if (idx === -1) return res.status(404).json({ error: 'Item not found' })
items.splice(idx, 1)
saveDB(req.db)
saveDB(req.db); broadcast(req.params.slug)
res.json({ ok: true })
})
@@ -200,12 +230,12 @@ app.post('/api/games/:slug/keywords', requirePassword, (req, res) => {
const existing = req.game.keywords.find(k => k.term.toLowerCase() === term.toLowerCase())
if (existing) {
existing.definition = definition || ''
saveDB(req.db)
saveDB(req.db); broadcast(req.params.slug)
return res.json(existing)
}
const kw = { id: newId(), term, definition: definition || '', created_at: now() }
req.game.keywords.push(kw)
saveDB(req.db)
saveDB(req.db); broadcast(req.params.slug)
res.status(201).json(kw)
})
@@ -215,7 +245,7 @@ app.put('/api/games/:slug/keywords/:id', requirePassword, (req, res) => {
if (idx === -1) return res.status(404).json({ error: 'Keyword not found' })
const { term, definition } = req.body
kws[idx] = { ...kws[idx], term: term || kws[idx].term, definition: definition || '' }
saveDB(req.db)
saveDB(req.db); broadcast(req.params.slug)
res.json(kws[idx])
})
@@ -224,7 +254,7 @@ app.delete('/api/games/:slug/keywords/:id', requirePassword, (req, res) => {
const idx = kws.findIndex(k => k.id === req.params.id)
if (idx === -1) return res.status(404).json({ error: 'Keyword not found' })
kws.splice(idx, 1)
saveDB(req.db)
saveDB(req.db); broadcast(req.params.slug)
res.json({ ok: true })
})