Add per-player autosaving game notebooks
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = 'Couldn’t 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 = 'Couldn’t 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,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>
|
||||
|
||||
@@ -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.'] },
|
||||
|
||||
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user