Files
pirats/frontend/src/components/EventLog.svelte
Tim McCarthy 7825ad2cca Toast new events into the Event Log button
When the Event Log is collapsed, a freshly-arrived event now flashes as
a toast anchored above the button, then animates down and fades into it.
Suppressed on the initial state load and on rollback reseeds so it only
fires for genuinely new events; clicking the toast opens the log.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 09:39:54 -07:00

452 lines
15 KiB
Svelte

<script>
import { tick } from 'svelte';
import { apiRequest } from '../lib/api';
export let state;
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;
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 (open && atBottom) {
await tick();
scrollToBottom();
}
// Toast only for genuinely new events (not the initial seed or a
// rollback reseed) and only while the log is closed.
if (initialized && !reset && !open && newest) {
toast = { id: newest.id, message: newest.message, kind: newest.kind || 'info' };
}
}
initialized = true;
}
// Opening the log makes the preview redundant.
$: if (open) toast = null;
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 {open ? 'open' : ''}">
{#if toast && !open}
{#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}
<button class="toggle-log-btn" on:click={toggleOpen}>
{open ? '⬇️ Hide Log' : '📜 Event Log'}
</button>
{#if open}
{#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;
}
.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>