2538 lines
102 KiB
HTML
2538 lines
102 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>Session — Orison Reach</title>
|
|
<style>
|
|
:root {
|
|
--void: #171513;
|
|
--void-soft: #211e1a;
|
|
--panel: #2a2520;
|
|
--paper: #e8dcc4;
|
|
--paper-deep: #cbbd9e;
|
|
--ink: #2c231c;
|
|
--text: #e9dfca;
|
|
--muted: #a89d89;
|
|
--brass: #b88a43;
|
|
--rust: #94452f;
|
|
--moss: #70794e;
|
|
--oxygen: #86a5a4;
|
|
--line: rgba(232, 220, 196, .18);
|
|
--text-secondary: #d0c6b3;
|
|
--text-body: #c2b7a4;
|
|
--text-dim: #b8ae9e;
|
|
--shadow: 0 22px 70px rgba(0,0,0,.42);
|
|
--serif: Spectral, Georgia, "Times New Roman", serif;
|
|
--sans: Jost, Inter, ui-sans-serif, system-ui, -apple-system, sans-serif;
|
|
--mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
|
}
|
|
* { box-sizing: border-box; }
|
|
html { scroll-behavior: smooth; }
|
|
body {
|
|
margin: 0;
|
|
color: var(--text);
|
|
background:
|
|
radial-gradient(circle at 12% 8%, rgba(134,165,164,.08), transparent 26rem),
|
|
linear-gradient(rgba(255,255,255,.018) 1px, transparent 1px),
|
|
linear-gradient(90deg, rgba(255,255,255,.014) 1px, transparent 1px),
|
|
var(--void);
|
|
background-size: auto, 28px 28px, 28px 28px, auto;
|
|
font-family: var(--sans);
|
|
line-height: 1.7;
|
|
}
|
|
body::before {
|
|
content: "";
|
|
position: fixed;
|
|
inset: 0;
|
|
pointer-events: none;
|
|
opacity: .16;
|
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.8' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.16'/%3E%3C/svg%3E");
|
|
mix-blend-mode: soft-light;
|
|
z-index: 10;
|
|
}
|
|
a { color: inherit; }
|
|
.shell { width: min(1180px, calc(100% - 36px)); margin: 0 auto; }
|
|
|
|
nav {
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 5;
|
|
border-bottom: 1px solid var(--line);
|
|
background: rgba(23,21,19,.86);
|
|
backdrop-filter: blur(14px);
|
|
}
|
|
nav .shell { display: flex; gap: 1.6rem; overflow-x: auto; align-items: center; justify-content: space-between; }
|
|
nav .nav-left { display: flex; gap: 1.6rem; align-items: center; }
|
|
nav a {
|
|
padding: 1rem 0;
|
|
text-decoration: none;
|
|
white-space: nowrap;
|
|
font: 700 .72rem/1 var(--mono);
|
|
letter-spacing: .14em;
|
|
text-transform: uppercase;
|
|
color: var(--muted);
|
|
}
|
|
nav a:hover { color: var(--paper); }
|
|
.edit-toggle {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: .6rem;
|
|
padding: .5rem .9rem;
|
|
border: 1px solid var(--line);
|
|
background: transparent;
|
|
font: 700 .65rem/1 var(--mono);
|
|
letter-spacing: .14em;
|
|
text-transform: uppercase;
|
|
cursor: pointer;
|
|
transition: background 0.18s cubic-bezier(0.22,1,.36,1), border-color 0.18s cubic-bezier(0.22,1,.36,1), color 0.18s cubic-bezier(0.22,1,.36,1);
|
|
white-space: nowrap;
|
|
}
|
|
.edit-toggle.locked { color: var(--muted); }
|
|
.edit-toggle.locked:hover { color: var(--brass); border-color: rgba(184,138,67,.4); }
|
|
.edit-toggle.unlocked { color: var(--moss); border-color: rgba(112,121,78,.5); }
|
|
.edit-toggle.unlocked:hover { color: var(--rust); border-color: rgba(148,69,47,.4); }
|
|
|
|
h1, h2, h3 { font-family: var(--serif); font-weight: 600; letter-spacing: -.02em; margin: 0; }
|
|
h2 { font-size: clamp(2.2rem, 5vw, 4.2rem); line-height: 1.05; color: var(--paper); }
|
|
h3 { font-size: clamp(1.35rem, 2.4vw, 2rem); line-height: 1.25; color: var(--paper); }
|
|
.eyebrow {
|
|
color: var(--brass);
|
|
font: 700 .74rem/1.2 var(--mono);
|
|
letter-spacing: .22em;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
main { padding: 4rem 0 7rem; }
|
|
|
|
.game-header { margin-bottom: 3.5rem; }
|
|
.game-header-top {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
justify-content: space-between;
|
|
gap: 2rem;
|
|
flex-wrap: wrap;
|
|
margin-top: .6rem;
|
|
}
|
|
.game-header-title { flex: 1; min-width: 0; }
|
|
.game-meta-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: .8rem;
|
|
flex-wrap: wrap;
|
|
margin-top: .8rem;
|
|
}
|
|
.mood-badge {
|
|
font: 700 .62rem/1 var(--mono);
|
|
letter-spacing: .12em;
|
|
text-transform: uppercase;
|
|
padding: .28rem .6rem;
|
|
border: 1px solid;
|
|
}
|
|
.mood-badge.light { color: var(--brass); border-color: rgba(184,138,67,.4); }
|
|
.mood-badge.dark { color: var(--rust); border-color: rgba(148,69,47,.4); }
|
|
.game-dates { font: .72rem/1 var(--mono); color: var(--muted); letter-spacing: .06em; }
|
|
.game-desc { color: var(--text-body); max-width: 72ch; margin-top: 1rem; font-size: 1rem; }
|
|
.game-header-actions { display: flex; gap: .7rem; flex-wrap: wrap; }
|
|
|
|
.btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: .45rem;
|
|
padding: .6rem 1.2rem;
|
|
border: 1px solid var(--line);
|
|
background: transparent;
|
|
color: var(--brass);
|
|
font: 700 .68rem/1 var(--mono);
|
|
letter-spacing: .14em;
|
|
text-transform: uppercase;
|
|
cursor: pointer;
|
|
text-decoration: none;
|
|
transition: background 0.18s cubic-bezier(0.22,1,.36,1), border-color 0.18s cubic-bezier(0.22,1,.36,1);
|
|
white-space: nowrap;
|
|
}
|
|
.btn:hover { background: rgba(184,138,67,.1); border-color: var(--brass); }
|
|
.btn.sm { padding: .38rem .75rem; font-size: .62rem; }
|
|
.btn.ghost { color: var(--muted); }
|
|
.btn.ghost:hover { color: var(--text); border-color: rgba(232,220,196,.3); background: rgba(232,220,196,.05); }
|
|
.btn.danger { color: var(--rust); }
|
|
.btn.danger:hover { background: rgba(148,69,47,.15); border-color: var(--rust); }
|
|
.btn.primary { background: var(--brass); color: var(--void); border-color: var(--brass); }
|
|
.btn.primary:hover { background: #cca055; }
|
|
|
|
/* Timeline */
|
|
.timeline-section { margin-bottom: 3.5rem; }
|
|
.tl-wrap {
|
|
overflow: hidden;
|
|
position: relative;
|
|
padding: 0;
|
|
}
|
|
|
|
/* Callout timeline canvas */
|
|
.tl-canvas {
|
|
position: relative;
|
|
width: 100%;
|
|
min-width: 400px;
|
|
}
|
|
.tl-rail-line {
|
|
position: absolute;
|
|
left: 0;
|
|
right: 0;
|
|
height: 1px;
|
|
background: rgba(232,220,196,.25);
|
|
}
|
|
.tl-stem {
|
|
position: absolute;
|
|
width: 1px;
|
|
background: rgba(232,220,196,.14);
|
|
}
|
|
.tl-evt-dot {
|
|
position: absolute;
|
|
border-radius: 50%;
|
|
transform: translate(-50%, -50%);
|
|
}
|
|
.tl-evt-dot.light { background: var(--brass); }
|
|
.tl-evt-dot.dark { background: var(--rust); }
|
|
.tl-evt-dot.anchor-dot {
|
|
background: transparent !important;
|
|
border: 2px solid;
|
|
}
|
|
.tl-evt-dot.anchor-dot.light { border-color: var(--brass); }
|
|
.tl-evt-dot.anchor-dot.dark { border-color: var(--rust); }
|
|
.tl-evt-dot.disc-icon {
|
|
background: transparent !important;
|
|
border: none !important;
|
|
border-radius: 0;
|
|
font-size: 15px;
|
|
line-height: 1;
|
|
width: auto;
|
|
height: auto;
|
|
filter: grayscale(1) sepia(1) saturate(1.5) brightness(0.9);
|
|
}
|
|
.disc-emoji {
|
|
filter: grayscale(1) sepia(1) saturate(1.5) brightness(0.9);
|
|
font-size: .9em;
|
|
vertical-align: middle;
|
|
}
|
|
.tl-lbl {
|
|
position: absolute;
|
|
max-width: 148px;
|
|
text-align: center;
|
|
}
|
|
.tl-lbl-name {
|
|
font: 600 .72rem/1.25 var(--mono);
|
|
letter-spacing: .04em;
|
|
color: var(--text);
|
|
overflow: hidden;
|
|
display: -webkit-box;
|
|
-webkit-line-clamp: 2;
|
|
-webkit-box-orient: vertical;
|
|
}
|
|
.tl-lbl-name.is-anchor { color: var(--paper); font-weight: 700; }
|
|
.tl-lbl-date {
|
|
font: .63rem/1.1 var(--mono);
|
|
color: var(--muted);
|
|
margin-top: .12rem;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
/* Bar rows (projects + cast) */
|
|
.tl-sublabel {
|
|
font: 700 .6rem/1 var(--mono);
|
|
letter-spacing: .1em;
|
|
text-transform: uppercase;
|
|
color: var(--muted);
|
|
margin: 1rem 0 .35rem;
|
|
}
|
|
.tl-rows { display: flex; flex-direction: column; gap: 5px; }
|
|
.tl-row { position: relative; height: 22px; min-width: 400px; }
|
|
.tl-bar {
|
|
position: absolute;
|
|
height: 20px;
|
|
top: 1px;
|
|
border-radius: 2px;
|
|
min-width: 4px;
|
|
overflow: hidden;
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 0 14px;
|
|
}
|
|
.tl-bar.project-bar { background: var(--moss); opacity: .85; }
|
|
.tl-bar.project-bar.dark { background: var(--rust); opacity: .7; }
|
|
/* Ragged edges — fixed 24px fade so text stays readable on long bars */
|
|
.tl-bar.open-right {
|
|
-webkit-mask-image: linear-gradient(to right, black calc(100% - 24px), transparent 100%);
|
|
mask-image: linear-gradient(to right, black calc(100% - 24px), transparent 100%);
|
|
opacity: 1 !important;
|
|
}
|
|
.tl-bar.open-left {
|
|
-webkit-mask-image: linear-gradient(to left, black calc(100% - 24px), transparent 100%);
|
|
mask-image: linear-gradient(to left, black calc(100% - 24px), transparent 100%);
|
|
opacity: 1 !important;
|
|
}
|
|
.tl-bar.open-both {
|
|
-webkit-mask-image: linear-gradient(to right, transparent 0px, black 24px, black calc(100% - 24px), transparent 100%);
|
|
mask-image: linear-gradient(to right, transparent 0px, black 24px, black calc(100% - 24px), transparent 100%);
|
|
opacity: 1 !important;
|
|
}
|
|
.tl-bar-text {
|
|
font: 600 .58rem/1 var(--mono);
|
|
color: rgba(255,255,255,.82);
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
width: 100%;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.divider { width: min(100%, 980px); margin: 4.2rem auto; opacity: .9; display: block; }
|
|
|
|
/* Chronicle sections — editorial, open layout */
|
|
.chronicle-section { margin-bottom: 6rem; }
|
|
.chronicle-header-row {
|
|
display: flex;
|
|
align-items: flex-end;
|
|
justify-content: space-between;
|
|
gap: 2rem;
|
|
flex-wrap: wrap;
|
|
padding-bottom: 2.5rem;
|
|
}
|
|
.chronicle-eyebrow {
|
|
color: var(--brass);
|
|
font: 700 .74rem/1.2 var(--mono);
|
|
letter-spacing: .22em;
|
|
text-transform: uppercase;
|
|
margin-bottom: .4rem;
|
|
}
|
|
.chronicle-title {
|
|
font-family: var(--serif);
|
|
font-size: clamp(2.2rem, 5vw, 4.2rem);
|
|
font-weight: 600;
|
|
color: var(--paper);
|
|
letter-spacing: -.02em;
|
|
line-height: 1.05;
|
|
margin: 0;
|
|
}
|
|
|
|
/* Entries — open, no box */
|
|
.entry-list { display: flex; flex-direction: column; }
|
|
.entry {
|
|
padding: 2.2rem 0;
|
|
border-bottom: 1px solid var(--line);
|
|
display: grid;
|
|
grid-template-columns: 1fr auto;
|
|
gap: 0 2rem;
|
|
align-items: start;
|
|
}
|
|
.entry:first-child { border-top: 1px solid var(--line); }
|
|
.entry-body { min-width: 0; }
|
|
.entry-eyebrow {
|
|
font: 700 .7rem/1.4 var(--mono);
|
|
letter-spacing: .14em;
|
|
text-transform: uppercase;
|
|
color: var(--brass);
|
|
margin-bottom: .6rem;
|
|
}
|
|
.entry-eyebrow .sep { color: var(--muted); opacity: .6; margin: 0 .25em; }
|
|
.entry-eyebrow .e-dark { color: var(--rust); }
|
|
.entry-eyebrow .e-muted { color: var(--muted); }
|
|
.entry-eyebrow .e-teal { color: var(--oxygen); }
|
|
.entry-eyebrow .e-anchor { color: var(--brass); opacity: .7; }
|
|
.entry-title {
|
|
font-family: var(--serif);
|
|
font-size: clamp(1.5rem, 3vw, 2.4rem);
|
|
font-weight: 600;
|
|
color: var(--paper);
|
|
letter-spacing: -.02em;
|
|
line-height: 1.05;
|
|
margin: 0 0 .75rem;
|
|
}
|
|
.entry-desc {
|
|
color: var(--text-body);
|
|
font-size: 1rem;
|
|
line-height: 1.7;
|
|
max-width: 72ch;
|
|
}
|
|
.entry-desc strong { color: var(--paper); font-weight: 600; }
|
|
.entry-desc em { font-style: italic; color: #d4c9b4; }
|
|
.entry-desc .cast-mention {
|
|
color: var(--oxygen);
|
|
text-decoration: none;
|
|
border-bottom: 1px dotted rgba(134,165,164,.45);
|
|
cursor: pointer;
|
|
}
|
|
.entry-desc .cast-mention:hover { color: var(--paper); }
|
|
.entry-desc .cast-mention.unknown { color: var(--muted); border-bottom-color: transparent; }
|
|
.entry-actions { display: flex; gap: .4rem; flex-shrink: 0; padding-top: .35rem; }
|
|
.cast-dot {
|
|
display: inline-block;
|
|
width: 8px; height: 8px;
|
|
border-radius: 50%;
|
|
vertical-align: middle;
|
|
margin-right: .4em;
|
|
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);
|
|
font: italic 1rem/1.6 var(--serif);
|
|
}
|
|
|
|
/* Modal */
|
|
.modal-overlay {
|
|
position: fixed;
|
|
inset: 0;
|
|
background: rgba(23,21,19,.88);
|
|
backdrop-filter: blur(6px);
|
|
z-index: 50;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 1rem;
|
|
}
|
|
.modal {
|
|
background: var(--void-soft);
|
|
border: 1px solid rgba(232,220,196,.3);
|
|
padding: 2rem 2rem 1.6rem;
|
|
width: min(620px, 100%);
|
|
max-height: 90vh;
|
|
overflow-y: auto;
|
|
}
|
|
.modal-title {
|
|
font-family: var(--serif);
|
|
font-size: 1.5rem;
|
|
font-weight: 600;
|
|
color: var(--paper);
|
|
margin: 0 0 1.6rem;
|
|
}
|
|
.form-section {
|
|
border-top: 1px solid var(--line);
|
|
margin-top: 1.4rem;
|
|
padding-top: 1.2rem;
|
|
}
|
|
.form-section-title {
|
|
font: 700 .68rem/1 var(--mono);
|
|
letter-spacing: .14em;
|
|
text-transform: uppercase;
|
|
color: var(--muted);
|
|
margin-bottom: 1rem;
|
|
}
|
|
.form-group { margin-bottom: 1.2rem; }
|
|
.form-label {
|
|
display: block;
|
|
font: 700 .67rem/1 var(--mono);
|
|
letter-spacing: .1em;
|
|
text-transform: uppercase;
|
|
color: var(--brass);
|
|
margin-bottom: .42rem;
|
|
}
|
|
.form-label span { color: var(--rust); margin-left: .2rem; }
|
|
input[type="text"], input[type="password"], input[type="date"], textarea, select {
|
|
width: 100%;
|
|
background: var(--panel);
|
|
border: 1px solid rgba(232,220,196,.2);
|
|
color: var(--text);
|
|
padding: .65rem .85rem;
|
|
font-family: var(--sans);
|
|
font-size: .94rem;
|
|
line-height: 1.5;
|
|
appearance: none;
|
|
-webkit-appearance: none;
|
|
}
|
|
select option { background: var(--panel); }
|
|
textarea { resize: vertical; min-height: 80px; }
|
|
input:focus, textarea:focus, select:focus { outline: none; border-color: var(--brass); box-shadow: 0 0 0 3px rgba(184,138,67,.12); }
|
|
.date-row { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
|
|
.mood-row { display: flex; gap: 1rem; }
|
|
.mood-radio { display: flex; align-items: center; gap: .5rem; cursor: pointer; }
|
|
.mood-radio input { width: auto; }
|
|
.mood-radio span { font: .82rem/1 var(--mono); color: var(--text); }
|
|
.form-hint { font: .7rem/1.4 var(--mono); color: var(--muted); margin-top: .35rem; }
|
|
.form-error { font: .78rem/1.4 var(--mono); color: var(--rust); margin-top: .5rem; }
|
|
.modal-actions {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
gap: .7rem;
|
|
margin-top: 1.6rem;
|
|
padding-top: 1.2rem;
|
|
border-top: 1px solid var(--line);
|
|
}
|
|
|
|
footer { border-top: 1px solid var(--line); padding: 2.5rem 0 4rem; color: var(--muted); }
|
|
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;
|
|
}
|
|
.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;
|
|
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 0.18s cubic-bezier(0.22,1,.36,1), border-color 0.18s cubic-bezier(0.22,1,.36,1);
|
|
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;
|
|
z-index: 200;
|
|
background: var(--panel);
|
|
border: 1px solid rgba(232,220,196,.3);
|
|
box-shadow: 0 8px 28px rgba(0,0,0,.55);
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1px;
|
|
transform: translateX(-50%);
|
|
}
|
|
.tl-chooser-btn {
|
|
background: transparent;
|
|
border: none;
|
|
color: var(--brass);
|
|
font: 700 .68rem/1 var(--mono);
|
|
letter-spacing: .12em;
|
|
text-transform: uppercase;
|
|
padding: .65rem 1.2rem;
|
|
cursor: pointer;
|
|
white-space: nowrap;
|
|
text-align: left;
|
|
}
|
|
.tl-chooser-btn:hover { background: rgba(184,138,67,.12); color: var(--paper); }
|
|
|
|
/* Truths */
|
|
.truth-list { display:flex; flex-direction:column; gap:.2rem; margin-top:.6rem; }
|
|
.truth-item {
|
|
display:flex; align-items:baseline; gap:.75rem;
|
|
font:italic 1rem/1.7 var(--serif); color:var(--paper);
|
|
}
|
|
.truth-item-n {
|
|
font:700 .62rem/1.7 var(--mono); letter-spacing:.1em;
|
|
color:var(--brass); flex-shrink:0; opacity:.7;
|
|
}
|
|
.truth-item-text { flex:1; }
|
|
/* Project sub-timeline row: dashed rail above, bar below */
|
|
.tl-row.project-tl {
|
|
height: 36px;
|
|
overflow: visible;
|
|
}
|
|
/* Dashed sub-rail at pin level */
|
|
.tl-row.project-tl::before {
|
|
content: '';
|
|
position: absolute;
|
|
left: 0; right: 0;
|
|
top: 7px;
|
|
border-top: 1px dashed rgba(232,220,196,.2);
|
|
z-index: 0;
|
|
pointer-events: none;
|
|
}
|
|
/* Project bar sits in lower half */
|
|
.tl-row.project-tl .tl-bar {
|
|
top: 16px;
|
|
height: 18px;
|
|
}
|
|
/* Pins sit on the dashed rail */
|
|
.tl-project-evt-dot {
|
|
position: absolute;
|
|
width: 10px; height: 10px;
|
|
border-radius: 50%;
|
|
top: 7px;
|
|
transform: translate(-50%, -50%);
|
|
z-index: 3; cursor: pointer;
|
|
border: 2px solid var(--void);
|
|
box-shadow: 0 1px 3px rgba(0,0,0,.5);
|
|
}
|
|
.tl-project-evt-dot.light { background: var(--paper); border-color: var(--brass); }
|
|
.tl-project-evt-dot.dark { background: var(--rust); border-color: var(--void); }
|
|
.tl-bar-add-overlay {
|
|
position: absolute; top: 0; height: 16px; /* rail area only */
|
|
z-index: 2; cursor: crosshair; background: transparent;
|
|
}
|
|
|
|
|
|
.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 */
|
|
.ac-wrap { position: relative; }
|
|
.ac-dropdown {
|
|
position: absolute;
|
|
bottom: calc(100% + 4px);
|
|
left: 0; right: 0;
|
|
background: var(--panel);
|
|
border: 1px solid rgba(232,220,196,.3);
|
|
box-shadow: 0 -12px 32px rgba(0,0,0,.5);
|
|
max-height: 220px;
|
|
overflow-y: auto;
|
|
z-index: 200;
|
|
}
|
|
.ac-item {
|
|
padding: .55rem .85rem;
|
|
cursor: pointer;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: .12rem;
|
|
border-bottom: 1px solid rgba(232,220,196,.07);
|
|
font: .88rem/1.3 var(--sans);
|
|
color: var(--text);
|
|
}
|
|
.ac-item:last-child { border-bottom: none; }
|
|
.ac-item:hover, .ac-item.ac-sel { background: rgba(184,138,67,.12); color: var(--paper); }
|
|
.ac-sub { font: .62rem/1 var(--mono); color: var(--muted); }
|
|
.ac-empty { padding: .55rem .85rem; font: .72rem/1 var(--mono); color: var(--muted); font-style: italic; }
|
|
/* Keyword links inside rendered descriptions */
|
|
.kw-link { color: var(--brass); text-decoration: none; border-bottom: 1px solid rgba(184,138,67,.35); cursor: pointer; font-weight: 600; }
|
|
.kw-link:hover { color: var(--paper); }
|
|
/* Glossary usage pills */
|
|
.kw-usage { font: .62rem/1 var(--mono); color: var(--muted); letter-spacing: .06em; text-transform: uppercase; text-decoration: none; border-bottom: 1px dotted rgba(168,157,137,.4); }
|
|
.kw-usage:hover { color: var(--text); }
|
|
.kw-usage-row { display: flex; gap: .7rem; flex-wrap: wrap; margin-top: .75rem; }
|
|
|
|
.loading-msg { padding: 6rem 0; text-align: center; font: .9rem/1 var(--mono); color: var(--muted); }
|
|
.error-msg { padding: 4rem; text-align: center; }
|
|
.error-msg h3 { color: var(--rust); margin-bottom: .5rem; }
|
|
.error-msg p { font: .9rem/1.5 var(--mono); color: var(--muted); }
|
|
|
|
@media (max-width: 640px) {
|
|
.entry { grid-template-columns: 1fr; }
|
|
.entry-actions { justify-content: flex-start; }
|
|
}
|
|
[x-cloak] { display: none !important; }
|
|
|
|
/* ── Animation ──────────────────────────────────────────────────── */
|
|
@media (prefers-reduced-motion: no-preference) {
|
|
|
|
/* Alpine x-transition utility classes (used by toast + modals) */
|
|
.transition { transition-property: opacity, transform; }
|
|
.ease-out { transition-timing-function: cubic-bezier(0.22,1,.36,1); }
|
|
.ease-in { transition-timing-function: cubic-bezier(0.4,0,1,1); }
|
|
.duration-150 { transition-duration: 150ms; }
|
|
.duration-200 { transition-duration: 200ms; }
|
|
.duration-250 { transition-duration: 250ms; }
|
|
.duration-300 { transition-duration: 300ms; }
|
|
.opacity-0 { opacity: 0; }
|
|
.opacity-100 { opacity: 1; }
|
|
.translate-y-2 { transform: translateY(8px); }
|
|
.translate-y-0 { transform: translateY(0); }
|
|
|
|
/* Card draw — comes up from below with a slight tilt */
|
|
.card-frame {
|
|
opacity: 1;
|
|
transform: translateY(0) scale(1) rotate(0deg);
|
|
transition: opacity 0.45s cubic-bezier(0.22,1,.36,1),
|
|
transform 0.45s cubic-bezier(0.22,1,.36,1);
|
|
}
|
|
@starting-style {
|
|
.card-frame {
|
|
opacity: 0;
|
|
transform: translateY(42px) scale(0.92) rotate(-1.5deg);
|
|
}
|
|
}
|
|
|
|
/* Modal backdrop fade */
|
|
.modal-overlay {
|
|
transition: opacity 0.22s cubic-bezier(0.22,1,.36,1);
|
|
opacity: 1;
|
|
}
|
|
@starting-style { .modal-overlay { opacity: 0; } }
|
|
|
|
/* Modal inner panel slides up */
|
|
.modal {
|
|
transition: opacity 0.3s cubic-bezier(0.22,1,.36,1),
|
|
transform 0.3s cubic-bezier(0.22,1,.36,1);
|
|
opacity: 1;
|
|
transform: translateY(0) scale(1);
|
|
}
|
|
@starting-style {
|
|
.modal {
|
|
opacity: 0;
|
|
transform: translateY(18px) scale(0.97);
|
|
}
|
|
}
|
|
|
|
/* Chronicle entry scroll reveals */
|
|
.entry[data-reveal] {
|
|
opacity: 0;
|
|
transform: translateY(18px);
|
|
transition: opacity 0.5s cubic-bezier(0.22,1,.36,1),
|
|
transform 0.5s cubic-bezier(0.22,1,.36,1);
|
|
}
|
|
.entry[data-reveal].revealed { opacity: 1; transform: translateY(0); }
|
|
|
|
/* Button press */
|
|
.btn:active {
|
|
transform: translateY(1px) scale(0.98);
|
|
transition-duration: 0.06s !important;
|
|
}
|
|
}
|
|
</style>
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
<link href="https://fonts.googleapis.com/css2?family=Spectral:ital,wght@0,300;0,400;0,600;0,700;1,300;1,400;1,600&family=Jost:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
|
</head>
|
|
<body x-data="gameApp()" x-cloak>
|
|
|
|
<nav aria-label="Session navigation">
|
|
<div class="shell">
|
|
<div class="nav-left">
|
|
<a href="index.html">Archive</a>
|
|
<a href="games.html">Sessions</a>
|
|
<a x-show="game" x-text="game && game.title" style="color:var(--paper);pointer-events:none;"></a>
|
|
</div>
|
|
<div style="display:flex;align-items:center;gap:.6rem">
|
|
<select x-show="game && (cards.length || customDecks.length)"
|
|
x-model="activeDeckId"
|
|
style="background:transparent;border:1px solid var(--line);color:var(--muted);
|
|
font:650 .65rem/1 var(--mono);letter-spacing:.1em;text-transform:uppercase;
|
|
padding:.45rem .6rem;cursor:pointer;appearance:none">
|
|
<option value="">Default Cards</option>
|
|
<template x-for="d in customDecks" :key="d.id">
|
|
<option :value="d.id" x-text="d.name" style="background:var(--panel)"></option>
|
|
</template>
|
|
</select>
|
|
<button
|
|
x-show="game && (cards.length || customDecks.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>
|
|
|
|
<div class="loading-msg" x-show="loading">Loading session…</div>
|
|
|
|
<div class="error-msg" x-show="!loading && error">
|
|
<h3 x-text="error"></h3>
|
|
<p>Check the URL or return to <a href="games.html" style="color:var(--brass)">all sessions</a>.</p>
|
|
</div>
|
|
|
|
<main class="shell" x-show="!loading && !error && game">
|
|
|
|
<!-- Game header -->
|
|
<div class="game-header">
|
|
<div class="eyebrow">Colony Chronicle // Session Record</div>
|
|
<div class="game-header-top">
|
|
<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 === '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>
|
|
</div>
|
|
<div class="game-header-actions" x-show="editMode">
|
|
<button class="btn sm" @click="openEditGame()">Edit Session Details</button>
|
|
</div>
|
|
<div class="game-meta-row" x-show="customDecks.length" style="margin-top:1rem;gap:.6rem">
|
|
<span style="font:700 .65rem/1 var(--mono);letter-spacing:.12em;text-transform:uppercase;color:var(--muted)">Deck</span>
|
|
<select x-model="activeDeckId" @change="saveDeckToGame()" style="background:var(--panel);border:1px solid var(--line);color:var(--text);font:700 .65rem/1 var(--mono);letter-spacing:.1em;text-transform:uppercase;padding:.35rem .7rem;cursor:pointer;appearance:none">
|
|
<option value="">Default Cards</option>
|
|
<template x-for="d in customDecks" :key="d.id">
|
|
<option :value="d.id" x-text="d.name"></option>
|
|
</template>
|
|
</select>
|
|
</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-n" x-text="String(i+1).padStart(2,'0')"></span>
|
|
<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 -->
|
|
<div class="timeline-section">
|
|
<div class="chronicle-eyebrow" style="margin-bottom:.8rem">Colony Chronicle // Timeline</div>
|
|
<div class="tl-wrap">
|
|
|
|
<!-- Callout canvas for events -->
|
|
<div class="tl-canvas"
|
|
:style="`height:${tlHeight()}px;${editMode ? 'cursor:crosshair' : ''}`"
|
|
@click="handleTimelineClick($event)">
|
|
<!-- Rail -->
|
|
<div class="tl-rail-line" :style="`top: ${tlRailY()}px`"></div>
|
|
|
|
<!-- All callout items (anchors + events) -->
|
|
<template x-for="e in tlItems" :key="e.id">
|
|
<div>
|
|
<!-- Stem -->
|
|
<div class="tl-stem" :style="tlStemStyle(e)"></div>
|
|
<!-- Dot -->
|
|
<div
|
|
class="tl-evt-dot"
|
|
:class="[e.mood || (game && game.mood) || 'light', e.isAnchor ? 'anchor-dot' : '', e.discipline ? 'disc-icon' : '']"
|
|
:style="tlDotStyle(e)"
|
|
:title="e.title + ' — ' + e.date"
|
|
x-text="e.discipline ? disciplineIcon(e.discipline) : ''"
|
|
></div>
|
|
<!-- Label — links to entry section -->
|
|
<a class="tl-lbl" :style="tlLabelStyle(e)" :href="e.isAnchor ? '#section-events' : '#entry-' + e.id" style="text-decoration:none">
|
|
<div class="tl-lbl-name" :class="e.isAnchor ? 'is-anchor' : ''" x-text="e.title"></div>
|
|
<div class="tl-lbl-date" x-text="e.date"></div>
|
|
</a>
|
|
</div>
|
|
</template>
|
|
|
|
|
|
</div>
|
|
|
|
<!-- Project bars -->
|
|
<div x-show="projects().length > 0">
|
|
<div class="tl-sublabel">Projects</div>
|
|
<div class="tl-rows">
|
|
<template x-for="item in projects()" :key="item.id">
|
|
<div class="tl-row project-tl">
|
|
<a
|
|
class="tl-bar project-bar"
|
|
:class="[item.mood || (game && game.mood), !item.end_date ? 'open-right' : '']"
|
|
:style="`left:${posPercent(item.date)}%;width:${barWidth(item.date,item.end_date)}%`"
|
|
:title="item.title + ' — ' + (item.status || 'active')"
|
|
:href="'#entry-' + item.id"
|
|
style="text-decoration:none"
|
|
>
|
|
<span class="tl-bar-text" x-text="item.title"></span>
|
|
</a>
|
|
<!-- Crosshair zone: click to add event at that date (edit mode) -->
|
|
<div x-show="editMode"
|
|
class="tl-bar-add-overlay"
|
|
:style="`left:${posPercent(item.date)}%;width:${barWidth(item.date,item.end_date)}%`"
|
|
@click="openAddEventToProject(item, $event)"></div>
|
|
<!-- Event dots for this project -->
|
|
<template x-for="ev in eventsForProject(item.id)" :key="ev.id">
|
|
<a class="tl-project-evt-dot"
|
|
:class="ev.mood || (game && game.mood) || 'light'"
|
|
:style="`left:${posPercent(ev.date)}%`"
|
|
:href="'#entry-' + ev.id"
|
|
:title="ev.title + ' — ' + ev.date"
|
|
@click.stop></a>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Cast bars -->
|
|
<div x-show="cast().length > 0">
|
|
<div class="tl-sublabel">Cast</div>
|
|
<div class="tl-rows">
|
|
<template x-for="(item, idx) in cast()" :key="item.id">
|
|
<div class="tl-row">
|
|
<a
|
|
class="tl-bar"
|
|
:class="{ 'open-right': !item.end_date, 'open-left': item.date === (game && game.timeline_start) }"
|
|
:style="`left:${posPercent(item.date)}%;width:${barWidth(item.date,item.end_date)}%;background:${castColor(idx)};opacity:.82`"
|
|
:title="item.title + (item.end_date ? ' — died ' + item.end_date : ' — active')"
|
|
:href="'#entry-' + item.id"
|
|
style="text-decoration:none"
|
|
>
|
|
<span class="tl-bar-text" x-text="item.title"></span>
|
|
</a>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
|
|
|
|
</div><!-- /.tl-wrap -->
|
|
</div>
|
|
|
|
<img class="divider" src="assets/divider.svg" alt="" />
|
|
|
|
<!-- Events -->
|
|
<div class="chronicle-section" x-show="allEventsForDisplay().length > 0 || editMode" id="section-events">
|
|
<div class="chronicle-header-row">
|
|
<div>
|
|
<div class="chronicle-eyebrow">Colony Chronicle</div>
|
|
<h2 class="chronicle-title">Events</h2>
|
|
</div>
|
|
<button class="btn sm" x-show="editMode" @click="openAddItem('event')">+ Add Event</button>
|
|
</div>
|
|
<div class="entry-list">
|
|
<template x-for="item in allEventsForDisplay()" :key="item.id">
|
|
<div class="entry" :id="'entry-' + item.id">
|
|
<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="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.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 || deckCache[item.id]">
|
|
<img
|
|
:src="item.env ? 'assets/' + item.env + '.jpg' : 'assets/deck-images/' + deckCache[item.id]"
|
|
: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>
|
|
<button class="btn sm danger" @click="deleteItem(item.id)">Delete</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
<div x-show="allEventsForDisplay().length === 0 && editMode" class="empty-section">
|
|
Events are point-in-time occurrences — discoveries, incidents, decisions. Enter edit mode and add the first one to begin the chronicle.
|
|
</div>
|
|
</div>
|
|
|
|
<img class="divider" src="assets/divider.svg" alt="" />
|
|
|
|
<!-- Projects -->
|
|
<div class="chronicle-section" x-show="projects().length > 0 || editMode" id="section-projects">
|
|
<div class="chronicle-header-row">
|
|
<div>
|
|
<div class="chronicle-eyebrow">Colony Chronicle</div>
|
|
<h2 class="chronicle-title">Projects</h2>
|
|
</div>
|
|
<button class="btn sm" x-show="editMode" @click="openAddItem('project')">+ Add Project</button>
|
|
</div>
|
|
<div class="entry-list">
|
|
<template x-for="item in projects()" :key="item.id">
|
|
<div class="entry" :id="'entry-' + item.id">
|
|
<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>
|
|
<span x-show="!item.end_date" class="e-muted"> → ongoing</span>
|
|
<span class="sep"> // </span>
|
|
<span :class="(item.status || 'active') === 'completed' ? 'e-teal' : (item.status === 'abandoned' ? 'e-muted' : '')" x-text="item.status || 'active'"></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>
|
|
</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="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]">
|
|
<img
|
|
:src="item.env ? 'assets/' + item.env + '.jpg' : 'assets/deck-images/' + deckCache[item.id]"
|
|
:alt="item.title" loading="lazy"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div class="entry-actions" x-show="editMode">
|
|
<button class="btn sm" @click="openAddEventToProject(item, null)">+ Event</button>
|
|
<button class="btn sm ghost" @click="openEditItem(item)">Edit</button>
|
|
<button class="btn sm danger" @click="deleteItem(item.id)">Delete</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
<div x-show="projects().length === 0 && editMode" class="empty-section">
|
|
Projects span periods of time — construction efforts, research programmes, social movements.
|
|
</div>
|
|
</div>
|
|
|
|
<img class="divider" src="assets/divider.svg" alt="" />
|
|
|
|
<!-- Cast -->
|
|
<div class="chronicle-section" x-show="cast().length > 0 || editMode" id="section-cast">
|
|
<div class="chronicle-header-row">
|
|
<div>
|
|
<div class="chronicle-eyebrow">Colony Chronicle</div>
|
|
<h2 class="chronicle-title">Cast</h2>
|
|
</div>
|
|
<button class="btn sm" x-show="editMode" @click="openAddItem('person')">+ Add Cast Member</button>
|
|
</div>
|
|
<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" :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>
|
|
<span x-show="item.end_date"><span class="sep"> // </span><span class="e-dark">Died <span x-text="item.end_date"></span></span></span>
|
|
<span x-show="!item.end_date"><span class="sep"> // </span>Active</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="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 || deckCache[item.id]">
|
|
<img
|
|
:src="item.env ? 'assets/' + item.env + '.jpg' : 'assets/deck-images/' + deckCache[item.id]"
|
|
:alt="item.title" loading="lazy"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div class="entry-actions" x-show="editMode">
|
|
<button class="btn sm ghost" @click="openEditItem(item)">Edit</button>
|
|
<button class="btn sm danger" @click="deleteItem(item.id)">Delete</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
<div x-show="cast().length === 0 && editMode" class="empty-section">
|
|
Use <code style="font-family:var(--mono);font-size:.9em">@"Name"</code> in descriptions to reference cast members. They appear here and cross-link throughout the chronicle.
|
|
</div>
|
|
</div>
|
|
|
|
<img class="divider" src="assets/divider.svg" alt="" />
|
|
|
|
<!-- Glossary -->
|
|
<div class="chronicle-section" x-show="allKeywords().length > 0 || editMode" id="section-glossary">
|
|
<div class="chronicle-header-row">
|
|
<div>
|
|
<div class="chronicle-eyebrow">Colony Chronicle</div>
|
|
<h2 class="chronicle-title">Glossary</h2>
|
|
</div>
|
|
<button class="btn sm" x-show="editMode" @click="openAddKeyword()">+ Define Term</button>
|
|
</div>
|
|
<div class="entry-list">
|
|
<template x-for="kw in allKeywords()" :key="kw.term">
|
|
<div class="entry">
|
|
<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-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">
|
|
<a class="kw-usage" :href="'#entry-' + item.id" x-text="item.title"></a>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
<div class="entry-actions" x-show="editMode">
|
|
<button class="btn sm ghost" @click="openEditKeyword(kw)" x-text="kw.definition ? 'Edit' : 'Define'"></button>
|
|
<button class="btn sm danger" x-show="kw.id" @click="deleteKeyword(kw)">Delete</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
<div x-show="allKeywords().length === 0 && editMode" class="empty-section">
|
|
Bold text becomes a keyword — write <code style="font-family:var(--mono);font-size:.9em">**term**</code> in any description to register it here.
|
|
</div>
|
|
</div>
|
|
|
|
</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">
|
|
<!-- Custom card image shown as subtle background within the inner frame -->
|
|
<img x-show="drawnCard?.cardImage" class="card-image-bg"
|
|
:src="'assets/deck-images/' + drawnCard?.cardImage" alt="" />
|
|
<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>
|
|
|
|
<!-- Live update toast -->
|
|
<div x-show="liveUpdate"
|
|
x-transition:enter="transition ease-out duration-250"
|
|
x-transition:enter-start="opacity-0 translate-y-2"
|
|
x-transition:enter-end="opacity-100 translate-y-0"
|
|
x-transition:leave="transition ease-in duration-150"
|
|
x-transition:leave-start="opacity-100"
|
|
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"
|
|
@click="tlChooser.show = false"
|
|
@keydown.escape.window="tlChooser.show = false"></div>
|
|
<div class="tl-chooser"
|
|
x-show="tlChooser.show"
|
|
:style="`left:${tlChooser.x}px;top:${tlChooser.y}px`"
|
|
@click.stop>
|
|
<button class="tl-chooser-btn" @click="openAddFromTimeline('event')">+ Event at <span x-text="tlChooser.date"></span></button>
|
|
<button class="tl-chooser-btn" @click="openAddFromTimeline('project')">+ Project from <span x-text="tlChooser.date"></span></button>
|
|
</div>
|
|
|
|
<footer>
|
|
<div class="shell">
|
|
<p>Orison Reach Public Archive<br />Civilian access edition</p>
|
|
<p>Knowledge builds tomorrow<br />Work keeps it standing</p>
|
|
</div>
|
|
</footer>
|
|
|
|
<!-- Modal — all sections use x-show (not x-if) so Alpine bindings are always live -->
|
|
<div class="modal-overlay" x-show="showModal" @click="closeModal()" @keydown.escape.window="closeModal()"
|
|
x-transition:enter="transition ease-out duration-250"
|
|
x-transition:enter-start="opacity-0"
|
|
x-transition:enter-end="opacity-100"
|
|
x-transition:leave="transition ease-in duration-150"
|
|
x-transition:leave-start="opacity-100"
|
|
x-transition:leave-end="opacity-0">
|
|
<div class="modal" @click.stop>
|
|
<div class="modal-title" x-text="modalTitle()"></div>
|
|
|
|
<!-- Unlock -->
|
|
<div x-show="modalType === 'unlock'">
|
|
<div class="form-group">
|
|
<label class="form-label">Password <span>*</span></label>
|
|
<input type="password" x-model="activeForm.password" @keydown.enter="unlock()" />
|
|
</div>
|
|
<div class="form-error" x-show="formError" x-text="formError"></div>
|
|
<div class="modal-actions">
|
|
<button class="btn ghost" @click="closeModal()">Cancel</button>
|
|
<button class="btn primary" @click="unlock()" :disabled="saving">
|
|
<span x-text="saving ? 'Checking…' : 'Unlock'"></span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Edit Game -->
|
|
<div x-show="modalType === 'editGame'">
|
|
<div class="form-group">
|
|
<label class="form-label">Title <span>*</span></label>
|
|
<input type="text" x-model="activeForm.title" />
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label">Description</label>
|
|
<textarea x-model="activeForm.description"></textarea>
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label">Overall Mood</label>
|
|
<div class="mood-row">
|
|
<label class="mood-radio"><input type="radio" x-model="activeForm.mood" value="light" /><span>Light</span></label>
|
|
<label class="mood-radio"><input type="radio" x-model="activeForm.mood" value="dark" /><span>Dark</span></label>
|
|
</div>
|
|
</div>
|
|
<div class="date-row">
|
|
<div class="form-group">
|
|
<label class="form-label">Timeline Start <span>*</span></label>
|
|
<input type="date" x-model="activeForm.timeline_start" />
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label">Timeline End <span>*</span></label>
|
|
<input type="date" x-model="activeForm.timeline_end" />
|
|
</div>
|
|
</div>
|
|
<div class="form-section">
|
|
<div class="form-section-title">Opening Event</div>
|
|
<div class="form-group">
|
|
<label class="form-label">Title</label>
|
|
<input type="text" x-model="activeForm.start_title" placeholder="Where does this story begin?" />
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label">Description</label>
|
|
<textarea x-model="activeForm.start_description" placeholder="The circumstances of the opening moment…" style="min-height:60px"></textarea>
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label">Mood</label>
|
|
<div class="mood-row">
|
|
<label class="mood-radio"><input type="radio" x-model="activeForm.start_mood" value="" /><span>Inherit</span></label>
|
|
<label class="mood-radio"><input type="radio" x-model="activeForm.start_mood" value="light" /><span>Light</span></label>
|
|
<label class="mood-radio"><input type="radio" x-model="activeForm.start_mood" value="dark" /><span>Dark</span></label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="form-section">
|
|
<div class="form-section-title">Closing Event</div>
|
|
<div class="form-group">
|
|
<label class="form-label">Title</label>
|
|
<input type="text" x-model="activeForm.end_title" placeholder="Where does this story end?" />
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label">Description</label>
|
|
<textarea x-model="activeForm.end_description" placeholder="The circumstances of the closing moment…" style="min-height:60px"></textarea>
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label">Mood</label>
|
|
<div class="mood-row">
|
|
<label class="mood-radio"><input type="radio" x-model="activeForm.end_mood" value="" /><span>Inherit</span></label>
|
|
<label class="mood-radio"><input type="radio" x-model="activeForm.end_mood" value="light" /><span>Light</span></label>
|
|
<label class="mood-radio"><input type="radio" x-model="activeForm.end_mood" value="dark" /><span>Dark</span></label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="form-section">
|
|
<div class="form-section-title">Card Deck</div>
|
|
<div class="form-group">
|
|
<label class="form-label">Default deck for this session</label>
|
|
<select x-model="activeForm.deck_id">
|
|
<option value="">Default Cards (built-in)</option>
|
|
<template x-for="d in customDecks" :key="d.id">
|
|
<option :value="d.id" x-text="d.name"></option>
|
|
</template>
|
|
</select>
|
|
<div class="form-hint">Players will draw from this deck by default. Can be overridden per-session in the nav.</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="modal-actions">
|
|
<button class="btn ghost" @click="closeModal()">Cancel</button>
|
|
<button class="btn primary" @click="saveGame()" :disabled="saving">
|
|
<span x-text="saving ? 'Saving…' : 'Save'"></span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Add/Edit Item (event / project / person) -->
|
|
<div x-show="modalType === 'item'">
|
|
<div class="form-group">
|
|
<label class="form-label" x-text="activeForm.type === 'person' ? 'Name *' : 'Title *'"></label>
|
|
<input type="text" x-model="activeForm.title"
|
|
:placeholder="activeForm.type === 'event' ? 'What happened?' : activeForm.type === 'project' ? 'Project name' : 'Character name'" />
|
|
</div>
|
|
|
|
<!-- Event: single date + optional project -->
|
|
<div x-show="activeForm.type === 'event'">
|
|
<div class="form-group">
|
|
<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>
|
|
|
|
<!-- Project: start + end + status -->
|
|
<div x-show="activeForm.type === 'project'">
|
|
<div class="date-row">
|
|
<div class="form-group">
|
|
<label class="form-label">Start 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">
|
|
<label class="form-label">End Date</label>
|
|
<input type="date" x-model="activeForm.end_date" :min="activeForm.date" :max="game && game.timeline_end" />
|
|
<div class="form-hint">Leave blank if ongoing.</div>
|
|
</div>
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label">Status</label>
|
|
<select x-model="activeForm.status">
|
|
<option value="active">Active</option>
|
|
<option value="completed">Completed</option>
|
|
<option value="abandoned">Abandoned</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Person: arrival + death -->
|
|
<div class="date-row" x-show="activeForm.type === 'person'">
|
|
<div class="form-group">
|
|
<label class="form-label">Arrived <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">
|
|
<label class="form-label">Died</label>
|
|
<input type="date" x-model="activeForm.end_date" :min="activeForm.date" :max="game && game.timeline_end" />
|
|
<div class="form-hint">Leave blank if alive.</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Mood + discipline + environment (not for persons) -->
|
|
<div x-show="activeForm.type !== 'person'">
|
|
<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">Environment Image</label>
|
|
<select x-model="activeForm.env">
|
|
<option value="">None</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">
|
|
<label class="form-label">Description</label>
|
|
<div class="ac-wrap">
|
|
<textarea x-model="activeForm.description"
|
|
:placeholder="activeForm.type === 'person' ? 'Role, background, notes…' : 'What happened, why it matters… Use @Name or **keyword**'"
|
|
@input="handleInput($event, 'description')"
|
|
@keydown="handleKeydown($event)"
|
|
@blur="$nextTick(() => acShow = false)"></textarea>
|
|
<div class="ac-dropdown" x-show="acShow && acField === 'description'" @mousedown.prevent>
|
|
<template x-for="(r, i) in acResults()" :key="i">
|
|
<div class="ac-item" :class="i === acIndex ? 'ac-sel' : ''" @click="selectAcResult(r)">
|
|
<span x-text="acMode === 'mention' ? r.title : r.term"></span>
|
|
<span class="ac-sub" x-show="acMode === 'keyword' && r.definition" x-text="r.definition"></span>
|
|
</div>
|
|
</template>
|
|
<div class="ac-empty" x-show="acResults().length === 0">
|
|
<span x-show="acMode === 'mention'">No matching cast members</span>
|
|
<span x-show="acMode === 'keyword'">New keyword — registered on save</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-error" x-show="formError" x-text="formError"></div>
|
|
<div class="modal-actions">
|
|
<button class="btn ghost" @click="closeModal()">Cancel</button>
|
|
<button class="btn primary" @click="saveItem()" :disabled="saving">
|
|
<span x-text="saving ? 'Saving…' : (editingId ? 'Save Changes' : 'Add to Timeline')"></span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- New cast members detected after save -->
|
|
<div x-show="modalType === 'castPrompt'">
|
|
<p style="color:var(--text);margin:0 0 1.4rem;font-size:.95rem;line-height:1.6">
|
|
These names were mentioned but aren't in the cast yet:
|
|
</p>
|
|
<div style="display:flex;flex-direction:column;gap:.8rem">
|
|
<template x-for="name in pendingCastNames" :key="name">
|
|
<div style="display:flex;align-items:center;justify-content:space-between;gap:1rem;padding:.6rem 0;border-bottom:1px solid var(--line)">
|
|
<span style="font:600 1.1rem/1 var(--serif);color:var(--paper)" x-text="name"></span>
|
|
<button class="btn sm" @click="addPendingCast(name)">Add to Cast</button>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
<div class="modal-actions">
|
|
<button class="btn ghost" @click="closeModal()">Skip</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Keyword definition -->
|
|
<div x-show="modalType === 'keyword'">
|
|
<div class="form-group">
|
|
<label class="form-label">Term</label>
|
|
<input type="text" x-model="activeForm.term" :readonly="!!editingId" :style="editingId ? 'opacity:.6' : ''" placeholder="Phase Crystal" />
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label">Definition</label>
|
|
<textarea x-model="activeForm.definition" placeholder="What does this term mean in this world?" style="min-height:100px"></textarea>
|
|
</div>
|
|
<div class="form-error" x-show="formError" x-text="formError"></div>
|
|
<div class="modal-actions">
|
|
<button class="btn ghost" @click="closeModal()">Cancel</button>
|
|
<button class="btn primary" @click="saveKeyword()" :disabled="saving">
|
|
<span x-text="saving ? 'Saving…' : 'Save'"></span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
|
|
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.3/dist/cdn.min.js"></script>
|
|
<script>
|
|
// Cast color palette — distinct, readable on dark backgrounds
|
|
const CAST_PALETTE = [
|
|
'#86a5a4','#c49a3c','#b05c3a','#7d9a5a',
|
|
'#8e7bbf','#c4876a','#4f9e9c','#a87c57',
|
|
'#9b7ab8','#5a8ea0','#b8834a','#6e9e6e'
|
|
]
|
|
|
|
function gameApp() {
|
|
return {
|
|
slug: new URLSearchParams(location.search).get('slug'),
|
|
game: null,
|
|
items: [],
|
|
keywords: [],
|
|
editMode: false,
|
|
loading: true,
|
|
error: null,
|
|
showModal: false,
|
|
modalType: null,
|
|
activeForm: {},
|
|
editingId: null,
|
|
formError: null,
|
|
saving: false,
|
|
tlItems: [],
|
|
_tlRows: { top: 1, bot: 1 },
|
|
// Image decks
|
|
deckImages: [],
|
|
deckCache: {},
|
|
customDecks: [], // full deck objects with cards
|
|
activeDeckId: '', // '' = default cards.json
|
|
// Live updates
|
|
liveUpdate: false,
|
|
// Card draw
|
|
cards: [],
|
|
drawnCard: null,
|
|
recentCardIds: [],
|
|
showCard: false,
|
|
// Timeline click chooser
|
|
tlChooser: { show: false, x: 0, y: 0, date: '' },
|
|
// Truths
|
|
newTruth: '',
|
|
// Autocomplete
|
|
acMode: null,
|
|
acQuery: '',
|
|
acIndex: 0,
|
|
acShow: false,
|
|
acField: null,
|
|
acEl: null,
|
|
// New cast prompt
|
|
pendingCastNames: [],
|
|
|
|
getPassword() {
|
|
return sessionStorage.getItem('game-pw-' + this.slug) || ''
|
|
},
|
|
|
|
authHeaders() {
|
|
return {
|
|
'Content-Type': 'application/json',
|
|
'X-Game-Password': this.getPassword()
|
|
}
|
|
},
|
|
|
|
async init() {
|
|
if (!this.slug) {
|
|
this.error = 'No session specified'
|
|
this.loading = false
|
|
return
|
|
}
|
|
this.loadCards()
|
|
this.loadDecks()
|
|
this.connectLive()
|
|
const ok = await this.loadGame()
|
|
if (!ok) { this.loading = false; return }
|
|
await Promise.all([this.loadItems(), this.loadKeywords()])
|
|
this.loading = false
|
|
if (this.getPassword()) {
|
|
const valid = await this.verifyPassword(this.getPassword())
|
|
if (valid) this.editMode = true
|
|
else sessionStorage.removeItem('game-pw-' + this.slug)
|
|
}
|
|
},
|
|
|
|
async loadGame() {
|
|
try {
|
|
const r = await fetch('/api/games/' + this.slug)
|
|
if (!r.ok) { this.error = 'Session not found'; return false }
|
|
this.game = await r.json()
|
|
document.title = this.game.title + ' — Orison Reach'
|
|
if (this.game.deck_id) this.activeDeckId = this.game.deck_id
|
|
return true
|
|
} catch {
|
|
this.error = 'Network error'
|
|
return false
|
|
}
|
|
},
|
|
|
|
async loadItems() {
|
|
try {
|
|
const r = await fetch('/api/games/' + this.slug + '/items')
|
|
this.items = r.ok ? await r.json() : []
|
|
} catch { this.items = [] }
|
|
this.tlItems = this.computeLayout()
|
|
this.buildDeckCache()
|
|
},
|
|
|
|
async loadDecks() {
|
|
try {
|
|
const r = await fetch('/api/decks')
|
|
if (!r.ok) return
|
|
const decks = await r.json()
|
|
this.customDecks = decks
|
|
this.deckImages = decks.flatMap(d => (d.images || []).map(img => ({
|
|
filename: img.filename, keywords: img.keywords || []
|
|
})))
|
|
this.buildDeckCache()
|
|
} catch {}
|
|
},
|
|
|
|
buildDeckCache() {
|
|
if (!this.deckImages.length) return
|
|
const cache = {}
|
|
for (const item of this.items) {
|
|
if (!item.env) cache[item.id] = this.pickDeckImage(item)
|
|
}
|
|
this.deckCache = cache
|
|
},
|
|
|
|
// Deterministic seed from item ID so image never changes on re-render
|
|
idSeed(id) {
|
|
return id.split('').reduce((a, c) => (a * 31 + c.charCodeAt(0)) >>> 0, 0)
|
|
},
|
|
|
|
pickDeckImage(item) {
|
|
if (!this.deckImages.length) return null
|
|
const tags = new Set()
|
|
if (item.mood) tags.add(item.mood.toLowerCase())
|
|
if (item.discipline) tags.add(item.discipline.toLowerCase())
|
|
for (const m of (item.description || '').matchAll(/\*\*([^*\n]+)\*\*/g))
|
|
tags.add(m[1].trim().toLowerCase())
|
|
const scored = this.deckImages.map(img => ({
|
|
img,
|
|
score: img.keywords.filter(k => tags.has(k.toLowerCase())).length + 1
|
|
}))
|
|
const total = scored.reduce((s, x) => s + x.score, 0)
|
|
let pick = (this.idSeed(item.id) % 10000) / 10000 * total
|
|
for (const { img, score } of scored) {
|
|
pick -= score
|
|
if (pick <= 0) return img.filename
|
|
}
|
|
return scored[0].img.filename
|
|
},
|
|
|
|
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')
|
|
this.cards = r.ok ? await r.json() : []
|
|
} catch { this.cards = [] }
|
|
},
|
|
|
|
drawCard() {
|
|
// Custom deck selected?
|
|
if (this.activeDeckId) {
|
|
const deck = this.customDecks.find(d => d.id === this.activeDeckId)
|
|
const pool = deck?.cards || []
|
|
if (!pool.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?.length) return true
|
|
return c.requires.every(r =>
|
|
r==='cast'?hasCast:r==='keyword'?hasKeyword:r==='project'?hasProject:true)
|
|
}
|
|
const eligible = pool.filter(meets)
|
|
const avail = eligible.filter(c => !this.recentCardIds.includes(c.id))
|
|
const src = avail.length ? avail : eligible
|
|
if (!src.length) return
|
|
const picked = src[Math.floor(Math.random() * src.length)]
|
|
const resolved = this.resolveDynamic(picked)
|
|
// Pick random card image if the card has images
|
|
if (picked.images?.length) {
|
|
const img = picked.images[Math.floor(Math.random() * picked.images.length)]
|
|
resolved.cardImage = img.filename
|
|
}
|
|
this.drawnCard = resolved
|
|
this.recentCardIds = [...this.recentCardIds.slice(-9), picked.id]
|
|
this.showCard = true
|
|
return
|
|
}
|
|
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,'&').replace(/</g,'<')}</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')
|
|
this.keywords = r.ok ? await r.json() : []
|
|
} catch { this.keywords = [] }
|
|
},
|
|
|
|
async verifyPassword(pw) {
|
|
try {
|
|
const r = await fetch('/api/games/' + this.slug + '/verify', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ password: pw })
|
|
})
|
|
return r.ok
|
|
} catch { return false }
|
|
},
|
|
|
|
async unlock() {
|
|
if (!this.activeForm.password) { this.formError = 'Enter password'; return }
|
|
this.saving = true
|
|
this.formError = null
|
|
const ok = await this.verifyPassword(this.activeForm.password)
|
|
this.saving = false
|
|
if (ok) {
|
|
sessionStorage.setItem('game-pw-' + this.slug, this.activeForm.password)
|
|
this.editMode = true
|
|
this.closeModal()
|
|
} else {
|
|
this.formError = 'Incorrect password'
|
|
}
|
|
},
|
|
|
|
lock() {
|
|
sessionStorage.removeItem('game-pw-' + this.slug)
|
|
this.editMode = false
|
|
},
|
|
|
|
// --- Timeline layout ---
|
|
|
|
computeLayout() {
|
|
if (!this.game) return []
|
|
const MIN_GAP = 14 // min % between labels on the same side
|
|
const ICON_SEP = 4 // min % between consecutive dots on the rail
|
|
const topTrack = [], botTrack = []
|
|
|
|
const allItems = [
|
|
{
|
|
id: '__start__', isAnchor: true,
|
|
title: this.game.start_title || this.game.timeline_start,
|
|
date: this.game.timeline_start,
|
|
mood: this.game.start_mood || this.game.mood || 'light',
|
|
pct: 0
|
|
},
|
|
...this.events().map(e => ({ ...e, pct: this.posPercent(e.date) })),
|
|
{
|
|
id: '__end__', isAnchor: true,
|
|
title: this.game.end_title || this.game.timeline_end,
|
|
date: this.game.timeline_end,
|
|
mood: this.game.end_mood || this.game.mood || 'light',
|
|
pct: 100
|
|
}
|
|
]
|
|
|
|
let prevPct = -ICON_SEP * 10
|
|
|
|
const placed = allItems.map((item, i) => {
|
|
// Spread dots that would visually overlap on the rail.
|
|
// User said exact position matters less than visual separation.
|
|
const pct = Math.max(item.pct, prevPct + ICON_SEP)
|
|
|
|
const side = i % 2 === 0 ? 'top' : 'bottom'
|
|
const track = side === 'top' ? topTrack : botTrack
|
|
|
|
// Find first row where this label fits
|
|
let row = 0
|
|
while (row < track.length && pct - track[row] < MIN_GAP) row++
|
|
while (track.length <= row) track.push(Number.NEGATIVE_INFINITY)
|
|
track[row] = pct
|
|
|
|
prevPct = pct
|
|
return { ...item, pct, side, row }
|
|
})
|
|
|
|
this._tlRows = {
|
|
top: Math.max(topTrack.length, 1),
|
|
bot: Math.max(botTrack.length, 1)
|
|
}
|
|
return placed
|
|
},
|
|
|
|
tlRailY() {
|
|
const ROW_H = 48, STEM = 16
|
|
return STEM + this._tlRows.top * ROW_H
|
|
},
|
|
|
|
tlHeight() {
|
|
const ROW_H = 48, STEM = 16
|
|
return this.tlRailY() + 1 + STEM + this._tlRows.bot * ROW_H
|
|
},
|
|
|
|
tlStemStyle(e) {
|
|
const ROW_H = 48, STEM = 16
|
|
const railY = this.tlRailY()
|
|
const h = STEM + e.row * ROW_H
|
|
if (e.side === 'top') {
|
|
return `left:${e.pct}%;top:${railY - h}px;height:${h}px;`
|
|
} else {
|
|
return `left:${e.pct}%;top:${railY + 1}px;height:${h}px;`
|
|
}
|
|
},
|
|
|
|
tlDotStyle(e) {
|
|
const railY = this.tlRailY()
|
|
if (e.discipline) {
|
|
return `left:${e.pct}%;top:${railY}px;`
|
|
}
|
|
const sz = e.isAnchor ? 13 : 9
|
|
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] || ''
|
|
},
|
|
|
|
tlLabelStyle(e) {
|
|
const ROW_H = 48, STEM = 16, LBL_H = 34
|
|
const railY = this.tlRailY()
|
|
const h = STEM + e.row * ROW_H
|
|
const xAlign = e.pct < 8 ? 'translateX(0)' : e.pct > 92 ? 'translateX(-100%)' : 'translateX(-50%)'
|
|
if (e.side === 'top') {
|
|
return `left:${e.pct}%;top:${railY - h - LBL_H}px;transform:${xAlign};`
|
|
} else {
|
|
return `left:${e.pct}%;top:${railY + 1 + h}px;transform:${xAlign};`
|
|
}
|
|
},
|
|
|
|
// --- Cast colors ---
|
|
|
|
castColor(idx) {
|
|
return CAST_PALETTE[idx % CAST_PALETTE.length]
|
|
},
|
|
|
|
// --- Description renderer (@mentions) ---
|
|
|
|
renderDesc(text) {
|
|
if (!text) return ''
|
|
const escaped = text
|
|
.replace(/&/g, '&').replace(/</g, '<')
|
|
.replace(/>/g, '>').replace(/"/g, '"')
|
|
.replace(/\n/g, '<br>')
|
|
return escaped
|
|
.replace(/\*\*\*([^*]+)\*\*\*/g, '<strong><em>$1</em></strong>')
|
|
.replace(/\*\*([^*\n]+)\*\*/g, '<a href="#section-glossary" class="kw-link">$1</a>')
|
|
.replace(/_([^_\n]+)_/g, '<em>$1</em>')
|
|
.replace(/@"([^&]+)"/g, (match, name) => {
|
|
const decoded = name.replace(/&/g, '&')
|
|
const member = this.cast().find(p => p.title.toLowerCase() === decoded.toLowerCase())
|
|
if (member) {
|
|
return `<a href="#entry-${member.id}" class="cast-mention">@${decoded}</a>`
|
|
}
|
|
return `<span class="cast-mention unknown">@${decoded}</span>`
|
|
})
|
|
},
|
|
|
|
// --- Timeline click to add ---
|
|
|
|
handleTimelineClick(event) {
|
|
if (!this.editMode || !this.game) return
|
|
// Ignore clicks on links (callout labels, bar links)
|
|
if (event.target.closest('a, button')) return
|
|
event.stopPropagation()
|
|
const rect = event.currentTarget.getBoundingClientRect()
|
|
const pct = Math.max(0, Math.min(100, ((event.clientX - rect.left) / rect.width) * 100))
|
|
this.tlChooser = {
|
|
show: true,
|
|
x: event.clientX,
|
|
y: event.clientY + 14,
|
|
date: this.pctToDate(pct)
|
|
}
|
|
},
|
|
|
|
pctToDate(pct) {
|
|
const start = new Date(this.game.timeline_start).getTime()
|
|
const end = new Date(this.game.timeline_end).getTime()
|
|
return new Date(Math.round(start + (pct / 100) * (end - start))).toISOString().split('T')[0]
|
|
},
|
|
|
|
openAddFromTimeline(type) {
|
|
const date = this.tlChooser.date
|
|
this.tlChooser.show = false
|
|
this.openAddItem(type)
|
|
this.activeForm.date = date
|
|
},
|
|
|
|
// --- Autocomplete ---
|
|
|
|
handleInput(event, field) {
|
|
this.acEl = event.target
|
|
this.acField = field
|
|
const before = event.target.value.slice(0, event.target.selectionStart)
|
|
|
|
// @mention: @ followed by chars that aren't @, " or newline, to end of before-cursor
|
|
const mentionMatch = before.match(/@([^@"\n]*)$/)
|
|
if (mentionMatch) {
|
|
this.acMode = 'mention'
|
|
this.acQuery = mentionMatch[1]
|
|
this.acIndex = 0
|
|
this.acShow = true
|
|
return
|
|
}
|
|
|
|
// **keyword: odd number of ** means we're inside an unclosed one
|
|
const kwCount = (before.match(/\*\*/g) || []).length
|
|
if (kwCount % 2 === 1) {
|
|
const lastKw = before.lastIndexOf('**')
|
|
const query = before.slice(lastKw + 2)
|
|
if (!query.includes('\n')) {
|
|
this.acMode = 'keyword'
|
|
this.acQuery = query
|
|
this.acIndex = 0
|
|
this.acShow = true
|
|
return
|
|
}
|
|
}
|
|
|
|
this.acShow = false
|
|
this.acMode = null
|
|
},
|
|
|
|
handleKeydown(event) {
|
|
if (!this.acShow) return
|
|
const results = this.acResults()
|
|
if (event.key === 'ArrowDown') {
|
|
event.preventDefault()
|
|
this.acIndex = Math.min(this.acIndex + 1, results.length - 1)
|
|
} else if (event.key === 'ArrowUp') {
|
|
event.preventDefault()
|
|
this.acIndex = Math.max(this.acIndex - 1, 0)
|
|
} else if (event.key === 'Enter' || event.key === 'Tab') {
|
|
if (results.length > 0) {
|
|
event.preventDefault()
|
|
this.selectAcResult(results[this.acIndex])
|
|
}
|
|
} else if (event.key === 'Escape') {
|
|
this.acShow = false
|
|
}
|
|
},
|
|
|
|
selectAcResult(result) {
|
|
if (!this.acEl || !this.acField) return
|
|
const val = this.acEl.value
|
|
const pos = this.acEl.selectionStart
|
|
const before = val.slice(0, pos)
|
|
const after = val.slice(pos)
|
|
let newBefore
|
|
if (this.acMode === 'mention') {
|
|
const atIdx = before.lastIndexOf('@')
|
|
newBefore = before.slice(0, atIdx) + `@"${result.title}"`
|
|
} else {
|
|
const kwIdx = before.lastIndexOf('**')
|
|
newBefore = before.slice(0, kwIdx) + `**${result.term}**`
|
|
}
|
|
this.activeForm[this.acField] = newBefore + after
|
|
this.acShow = false
|
|
this.$nextTick(() => {
|
|
if (this.acEl) {
|
|
this.acEl.focus()
|
|
this.acEl.setSelectionRange(newBefore.length, newBefore.length)
|
|
}
|
|
})
|
|
},
|
|
|
|
acResults() {
|
|
if (!this.acShow) return []
|
|
if (this.acMode === 'mention') {
|
|
return this.cast().filter(c =>
|
|
c.title.toLowerCase().includes(this.acQuery.toLowerCase())
|
|
).slice(0, 8)
|
|
}
|
|
if (this.acMode === 'keyword') {
|
|
return this.allKeywords().filter(k =>
|
|
k.term.toLowerCase().includes(this.acQuery.toLowerCase())
|
|
).slice(0, 8)
|
|
}
|
|
return []
|
|
},
|
|
|
|
// --- New cast prompt ---
|
|
|
|
findUnmatchedMentions(text) {
|
|
if (!text) return []
|
|
const found = [...(text.matchAll(/@"([^"]+)"/g))].map(m => m[1])
|
|
const castNames = new Set(this.cast().map(c => c.title.toLowerCase()))
|
|
return [...new Set(found.filter(n => !castNames.has(n.toLowerCase())))]
|
|
},
|
|
|
|
addPendingCast(name) {
|
|
this.pendingCastNames = this.pendingCastNames.filter(n => n !== name)
|
|
this.openAddItem('person')
|
|
this.activeForm.title = name
|
|
},
|
|
|
|
// --- Keywords ---
|
|
|
|
allKeywords() {
|
|
const extracted = new Map()
|
|
const descs = [
|
|
...this.items.map(i => i.description || ''),
|
|
this.game?.start_description || '',
|
|
this.game?.end_description || '',
|
|
this.game?.description || ''
|
|
]
|
|
for (const d of descs) {
|
|
for (const m of d.matchAll(/\*\*([^*\n]+)\*\*/g)) {
|
|
const term = m[1].trim()
|
|
if (term) extracted.set(term.toLowerCase(), term)
|
|
}
|
|
}
|
|
const result = []
|
|
const seen = new Set()
|
|
for (const [key, term] of extracted) {
|
|
const stored = this.keywords.find(k => k.term.toLowerCase() === key)
|
|
result.push({ term, definition: stored?.definition || '', id: stored?.id || null })
|
|
seen.add(key)
|
|
}
|
|
for (const k of this.keywords) {
|
|
if (!seen.has(k.term.toLowerCase())) {
|
|
result.push({ term: k.term, definition: k.definition, id: k.id })
|
|
}
|
|
}
|
|
return result.sort((a, b) => a.term.localeCompare(b.term))
|
|
},
|
|
|
|
openAddEventToProject(project, clickEvent) {
|
|
const env = this.randomEnv(project.discipline)
|
|
this.activeForm = {
|
|
type: 'event', title: '', description: '',
|
|
mood: project.mood || '', date: '', end_date: '',
|
|
status: 'active', discipline: project.discipline || '',
|
|
env, env_layout: env ? this.randomEnvLayout() : 'after',
|
|
project_id: project.id
|
|
}
|
|
// Calculate date from click position on the bar
|
|
if (clickEvent) {
|
|
const rect = clickEvent.currentTarget.getBoundingClientRect()
|
|
const pct = Math.max(0, Math.min(100, ((clickEvent.clientX - rect.left) / rect.width) * 100))
|
|
const start = new Date(project.date).getTime()
|
|
const end = new Date(project.end_date || (this.game && this.game.timeline_end)).getTime()
|
|
this.activeForm.date = new Date(Math.round(start + (pct / 100) * (end - start))).toISOString().split('T')[0]
|
|
}
|
|
this.editingId = null
|
|
this.formError = null
|
|
this.modalType = 'item'
|
|
this.showModal = true
|
|
},
|
|
|
|
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) {
|
|
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 || ''))
|
|
},
|
|
|
|
openAddKeyword() {
|
|
this.activeForm = { term: '', definition: '' }
|
|
this.editingId = null
|
|
this.formError = null
|
|
this.modalType = 'keyword'
|
|
this.showModal = true
|
|
},
|
|
|
|
openEditKeyword(kw) {
|
|
this.activeForm = { term: kw.term, definition: kw.definition || '' }
|
|
this.editingId = kw.id
|
|
this.formError = null
|
|
this.modalType = 'keyword'
|
|
this.showModal = true
|
|
},
|
|
|
|
async saveKeyword() {
|
|
if (!this.activeForm.term) { this.formError = 'Term is required'; return }
|
|
this.saving = true; this.formError = null
|
|
try {
|
|
const url = this.editingId
|
|
? '/api/games/' + this.slug + '/keywords/' + this.editingId
|
|
: '/api/games/' + this.slug + '/keywords'
|
|
const r = await fetch(url, {
|
|
method: this.editingId ? 'PUT' : 'POST',
|
|
headers: this.authHeaders(),
|
|
body: JSON.stringify(this.activeForm)
|
|
})
|
|
if (r.status === 401) { this.lock(); this.closeModal(); return }
|
|
const data = await r.json()
|
|
if (!r.ok) { this.formError = data.error || 'Failed to save'; return }
|
|
await this.loadKeywords()
|
|
this.closeModal()
|
|
} catch { this.formError = 'Network error' }
|
|
finally { this.saving = false }
|
|
},
|
|
|
|
async deleteKeyword(kw) {
|
|
if (!kw.id || !confirm(`Remove definition for "${kw.term}"?`)) return
|
|
try {
|
|
const r = await fetch('/api/games/' + this.slug + '/keywords/' + kw.id, {
|
|
method: 'DELETE', headers: this.authHeaders()
|
|
})
|
|
if (r.ok) await this.loadKeywords()
|
|
} catch {}
|
|
},
|
|
|
|
// --- Filtered lists ---
|
|
|
|
events() {
|
|
return this.items.filter(i => i.type === 'event').sort((a, b) => a.date.localeCompare(b.date))
|
|
},
|
|
|
|
projects() {
|
|
return this.items.filter(i => i.type === 'project').sort((a, b) => a.date.localeCompare(b.date))
|
|
},
|
|
|
|
cast() {
|
|
return this.items.filter(i => i.type === 'person').sort((a, b) => a.date.localeCompare(b.date))
|
|
},
|
|
|
|
allEventsForDisplay() {
|
|
const evts = this.events()
|
|
const result = []
|
|
if (this.game && this.game.start_title) {
|
|
result.push({
|
|
id: '__start__', isAnchor: true, anchorType: 'start',
|
|
title: this.game.start_title,
|
|
description: this.game.start_description,
|
|
mood: this.game.start_mood || this.game.mood,
|
|
date: this.game.timeline_start
|
|
})
|
|
}
|
|
result.push(...evts)
|
|
if (this.game && this.game.end_title) {
|
|
result.push({
|
|
id: '__end__', isAnchor: true, anchorType: 'end',
|
|
title: this.game.end_title,
|
|
description: this.game.end_description,
|
|
mood: this.game.end_mood || this.game.mood,
|
|
date: this.game.timeline_end
|
|
})
|
|
}
|
|
return result
|
|
},
|
|
|
|
// --- Timeline helpers ---
|
|
|
|
posPercent(date) {
|
|
if (!this.game || !date) return 0
|
|
const start = new Date(this.game.timeline_start).getTime()
|
|
const end = new Date(this.game.timeline_end).getTime()
|
|
const d = new Date(date).getTime()
|
|
if (end === start) return 0
|
|
return Math.max(0, Math.min(100, ((d - start) / (end - start)) * 100))
|
|
},
|
|
|
|
barWidth(startDate, endDate) {
|
|
const resolvedEnd = endDate || (this.game && this.game.timeline_end)
|
|
return Math.max(0.5, this.posPercent(resolvedEnd) - this.posPercent(startDate))
|
|
},
|
|
|
|
// --- Modals ---
|
|
|
|
openUnlock() {
|
|
this.activeForm = { password: '' }
|
|
this.formError = null
|
|
this.modalType = 'unlock'
|
|
this.showModal = true
|
|
},
|
|
|
|
openAddItem(type) {
|
|
const env = this.randomEnv(null)
|
|
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.formError = null
|
|
this.modalType = 'item'
|
|
this.showModal = true
|
|
},
|
|
|
|
openEditItem(item) {
|
|
this.activeForm = Object.assign({ env: '', env_layout: 'after', project_id: '' }, item)
|
|
this.editingId = item.id
|
|
this.formError = null
|
|
this.modalType = 'item'
|
|
this.showModal = true
|
|
},
|
|
|
|
async saveDeckToGame() {
|
|
if (!this.game || !this.editMode) return
|
|
await fetch('/api/games/' + this.slug, {
|
|
method: 'PUT',
|
|
headers: this.authHeaders(),
|
|
body: JSON.stringify({ ...this.game, deck_id: this.activeDeckId })
|
|
})
|
|
},
|
|
|
|
openEditGame() {
|
|
this.activeForm = Object.assign({ deck_id: '', truths: [] }, this.game)
|
|
this.activeForm.truths = [...(this.game.truths || [])]
|
|
this.formError = null
|
|
this.modalType = 'editGame'
|
|
this.showModal = true
|
|
},
|
|
|
|
closeModal() {
|
|
this.showModal = false
|
|
this.modalType = null
|
|
this.activeForm = {}
|
|
this.editingId = null
|
|
this.formError = null
|
|
this.pendingCastNames = []
|
|
this.acShow = false
|
|
},
|
|
|
|
// --- CRUD ---
|
|
|
|
async saveItem() {
|
|
const f = this.activeForm
|
|
if (!f.title) { this.formError = 'Title is required'; return }
|
|
if (!f.date) { this.formError = 'Date is required'; return }
|
|
if (f.end_date && f.end_date < f.date) { this.formError = 'End date must be after start date'; return }
|
|
this.saving = true; this.formError = null
|
|
try {
|
|
const url = this.editingId
|
|
? '/api/games/' + this.slug + '/items/' + this.editingId
|
|
: '/api/games/' + this.slug + '/items'
|
|
const r = await fetch(url, {
|
|
method: this.editingId ? 'PUT' : 'POST',
|
|
headers: this.authHeaders(),
|
|
body: JSON.stringify(f)
|
|
})
|
|
if (r.status === 401) { this.lock(); this.closeModal(); return }
|
|
const data = await r.json()
|
|
if (!r.ok) { this.formError = data.error || 'Failed to save'; return }
|
|
await this.loadItems()
|
|
// After saving a non-person, prompt for any unmatched @mentions
|
|
if (f.type !== 'person') {
|
|
const unmatched = this.findUnmatchedMentions(f.description)
|
|
if (unmatched.length > 0) {
|
|
this.pendingCastNames = unmatched
|
|
this.modalType = 'castPrompt'
|
|
return
|
|
}
|
|
} else if (this.pendingCastNames.length > 0) {
|
|
this.modalType = 'castPrompt'
|
|
return
|
|
}
|
|
this.closeModal()
|
|
} catch { this.formError = 'Network error' }
|
|
finally { this.saving = false }
|
|
},
|
|
|
|
async deleteItem(id) {
|
|
if (!confirm('Delete this entry? This cannot be undone.')) return
|
|
try {
|
|
const r = await fetch('/api/games/' + this.slug + '/items/' + id, {
|
|
method: 'DELETE', headers: this.authHeaders()
|
|
})
|
|
if (r.status === 401) { this.lock(); return }
|
|
if (r.ok) await this.loadItems()
|
|
} catch {}
|
|
},
|
|
|
|
async saveGame() {
|
|
const f = this.activeForm
|
|
if (!f.title) { this.formError = 'Title is required'; return }
|
|
if (!f.timeline_start || !f.timeline_end) { this.formError = 'Both dates are required'; return }
|
|
this.saving = true; this.formError = null
|
|
try {
|
|
const r = await fetch('/api/games/' + this.slug, {
|
|
method: 'PUT',
|
|
headers: this.authHeaders(),
|
|
body: JSON.stringify(f)
|
|
})
|
|
if (r.status === 401) { this.lock(); this.closeModal(); return }
|
|
const data = await r.json()
|
|
if (!r.ok) { this.formError = data.error || 'Failed to save'; return }
|
|
this.game = data
|
|
document.title = data.title + ' — Orison Reach'
|
|
if (data.deck_id !== undefined) this.activeDeckId = data.deck_id
|
|
this.tlItems = this.computeLayout()
|
|
this.closeModal()
|
|
} catch { this.formError = 'Network error' }
|
|
finally { this.saving = false }
|
|
},
|
|
|
|
// --- Modal title ---
|
|
|
|
itemTypeLabel() {
|
|
return { event: 'Event', project: 'Project', person: 'Cast Member' }[this.activeForm.type] || 'Entry'
|
|
},
|
|
|
|
modalTitle() {
|
|
if (this.modalType === 'unlock') return 'Edit Access'
|
|
if (this.modalType === 'editGame') return 'Edit Session'
|
|
if (this.modalType === 'item') return (this.editingId ? 'Edit ' : 'Add ') + this.itemTypeLabel()
|
|
if (this.modalType === 'castPrompt') return 'New Characters Mentioned'
|
|
if (this.modalType === 'keyword') return this.editingId ? 'Edit Definition' : 'Define Term'
|
|
return ''
|
|
}
|
|
}
|
|
}
|
|
</script>
|
|
<script>
|
|
if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
|
// Scroll reveals for chronicle entries.
|
|
// Uses MutationObserver so it works with Alpine's dynamic rendering.
|
|
const revealObs = new IntersectionObserver((entries) => {
|
|
entries.forEach(e => {
|
|
if (e.isIntersecting) {
|
|
const delay = +(e.target.dataset.revealDelay || 0)
|
|
setTimeout(() => e.target.classList.add('revealed'), delay)
|
|
revealObs.unobserve(e.target)
|
|
}
|
|
})
|
|
}, { threshold: 0.04, rootMargin: '0px 0px -24px 0px' })
|
|
|
|
const observe = (el) => {
|
|
if (!el.classList?.contains('entry') || el.hasAttribute('data-reveal')) return
|
|
const siblings = Array.from(el.parentElement?.querySelectorAll('.entry') || [])
|
|
el.dataset.revealDelay = Math.min(siblings.indexOf(el) * 45, 200)
|
|
el.setAttribute('data-reveal', '')
|
|
revealObs.observe(el)
|
|
}
|
|
|
|
// Watch for entries Alpine adds dynamically
|
|
new MutationObserver((muts) => {
|
|
muts.forEach(m => m.addedNodes.forEach(node => {
|
|
if (node.nodeType !== 1) return
|
|
if (node.classList?.contains('entry')) observe(node)
|
|
node.querySelectorAll?.('.entry').forEach(observe)
|
|
}))
|
|
}).observe(document.body, { childList: true, subtree: true })
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|