Fable will fix it! Pt 2
This commit is contained in:
280
frontend/src/components/EventLog.svelte
Normal file
280
frontend/src/components/EventLog.svelte
Normal file
@@ -0,0 +1,280 @@
|
||||
<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;
|
||||
|
||||
$: 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;
|
||||
if (events.length === 0) {
|
||||
haveMore = state.events_have_more ?? polled.length >= PAGE_SIZE;
|
||||
}
|
||||
let added = false;
|
||||
for (const e of polled) {
|
||||
if (!seenIds.has(e.id)) {
|
||||
seenIds.add(e.id);
|
||||
events.push(e);
|
||||
added = true;
|
||||
}
|
||||
}
|
||||
if (added) {
|
||||
events = events;
|
||||
if (open && atBottom) {
|
||||
await tick();
|
||||
scrollToBottom();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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' });
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="floating-event-log {open ? 'open' : ''}">
|
||||
<button class="toggle-log-btn" on:click={toggleOpen}>
|
||||
{open ? '⬇️ Hide Log' : '📜 Event Log'}
|
||||
</button>
|
||||
|
||||
{#if open}
|
||||
<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)}
|
||||
<div class="log-entry log-kind-{event.kind || 'info'}">
|
||||
<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>
|
||||
</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: rgba(10, 15, 25, 0.95);
|
||||
border: 1px solid var(--glass-border, rgba(255, 255, 255, 0.1));
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
|
||||
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: var(--dark-bg, #1a1a2e);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
border-radius: 8px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
font-family: var(--font-heading);
|
||||
}
|
||||
.toggle-log-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.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, #94a3b8);
|
||||
font-style: italic;
|
||||
}
|
||||
.load-older-btn {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
color: var(--text-muted, #94a3b8);
|
||||
font-size: 0.75rem;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.load-older-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: var(--text-primary, #f1f5f9);
|
||||
}
|
||||
.log-entry {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
gap: 8px;
|
||||
align-items: baseline;
|
||||
padding: 6px 8px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-left: 3px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 0 6px 6px 0;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.35;
|
||||
color: #c3cede;
|
||||
}
|
||||
.log-icon {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.log-message {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.log-time {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted, #94a3b8);
|
||||
white-space: nowrap;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.log-empty {
|
||||
padding: 10px;
|
||||
color: var(--text-muted, #94a3b8);
|
||||
font-style: italic;
|
||||
}
|
||||
.jump-to-bottom {
|
||||
position: absolute;
|
||||
bottom: 12px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--gold, #d4af37);
|
||||
color: var(--text-dark, #0f172a);
|
||||
border: none;
|
||||
border-radius: 16px;
|
||||
padding: 5px 14px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.jump-to-bottom:hover {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
/* Accent colors by event kind */
|
||||
.log-kind-challenge { border-left-color: var(--red-suit, #ff2a5f); }
|
||||
.log-kind-card { border-left-color: var(--neon-cyan, #00f2fe); }
|
||||
.log-kind-captain,
|
||||
.log-kind-tax,
|
||||
.log-kind-rank,
|
||||
.log-kind-victory { border-left-color: var(--gold, #d4af37); }
|
||||
.log-kind-scene,
|
||||
.log-kind-phase { border-left-color: var(--neon-emerald, #00ff87); }
|
||||
.log-kind-joker,
|
||||
.log-kind-warning { border-left-color: #ff9f43; }
|
||||
.log-kind-obstacle { border-left-color: #4aa3df; }
|
||||
.log-kind-join,
|
||||
.log-kind-vote,
|
||||
.log-kind-objective { border-left-color: #9b8cff; }
|
||||
.log-kind-victory {
|
||||
background: rgba(212, 175, 55, 0.08);
|
||||
color: var(--gold, #d4af37);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user