Fable will fix it! Pt 2

This commit is contained in:
2026-06-11 21:32:00 -07:00
parent a7cab7bcb6
commit a074ff77b1
44 changed files with 1547 additions and 4505 deletions

View 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>