Announcements: a site-wide notice with a start and an end
The one string on this site where a deploy is the wrong latency is "tonight's meeting is cancelled, the lot is flooded" at 4pm on a Tuesday. That is a record with a lifecycle, not site copy, so it gets a table and a write path rather than a commit. store.py announcements table, created by the existing IF NOT EXISTS path so there is no migration. ends_at is REQUIRED: an announcement that never expires is site copy, and site copy belongs in the repo where it has a diff. Nothing is hard-deleted; taking one down early sets revoked_at, so what the site said and when survives. Ranking is urgent first, then most recent. Recency alone would let a routine Wednesday notice bury a Tuesday cancellation still live. Two caps, enforced here rather than in the route so the future panel inherits them: 200 characters, and 3 live at once. Both reject rather than truncate. Clipping a cancellation mid-sentence is worse than making someone shorten it, and a 4th live notice is a signal nobody is expiring things rather than something to render. app.py announcement_bar() above the sticky nav. Native <details>, no JavaScript, which matters on a read-only rootfs with no build step. Collapsed clamps to one line with a count; expanded lists all of them and caps at 40vh. One notice renders with no chevron and no count: the common case must not look like a widget. FAILS OPEN. The admin API fails closed because it serves family phone numbers. This is the opposite case, and a broken announcement must never take down the public homepage. Colours are the existing note and rust token pairs from the brand standard. No new colour enters the palette. admin_api.py GET/POST/DELETE on /api/admin/announcements. Leads stay read-only; announcements are the deliberate exception, because being mutable and expiring is the entire feature rather than a guess at a process. list returns a computed state per row (live, scheduled, expired, revoked, over_cap) so "why is my notice not showing" is answerable from the API and not from the homepage.
This commit is contained in:
+98
-1
@@ -1,4 +1,4 @@
|
||||
import json, os, re, time, datetime, urllib.request, urllib.parse
|
||||
import html, json, os, re, time, datetime, urllib.request, urllib.parse
|
||||
from pathlib import Path
|
||||
from fastapi import FastAPI, Form, Request
|
||||
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
|
||||
@@ -268,8 +268,104 @@ footer a.fb{display:inline-flex;align-items:center;gap:8px}
|
||||
.docd{display:block;margin-top:3px;color:#6A7280;font-size:14px}
|
||||
.docmeta{font-size:12.5px;font-weight:600;color:#8B93A3;white-space:nowrap}
|
||||
@media(max-width:560px){.docrow{flex-wrap:wrap}.docmeta{width:100%;padding-left:74px}}
|
||||
.annc{border-bottom:1px solid;font-family:'Public Sans',system-ui,sans-serif}
|
||||
.annc-info{background:#F3EBD8;border-color:#E8DCBB;color:#6B5510}
|
||||
.annc-urgent{background:#F6E3DA;border-color:#DFB9A4;color:#8A3B1D}
|
||||
.anncrow{display:flex;align-items:center;gap:9px;padding:11px 0}
|
||||
.annc summary{cursor:pointer;list-style:none;-webkit-tap-highlight-color:transparent}
|
||||
.annc summary::-webkit-details-marker{display:none}
|
||||
.annc summary:focus-visible{outline:2px solid currentColor;outline-offset:-3px}
|
||||
.anncico,.anncchev{flex:none;display:flex;align-items:center}
|
||||
.anncchev{transition:transform .15s ease}
|
||||
.annc[open] .anncchev{transform:rotate(180deg)}
|
||||
.anncline{flex:1;min-width:0;font-size:14px;line-height:1.35;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.anncopen{display:none}
|
||||
.annc[open] .ancctop{display:none}
|
||||
.annc[open] .anncopen{display:block;font-weight:600}
|
||||
.annccount{flex:none;font-size:11px;font-weight:700;padding:1px 7px;border-radius:999px;border:1px solid currentColor}
|
||||
.annc[open] .annccount{display:none}
|
||||
.anncbody{max-height:40vh;overflow-y:auto;padding-bottom:12px}
|
||||
.anncitem{padding:10px 0 0;font-size:14px;line-height:1.5;overflow-wrap:anywhere}
|
||||
.anncitem+.anncitem{border-top:1px solid rgba(0,0,0,.09);padding-top:10px;margin-top:10px}
|
||||
.anncitem a{color:inherit;text-decoration:underline;font-size:13px}
|
||||
.ancclink{flex:none;color:inherit;text-decoration:underline;font-size:13px;white-space:nowrap}
|
||||
"""
|
||||
|
||||
ICO_INFO = ('<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor"'
|
||||
' stroke-width="2" stroke-linecap="round" aria-hidden="true">'
|
||||
'<circle cx="12" cy="12" r="9"/><path d="M12 11v5M12 7.6v.01"/></svg>')
|
||||
|
||||
ICO_URGENT = ('<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor"'
|
||||
' stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">'
|
||||
'<path d="M10.3 4.3 2.6 17.6a2 2 0 0 0 1.7 3h15.4a2 2 0 0 0 1.7-3L13.7 4.3a2 2 0 0 0-3.4 0Z"/>'
|
||||
'<path d="M12 9.5v4M12 17.2v.01"/></svg>')
|
||||
|
||||
ICO_CHEV = ('<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor"'
|
||||
' stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">'
|
||||
'<path d="m6 9 6 6 6-6"/></svg>')
|
||||
|
||||
|
||||
def _annc_item(a):
|
||||
"""One announcement in the expanded list. Text is escaped; the link is
|
||||
rendered only from a url store.create_announcement already validated."""
|
||||
out = '<div class="anncitem">%s' % html.escape(a["message"])
|
||||
if a.get("link_url"):
|
||||
out += '<br><a href="%s">%s</a>' % (
|
||||
html.escape(a["link_url"], quote=True),
|
||||
html.escape(a.get("link_text") or "More details"))
|
||||
return out + "</div>"
|
||||
|
||||
|
||||
def announcement_bar():
|
||||
"""Render the live announcements, or nothing at all.
|
||||
|
||||
FAILS OPEN, deliberately. The admin API fails closed because it serves
|
||||
family phone numbers; this is the opposite case. A broken announcement
|
||||
must never take down the public homepage, so any error here renders an
|
||||
empty string and logs.
|
||||
|
||||
Collapsed shows the top-ranked notice on one clamped line plus a count.
|
||||
Expanded lists all of them. Native <details>, so there is no JavaScript
|
||||
and nothing to load - which matters on a read-only rootfs with no build
|
||||
step. The single-notice case, which is nearly always the case, renders
|
||||
with no chevron and no count: it must not look like a widget.
|
||||
"""
|
||||
try:
|
||||
items = store.active_announcements()
|
||||
except Exception as e:
|
||||
print("announcement_bar: %s" % e, flush=True)
|
||||
return ""
|
||||
if not items:
|
||||
return ""
|
||||
|
||||
top = items[0]
|
||||
level = "urgent" if any(i["level"] == "urgent" for i in items) else "info"
|
||||
ico = ICO_URGENT if level == "urgent" else ICO_INFO
|
||||
label = "Urgent notice" if level == "urgent" else "Notice"
|
||||
|
||||
if len(items) == 1:
|
||||
link = ""
|
||||
if top.get("link_url"):
|
||||
link = ('<a class="ancclink" href="%s">%s</a>' % (
|
||||
html.escape(top["link_url"], quote=True),
|
||||
html.escape(top.get("link_text") or "More details")))
|
||||
return ('<div class="annc annc-%s" role="region" aria-label="%s"><div class="wrap anncrow">'
|
||||
'<span class="anncico">%s</span>'
|
||||
'<span class="anncline">%s</span>%s'
|
||||
'</div></div>' % (level, label, ico, html.escape(top["message"]), link))
|
||||
|
||||
body = "".join(_annc_item(i) for i in items)
|
||||
return ('<details class="annc annc-%s"><summary><div class="wrap anncrow">'
|
||||
'<span class="anncico">%s</span>'
|
||||
'<span class="anncline ancctop">%s</span>'
|
||||
'<span class="anncline anncopen">%d notices</span>'
|
||||
'<span class="annccount">+%d</span>'
|
||||
'<span class="anncchev">%s</span>'
|
||||
'</div></summary><div class="wrap anncbody">%s</div></details>'
|
||||
% (level, ico, html.escape(top["message"]), len(items),
|
||||
len(items) - 1, ICO_CHEV, body))
|
||||
|
||||
|
||||
def page(title, body, active=""):
|
||||
def on(k): return ' class="on"' if active == k else ""
|
||||
return f"""<!doctype html>
|
||||
@@ -290,6 +386,7 @@ def page(title, body, active=""):
|
||||
<link href="https://fonts.googleapis.com/css2?family=Archivo:wght@500;600;700;800;900&family=Public+Sans:wght@400;600;700&display=swap" rel="stylesheet">
|
||||
<style>{CSS}</style>
|
||||
</head><body style="display:flex;flex-direction:column;min-height:100vh">
|
||||
{announcement_bar()}
|
||||
<header class="nav"><div class="wrap navrow">
|
||||
<a class="brand" href="/"><img src="/static/img/patch-73.png" alt="Pack and Troop 73 patch"><span><span class="b1">PACK & TROOP 73</span><span class="b2">Zieglerville, PA · Cradle of Liberty Council</span></span></a>
|
||||
<nav class="links">
|
||||
|
||||
Reference in New Issue
Block a user