488 lines
16 KiB
Svelte
488 lines
16 KiB
Svelte
<script>
|
|
import { tick, onMount } from 'svelte';
|
|
import { apiRequest } from '../lib/api';
|
|
|
|
export let state;
|
|
// 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;
|
|
|
|
const PAGE_SIZE = 50;
|
|
const BOTTOM_TOLERANCE = 40; // px of slack before we consider the player "scrolled away"
|
|
const TOP_LOAD_THRESHOLD = 60; // px from the top that triggers loading older events
|
|
|
|
const KIND_ICONS = {
|
|
join: '👋',
|
|
phase: '🧭',
|
|
scene: '🎬',
|
|
captain: '🏴☠️',
|
|
challenge: '⚔️',
|
|
card: '🃏',
|
|
tax: '💰',
|
|
objective: '🎯',
|
|
vote: '🗳️',
|
|
obstacle: '🌊',
|
|
joker: '🤡',
|
|
victory: '🎉',
|
|
warning: '⚠️',
|
|
rank: '🎖️',
|
|
info: '📜',
|
|
};
|
|
|
|
let open = false;
|
|
// The content is visible whenever the floating log is open OR it's pinned inline.
|
|
$: shown = open || inline;
|
|
let logEl;
|
|
let events = []; // ascending by timestamp; accumulated across polls + history loads
|
|
let seenIds = new Set();
|
|
let haveMore = true;
|
|
let loadingOlder = false;
|
|
let atBottom = true;
|
|
let error = '';
|
|
let timelineVersion = null;
|
|
// Toast that briefly previews a freshly-arrived event while the log is
|
|
// collapsed, then animates down into the Event Log button.
|
|
let toast = null; // { id, message, kind }
|
|
let initialized = false; // suppresses a toast flood on the first state load
|
|
|
|
|
|
// Rollback buttons appear during a scene, for Admins and Deep players (server-enforced too).
|
|
$: canRollback = state.game?.phase === 'scene'
|
|
&& (state.player?.is_admin || state.player?.role === 'deep');
|
|
// When set, the game is at a rolled-back state; events past this checkpoint are the
|
|
// greyed, still-undoable future. Rolling forward to one of them redoes the rollback.
|
|
$: head = state.game?.rollback_head_checkpoint_id ?? null;
|
|
// The point in time the game is currently at: the rollback head if set, otherwise
|
|
// the newest checkpoint (supplied by the backend). A button targeting it would be a
|
|
// no-op, so the "you are here" event — and, in live play, the latest event — has none.
|
|
$: currentPos = head !== null ? head : (state.latest_checkpoint_id ?? 0);
|
|
|
|
$: mergePolled(state.events);
|
|
|
|
// The poll only carries the newest PAGE_SIZE events, so accumulate them here —
|
|
// otherwise events the player paged back to would vanish as the window slides.
|
|
async function mergePolled(polled) {
|
|
if (!polled) return;
|
|
// Plain rollback/redo keeps every event (only `head` moves); but taking a new
|
|
// action from a rolled-back state truncates the abandoned future and bumps the
|
|
// version, which tells us to drop what we'd accumulated and reseed.
|
|
const version = state.game?.rollback_timeline_version ?? 0;
|
|
let reset = false;
|
|
if (timelineVersion !== null && version !== timelineVersion) {
|
|
events = [];
|
|
seenIds = new Set();
|
|
reset = true;
|
|
}
|
|
timelineVersion = version;
|
|
if (events.length === 0) {
|
|
haveMore = state.events_have_more ?? polled.length >= PAGE_SIZE;
|
|
}
|
|
let added = false;
|
|
let newest = null;
|
|
for (const e of polled) {
|
|
if (!seenIds.has(e.id)) {
|
|
seenIds.add(e.id);
|
|
events.push(e);
|
|
added = true;
|
|
newest = e;
|
|
}
|
|
}
|
|
if (added) {
|
|
events = events;
|
|
if (shown && atBottom) {
|
|
await tick();
|
|
scrollToBottom();
|
|
}
|
|
// Toast only for genuinely new events (not the initial seed or a
|
|
// rollback reseed) and only while the floating log is closed.
|
|
if (initialized && !reset && !shown && newest) {
|
|
toast = { id: newest.id, message: newest.message, kind: newest.kind || 'info' };
|
|
}
|
|
}
|
|
initialized = true;
|
|
}
|
|
|
|
// An open/inline log makes the preview toast redundant.
|
|
$: if (shown) toast = null;
|
|
|
|
onMount(() => {
|
|
if (inline) scrollToBottom();
|
|
});
|
|
|
|
function clearToast(id) {
|
|
if (toast && toast.id === id) toast = null;
|
|
}
|
|
|
|
function scrollToBottom() {
|
|
if (logEl) logEl.scrollTop = logEl.scrollHeight;
|
|
atBottom = true;
|
|
}
|
|
|
|
function onScroll() {
|
|
if (!logEl) return;
|
|
atBottom = logEl.scrollHeight - logEl.scrollTop - logEl.clientHeight <= BOTTOM_TOLERANCE;
|
|
if (logEl.scrollTop <= TOP_LOAD_THRESHOLD) loadOlder();
|
|
}
|
|
|
|
async function loadOlder() {
|
|
if (loadingOlder || !haveMore || events.length === 0) return;
|
|
loadingOlder = true;
|
|
try {
|
|
const oldest = events[0].timestamp;
|
|
const data = await apiRequest(`/game/${state.game.id}/events?before=${oldest}&limit=${PAGE_SIZE}`);
|
|
haveMore = data.have_more;
|
|
const fresh = data.events.filter(e => !seenIds.has(e.id));
|
|
for (const e of fresh) seenIds.add(e.id);
|
|
if (fresh.length) {
|
|
// Prepending grows the content above the viewport; restore the
|
|
// player's position so the log doesn't visually jump.
|
|
const prevHeight = logEl.scrollHeight;
|
|
const prevTop = logEl.scrollTop;
|
|
events = [...fresh, ...events];
|
|
await tick();
|
|
logEl.scrollTop = prevTop + (logEl.scrollHeight - prevHeight);
|
|
}
|
|
} catch (e) {
|
|
// Transient fetch failure; scrolling again retries.
|
|
} finally {
|
|
loadingOlder = false;
|
|
}
|
|
}
|
|
|
|
async function toggleOpen() {
|
|
open = !open;
|
|
if (open) {
|
|
await tick();
|
|
scrollToBottom();
|
|
}
|
|
}
|
|
|
|
function formatTime(ts) {
|
|
return new Date(ts * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
}
|
|
|
|
// Both rollback (to a past event) and redo (to a greyed future event) are the same
|
|
// call — it just moves the head. It's reversible until someone takes a new action,
|
|
// so no confirm; the greyed future makes the state obvious.
|
|
async function rollback(event) {
|
|
error = '';
|
|
try {
|
|
await apiRequest(
|
|
`/game/${state.game.id}/player/${state.player.id}/rollback`,
|
|
'POST',
|
|
{ checkpoint_id: event.checkpoint_id },
|
|
);
|
|
// The state-changed ping drives Dashboard to refetch the restored state.
|
|
} catch (e) {
|
|
error = e.message;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<div class="floating-event-log {shown ? 'open' : ''} {inline ? 'inline' : ''}">
|
|
{#if toast && !shown}
|
|
{#key toast.id}
|
|
<button
|
|
class="event-toast log-kind-{toast.kind}"
|
|
title="Open the Event Log"
|
|
on:click={toggleOpen}
|
|
on:animationend={() => clearToast(toast.id)}
|
|
>
|
|
<span class="log-icon">{KIND_ICONS[toast.kind] || KIND_ICONS.info}</span>
|
|
<span class="toast-message">{toast.message}</span>
|
|
</button>
|
|
{/key}
|
|
{/if}
|
|
{#if inline}
|
|
<div class="log-header">📜 Event Log</div>
|
|
{:else}
|
|
<button class="toggle-log-btn" on:click={toggleOpen}>
|
|
{open ? '⬇️ Hide Log' : '📜 Event Log'}
|
|
</button>
|
|
{/if}
|
|
|
|
{#if shown}
|
|
{#if error}
|
|
<div class="log-error">{error}</div>
|
|
{/if}
|
|
{#if head !== null}
|
|
<div class="log-rolledback">
|
|
↩ Rolled back — greyed entries are undone. Click one to redo, or take an action to make it permanent.
|
|
</div>
|
|
{/if}
|
|
<div class="log-content" bind:this={logEl} on:scroll={onScroll}>
|
|
{#if events.length}
|
|
<div class="log-top">
|
|
{#if loadingOlder}
|
|
<span class="log-top-note">Hauling up older entries…</span>
|
|
{:else if haveMore}
|
|
<button class="load-older-btn" on:click={loadOlder}>⬆️ Load earlier events</button>
|
|
{:else}
|
|
<span class="log-top-note">⚓ The story begins here</span>
|
|
{/if}
|
|
</div>
|
|
{#each events as event (event.id)}
|
|
{@const isFuture = head !== null && event.checkpoint_id > head}
|
|
<div class="log-entry log-kind-{event.kind || 'info'}" class:greyed={isFuture}>
|
|
<span class="log-icon">{KIND_ICONS[event.kind] || KIND_ICONS.info}</span>
|
|
<span class="log-message">{event.message}</span>
|
|
<span class="log-time">{formatTime(event.timestamp)}</span>
|
|
{#if canRollback && event.checkpoint_id > 0 && event.checkpoint_id !== currentPos}
|
|
<button
|
|
class="rollback-btn"
|
|
title={isFuture ? 'Redo forward to just after this event' : 'Roll the game back to just after this event'}
|
|
on:click={() => rollback(event)}
|
|
>{isFuture ? '↪' : '↩'}</button>
|
|
{/if}
|
|
</div>
|
|
{/each}
|
|
{:else}
|
|
<div class="log-empty">No events yet.</div>
|
|
{/if}
|
|
</div>
|
|
|
|
{#if !atBottom}
|
|
<button class="jump-to-bottom" on:click={scrollToBottom}>⬇️ Latest</button>
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
|
|
<style>
|
|
.floating-event-log {
|
|
position: fixed;
|
|
bottom: 20px;
|
|
right: 20px;
|
|
width: 380px;
|
|
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);
|
|
z-index: 1000;
|
|
display: flex;
|
|
flex-direction: column;
|
|
max-height: 60vh;
|
|
backdrop-filter: blur(10px);
|
|
}
|
|
.floating-event-log:not(.open) {
|
|
width: auto;
|
|
}
|
|
/* Inline mode: pinned as the scene's third column instead of floating. */
|
|
.floating-event-log.inline {
|
|
position: sticky;
|
|
top: 1rem;
|
|
right: auto;
|
|
bottom: auto;
|
|
width: 100%;
|
|
max-height: calc(100vh - 2rem);
|
|
}
|
|
@media (max-width: 1100px) {
|
|
.floating-event-log.inline {
|
|
position: static;
|
|
max-height: 60vh;
|
|
}
|
|
}
|
|
.log-header {
|
|
padding: 10px;
|
|
text-align: center;
|
|
font-weight: bold;
|
|
font-family: var(--font-heading);
|
|
border-bottom: 1px solid var(--edge-soft);
|
|
color: var(--text);
|
|
}
|
|
.toggle-log-btn {
|
|
background: transparent;
|
|
color: var(--text);
|
|
border: none;
|
|
padding: 10px;
|
|
text-align: center;
|
|
cursor: pointer;
|
|
font-weight: bold;
|
|
border-radius: var(--radius-md);
|
|
border-bottom: 1px solid var(--edge-soft);
|
|
font-family: var(--font-heading);
|
|
}
|
|
.toggle-log-btn:hover {
|
|
background: color-mix(in srgb, var(--text) 10%, transparent);
|
|
}
|
|
.log-content {
|
|
overflow-y: auto;
|
|
padding: 10px;
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 6px;
|
|
scrollbar-width: thin;
|
|
}
|
|
.log-top {
|
|
text-align: center;
|
|
padding: 2px 0 6px;
|
|
}
|
|
.log-top-note {
|
|
font-size: 0.75rem;
|
|
color: var(--text-muted);
|
|
font-style: italic;
|
|
}
|
|
.load-older-btn {
|
|
background: color-mix(in srgb, var(--text) 6%, transparent);
|
|
border: 1px solid var(--edge);
|
|
color: var(--text-muted);
|
|
font-size: 0.75rem;
|
|
padding: 4px 12px;
|
|
border-radius: 12px;
|
|
cursor: pointer;
|
|
}
|
|
.load-older-btn:hover {
|
|
background: color-mix(in srgb, var(--text) 12%, transparent);
|
|
color: var(--text);
|
|
}
|
|
.log-entry {
|
|
display: grid;
|
|
grid-template-columns: auto 1fr auto auto;
|
|
gap: 8px;
|
|
align-items: baseline;
|
|
padding: 6px 8px;
|
|
background: color-mix(in srgb, var(--text) 3%, transparent);
|
|
border-left: 3px solid var(--edge);
|
|
border-radius: 0 6px 6px 0;
|
|
font-size: 0.85rem;
|
|
line-height: 1.35;
|
|
color: color-mix(in srgb, var(--text) 85%, var(--text-muted));
|
|
}
|
|
.rollback-btn {
|
|
background: transparent;
|
|
border: 1px solid var(--edge);
|
|
color: var(--text-muted);
|
|
border-radius: 6px;
|
|
padding: 0 6px;
|
|
font-size: 0.8rem;
|
|
line-height: 1.4;
|
|
cursor: pointer;
|
|
align-self: center;
|
|
}
|
|
.rollback-btn:hover {
|
|
background: color-mix(in srgb, var(--danger) 18%, transparent);
|
|
border-color: var(--danger);
|
|
color: var(--text);
|
|
}
|
|
.log-error {
|
|
margin: 6px 8px 0;
|
|
padding: 6px 10px;
|
|
background: color-mix(in srgb, var(--danger) 14%, transparent);
|
|
border: 1px solid var(--danger);
|
|
border-radius: var(--radius-md);
|
|
color: var(--text);
|
|
font-size: 0.8rem;
|
|
}
|
|
.log-rolledback {
|
|
margin: 6px 8px 0;
|
|
padding: 6px 10px;
|
|
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
|
border: 1px solid var(--accent);
|
|
border-radius: var(--radius-md);
|
|
color: var(--text);
|
|
font-size: 0.78rem;
|
|
line-height: 1.3;
|
|
}
|
|
.log-entry.greyed {
|
|
opacity: 0.45;
|
|
font-style: italic;
|
|
}
|
|
.log-icon {
|
|
font-size: 0.9rem;
|
|
}
|
|
.log-message {
|
|
overflow-wrap: anywhere;
|
|
}
|
|
.log-time {
|
|
font-size: 0.7rem;
|
|
color: var(--text-muted);
|
|
white-space: nowrap;
|
|
opacity: 0.8;
|
|
}
|
|
.log-empty {
|
|
padding: 10px;
|
|
color: var(--text-muted);
|
|
font-style: italic;
|
|
}
|
|
.jump-to-bottom {
|
|
position: absolute;
|
|
bottom: 12px;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
background: var(--accent);
|
|
color: var(--text-inverse);
|
|
border: none;
|
|
border-radius: 16px;
|
|
padding: 5px 14px;
|
|
font-size: 0.8rem;
|
|
font-weight: 700;
|
|
cursor: pointer;
|
|
box-shadow: var(--shadow-soft);
|
|
}
|
|
.jump-to-bottom:hover {
|
|
filter: brightness(1.1);
|
|
}
|
|
|
|
/* Toast preview of a fresh event, anchored above the button */
|
|
.event-toast {
|
|
position: absolute;
|
|
bottom: calc(100% + 8px);
|
|
right: 0;
|
|
width: 320px;
|
|
max-width: 80vw;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
text-align: left;
|
|
padding: 10px 12px;
|
|
background: color-mix(in srgb, var(--bg-deep) 95%, transparent);
|
|
border: 1px solid var(--edge);
|
|
border-left: 4px solid var(--accent);
|
|
border-radius: var(--radius-md);
|
|
box-shadow: var(--shadow-deep);
|
|
backdrop-filter: blur(10px);
|
|
color: var(--text);
|
|
font-family: var(--font-body);
|
|
cursor: pointer;
|
|
transform-origin: bottom right;
|
|
animation: event-toast-into-button 3.4s ease-in forwards;
|
|
}
|
|
.event-toast .toast-message {
|
|
flex: 1;
|
|
font-size: 0.85rem;
|
|
overflow: hidden;
|
|
display: -webkit-box;
|
|
-webkit-line-clamp: 2;
|
|
-webkit-box-orient: vertical;
|
|
}
|
|
@keyframes event-toast-into-button {
|
|
0% { opacity: 0; transform: translateY(-6px) scale(0.96); }
|
|
8% { opacity: 1; transform: translateY(0) scale(1); }
|
|
75% { opacity: 1; transform: translateY(0) scale(1); }
|
|
100% { opacity: 0; transform: translateY(40px) scale(0.4); }
|
|
}
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.event-toast { animation-duration: 2.5s; animation-timing-function: linear; }
|
|
}
|
|
|
|
/* Accent colors by event kind */
|
|
.log-kind-challenge { border-left-color: var(--danger); }
|
|
.log-kind-card { border-left-color: var(--deep); }
|
|
.log-kind-captain,
|
|
.log-kind-tax,
|
|
.log-kind-rank,
|
|
.log-kind-victory { border-left-color: var(--accent); }
|
|
.log-kind-scene,
|
|
.log-kind-phase { border-left-color: var(--pirat); }
|
|
.log-kind-joker,
|
|
.log-kind-warning { border-left-color: var(--warning); }
|
|
.log-kind-obstacle { border-left-color: var(--deep); }
|
|
.log-kind-join,
|
|
.log-kind-vote,
|
|
.log-kind-objective { border-left-color: var(--mystic); }
|
|
.log-kind-victory {
|
|
background: color-mix(in srgb, var(--accent) 9%, transparent);
|
|
color: var(--accent);
|
|
}
|
|
</style>
|