share pages: carry the real HubBar toolbar (consistency with the SPA)

The server-rendered /a/<id> and digest pages predated "HubBar everywhere" and showed
a stripped bar (logo + a bespoke Back pill). They can't run the Svelte component, so
add a hand-kept static replica of HubBar (logo + News/Play/Art nav + account glyph +
mobile burger/drop-panel) plus HubShell's borderless ← Back. A signed-in reader's
avatar paints from the same localStorage cache HubBar uses. /a/<id> now looks like any
detail page (/art, /word). Reusable _top_bar_html/_TOP_BAR_CSS/_TOP_BAR_JS/_back_link
helpers; applied to both share pages. Kept in sync with HubBar.svelte by hand (noted).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-30 04:26:01 -04:00
parent c33dad9832
commit 3740e09d02
214 changed files with 117 additions and 24 deletions
+117 -24
View File
@@ -17,6 +17,115 @@ def _tag(name: str, content: str | None, attr: str = "property") -> str:
return f'<meta {attr}="{escape(name)}" content="{escape(content)}">'
# --- Shared top bar -----------------------------------------------------------------
# A static replica of the SPA's HubBar so server-rendered share pages (/a/<id>, the
# digest) carry the SAME toolbar as the rest of the site. These pages can't run the
# Svelte component, so this is kept in sync with frontend/src/lib/components/HubBar.svelte
# (+ HubShell's borderless Back) BY HAND — change both together. `active` highlights a
# section ('' = none, as on an article). The account glyph is the signed-out state;
# _TOP_BAR_JS swaps in the cached avatar for signed-in readers, just like HubBar.
_TOP_NAV = (("/", "Home", "home"), ("/news", "News", "news"),
("/play", "Games", "games"), ("/art", "Art", "art"))
def _nav_links(active: str) -> str:
return "".join(f'<a class="{"on" if k == active else ""}" href="{href}">{label}</a>'
for href, label, k in _TOP_NAV)
def _top_bar_html(active: str = "") -> str:
return (
'<header class="bar">'
'<a class="brand" href="/" aria-label="upbeatBytes home"><img src="/logo.svg" alt="upbeatBytes"></a>'
'<div class="bar-end">'
f'<nav class="nav">{_nav_links(active)}<span class="nav-soon">Entertainment</span></nav>'
'<a class="acct" href="/account" aria-label="Your account">'
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#C98A2E" stroke-width="2" aria-hidden="true">'
'<circle cx="12" cy="8" r="4"/><path d="M4 21c0-4 4-6 8-6s8 2 8 6"/></svg></a>'
'<button class="burger" type="button" aria-label="Menu" aria-expanded="false" data-burger>'
'<span></span><span></span><span></span></button>'
'</div></header>'
f'<div class="menu-wrap" data-menu hidden><nav class="menu">{_nav_links(active)}'
'<span class="menu-soon">Entertainment <em>soon</em></span></nav></div>'
)
def _back_link_html(label: str = "Back") -> str:
return ('<button class="hb-back" type="button" data-back aria-label="Go back">'
'<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" '
'stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">'
f'<path d="M15 18l-6-6 6-6"/></svg>{label}</button>')
# Ported verbatim from HubBar.svelte's <style> (+ HubShell's .back), scoped to the same
# class names so the bar looks pixel-identical to the SPA regardless of page palette.
_TOP_BAR_CSS = """
@font-face { font-family:'Hanken Grotesk'; src:url('/fonts/hanken-var.woff2') format('woff2'); font-weight:400 700; font-style:normal; font-display:swap; }
header.bar { display:flex; align-items:center; justify-content:space-between; max-width:1180px; width:100%; margin:0 auto; box-sizing:border-box; padding:26px clamp(18px,5vw,44px) 0; font-family:'Hanken Grotesk',ui-sans-serif,system-ui,sans-serif; background:none; border:none; }
header.bar .brand { display:block; line-height:0; }
header.bar .brand img { height:48px; width:auto; display:block; }
.bar-end { display:flex; align-items:center; gap:clamp(16px,2.4vw,32px); }
.nav { display:flex; align-items:center; gap:clamp(16px,2.4vw,32px); font-size:16.5px; font-weight:500; }
.nav a { color:#6b6256; text-decoration:none; }
.nav a.on { color:#23201b; }
.nav a:hover { color:#0083ad; }
.nav-soon { color:#b3a890; }
.acct { width:32px; height:32px; border-radius:50%; border:1.5px solid #e6c9a0; background:#FCEFD7; display:flex; align-items:center; justify-content:center; flex:none; text-decoration:none; }
.acct.hasimg { background:none; overflow:hidden; padding:0; }
.acct:hover { background:#fbe6c4; }
.burger { display:none; flex-direction:column; align-items:center; justify-content:center; gap:4px; width:40px; height:40px; border-radius:11px; border:1.5px solid #e6c9a0; background:#FCEFD7; cursor:pointer; padding:0; flex:none; }
.burger:hover { background:#fbe6c4; }
.burger span { width:18px; height:2px; border-radius:2px; background:#7a6a52; transition:transform .2s ease, opacity .15s ease; }
.burger.open span:nth-child(1) { transform:translateY(6px) rotate(45deg); }
.burger.open span:nth-child(2) { opacity:0; }
.burger.open span:nth-child(3) { transform:translateY(-6px) rotate(-45deg); }
.menu-wrap { max-width:1180px; width:100%; margin:10px auto 0; box-sizing:border-box; padding:0 clamp(18px,5vw,44px); font-family:'Hanken Grotesk',ui-sans-serif,system-ui,sans-serif; }
.menu { display:flex; flex-direction:column; background:#fff; border:1px solid #f2e7d3; border-radius:14px; overflow:hidden; box-shadow:0 14px 34px -20px rgba(60,50,30,.4); }
.menu a, .menu .menu-soon { padding:14px 18px; font-size:16px; font-weight:500; text-decoration:none; color:#6b6256; border-top:1px solid #f3ece0; }
.menu a:first-child { border-top:none; }
.menu a.on { color:#23201b; }
.menu a:hover { background:#FFF9EF; color:#0083ad; }
.menu-soon { display:flex; align-items:center; justify-content:space-between; color:#b3a890; }
.menu-soon em { font-style:normal; font-size:10px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:#c3b69c; }
.hb-back { display:inline-flex; align-items:center; gap:6px; margin:0 0 clamp(14px,3vw,24px); background:none; border:none; cursor:pointer; padding:6px 10px 6px 0; font-family:'Hanken Grotesk',ui-sans-serif,system-ui,sans-serif; font-size:14px; font-weight:600; color:#6b6256; transition:color .15s ease; }
.hb-back:hover { color:#0083ad; }
.hb-back svg { transition:transform .15s ease; }
.hb-back:hover svg { transform:translateX(-2px); }
@media (max-width:720px) { .nav { display:none; } .burger { display:flex; } }
@media (min-width:721px) { .menu-wrap { display:none !important; } }
"""
# Burger toggle + signed-in avatar (read from the SPA's localStorage cache, same as HubBar).
_TOP_BAR_JS = """<script>
(function(){
var b=document.querySelector('[data-burger]'), m=document.querySelector('[data-menu]');
if(b&&m){ b.addEventListener('click',function(){
if(m.hasAttribute('hidden')){ m.removeAttribute('hidden'); b.classList.add('open'); b.setAttribute('aria-expanded','true'); }
else { m.setAttribute('hidden',''); b.classList.remove('open'); b.setAttribute('aria-expanded','false'); }
}); }
try{
var u=JSON.parse(localStorage.getItem('goodnews:auth_user')||'null');
if(u&&u.avatar_url){
var a=document.querySelector('.acct');
if(a){ var img=document.createElement('img'); img.src=u.avatar_url; img.alt=''; img.referrerPolicy='no-referrer';
img.style.cssText='width:32px;height:32px;border-radius:999px;object-fit:cover';
a.classList.add('hasimg'); a.textContent=''; a.appendChild(img); }
}
}catch(e){}
})();
</script>"""
# Single-history Back: return to where you came from in-app, else home (mirrors HubShell).
_BACK_JS = """<script>
(function(){
var b=document.querySelector('[data-back]'); if(!b) return;
b.addEventListener('click',function(){
if(document.referrer && history.length>1){ history.back(); } else { location.href='/'; }
});
})();
</script>"""
def render_share_page(article: dict, base_url: str, summary: str | None = None,
explanation: dict | None = None) -> str:
aid = article["id"]
@@ -153,17 +262,7 @@ def render_share_page(article: dict, base_url: str, summary: str | None = None,
body {{ margin:0; background:var(--bg); color:var(--ink);
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
line-height:1.6; }}
.bar {{ background:var(--surface); border-bottom:1px solid var(--line); }}
.bar .inner {{ max-width:680px; margin:0 auto; padding:12px 20px;
display:flex; align-items:center; justify-content:space-between; gap:12px; }}
.bar img {{ height:40px; display:block; }}
.back {{ display:inline-flex; align-items:center; gap:7px;
background:none; border:1px solid var(--line); color:var(--accent-deep);
border-radius:999px; padding:8px 17px 8px 14px; font-size:.92rem; font-weight:600;
font-family:inherit; cursor:pointer; line-height:1; white-space:nowrap;
transition:border-color .14s ease, background .14s ease; }}
.back svg {{ width:19px; height:19px; display:block; }}
.back:hover {{ border-color:var(--accent); background:var(--bg); }}
{_TOP_BAR_CSS}
.wrap {{ max-width:680px; margin:0 auto; padding:24px 20px 60px; }}
.card {{ background:var(--surface); border:1px solid var(--line); border-radius:16px;
overflow:hidden; box-shadow:0 10px 30px rgba(40,38,28,.06); }}
@@ -197,8 +296,9 @@ def render_share_page(article: dict, base_url: str, summary: str | None = None,
</style>
</head>
<body>
<div class="bar"><div class="inner"><a href="/"><img src="/logo.svg" alt="upbeatBytes"></a><button class="back" type="button" data-back aria-label="Go back"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M19 12H5M11 6l-6 6 6 6" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round"/></svg>Back</button></div></div>
{_top_bar_html()}
<main class="wrap">
{_back_link_html()}
<article class="card">
{media}
<div class="body">
@@ -241,14 +341,8 @@ def render_share_page(article: dict, base_url: str, summary: str | None = None,
}});
}})();
</script>
<script>
(function(){{
var b=document.querySelector('[data-back]'); if(!b) return;
b.addEventListener('click',function(){{
if(document.referrer && history.length>1){{ history.back(); }} else {{ location.href='/'; }}
}});
}})();
</script>
{_BACK_JS}
{_TOP_BAR_JS}
{poll}
</body>
</html>"""
@@ -306,9 +400,7 @@ def render_digest(items: list[dict], base_url: str, brief_date: str | None) -> s
* {{ box-sizing:border-box; }}
body {{ margin:0; background:var(--bg); color:var(--ink);
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; line-height:1.6; }}
.bar {{ background:var(--surface); border-bottom:1px solid var(--line); }}
.bar .inner {{ max-width:720px; margin:0 auto; padding:12px 20px; }}
.bar img {{ height:40px; display:block; }}
{_TOP_BAR_CSS}
.wrap {{ max-width:720px; margin:0 auto; padding:26px 20px 60px; }}
h1 {{ font-family:"Iowan Old Style",Palatino,Georgia,serif; font-weight:600; font-size:2rem; margin:0 0 4px; }}
.lede {{ color:var(--muted); margin:0 0 26px; }}
@@ -327,13 +419,14 @@ def render_digest(items: list[dict], base_url: str, brief_date: str | None) -> s
</style>
</head>
<body>
<div class="bar"><div class="inner"><a href="/"><img src="/logo.svg" alt="upbeatBytes"></a></div></div>
{_top_bar_html()}
<main class="wrap">
<h1>Today's good news</h1>
<p class="lede">{escape(intro)}{f' · {escape(brief_date)}' if brief_date else ''}</p>
{cards}
<p class="more"><a href="/news">Browse more on upbeatBytes →</a></p>
</main>
{_TOP_BAR_JS}
</body>
</html>"""