v0.3
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 53 KiB |
@@ -989,6 +989,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Timeline click chooser — dismiss on outside click via transparent backdrop -->
|
||||||
<div x-show="tlChooser.show"
|
<div x-show="tlChooser.show"
|
||||||
style="position:fixed;inset:0;z-index:199"
|
style="position:fixed;inset:0;z-index:199"
|
||||||
@@ -1351,6 +1361,8 @@
|
|||||||
saving: false,
|
saving: false,
|
||||||
tlItems: [],
|
tlItems: [],
|
||||||
_tlRows: { top: 1, bot: 1 },
|
_tlRows: { top: 1, bot: 1 },
|
||||||
|
// Live updates
|
||||||
|
liveUpdate: false,
|
||||||
// Card draw
|
// Card draw
|
||||||
cards: [],
|
cards: [],
|
||||||
drawnCard: null,
|
drawnCard: null,
|
||||||
@@ -1386,6 +1398,7 @@
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.loadCards()
|
this.loadCards()
|
||||||
|
this.connectLive()
|
||||||
const ok = await this.loadGame()
|
const ok = await this.loadGame()
|
||||||
if (!ok) { this.loading = false; return }
|
if (!ok) { this.loading = false; return }
|
||||||
await Promise.all([this.loadItems(), this.loadKeywords()])
|
await Promise.all([this.loadItems(), this.loadKeywords()])
|
||||||
@@ -1418,6 +1431,23 @@
|
|||||||
this.tlItems = this.computeLayout()
|
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() {
|
async loadCards() {
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/assets/cards.json')
|
const r = await fetch('/assets/cards.json')
|
||||||
|
|||||||
@@ -6,6 +6,18 @@ const path = require('path')
|
|||||||
|
|
||||||
const app = express()
|
const app = express()
|
||||||
const PORT = process.env.PORT || 3000
|
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')
|
const DB_PATH = path.join(__dirname, 'colony.json')
|
||||||
|
|
||||||
function loadDB() {
|
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 ---
|
// --- Games ---
|
||||||
app.get('/api/games', (req, res) => {
|
app.get('/api/games', (req, res) => {
|
||||||
const db = loadDB()
|
const db = loadDB()
|
||||||
@@ -120,7 +150,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 || ''
|
||||||
saveDB(req.db)
|
saveDB(req.db); broadcast(req.params.slug)
|
||||||
res.json(publicGame(game))
|
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 = req.game.items || []
|
||||||
req.game.items.push(item)
|
req.game.items.push(item)
|
||||||
saveDB(req.db)
|
saveDB(req.db); broadcast(req.params.slug)
|
||||||
res.status(201).json(item)
|
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' })
|
if (!title || !date) return res.status(400).json({ error: 'title, date required' })
|
||||||
const { env, env_layout } = req.body
|
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 }
|
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])
|
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)
|
const idx = items.findIndex(i => i.id === req.params.id)
|
||||||
if (idx === -1) return res.status(404).json({ error: 'Item not found' })
|
if (idx === -1) return res.status(404).json({ error: 'Item not found' })
|
||||||
items.splice(idx, 1)
|
items.splice(idx, 1)
|
||||||
saveDB(req.db)
|
saveDB(req.db); broadcast(req.params.slug)
|
||||||
res.json({ ok: true })
|
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())
|
const existing = req.game.keywords.find(k => k.term.toLowerCase() === term.toLowerCase())
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.definition = definition || ''
|
existing.definition = definition || ''
|
||||||
saveDB(req.db)
|
saveDB(req.db); broadcast(req.params.slug)
|
||||||
return res.json(existing)
|
return res.json(existing)
|
||||||
}
|
}
|
||||||
const kw = { id: newId(), term, definition: definition || '', created_at: now() }
|
const kw = { id: newId(), term, definition: definition || '', created_at: now() }
|
||||||
req.game.keywords.push(kw)
|
req.game.keywords.push(kw)
|
||||||
saveDB(req.db)
|
saveDB(req.db); broadcast(req.params.slug)
|
||||||
res.status(201).json(kw)
|
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' })
|
if (idx === -1) return res.status(404).json({ error: 'Keyword not found' })
|
||||||
const { term, definition } = req.body
|
const { term, definition } = req.body
|
||||||
kws[idx] = { ...kws[idx], term: term || kws[idx].term, definition: definition || '' }
|
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])
|
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)
|
const idx = kws.findIndex(k => k.id === req.params.id)
|
||||||
if (idx === -1) return res.status(404).json({ error: 'Keyword not found' })
|
if (idx === -1) return res.status(404).json({ error: 'Keyword not found' })
|
||||||
kws.splice(idx, 1)
|
kws.splice(idx, 1)
|
||||||
saveDB(req.db)
|
saveDB(req.db); broadcast(req.params.slug)
|
||||||
res.json({ ok: true })
|
res.json({ ok: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||