Add Prettier and ESLint tooling (Refs #77)
This commit is contained in:
@@ -8,12 +8,22 @@
|
||||
import Button from './primitives/Button.svelte';
|
||||
import Freshness from './Freshness.svelte';
|
||||
|
||||
let { server, history, hostOffline = false, hostName }: { server?: string; history?: ContextualChatEntry[]; hostOffline?: boolean; hostName?: string } = $props();
|
||||
let {
|
||||
server,
|
||||
history,
|
||||
hostOffline = false,
|
||||
hostName
|
||||
}: {
|
||||
server?: string;
|
||||
history?: ContextualChatEntry[];
|
||||
hostOffline?: boolean;
|
||||
hostName?: string;
|
||||
} = $props();
|
||||
let updatedAt = $state<number | null>(null);
|
||||
let liveEntries = $state<ContextualChatEntry[]>([]);
|
||||
const entries = $derived(history ?? liveEntries);
|
||||
let timeline = $derived(groupChatTimeline(entries));
|
||||
let hasMessages = $derived(entries.some(entry => entry.type === 'message'));
|
||||
let hasMessages = $derived(entries.some((entry) => entry.type === 'message'));
|
||||
let liveLoaded = $state(false);
|
||||
const loaded = $derived(history !== undefined || liveLoaded);
|
||||
let loading = $state(false);
|
||||
@@ -36,23 +46,33 @@
|
||||
const request = new AbortController();
|
||||
controller = request;
|
||||
try {
|
||||
const response = await fetch(`/api/servers/${encodeURIComponent(server)}/chat`, { signal: request.signal });
|
||||
const response = await fetch(`/api/servers/${encodeURIComponent(server)}/chat`, {
|
||||
signal: request.signal
|
||||
});
|
||||
const data = await response.json().catch(() => null);
|
||||
if (!response.ok || !data?.ok) throw new Error('Could not refresh chat. Try again shortly.');
|
||||
if (!response.ok || !data?.ok)
|
||||
throw new Error('Could not refresh chat. Try again shortly.');
|
||||
if (request.signal.aborted || hostOffline) return;
|
||||
if (paused && !manual) return;
|
||||
liveEntries = data.entries ?? [];
|
||||
missing = !!data.missing;
|
||||
liveLoaded = true; updatedAt = Date.now();
|
||||
liveLoaded = true;
|
||||
updatedAt = Date.now();
|
||||
error = '';
|
||||
await tick();
|
||||
if (follow && body) body.scrollTop = body.scrollHeight;
|
||||
} catch (err) {
|
||||
if (!request.signal.aborted) error = err instanceof Error ? err.message : 'Could not refresh chat.';
|
||||
} finally { loading = false; manualRefreshing = false; }
|
||||
if (!request.signal.aborted)
|
||||
error = err instanceof Error ? err.message : 'Could not refresh chat.';
|
||||
} finally {
|
||||
loading = false;
|
||||
manualRefreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => { if (hostOffline) controller?.abort(); });
|
||||
$effect(() => {
|
||||
if (hostOffline) controller?.abort();
|
||||
});
|
||||
|
||||
function onScroll() {
|
||||
if (body) follow = body.scrollHeight - body.scrollTop - body.clientHeight < 40;
|
||||
@@ -64,8 +84,11 @@
|
||||
}
|
||||
onMount(() => {
|
||||
if (!server) return;
|
||||
const stop = pollVisible(() => paused ? Promise.resolve() : refresh(), 5000);
|
||||
return () => { stop(); controller?.abort(); };
|
||||
const stop = pollVisible(() => (paused ? Promise.resolve() : refresh()), 5000);
|
||||
return () => {
|
||||
stop();
|
||||
controller?.abort();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -74,54 +97,128 @@
|
||||
{/snippet}
|
||||
|
||||
{#snippet clockTime(value: string)}
|
||||
<time datetime={value} title={$timeFormat.dateTime(value)} aria-label={$timeFormat.dateTime(value)}>{$timeFormat.clockTime(value)}</time>
|
||||
<time
|
||||
datetime={value}
|
||||
title={$timeFormat.dateTime(value)}
|
||||
aria-label={$timeFormat.dateTime(value)}>{$timeFormat.clockTime(value)}</time
|
||||
>
|
||||
{/snippet}
|
||||
|
||||
{#snippet chatContext(context: ChatContext)}
|
||||
<dl class="context" aria-label="Message context">
|
||||
<div><dt>Match</dt><dd>{#if context.matchElapsedMs === null}{@render unavailable()}{:else}{formatDuration(context.matchElapsedMs)}{/if}</dd></div>
|
||||
<div><dt>Round</dt><dd>{#if context.round === null}{@render unavailable()}{:else}{context.round}{/if}</dd></div>
|
||||
<div>
|
||||
<dt>Match</dt>
|
||||
<dd>
|
||||
{#if context.matchElapsedMs === null}{@render unavailable()}{:else}{formatDuration(
|
||||
context.matchElapsedMs
|
||||
)}{/if}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Round</dt>
|
||||
<dd>
|
||||
{#if context.round === null}{@render unavailable()}{:else}{context.round}{/if}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{/snippet}
|
||||
|
||||
{#snippet matchBoundary(entry: ChatBoundaryEntry)}
|
||||
<div class="match-divider">
|
||||
<div class="match-label">
|
||||
<span>{entry.event === 'start' ? 'Match started' : entry.event === 'end' ? 'Match ended' : 'New match'}</span>
|
||||
{#if entry.context.realTime}<span class="boundary-context">{@render clockTime(entry.context.realTime)}</span>{/if}
|
||||
{#if entry.context.matchElapsedMs !== null}<span class="boundary-context">Match <span class="number">{formatDuration(entry.context.matchElapsedMs)}</span></span>{/if}
|
||||
{#if entry.context.round !== null}<span class="boundary-context">Round <span class="number">{entry.context.round}</span></span>{/if}
|
||||
<span
|
||||
>{entry.event === 'start'
|
||||
? 'Match started'
|
||||
: entry.event === 'end'
|
||||
? 'Match ended'
|
||||
: 'New match'}</span
|
||||
>
|
||||
{#if entry.context.realTime}<span class="boundary-context"
|
||||
>{@render clockTime(entry.context.realTime)}</span
|
||||
>{/if}
|
||||
{#if entry.context.matchElapsedMs !== null}<span class="boundary-context"
|
||||
>Match <span class="number">{formatDuration(entry.context.matchElapsedMs)}</span
|
||||
></span
|
||||
>{/if}
|
||||
{#if entry.context.round !== null}<span class="boundary-context"
|
||||
>Round <span class="number">{entry.context.round}</span></span
|
||||
>{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<section aria-label="Player chat">
|
||||
<div class="chat-head">
|
||||
<div class="chat-title"><h2>Chat</h2>{#if !server}<span class="update-cadence">Match history</span>{:else if hostOffline}<span class="update-cadence">Host offline</span>{:else if paused}<span class="update-cadence">Paused</span>{:else if !error}<Freshness {updatedAt} intervalLabel="every 5 s" />{/if}</div>
|
||||
<div class="chat-title">
|
||||
<h2>Chat</h2>
|
||||
{#if !server}<span class="update-cadence">Match history</span
|
||||
>{:else if hostOffline}<span class="update-cadence">Host offline</span
|
||||
>{:else if paused}<span class="update-cadence">Paused</span
|
||||
>{:else if !error}<Freshness {updatedAt} intervalLabel="every 5 s" />{/if}
|
||||
</div>
|
||||
{#if server}
|
||||
<div class="actions">
|
||||
{#if hasMessages}<Button size="sm" variant="ghost" onclick={() => paused = !paused}>{paused ? 'Resume' : 'Pause'}</Button>{/if}
|
||||
{#if !follow}<Button size="sm" variant="ghost" onclick={latest}>Latest messages</Button>{/if}
|
||||
{#if hasMessages}<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onclick={() => (paused = !paused)}>{paused ? 'Resume' : 'Pause'}</Button
|
||||
>{/if}
|
||||
{#if !follow}<Button size="sm" variant="ghost" onclick={latest}
|
||||
>Latest messages</Button
|
||||
>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if hostOffline}<p class="outage">Chat resumes when {hostName ?? 'the host agent'} reconnects.{loaded ? ' Showing the last loaded messages.' : ''}</p>
|
||||
{:else if error}<p class="ui-error" role="alert">Chat updates paused. {loaded ? 'Showing the last loaded messages.' : 'Could not load messages.'} <Button size="sm" onclick={() => refresh(true)} loading={manualRefreshing}>Retry</Button></p>{/if}
|
||||
{#if hostOffline}<p class="outage">
|
||||
Chat resumes when {hostName ?? 'the host agent'} reconnects.{loaded
|
||||
? ' Showing the last loaded messages.'
|
||||
: ''}
|
||||
</p>
|
||||
{:else if error}<p class="ui-error" role="alert">
|
||||
Chat updates paused. {loaded
|
||||
? 'Showing the last loaded messages.'
|
||||
: 'Could not load messages.'}
|
||||
<Button size="sm" onclick={() => refresh(true)} loading={manualRefreshing}>Retry</Button
|
||||
>
|
||||
</p>{/if}
|
||||
{#if !loaded && !error && !hostOffline}
|
||||
<p class="empty" role="status">Loading chat…</p>
|
||||
{:else if loaded && !hasMessages}
|
||||
<p class="empty">{history !== undefined ? 'No chat messages were saved for this match.' : missing ? 'Waiting for the game log. Player messages will appear here.' : 'No player messages in this match yet.'}</p>
|
||||
<p class="empty">
|
||||
{history !== undefined
|
||||
? 'No chat messages were saved for this match.'
|
||||
: missing
|
||||
? 'Waiting for the game log. Player messages will appear here.'
|
||||
: 'No player messages in this match yet.'}
|
||||
</p>
|
||||
{:else if hasMessages}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex (The scrollable chat region must support keyboard scrolling.) -->
|
||||
<div class="messages" bind:this={body} onscroll={onScroll} tabindex="0" role="region" aria-label="Chat messages">
|
||||
<div
|
||||
class="messages"
|
||||
bind:this={body}
|
||||
onscroll={onScroll}
|
||||
tabindex="0"
|
||||
role="region"
|
||||
aria-label="Chat messages"
|
||||
>
|
||||
<ol>
|
||||
{#each timeline as item}
|
||||
{#if item.type === 'boundaries'}
|
||||
<li class="match-events">
|
||||
{#if item.entries.length > 1}
|
||||
{#key JSON.stringify(item.entries)}
|
||||
<details class="earlier-events" ontoggle={(event) => { if (event.currentTarget.open) follow = false; }}>
|
||||
<summary>{item.entries.length - 1} earlier match {item.entries.length === 2 ? 'event' : 'events'}</summary>
|
||||
<details
|
||||
class="earlier-events"
|
||||
ontoggle={(event) => {
|
||||
if (event.currentTarget.open) follow = false;
|
||||
}}
|
||||
>
|
||||
<summary
|
||||
>{item.entries.length - 1} earlier match {item.entries
|
||||
.length === 2
|
||||
? 'event'
|
||||
: 'events'}</summary
|
||||
>
|
||||
<ol aria-label="Earlier match events">
|
||||
{#each item.entries.slice(0, -1) as entry}
|
||||
<li>{@render matchBoundary(entry)}</li>
|
||||
@@ -135,10 +232,17 @@
|
||||
{:else}
|
||||
<li class="message">
|
||||
<span class="message-clock">
|
||||
{#if item.entry.context.realTime}{@render clockTime(item.entry.context.realTime)}{:else}<span class="sr-only">Time: </span>{@render unavailable()}{/if}
|
||||
{#if item.entry.context.realTime}{@render clockTime(
|
||||
item.entry.context.realTime
|
||||
)}{:else}<span class="sr-only"
|
||||
>Time:
|
||||
</span>{@render unavailable()}{/if}
|
||||
</span>
|
||||
<div class="message-content">
|
||||
<div class="message-head"><strong>{item.entry.message.player}</strong>{@render chatContext(item.entry.context)}</div>
|
||||
<div class="message-head">
|
||||
<strong>{item.entry.message.player}</strong
|
||||
>{@render chatContext(item.entry.context)}
|
||||
</div>
|
||||
<p>{item.entry.message.message}</p>
|
||||
</div>
|
||||
</li>
|
||||
@@ -150,44 +254,214 @@
|
||||
</section>
|
||||
|
||||
<style>
|
||||
section { container-type: inline-size; }
|
||||
.chat-head, .actions, .chat-title { display: flex; align-items: center; gap: var(--space-3); flex-wrap: wrap; }
|
||||
.chat-head { justify-content: space-between; }
|
||||
.chat-title { gap: var(--space-2) var(--space-3); }
|
||||
h2 { margin: 0; font-size: var(--text-lg); }
|
||||
.update-cadence { color: var(--text-secondary); font-size: var(--text-xs); }
|
||||
.outage { color: var(--state-warning); font-size: var(--text-sm); line-height: var(--leading-body); }
|
||||
.empty { color: var(--text-secondary); font-size: var(--text-sm); padding: 8px 0; margin-bottom: 0; }
|
||||
.messages { max-height: 360px; overflow: auto; margin-top: var(--space-4); }
|
||||
.messages:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--accent-primary); border-radius: var(--radius-sm); }
|
||||
ol { list-style: none; padding: 0; margin: 0; }
|
||||
.message { display: grid; grid-template-columns: 6rem minmax(0, 1fr); align-items: baseline; column-gap: var(--space-4); padding: var(--space-3) 0; }
|
||||
.message-clock, .number { font-family: var(--font-numeric); font-variant-numeric: tabular-nums; }
|
||||
.message-clock { font-size: var(--text-xs); color: var(--text-secondary); white-space: nowrap; }
|
||||
.message-content { min-width: 0; }
|
||||
.message-head { display: flex; align-items: baseline; flex-wrap: wrap; gap: var(--space-1) var(--space-3); }
|
||||
strong { min-width: 0; color: var(--text-primary); font-size: var(--text-base); font-weight: var(--weight-semibold); overflow-wrap: anywhere; }
|
||||
.context { display: flex; align-items: baseline; flex-wrap: wrap; gap: var(--space-1) var(--space-3); margin: 0; color: var(--text-secondary); font-size: var(--text-xs); font-weight: var(--weight-regular); }
|
||||
.context > div { display: flex; align-items: baseline; gap: var(--space-1); white-space: nowrap; }
|
||||
.context > div + div::before { content: '·'; margin-right: var(--space-1); color: var(--text-tertiary); }
|
||||
.context dd { margin: 0; font-family: var(--font-numeric); font-variant-numeric: tabular-nums; }
|
||||
.message p { margin: var(--space-1) 0 0; font-size: var(--text-base); line-height: 1.6; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.match-events { padding: var(--space-1) 0; }
|
||||
.match-divider { display: flex; align-items: center; gap: var(--space-3); padding: var(--space-2) 0; color: var(--text-secondary); font-size: var(--text-xs); }
|
||||
.match-divider::before, .match-divider::after { content: ''; height: 1px; flex: 1; min-width: var(--space-3); background: var(--border-subtle); }
|
||||
.match-label { min-width: 0; display: flex; align-items: baseline; justify-content: center; flex-wrap: wrap; gap: var(--space-1) var(--space-2); }
|
||||
.boundary-context { white-space: nowrap; }
|
||||
.boundary-context::before { content: '·'; margin-right: var(--space-2); color: var(--text-tertiary); }
|
||||
.earlier-events summary { width: fit-content; max-width: 100%; box-sizing: border-box; min-height: 32px; margin-inline: auto; padding: var(--space-2); border-radius: var(--radius-sm); color: var(--text-secondary); font-size: var(--text-xs); cursor: pointer; }
|
||||
.earlier-events summary:hover { color: var(--text-primary); background: var(--surface-raised); }
|
||||
.earlier-events summary:focus-visible { outline: none; box-shadow: var(--focus-ring); }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
||||
section {
|
||||
container-type: inline-size;
|
||||
}
|
||||
.chat-head,
|
||||
.actions,
|
||||
.chat-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.chat-head {
|
||||
justify-content: space-between;
|
||||
}
|
||||
.chat-title {
|
||||
gap: var(--space-2) var(--space-3);
|
||||
}
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
.update-cadence {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.outage {
|
||||
color: var(--state-warning);
|
||||
font-size: var(--text-sm);
|
||||
line-height: var(--leading-body);
|
||||
}
|
||||
.empty {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
padding: 8px 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.messages {
|
||||
max-height: 360px;
|
||||
overflow: auto;
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
.messages:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: inset 0 0 0 2px var(--accent-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
ol {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.message {
|
||||
display: grid;
|
||||
grid-template-columns: 6rem minmax(0, 1fr);
|
||||
align-items: baseline;
|
||||
column-gap: var(--space-4);
|
||||
padding: var(--space-3) 0;
|
||||
}
|
||||
.message-clock,
|
||||
.number {
|
||||
font-family: var(--font-numeric);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.message-clock {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.message-content {
|
||||
min-width: 0;
|
||||
}
|
||||
.message-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1) var(--space-3);
|
||||
}
|
||||
strong {
|
||||
min-width: 0;
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--weight-semibold);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.context {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1) var(--space-3);
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--weight-regular);
|
||||
}
|
||||
.context > div {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--space-1);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.context > div + div::before {
|
||||
content: '·';
|
||||
margin-right: var(--space-1);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.context dd {
|
||||
margin: 0;
|
||||
font-family: var(--font-numeric);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.message p {
|
||||
margin: var(--space-1) 0 0;
|
||||
font-size: var(--text-base);
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.match-events {
|
||||
padding: var(--space-1) 0;
|
||||
}
|
||||
.match-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-2) 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.match-divider::before,
|
||||
.match-divider::after {
|
||||
content: '';
|
||||
height: 1px;
|
||||
flex: 1;
|
||||
min-width: var(--space-3);
|
||||
background: var(--border-subtle);
|
||||
}
|
||||
.match-label {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1) var(--space-2);
|
||||
}
|
||||
.boundary-context {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.boundary-context::before {
|
||||
content: '·';
|
||||
margin-right: var(--space-2);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.earlier-events summary {
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
min-height: 32px;
|
||||
margin-inline: auto;
|
||||
padding: var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
cursor: pointer;
|
||||
}
|
||||
.earlier-events summary:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
.earlier-events summary:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
@container (max-width: 560px) {
|
||||
.message { grid-template-columns: minmax(0, 1fr) auto; gap: var(--space-1) var(--space-3); }
|
||||
.message-clock { grid-column: 2; grid-row: 1; }
|
||||
.message-content, .message-head { display: contents; }
|
||||
strong { grid-column: 1; grid-row: 1; }
|
||||
.context { grid-column: 1 / -1; grid-row: 2; }
|
||||
.message p { grid-column: 1 / -1; grid-row: 3; margin-top: 0; }
|
||||
.message {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: var(--space-1) var(--space-3);
|
||||
}
|
||||
.message-clock {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
}
|
||||
.message-content,
|
||||
.message-head {
|
||||
display: contents;
|
||||
}
|
||||
strong {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
}
|
||||
.context {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 2;
|
||||
}
|
||||
.message p {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 3;
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user