A notice the whole hall shows: hall-announce on the host, served by every game's site, shown at the top of each page

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
This commit is contained in:
Eric Wagoner
2026-09-29 13:03:15 -04:00
co-authored by Claude Opus 5.5
parent 294b1d5460
commit 96ec6861d5
9 changed files with 242 additions and 1 deletions
+3
View File
@@ -23,6 +23,9 @@ ssh "root@$HOST" '
install -m 755 /tmp/gamehall-ops/gamehall-rollup.sh /usr/local/bin/gamehall-rollup.sh
install -m 644 -D /tmp/gamehall-ops/traffic.py /usr/local/lib/gamehall/traffic.py
install -m 644 /tmp/gamehall-ops/gamehall.cron /etc/cron.d/gamehall
# The hall-wide notice every game serves at /hall/announcement.json.
install -m 755 /tmp/gamehall-ops/hall-announce.sh /usr/local/bin/hall-announce
install -d -m 755 /var/lib/hall
mkdir -p /var/lib/gamehall /var/log/gamehall
rm -r /tmp/gamehall-ops
'
+7
View File
@@ -1,5 +1,12 @@
# Kestrel's Hall, the front door of the family: a static page, no server.
gamehall.kestrelsnest.social, gamehall.159.203.115.252.sslip.io {
# 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
}
root * /opt/gamehall
encode gzip zstd
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# The notice every game of Kestrel's Hall shows at the top of its pages.
# Each site serves /var/lib/hall/announcement.json at /hall/announcement.json;
# a page checks it on load and every few minutes. Run on the host as root:
# hall-announce "Kestrel's Hall is down for five minutes of maintenance" --at "2026-09-30 11:00 UTC" [--until "2026-09-30 11:15 UTC"]
# hall-announce --show
# hall-announce --clear
# --at is when it happens, told in each reader's own time; --until is when the
# notice stops showing (an hour after --at when not given).
set -euo pipefail
FILE=/var/lib/hall/announcement.json
case "${1:-}" in
--clear) rm -f "$FILE"; echo "cleared."; exit 0 ;;
--show) cat "$FILE" 2>/dev/null || echo "no announcement."; exit 0 ;;
''|-*) sed -n '2,9p' "$0" | sed 's/^# \{0,1\}//'; exit 2 ;;
esac
TEXT="$1"; shift
AT=""; UNTIL=""
while [ $# -gt 0 ]; do
case "$1" in
--at) AT="$(date -u -d "$2" +%Y-%m-%dT%H:%M:%SZ)"; shift 2 ;;
--until) UNTIL="$(date -u -d "$2" +%Y-%m-%dT%H:%M:%SZ)"; shift 2 ;;
*) echo "unknown option: $1" >&2; exit 2 ;;
esac
done
[ -n "$AT" ] && [ -z "$UNTIL" ] && UNTIL="$(date -u -d "$AT + 1 hour" +%Y-%m-%dT%H:%M:%SZ)"
mkdir -p "$(dirname "$FILE")"
# A new id each time, so a reader who dismissed the last notice still sees this one.
TEXT="$TEXT" AT="$AT" UNTIL="$UNTIL" ID="$(date -u +%Y%m%dT%H%M%S)" python3 -c '
import json, os
a = {"id": os.environ["ID"], "text": os.environ["TEXT"]}
if os.environ["AT"]: a["at"] = os.environ["AT"]
if os.environ["UNTIL"]: a["until"] = os.environ["UNTIL"]
print(json.dumps(a))' > "$FILE.tmp"
chmod 644 "$FILE.tmp"
mv "$FILE.tmp" "$FILE"
cat "$FILE"
+48
View File
@@ -113,6 +113,9 @@
footer { margin-top: 64px; padding: 28px 0 48px; border-top: 1px solid var(--line); color: var(--bone-faint); font-size: 0.9rem; }
footer p { margin: 0 0 8px; }
/* The hall-wide notice: its own colors, 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: .8rem; padding: .45rem 2.4rem .45rem 1rem; background: #f2c45a; color: #1d1a12; font: 500 .95rem/1.35 system-ui, -apple-system, 'Segoe UI', sans-serif; text-align: center; box-shadow: 0 1px 4px #0005; }
.hall-announcement button { position: absolute; right: .5rem; background: none; border: 0; color: inherit; font-size: 1.3rem; line-height: 1; padding: .2rem .5rem; cursor: pointer; }
</style>
</head>
<body>
@@ -214,5 +217,50 @@
</footer>
</div>
<script>
// The hall-wide notice every game of Kestrel's Hall shows (hall-announce on the
// host): checked on load, every few minutes, and when the page is looked at
// again; a reader may dismiss one, and the next still shows.
(() => {
const KEY = 'hall:dismissed';
let bar = null;
let current = null;
const read = () => { try { return localStorage.getItem(KEY); } catch { return null; } };
function when(at) {
const t = at ? Date.parse(at) : NaN;
if (!Number.isFinite(t)) return '';
const minutes = Math.round((t - Date.now()) / 60000);
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 show() {
const a = current;
const live = a && a.id !== read() && !(a.until && Date.parse(a.until) < Date.now());
if (!live) { bar?.remove(); bar = null; return; }
if (!bar) {
bar = document.createElement('div');
bar.className = 'hall-announcement';
bar.setAttribute('role', 'status');
bar.innerHTML = '<span></span><button type="button" aria-label="Dismiss this announcement">×</button>';
bar.querySelector('button').onclick = () => { try { localStorage.setItem(KEY, current.id); } catch {} show(); };
document.body.prepend(bar);
}
bar.querySelector('span').textContent = a.text + when(a.at);
}
async function check() {
try {
const res = await fetch('/hall/announcement.json', { cache: 'no-store' });
const a = res.ok ? await res.json() : null;
current = a && typeof a.text === 'string' && a.text && typeof a.id === 'string' ? a : null;
} catch {}
show();
}
check();
setInterval(check, 5 * 60000);
setInterval(show, 30000);
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') check(); });
})();
</script>
</body>
</html>