Add per-player autosaving game notebooks

This commit is contained in:
2026-09-04 20:13:14 -07:00
parent 0ae1701b82
commit 145f526634
12 changed files with 317 additions and 69 deletions
-25
View File
@@ -24,31 +24,6 @@
}
}
/* Corner button that toggles the inline Event Log without moving. */
.log-reopen-btn {
box-sizing: border-box;
width: 132px;
height: 44px;
white-space: nowrap;
position: fixed;
bottom: 20px;
right: 20px;
z-index: 1000;
padding: 10px 14px;
background: color-mix(in srgb, var(--bg-deep) 95%, transparent);
border: 1px solid var(--edge);
border-radius: var(--radius-md);
box-shadow: var(--shadow-deep);
color: var(--text);
font-weight: bold;
font-family: var(--font-heading);
cursor: pointer;
backdrop-filter: blur(10px);
}
.log-reopen-btn:hover {
background: color-mix(in srgb, var(--accent) 12%, var(--bg-deep));
}
@media (max-width: 1100px) {
.scene-view-layout {
grid-template-columns: 1fr;
+3 -2
View File
@@ -8,6 +8,7 @@
// Inline mode pins the log open as a column (scene phase) instead of the
// floating, collapsible corner panel used in every other phase.
export let inline = false;
export let headerless = false;
const PAGE_SIZE = 50;
const BOTTOM_TOLERANCE = 40; // px of slack before we consider the player "scrolled away"
@@ -195,12 +196,12 @@
</button>
{/key}
{/if}
{#if inline}
{#if inline && !headerless}
<div class="log-header">
📜 Event Log
<button class="log-hide-btn" title="Collapse the event log" on:click={() => dispatch('collapse')}>✕</button>
</div>
{:else}
{:else if !inline}
<button class="toggle-log-btn" on:click={toggleOpen}>
{open ? '⬇️ Hide Log' : '📜 Event Log'}
</button>
+148
View File
@@ -0,0 +1,148 @@
<script>
import { onMount, onDestroy } from 'svelte';
import { apiRequest } from '../lib/api';
import EventLog from './EventLog.svelte';
export let state;
export let open = true;
let tab = 'log';
let draft = '';
let saved = '';
let loaded = false;
let error = '';
let saving = false;
let timer;
let pendingSave;
const endpoint = `/game/${state.game.id}/player/${state.player.id}/notes`;
async function load() {
error = '';
try {
const data = await apiRequest(endpoint);
draft = saved = data.text;
loaded = true;
} catch {
error = 'Couldnt load notes';
}
}
// Serialize writes so a slow earlier request cannot overwrite a newer draft.
async function save() {
clearTimeout(timer);
if (pendingSave) {
await pendingSave;
if (error) return;
}
if (!loaded || draft === saved) return;
const text = draft;
saving = true;
error = '';
pendingSave = (async () => {
try {
await apiRequest(endpoint, 'PUT', { text });
saved = text;
} catch {
error = 'Couldnt save notes';
} finally {
saving = false;
pendingSave = null;
}
})();
await pendingSave;
if (!error && draft !== saved) await save();
}
function edited() {
clearTimeout(timer);
timer = setTimeout(save, 1000);
}
function selectTab(next) {
if (tab === 'notes') save();
tab = next;
open = true;
}
function tabKey(event) {
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
event.preventDefault();
const next = event.key === 'Home' ? 'log' : event.key === 'End' ? 'notes' : tab === 'log' ? 'notes' : 'log';
selectTab(next);
document.getElementById(`${next}-tab`)?.focus();
}
function close() {
save();
open = false;
}
onMount(load);
onDestroy(() => { clearTimeout(timer); save(); });
</script>
<svelte:window on:keydown={(event) => { if (event.key === 'Escape' && open && tab === 'notes') close(); }} />
<div class="log-column" class:closed={!open}>
<section class="notebook-panel" class:notes-active={tab === 'notes'} aria-label="Game notebook">
<div class="panel-header">
<div class="panel-tabs" role="tablist" aria-label="Game notebook sections">
<button id="log-tab" role="tab" aria-selected={tab === 'log'} aria-controls="log-content" tabindex={tab === 'log' ? 0 : -1} on:keydown={tabKey} on:click={() => selectTab('log')}>📜 Event Log</button>
<button id="notes-tab" role="tab" aria-selected={tab === 'notes'} aria-controls="notes-content" tabindex={tab === 'notes' ? 0 : -1} on:keydown={tabKey} on:click={() => selectTab('notes')}> My Notes</button>
</div>
<button class="close-panel" title="Close panel" aria-label="Close panel" on:click={close}>✕</button>
</div>
<div id="log-content" role="tabpanel" aria-labelledby="log-tab" hidden={tab !== 'log'}>
<EventLog {state} inline headerless />
</div>
<div id="notes-content" class="notes-content" role="tabpanel" aria-labelledby="notes-tab" hidden={tab !== 'notes'}>
<p class="notes-hint">Your private notes for this game.</p>
<textarea aria-label="My notes" bind:value={draft} on:input={edited} disabled={!loaded} maxlength="50000" placeholder="Where did we leave off? Names, plans, loose ends…"></textarea>
<div class="save-status" role="status">
{#if error}
<span class="save-error">{error}</span>
<button on:click={() => loaded ? save() : load()}>Retry</button>
{:else}
{ !loaded ? 'Loading…' : saving ? 'Saving…' : draft !== saved ? 'Unsaved changes' : 'Saved' }
{/if}
</div>
</div>
</section>
</div>
<div class="notebook-controls">
{#if error}<span class="control-error" role="status">{error} · <button on:click={() => selectTab('notes')}>Open notes</button></span>{/if}
<button aria-expanded={open && tab === 'log'} on:click={() => open && tab === 'log' ? close() : selectTab('log')}>{open && tab === 'log' ? '✕ Close Log' : '📜 Event Log'}</button>
<button aria-expanded={open && tab === 'notes'} on:click={() => open && tab === 'notes' ? close() : selectTab('notes')}>{open && tab === 'notes' ? '✕ Close Notes' : '✎ My Notes'}</button>
</div>
<style>
.closed { display: none; }
.notebook-panel { position: sticky; top: 3.5rem; background: var(--bg-deep); border: 1px solid var(--edge); border-radius: var(--radius-md); box-shadow: var(--shadow-deep); overflow: hidden; }
.panel-header, .panel-tabs { display: flex; align-items: stretch; }
.panel-header { border-bottom: 1px solid var(--edge-soft); }
.panel-tabs { flex: 1; }
button { cursor: pointer; color: var(--text); font-family: var(--font-heading); font-weight: bold; }
.panel-tabs button, .close-panel { background: transparent; border: 0; padding: 12px 10px; }
.panel-tabs button { flex: 1; border-bottom: 2px solid transparent; }
.panel-tabs button[aria-selected=true] { color: var(--accent); border-bottom-color: var(--accent); background: color-mix(in srgb, var(--accent) 8%, transparent); }
button:hover { background: color-mix(in srgb, var(--accent) 12%, var(--bg-deep)); }
.notes-content { padding: 12px; }
.notes-content[hidden] { display: none; }
.notes-hint { margin: 0 0 10px; color: var(--text-muted); font-size: .85rem; }
textarea { display: block; box-sizing: border-box; width: 100%; height: min(55vh, 520px); min-height: 180px; resize: vertical; padding: 12px; font: inherit; line-height: 1.5; color: var(--text); background: var(--surface-raised); border: 1px solid var(--edge); border-radius: var(--radius-sm); }
textarea:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
.save-status { display: flex; align-items: center; gap: 8px; min-height: 30px; padding-top: 8px; color: var(--text-muted); font-size: .8rem; }
.save-status button { border: 1px solid var(--edge); border-radius: 4px; background: var(--surface-raised); }
.save-error, .control-error { color: var(--danger); }
.notebook-controls { position: fixed; bottom: 20px; right: 20px; z-index: 1000; display: flex; gap: 8px; }
.notebook-controls > button { width: 132px; height: 44px; background: var(--bg-deep); border: 1px solid var(--edge); border-radius: var(--radius-md); box-shadow: var(--shadow-deep); }
.control-error { position: absolute; bottom: 52px; right: 0; width: max-content; max-width: 90vw; background: var(--bg-deep); padding: 8px; border: 1px solid var(--danger); border-radius: var(--radius-sm); font-size: .85rem; }
.control-error button { color: var(--text); background: transparent; border: 0; text-decoration: underline; }
.notebook-panel :global(.floating-event-log.inline) { position: static; border: none; border-radius: 0; box-shadow: none; max-height: calc(100dvh - 12rem); }
@media (max-width: 1100px) {
.notebook-panel { position: static; }
.notebook-panel.notes-active { position: fixed; z-index: 1002; top: auto; bottom: 0; left: 0; right: 0; border-radius: var(--radius-md) var(--radius-md) 0 0; max-height: 90dvh; }
.notes-active textarea { height: 55dvh; min-height: 100px; resize: none; }
.notebook-controls { right: 12px; bottom: 12px; }
}
</style>
+4 -18
View File
@@ -4,12 +4,9 @@
import ChallengePanel from './scene/ChallengePanel.svelte';
import ObstacleBoard from './scene/ObstacleBoard.svelte';
import CrewColumn from './scene/CrewColumn.svelte';
import EventLog from './EventLog.svelte';
export let state;
// The inline Event Log can be toggled from a fixed corner button to declutter.
let logOpen = true;
let dismissedDuelIds = [];
$: dismissalKey = `dismissed-duels:${state.game.id}:${state.player.id}:${state.game.current_scene_number}`;
@@ -38,7 +35,7 @@
$: showChallengeArea = isDeep || openChallenges.length > 0 || completedDuels.length > 0;
</script>
<div class="scene-view-layout" class:log-collapsed={!logOpen} id="scene-layout-container" data-game-id={state.game.id} data-player-id={state.player.id}>
<div class="scene-content" id="scene-layout-container" data-game-id={state.game.id} data-player-id={state.player.id}>
<!-- LEFT COLUMN: The Crew -->
<CrewColumn {state} />
@@ -81,19 +78,8 @@
</div>
</div>
<!-- RIGHT COLUMN: The Event Log (collapsible to a corner button) -->
{#if logOpen}
<div class="log-column">
<EventLog {state} inline on:collapse={() => (logOpen = false)} />
</div>
{/if}
</div>
<button
class="log-reopen-btn"
aria-expanded={logOpen}
title={logOpen ? 'Hide the event log' : 'Show the event log'}
on:click={() => (logOpen = !logOpen)}
>
{logOpen ? '✕ Close Log' : '📜 Event Log'}
</button>
<style>
.scene-content { display: contents; }
</style>
+2 -1
View File
@@ -6,10 +6,11 @@
// - Add a CHANGELOG entry only when a commit changes something players can
// see. Skip refactors, tests, and tooling. Keep wording player-facing.
export const VERSION = 47;
export const VERSION = 48;
// Newest first. Each entry: { version, date: 'YYYY-MM-DD', changes: [string, ...] }.
export const CHANGELOG = [
{ version: 48, date: '2026-09-04', changes: ['Keep your own notes alongside the Event Log. Notes save automatically between sessions, including when you close the panel, and stay intact when the game rewinds or you create a new Pi-Rat.'] },
{ version: 47, date: '2026-09-04', changes: ['Rewrote character suggestions with scrappy pirate looks, distinctive smells, first words, and crew quirks that fit Yeld.'] },
{ version: 46, date: '2026-09-04', changes: ['Added a printable TL;DR rules page, linked at the top of the full rulebook.'] },
{ version: 44, date: '2026-09-04', changes: ['Crew Objectives opens as one connected panel, with the toggle and checklist sharing a border and background.'] },
+5 -21
View File
@@ -16,7 +16,7 @@
import RecruitPhase from '../components/RecruitPhase.svelte';
import GameOverPhase from '../components/GameOverPhase.svelte';
import CrewSidebar from '../components/CrewSidebar.svelte';
import EventLog from '../components/EventLog.svelte';
import GameNotebook from '../components/GameNotebook.svelte';
import NameModal from '../components/NameModal.svelte';
import GatModal from '../components/GatModal.svelte';
import RankBonusModal from '../components/RankBonusModal.svelte';
@@ -35,9 +35,7 @@
let reconnectDelay = 1000;
let destroyed = false;
let staleSession = false;
// Non-scene phases use the same pinned Event Log column and fixed corner
// toggle that ScenePhase owns for the main play screen.
let phaseLogOpen = true;
let panelOpen = true;
function discardStaleSession(message) {
staleSession = true;
@@ -230,10 +228,10 @@
{#if state}
<div class="dashboard-container {state.player.is_ghost ? 'ghost-world' : ''}">
<div class={state.game.phase === 'scene' ? 'scene-view-layout' : 'phase-view-layout'} class:log-collapsed={!panelOpen}>
{#if state.game.phase === 'scene'}
<ScenePhase state={rosterState} />
{:else}
<div class="phase-view-layout" class:log-collapsed={!phaseLogOpen}>
<CrewSidebar state={rosterState} />
<main class="phase-main-column">
{#if ['scene_setup', 'recruit_creation'].includes(state.game.phase) && (state.game.last_rank_up_player_id || state.game.current_scene_number > 1)}
@@ -261,25 +259,11 @@
<div class="card p-4">Unknown phase: {state.game.phase}</div>
{/if}
</main>
{#if phaseLogOpen}
<div class="log-column">
<EventLog {state} inline on:collapse={() => (phaseLogOpen = false)} />
</div>
{/if}
</div>
{/if}
<GameNotebook {state} bind:open={panelOpen} />
</div>
</div>
{#if state.game.phase !== 'scene'}
<button
class="log-reopen-btn"
aria-expanded={phaseLogOpen}
title={phaseLogOpen ? 'Hide the event log' : 'Show the event log'}
on:click={() => (phaseLogOpen = !phaseLogOpen)}
>
{phaseLogOpen ? '✕ Close Log' : '📜 Event Log'}
</button>
{/if}
<NameModal {state} />
<GatModal {state} />
<RankBonusModal {state} />