events linked to projects; truths; rules audit

This commit is contained in:
2026-08-08 19:54:06 +02:00
parent f3b536bc70
commit 36cd3b81f5
2 changed files with 109 additions and 10 deletions
+103 -7
View File
@@ -626,6 +626,30 @@
} }
.tl-chooser-btn:hover { background: rgba(184,138,67,.12); color: var(--paper); } .tl-chooser-btn:hover { background: rgba(184,138,67,.12); color: var(--paper); }
/* Truths */
.truth-list { display:flex; flex-direction:column; gap:.6rem; margin-top:.8rem; }
.truth-item {
display:flex; align-items:flex-start; gap:1rem;
padding:.8rem 1rem; border-left:3px solid var(--brass);
background:rgba(184,138,67,.05);
font:italic 1rem/1.6 var(--serif); color:var(--paper);
}
.truth-item-text { flex:1; }
.project-events {
margin-top:.8rem; padding-top:.8rem; border-top:1px solid var(--line);
}
.project-events-label {
font:700 .62rem/1 var(--mono); letter-spacing:.12em; text-transform:uppercase;
color:var(--muted); margin-bottom:.5rem;
}
.project-events-list { display:flex; flex-direction:column; gap:.3rem; }
.project-event-link {
font:.85rem/1.4 var(--sans); color:var(--muted); text-decoration:none;
display:flex; align-items:baseline; gap:.5rem;
}
.project-event-link:hover { color:var(--paper); }
.project-event-date { font:.62rem/1 var(--mono); color:var(--muted); flex-shrink:0; }
/* Autocomplete dropdown */ /* Autocomplete dropdown */
.ac-wrap { position: relative; } .ac-wrap { position: relative; }
.ac-dropdown { .ac-dropdown {
@@ -747,6 +771,24 @@
</div> </div>
</div> </div>
<!-- Truths -->
<div class="section" x-show="(game && game.truths && game.truths.length) || editMode" style="margin-bottom:3rem">
<div class="section-header">
<div class="section-label">Truths</div>
<button class="btn sm" x-show="editMode" @click="openEditGame()">Edit</button>
</div>
<div class="truth-list" x-show="game && game.truths && game.truths.length">
<template x-for="(t, i) in (game && game.truths || [])" :key="i">
<div class="truth-item">
<span class="truth-item-text" x-text="t"></span>
</div>
</template>
</div>
<div x-show="editMode && (!game || !game.truths || !game.truths.length)" class="empty-section">
No truths established. Add the absolute rules of your setting in Edit Session Details.
</div>
</div>
<!-- Timeline --> <!-- Timeline -->
<div class="timeline-section"> <div class="timeline-section">
<div class="chronicle-eyebrow" style="margin-bottom:.8rem">Colony Chronicle // Timeline</div> <div class="chronicle-eyebrow" style="margin-bottom:.8rem">Colony Chronicle // Timeline</div>
@@ -846,6 +888,7 @@
<div class="entry-eyebrow"> <div class="entry-eyebrow">
<span x-text="item.date"></span> <span x-text="item.date"></span>
<span x-show="item.isAnchor"><span class="sep"> // </span><span class="e-anchor" x-text="item.anchorType === 'start' ? 'Opening' : 'Closing'"></span></span> <span x-show="item.isAnchor"><span class="sep"> // </span><span class="e-anchor" x-text="item.anchorType === 'start' ? 'Opening' : 'Closing'"></span></span>
<span x-show="projectForEvent(item)"><span class="sep"> // </span><a :href="'#entry-' + item.project_id" style="color:var(--oxygen);text-decoration:none" x-text="projectForEvent(item)?.title"></a></span>
<span x-show="item.discipline"><span class="sep"> // </span><span class="disc-emoji" x-text="disciplineIcon(item.discipline)"></span> <span x-text="item.discipline"></span></span> <span x-show="item.discipline"><span class="sep"> // </span><span class="disc-emoji" x-text="disciplineIcon(item.discipline)"></span> <span x-text="item.discipline"></span></span>
<span x-show="item.mood"><span class="sep"> // </span><span :class="item.mood === 'dark' ? 'e-dark' : ''" x-text="item.mood === 'dark' ? '◉' : '○'"></span></span> <span x-show="item.mood"><span class="sep"> // </span><span :class="item.mood === 'dark' ? 'e-dark' : ''" x-text="item.mood === 'dark' ? '◉' : '○'"></span></span>
</div> </div>
@@ -895,6 +938,17 @@
</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="project-events" x-show="eventsForProject(item.id).length > 0">
<div class="project-events-label">Events in this project</div>
<div class="project-events-list">
<template x-for="ev in eventsForProject(item.id)" :key="ev.id">
<a class="project-event-link" :href="'#entry-' + ev.id">
<span class="project-event-date" x-text="ev.date"></span>
<span x-text="ev.title"></span>
</a>
</template>
</div>
</div>
<div class="entry-env" x-show="item.env || deckCache[item.id]"> <div class="entry-env" x-show="item.env || deckCache[item.id]">
<img <img
:src="item.env ? 'assets/' + item.env + '.jpg' : 'assets/deck-images/' + deckCache[item.id]" :src="item.env ? 'assets/' + item.env + '.jpg' : 'assets/deck-images/' + deckCache[item.id]"
@@ -1156,6 +1210,25 @@
</div> </div>
</div> </div>
<div class="form-section">
<div class="form-section-title">Truths</div>
<p style="font:.82rem/1.5 var(--sans);color:var(--muted);margin:0 0 .8rem">Absolute rules of the setting. Nothing created during play can contradict them.</p>
<div style="display:flex;flex-direction:column;gap:.4rem;margin-bottom:.8rem">
<template x-for="(t, i) in (activeForm.truths || [])" :key="i">
<div style="display:flex;align-items:center;gap:.6rem;padding:.5rem .8rem;border-left:2px solid var(--brass);background:rgba(184,138,67,.05)">
<span style="flex:1;font:italic .9rem/1.5 var(--serif);color:var(--paper)" x-text="t"></span>
<button class="btn sm danger" @click="activeForm.truths.splice(i,1)">✕</button>
</div>
</template>
</div>
<div style="display:flex;gap:.6rem">
<input type="text" x-model="newTruth" placeholder="e.g. There is no faster-than-light travel."
@keydown.enter.prevent="if(newTruth.trim()){(activeForm.truths=activeForm.truths||[]).push(newTruth.trim());newTruth=''}"
style="flex:1" />
<button class="btn sm" @click="if(newTruth.trim()){(activeForm.truths=activeForm.truths||[]).push(newTruth.trim());newTruth=''}">Add</button>
</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>
@@ -1173,10 +1246,21 @@
:placeholder="activeForm.type === 'event' ? 'What happened?' : activeForm.type === 'project' ? 'Project name' : 'Character name'" /> :placeholder="activeForm.type === 'event' ? 'What happened?' : activeForm.type === 'project' ? 'Project name' : 'Character name'" />
</div> </div>
<!-- Event: single date --> <!-- Event: single date + optional project -->
<div class="form-group" x-show="activeForm.type === 'event'"> <div x-show="activeForm.type === 'event'">
<label class="form-label">Date <span>*</span></label> <div class="form-group">
<input type="date" x-model="activeForm.date" :min="game && game.timeline_start" :max="game && game.timeline_end" /> <label class="form-label">Date <span>*</span></label>
<input type="date" x-model="activeForm.date" :min="game && game.timeline_start" :max="game && game.timeline_end" />
</div>
<div class="form-group" x-show="projects().length">
<label class="form-label">Project (optional)</label>
<select x-model="activeForm.project_id">
<option value="">— Standalone event —</option>
<template x-for="p in projects()" :key="p.id">
<option :value="p.id" x-text="p.title"></option>
</template>
</select>
</div>
</div> </div>
<!-- Project: start + end + status --> <!-- Project: start + end + status -->
@@ -1427,6 +1511,8 @@
showCard: false, showCard: false,
// Timeline click chooser // Timeline click chooser
tlChooser: { show: false, x: 0, y: 0, date: '' }, tlChooser: { show: false, x: 0, y: 0, date: '' },
// Truths
newTruth: '',
// Autocomplete // Autocomplete
acMode: null, acMode: null,
acQuery: '', acQuery: '',
@@ -1989,6 +2075,15 @@
return result.sort((a, b) => a.term.localeCompare(b.term)) return result.sort((a, b) => a.term.localeCompare(b.term))
}, },
eventsForProject(projectId) {
return this.events().filter(e => e.project_id === projectId)
},
projectForEvent(item) {
if (!item.project_id) return null
return this.projects().find(p => p.id === item.project_id) || null
},
castUsages(name) { castUsages(name) {
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const re = new RegExp(`@"${escaped}"`, 'i') const re = new RegExp(`@"${escaped}"`, 'i')
@@ -2113,7 +2208,7 @@
openAddItem(type) { openAddItem(type) {
const env = this.randomEnv(null) const env = this.randomEnv(null)
this.activeForm = { type, title: '', description: '', mood: '', date: '', end_date: '', status: 'active', discipline: '', env, env_layout: env ? this.randomEnvLayout() : 'after' } this.activeForm = { type, title: '', description: '', mood: '', date: '', end_date: '', status: 'active', discipline: '', env, env_layout: env ? this.randomEnvLayout() : 'after', project_id: '' }
this.editingId = null this.editingId = null
this.formError = null this.formError = null
this.modalType = 'item' this.modalType = 'item'
@@ -2121,7 +2216,7 @@
}, },
openEditItem(item) { openEditItem(item) {
this.activeForm = Object.assign({ env: '', env_layout: 'after' }, item) this.activeForm = Object.assign({ env: '', env_layout: 'after', project_id: '' }, item)
this.editingId = item.id this.editingId = item.id
this.formError = null this.formError = null
this.modalType = 'item' this.modalType = 'item'
@@ -2138,7 +2233,8 @@
}, },
openEditGame() { openEditGame() {
this.activeForm = Object.assign({ deck_id: '' }, this.game) this.activeForm = Object.assign({ deck_id: '', truths: [] }, this.game)
this.activeForm.truths = [...(this.game.truths || [])]
this.formError = null this.formError = null
this.modalType = 'editGame' this.modalType = 'editGame'
this.showModal = true this.showModal = true
+6 -3
View File
@@ -90,6 +90,7 @@ function publicGame(g) {
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 || '', deck_id: g.deck_id || '',
truths: g.truths || [],
created_at: g.created_at created_at: g.created_at
} }
} }
@@ -183,6 +184,7 @@ app.put('/api/games/:slug', requirePassword, (req, res) => {
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 || '' game.deck_id = req.body.deck_id || ''
game.truths = req.body.truths || []
saveDB(req.db); broadcast(req.params.slug) saveDB(req.db); broadcast(req.params.slug)
res.json(publicGame(game)) res.json(publicGame(game))
}) })
@@ -209,7 +211,7 @@ app.get('/api/games/:slug/items', (req, res) => {
}) })
app.post('/api/games/:slug/items', requirePassword, (req, res) => { app.post('/api/games/:slug/items', requirePassword, (req, res) => {
const { type, title, description, mood, date, end_date, status, discipline, env } = req.body const { type, title, description, mood, date, end_date, status, discipline, env, project_id } = req.body
if (!type || !title || !date) return res.status(400).json({ error: 'type, title, date required' }) if (!type || !title || !date) return res.status(400).json({ error: 'type, title, date required' })
if (!['event', 'project', 'person'].includes(type)) { if (!['event', 'project', 'person'].includes(type)) {
return res.status(400).json({ error: 'type must be event, project, or person' }) return res.status(400).json({ error: 'type must be event, project, or person' })
@@ -218,7 +220,8 @@ app.post('/api/games/:slug/items', requirePassword, (req, res) => {
id: newId(), type, title, description: description || '', id: newId(), type, title, description: description || '',
mood: mood || null, date, end_date: end_date || null, mood: mood || null, date, end_date: end_date || null,
status: status || null, discipline: discipline || null, status: status || null, discipline: discipline || null,
env: env || null, env_layout: req.body.env_layout || null, created_at: now() env: env || null, env_layout: req.body.env_layout || null,
project_id: project_id || null, created_at: now()
} }
req.game.items = req.game.items || [] req.game.items = req.game.items || []
req.game.items.push(item) req.game.items.push(item)
@@ -233,7 +236,7 @@ app.put('/api/games/:slug/items/:id', requirePassword, (req, res) => {
const { title, description, mood, date, end_date, status, discipline } = req.body const { title, description, mood, date, end_date, status, discipline } = req.body
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, project_id: req.body.project_id || null }
saveDB(req.db); broadcast(req.params.slug) saveDB(req.db); broadcast(req.params.slug)
res.json(items[idx]) res.json(items[idx])
}) })