Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
120 lines
3.7 KiB
Svelte
120 lines
3.7 KiB
Svelte
<script lang="ts">
|
||
// The site-family announcement, shown by every game: the maintainer writes
|
||
// /var/lib/hall/announcement.json on the host (hall-announce), and each game's
|
||
// site serves it at /hall/announcement.json. It is fetched on load, every few
|
||
// minutes, and whenever the tab becomes visible again. Dismissal is per
|
||
// announcement id, so the next one still shows.
|
||
import { onMount } from 'svelte';
|
||
|
||
interface Announcement {
|
||
/** Names this announcement, so dismissing it does not hide the next. */
|
||
id: string;
|
||
text: string;
|
||
/** When what it announces happens, told in each reader's own time. */
|
||
at?: string;
|
||
/** After this it is no longer shown. */
|
||
until?: string;
|
||
}
|
||
|
||
const SOURCE = '/hall/announcement.json';
|
||
const CHECK_MS = 5 * 60_000;
|
||
const DISMISSED = 'hall:dismissed';
|
||
|
||
let current = $state<Announcement | null>(null);
|
||
let dismissed = $state<string | null>(null);
|
||
let now = $state(Date.now());
|
||
|
||
async function check() {
|
||
try {
|
||
const res = await fetch(SOURCE, { cache: 'no-store' });
|
||
const a: unknown = res.ok ? await res.json() : null;
|
||
current = a && typeof a === 'object' && typeof (a as Announcement).text === 'string' && (a as Announcement).text && typeof (a as Announcement).id === 'string' ? (a as Announcement) : null;
|
||
} catch {
|
||
// No announcement reachable: none shown.
|
||
}
|
||
now = Date.now();
|
||
}
|
||
|
||
onMount(() => {
|
||
try {
|
||
dismissed = localStorage.getItem(DISMISSED);
|
||
} catch {
|
||
// Without storage a dismissal lasts this visit.
|
||
}
|
||
void check();
|
||
const poll = setInterval(check, CHECK_MS);
|
||
const tick = setInterval(() => (now = Date.now()), 30_000);
|
||
const looked = () => {
|
||
if (document.visibilityState === 'visible') void check();
|
||
};
|
||
document.addEventListener('visibilitychange', looked);
|
||
return () => {
|
||
clearInterval(poll);
|
||
clearInterval(tick);
|
||
document.removeEventListener('visibilitychange', looked);
|
||
};
|
||
});
|
||
|
||
const shown = $derived(current && current.id !== dismissed && !(current.until && Date.parse(current.until) < now) ? current : null);
|
||
|
||
/** When it happens, in the reader's own clock: "in 25 minutes, at 7:00 AM", or the day and time further off. */
|
||
const when = $derived.by(() => {
|
||
const t = shown?.at ? Date.parse(shown.at) : NaN;
|
||
if (!Number.isFinite(t)) return '';
|
||
const minutes = Math.round((t - now) / 60_000);
|
||
if (minutes > 90) return ` (${new Date(t).toLocaleString([], { weekday: 'short', hour: 'numeric', minute: '2-digit' })}, your time)`;
|
||
if (minutes > 1) return ` (in ${minutes} minutes, at ${new Date(t).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })})`;
|
||
return minutes >= -15 ? ' (now)' : '';
|
||
});
|
||
|
||
function dismiss() {
|
||
// Taken first: once dismissed, nothing is shown.
|
||
const id = shown?.id;
|
||
if (!id) return;
|
||
dismissed = id;
|
||
try {
|
||
localStorage.setItem(DISMISSED, id);
|
||
} catch {
|
||
// The dismissal still holds for this visit.
|
||
}
|
||
}
|
||
</script>
|
||
|
||
{#if shown}
|
||
<div class="hall-announcement" role="status">
|
||
<span>{shown.text}{when}</span>
|
||
<button type="button" aria-label="Dismiss this announcement" onclick={dismiss}>×</button>
|
||
</div>
|
||
{/if}
|
||
|
||
<style>
|
||
/* Fixed colours rather than the game's tokens, so the notice looks the same on every site that shows it. */
|
||
.hall-announcement {
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 1000;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 0.8rem;
|
||
padding: 0.45rem 2.4rem 0.45rem 1rem;
|
||
background: #f2c45a;
|
||
color: #1d1a12;
|
||
font: 500 0.95rem/1.35 system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||
text-align: center;
|
||
box-shadow: 0 1px 4px #0005;
|
||
}
|
||
|
||
button {
|
||
position: absolute;
|
||
right: 0.5rem;
|
||
background: none;
|
||
border: 0;
|
||
color: inherit;
|
||
font-size: 1.3rem;
|
||
line-height: 1;
|
||
padding: 0.2rem 0.5rem;
|
||
cursor: pointer;
|
||
}
|
||
</style>
|