This commit is contained in:
2026-08-03 15:35:36 +02:00
parent 8798c74d4b
commit 50c04d56b2
59 changed files with 1601 additions and 34 deletions
+373 -30
View File
@@ -370,6 +370,43 @@
flex-shrink: 0;
}
.entry-env {
border: 1px solid rgba(232,220,196,.22);
box-shadow: var(--shadow);
background: var(--panel);
padding: 6px;
}
.entry-env img { width: 100%; display: block; }
/* Image below text (default) */
.entry-body.env-after .entry-env { margin-top: 1.2rem; max-width: 540px; }
/* Image to the right — text left, image right */
.entry-body.env-right {
display: grid;
grid-template-columns: 1fr 280px;
column-gap: 2rem;
align-items: start;
}
.entry-body.env-right > *:not(.entry-env) { grid-column: 1; }
.entry-body.env-right .entry-env { grid-column: 2; grid-row: 1 / 10; position: sticky; top: 5.3rem; }
/* Image to the left — image left, text right */
.entry-body.env-left {
display: grid;
grid-template-columns: 280px 1fr;
column-gap: 2rem;
align-items: start;
}
.entry-body.env-left > *:not(.entry-env) { grid-column: 2; }
.entry-body.env-left .entry-env { grid-column: 1; grid-row: 1 / 10; position: sticky; top: 5.3rem; }
@media (max-width: 700px) {
.entry-body.env-left, .entry-body.env-right { display: block; }
.entry-body.env-left .entry-env,
.entry-body.env-right .entry-env { position: relative; top: auto; margin-top: 1rem; }
}
.empty-section {
padding: 2rem 0;
color: var(--muted);
@@ -460,6 +497,102 @@
footer .shell { display: flex; justify-content: space-between; gap: 2rem; flex-wrap: wrap; }
footer p { margin: 0; font: .72rem/1.7 var(--mono); letter-spacing: .08em; text-transform: uppercase; }
/* Card draw */
.card-overlay {
position: fixed;
inset: 0;
background: rgba(23,21,19,.92);
backdrop-filter: blur(8px);
z-index: 300;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
/* Card scales to fit viewport in both dimensions */
.card-frame {
position: relative;
width: min(360px, 88vw, calc(88vh * 9 / 13));
aspect-ratio: 9 / 13;
background: url('assets/card-frame.png') center / cover no-repeat;
flex-shrink: 0;
}
/* Inner content area sits on the lined paper, inside the ornate border */
.card-inner {
position: absolute;
top: 16%;
left: 17%;
right: 17%;
bottom: 13%;
display: flex;
flex-direction: column;
}
.card-content {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
}
.card-type-label {
font: 700 .55rem/1 var(--mono);
letter-spacing: .22em;
text-transform: uppercase;
color: #8a5c2c;
text-align: center;
margin-bottom: .3rem;
}
.card-title {
font-family: var(--serif);
font-size: 1.3rem;
font-weight: 700;
color: #160d04;
letter-spacing: -.01em;
line-height: 1.1;
text-align: center;
margin-bottom: .4rem;
}
.card-question {
font: italic .75rem/1.45 var(--serif);
color: #2c1a0e;
text-align: center;
margin-bottom: .3rem;
}
.card-divider {
width: 35%;
height: 1px;
background: rgba(90,50,20,.3);
margin: .2rem auto .45rem;
}
.card-options { display: flex; flex-direction: column; gap: .45rem; }
.card-option {
font: .75rem/1.45 var(--serif);
color: #1e0e04;
padding-left: 1.8rem;
position: relative;
}
.card-option-label {
position: absolute;
left: 0;
font: 700 .6rem/1.6 var(--mono);
letter-spacing: .06em;
color: #7a4c1e;
}
.card-dismiss {
display: block;
width: 100%;
background: rgba(90,50,20,.07);
border: 1px solid rgba(90,50,20,.55);
color: #3d2008;
font: 700 .58rem/1 var(--mono);
letter-spacing: .18em;
text-transform: uppercase;
padding: .5rem;
cursor: pointer;
transition: background .15s, border-color .15s;
margin-top: .5rem;
}
.card-dismiss:hover { background: rgba(90,50,20,.18); border-color: rgba(90,50,20,.8); }
/* Timeline click chooser */
.tl-chooser {
position: fixed;
@@ -542,14 +675,22 @@
<a href="games.html">Sessions</a>
<a x-show="game" x-text="game && game.title" style="color:var(--paper);pointer-events:none;"></a>
</div>
<button
x-show="game"
class="edit-toggle"
:class="editMode ? 'unlocked' : 'locked'"
@click="editMode ? lock() : openUnlock()"
>
<span x-text="editMode ? '⊙ Editing' : '⊘ Read-only'"></span>
</button>
<div style="display:flex;align-items:center;gap:.8rem">
<button
x-show="game && cards.length"
class="edit-toggle locked"
style="border-color:rgba(184,138,67,.35)"
@click="drawCard()"
>Draw Card</button>
<button
x-show="game"
class="edit-toggle"
:class="editMode ? 'unlocked' : 'locked'"
@click="editMode ? lock() : openUnlock()"
>
<span x-text="editMode ? '⊙ Editing' : '⊘ Read-only'"></span>
</button>
</div>
</div>
</nav>
@@ -569,7 +710,7 @@
<div class="game-header-title">
<h2 x-text="game && game.title"></h2>
<div class="game-meta-row">
<span class="mood-badge" :class="game && game.mood" x-text="game && game.mood"></span>
<span class="mood-badge" :class="game && game.mood" x-text="game && (game.mood === 'dark' ? '◉' : '○')"></span>
<span class="game-dates" x-text="game && (game.timeline_start + ' → ' + game.timeline_end)"></span>
</div>
<p class="game-desc" x-show="game && game.description" x-text="game && game.description"></p>
@@ -675,15 +816,18 @@
<div class="entry-list">
<template x-for="item in allEventsForDisplay()" :key="item.id">
<div class="entry" :id="'entry-' + item.id">
<div class="entry-body">
<div class="entry-body" :class="item.env ? 'env-' + (item.env_layout || 'after') : ''">
<div class="entry-eyebrow">
<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.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"></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 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>
</div>
<div class="entry-actions" x-show="editMode && !item.isAnchor">
<button class="btn sm ghost" @click="openEditItem(item)">Edit</button>
@@ -711,7 +855,7 @@
<div class="entry-list">
<template x-for="item in projects()" :key="item.id">
<div class="entry" :id="'entry-' + item.id">
<div class="entry-body">
<div class="entry-body" :class="item.env ? 'env-' + (item.env_layout || 'after') : ''">
<div class="entry-eyebrow">
<span x-text="item.date"></span>
<span x-show="item.end_date"> → <span x-text="item.end_date"></span></span>
@@ -722,6 +866,9 @@
</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>
</div>
<div class="entry-actions" x-show="editMode">
<button class="btn sm ghost" @click="openEditItem(item)">Edit</button>
@@ -749,7 +896,7 @@
<div class="entry-list">
<template x-for="(item, idx) in cast()" :key="item.id">
<div class="entry" :id="'entry-' + item.id">
<div class="entry-body">
<div class="entry-body" :class="item.env ? 'env-' + (item.env_layout || 'after') : ''">
<div class="entry-eyebrow">
<span class="cast-dot" :style="`background:${castColor(idx)}`"></span>
Arrived <span x-text="item.date"></span>
@@ -758,6 +905,14 @@
</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="kw-usage-row" x-show="castUsages(item.title).length > 0">
<template x-for="ref in castUsages(item.title)" :key="ref.id">
<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>
</div>
<div class="entry-actions" x-show="editMode">
<button class="btn sm ghost" @click="openEditItem(item)">Edit</button>
@@ -785,9 +940,9 @@
<div class="entry-list">
<template x-for="kw in allKeywords()" :key="kw.term">
<div class="entry">
<div class="entry-body">
<div class="entry-body" :class="item.env ? 'env-' + (item.env_layout || 'after') : ''">
<div class="entry-title" x-text="kw.term"></div>
<div class="entry-desc" x-show="kw.definition" x-text="kw.definition"></div>
<div class="entry-desc" x-show="kw.definition" x-html="renderDesc(kw.definition)"></div>
<div class="entry-desc" x-show="!kw.definition" style="font-style:italic;color:var(--muted)">No definition yet.</div>
<div class="kw-usage-row" x-show="keywordUsages(kw.term).length > 0">
<template x-for="item in keywordUsages(kw.term)" :key="item.id">
@@ -809,6 +964,31 @@
</main>
<!-- 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">
<div class="card-inner">
<div class="card-content">
<div class="card-type-label" x-text="drawnCard && drawnCard.type === 'question' ? 'Question' : 'Action'"></div>
<div class="card-title" x-text="drawnCard && drawnCard.title"></div>
<div class="card-question" x-show="drawnCard && drawnCard.question" x-text="drawnCard && drawnCard.question"></div>
<div class="card-divider"></div>
<div class="card-options">
<div class="card-option">
<span class="card-option-label">A —</span>
<span x-html="drawnCard && drawnCard.a"></span>
</div>
<div class="card-option">
<span class="card-option-label">B —</span>
<span x-html="drawnCard && drawnCard.b"></span>
</div>
</div>
</div>
<button class="card-dismiss" @click="showCard = false">Dismiss</button>
</div>
</div>
</div>
<!-- Timeline click chooser — dismiss on outside click via transparent backdrop -->
<div x-show="tlChooser.show"
style="position:fixed;inset:0;z-index:199"
@@ -973,25 +1153,104 @@
</div>
</div>
<!-- Mood + discipline (not for persons) -->
<!-- Mood + discipline + environment (not for persons) -->
<div x-show="activeForm.type !== 'person'">
<div class="form-group">
<label class="form-label">Mood</label>
<select x-model="activeForm.mood">
<option value="">Inherit from session</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
<div class="date-row">
<div class="form-group">
<label class="form-label">Mood</label>
<select x-model="activeForm.mood">
<option value="">Inherit</option>
<option value="light">○ Light</option>
<option value="dark">◉ Dark</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Discipline</label>
<select x-model="activeForm.discipline"
@change="if(!activeForm.env) { activeForm.env = randomEnv($event.target.value); if(activeForm.env) activeForm.env_layout = randomEnvLayout() }">
<option value="">None</option>
<option value="science">🔬 Science</option>
<option value="engineering">⚙️ Engineering</option>
<option value="labour">👷 Labour</option>
</select>
</div>
</div>
<div class="form-group">
<label class="form-label">Discipline</label>
<select x-model="activeForm.discipline">
<label class="form-label">Environment Image</label>
<select x-model="activeForm.env">
<option value="">None</option>
<option value="science">Science</option>
<option value="engineering">Engineering</option>
<option value="labour">Labour</option>
<optgroup label="Central Environments">
<option value="env-grand-hall">Grand Hall</option>
<option value="env-workshop">Workshop</option>
<option value="env-greenhouse">Greenhouse</option>
<option value="env-library">Library</option>
<option value="env-reactor">Reactor</option>
<option value="env-observatory">Observatory</option>
</optgroup>
<optgroup label="Engineering">
<option value="eng-blueprint">Blueprint Room</option>
<option value="eng-scaffold">Scaffolding</option>
<option value="eng-survey">Survey Exterior</option>
<option value="eng-planning-room">Planning Room</option>
<option value="eng-facility">Industrial Facility</option>
<option value="eng-construction">Construction Site</option>
</optgroup>
<optgroup label="Science">
<option value="sci-botanist">Botanist at Work</option>
<option value="sci-specimen-hall">Specimen Hall</option>
<option value="sci-laboratory">Laboratory</option>
<option value="sci-telescope">Telescope Room</option>
<option value="sci-excavation">Excavation Site</option>
<option value="sci-weather-station">Weather Station</option>
<option value="sci-specimen-tanks">Specimen Tanks</option>
<option value="sci-aquatic-lab">Aquatic Lab</option>
<option value="sci-growth-chamber">Growth Chamber</option>
<option value="sci-herbarium">Herbarium</option>
<option value="sci-globe-room">Globe Room</option>
<option value="sci-paleontology">Paleontology</option>
</optgroup>
<optgroup label="Labour">
<option value="lab-pipeline">Pipeline</option>
<option value="lab-tunnel">Tunnel</option>
<option value="lab-crane-lift">Crane Lift</option>
<option value="lab-shift-change">Shift Change</option>
<option value="lab-pour">Concrete Pour</option>
<option value="lab-solar-panels">Solar Panels</option>
<option value="lab-machinery">Machinery</option>
<option value="lab-transport">Transport</option>
<option value="lab-excavator">Excavator</option>
<option value="lab-scaffold">Scaffolding</option>
<option value="lab-canteen">Canteen</option>
<option value="lab-archway">Archway</option>
</optgroup>
<optgroup label="Atmospheric">
<option value="atm-corridor">Corridor</option>
<option value="atm-study">Study at Night</option>
<option value="atm-dome-rain">Dome in Rain</option>
<option value="atm-dawn">Dawn Exterior</option>
<option value="atm-sunset-city">Sunset City</option>
<option value="atm-moonrise">Moonrise</option>
<option value="atm-dust-storm">Dust Storm</option>
<option value="atm-reading-room">Reading Room</option>
<option value="atm-breakroom">Breakroom</option>
<option value="atm-cemetery">Cemetery</option>
<option value="atm-festival">Festival</option>
<option value="atm-children-play">Children Playing</option>
<option value="atm-night-observatory">Night Observatory</option>
<option value="atm-garden-light">Garden Light</option>
<option value="atm-lantern-hall">Lantern Hall</option>
<option value="atm-blizzard">Blizzard</option>
</optgroup>
</select>
</div>
<div class="form-group" x-show="activeForm.env">
<label class="form-label">Image Layout</label>
<div style="display:flex;gap:1.4rem">
<label class="mood-radio"><input type="radio" x-model="activeForm.env_layout" value="after" /><span>Below</span></label>
<label class="mood-radio"><input type="radio" x-model="activeForm.env_layout" value="left" /><span>Left</span></label>
<label class="mood-radio"><input type="radio" x-model="activeForm.env_layout" value="right" /><span>Right</span></label>
</div>
</div>
</div>
<div class="form-group">
@@ -1092,6 +1351,11 @@
saving: false,
tlItems: [],
_tlRows: { top: 1, bot: 1 },
// Card draw
cards: [],
drawnCard: null,
recentCardIds: [],
showCard: false,
// Timeline click chooser
tlChooser: { show: false, x: 0, y: 0, date: '' },
// Autocomplete
@@ -1121,6 +1385,7 @@
this.loading = false
return
}
this.loadCards()
const ok = await this.loadGame()
if (!ok) { this.loading = false; return }
await Promise.all([this.loadItems(), this.loadKeywords()])
@@ -1153,6 +1418,60 @@
this.tlItems = this.computeLayout()
},
async loadCards() {
try {
const r = await fetch('/assets/cards.json')
this.cards = r.ok ? await r.json() : []
} catch { this.cards = [] }
},
drawCard() {
if (!this.cards.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) return true
return c.requires.every(r =>
r === 'cast' ? hasCast : r === 'keyword' ? hasKeyword : r === 'project' ? hasProject : true
)
}
const eligible = this.cards.filter(meets)
const available = eligible.filter(c => !this.recentCardIds.includes(c.id))
const pool = available.length > 0 ? available : eligible
if (!pool.length) return
const idx = Math.floor(Math.random() * pool.length)
this.drawnCard = this.resolveDynamic(pool[idx])
this.recentCardIds = [...this.recentCardIds.slice(-9), pool[idx].id]
this.showCard = true
},
resolveDynamic(card) {
const pick = arr => arr.length ? arr[Math.floor(Math.random() * arr.length)] : null
const castList = [...this.cast()]
const kwList = this.allKeywords()
const projectList = this.projects()
let usedCastIds = []
const resolve = text => text
.replace(/\{cast\}/g, () => {
const avail = castList.filter(c => !usedCastIds.includes(c.id))
const p = pick(avail.length ? avail : castList)
if (p) { usedCastIds.push(p.id); return `<em>${p.title.replace(/&/g,'&amp;').replace(/</g,'&lt;')}</em>` }
return 'a cast member'
})
.replace(/\{keyword\}/g, () => {
const p = pick(kwList)
return p ? `**${p.term}**` : 'an existing keyword'
})
.replace(/\{project\}/g, () => {
const p = pick(projectList)
return p ? `"${p.title}"` : 'an active project'
})
return { ...card, a: resolve(card.a), b: resolve(card.b) }
},
async loadKeywords() {
try {
const r = await fetch('/api/games/' + this.slug + '/keywords')
@@ -1274,6 +1593,23 @@
return `left:${e.pct}%;top:${railY}px;width:${sz}px;height:${sz}px;`
},
randomEnv(discipline) {
if (Math.random() > 0.33) return ''
const pools = {
science: ['sci-botanist','sci-specimen-hall','sci-laboratory','sci-telescope','sci-excavation','sci-weather-station','sci-specimen-tanks','sci-aquatic-lab','sci-growth-chamber','sci-herbarium','sci-globe-room','sci-paleontology'],
engineering: ['eng-blueprint','eng-scaffold','eng-survey','eng-planning-room','eng-facility','eng-construction'],
labour: ['lab-pipeline','lab-tunnel','lab-crane-lift','lab-shift-change','lab-pour','lab-solar-panels','lab-machinery','lab-transport','lab-excavator','lab-scaffold','lab-canteen','lab-archway'],
default: ['env-grand-hall','env-workshop','env-greenhouse','env-library','env-reactor','env-observatory','atm-corridor','atm-study','atm-dome-rain','atm-dawn','atm-sunset-city','atm-moonrise','atm-dust-storm','atm-reading-room','atm-breakroom','atm-cemetery','atm-festival','atm-children-play','atm-night-observatory','atm-garden-light','atm-lantern-hall','atm-blizzard'],
}
const pool = pools[discipline] || pools.default
return pool[Math.floor(Math.random() * pool.length)]
},
randomEnvLayout() {
const r = Math.random()
return r < 0.34 ? 'left' : r < 0.67 ? 'right' : 'after'
},
disciplineIcon(disc) {
return { science: '🔬', engineering: '⚙️', labour: '👷' }[disc] || ''
},
@@ -1487,6 +1823,12 @@
return result.sort((a, b) => a.term.localeCompare(b.term))
},
castUsages(name) {
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const re = new RegExp(`@"${escaped}"`, 'i')
return this.items.filter(i => re.test(i.description || ''))
},
keywordUsages(term) {
const re = new RegExp('\\*\\*' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\*\\*', 'i')
return this.items.filter(i => re.test(i.description || ''))
@@ -1604,7 +1946,8 @@
},
openAddItem(type) {
this.activeForm = { type, title: '', description: '', mood: '', date: '', end_date: '', status: 'active', discipline: '' }
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.editingId = null
this.formError = null
this.modalType = 'item'
@@ -1612,7 +1955,7 @@
},
openEditItem(item) {
this.activeForm = Object.assign({}, item)
this.activeForm = Object.assign({ env: '', env_layout: 'after' }, item)
this.editingId = item.id
this.formError = null
this.modalType = 'item'