Pages show the hall-wide notice, when there is one, at the top
The keeper's hall-announce writes one file on the shared host; the site serves it at /hall/announcement.json, and the banner shows it with the time in the reader's own clock, dismissable one notice at a time. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
397c68a1c8
commit
cbf897ff13
@@ -1,6 +1,13 @@
|
||||
# Wiz-War's site on a host shared with the other games of Kestrel's Hall:
|
||||
# setup-droplet.sh fills __HOST__ and writes it to /etc/caddy/sites/wizwar.caddy.
|
||||
__HOST__ {
|
||||
# The hall-wide notice (hall-announce on the host): one file, served by every game's site.
|
||||
handle_path /hall/* {
|
||||
root * /var/lib/hall
|
||||
header Cache-Control "no-store"
|
||||
file_server
|
||||
}
|
||||
|
||||
header {
|
||||
Strict-Transport-Security "max-age=31536000"
|
||||
X-Content-Type-Options "nosniff"
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
<script lang="ts">
|
||||
// The host's announcement, shown by every game of Kestrel's Hall: the keeper
|
||||
// writes /var/lib/hall/announcement.json on the host (hall-announce), and each
|
||||
// game's site serves it at /hall/announcement.json. It is checked when the page
|
||||
// loads, every few minutes, and whenever the page is looked at again. A reader
|
||||
// may dismiss one announcement; 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>
|
||||
/* Its own colors, not the game's: the same notice reads the same in every game of the hall. */
|
||||
.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>
|
||||
@@ -1,4 +1,5 @@
|
||||
import { mount } from "svelte";
|
||||
import Announcement from "./Announcement.svelte";
|
||||
import * as Sentry from "@sentry/browser";
|
||||
|
||||
// Errors only — no tracing, no replay. Without a DSN baked at build time
|
||||
@@ -35,3 +36,5 @@ const page = location.pathname.startsWith("/watch/")
|
||||
? import("./SharePage.svelte")
|
||||
: import("./App.svelte");
|
||||
page.then(({ default: Root }) => mount(Root, { target }));
|
||||
// The hall-wide notice sits above the game, apart from it: every game of Kestrel's Hall shows the same one.
|
||||
mount(Announcement, { target: document.body, anchor: document.body.firstChild ?? undefined });
|
||||
|
||||
Reference in New Issue
Block a user