brand: standardize "Upbeat Bytes" → "upbeatBytes" everywhere
Per the logo + brand: the name is upbeatBytes (camelCase). Swept all user-facing strings — titles/og:site_name/og:title, logo alt text, share pages (share.py), emails (email_send), classifier prompt (llm), digest/unsubscribe (api), PWA manifest, game share text, sign-in, the SPA shell + patch-static-heads (play title) — plus README/publish.sh and the email test fixture. (SMTP From env was already upbeatBytes.) Domains (upbeatbytes.com) unchanged. 425 BE + 36 FE green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,8 @@
|
|||||||
# Upbeat Bytes
|
# upbeatBytes
|
||||||
|
|
||||||
Calm, constructive news — local-first ingestion, scoring, and a daily brief.
|
Calm, constructive news — local-first ingestion, scoring, and a daily brief.
|
||||||
(The Python package and CLI are named `goodnews` for historical reasons; the
|
(The Python package and CLI are named `goodnews` for historical reasons; the
|
||||||
product is **Upbeat Bytes**, at upbeatbytes.com.)
|
product is **upbeatBytes**, at upbeatbytes.com.)
|
||||||
|
|
||||||
The first milestone is intentionally small: collect public RSS/Atom metadata, dedupe it, store short source-provided snippets, and attach early reason-coded heuristic scores. It does not store full article bodies.
|
The first milestone is intentionally small: collect public RSS/Atom metadata, dedupe it, store short source-provided snippets, and attach early reason-coded heuristic scores. It does not store full article bodies.
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Publish Upbeat Bytes: build the frontend, sync it to the live Caddy site,
|
# Publish upbeatBytes: build the frontend, sync it to the live Caddy site,
|
||||||
# rebuild/restart the API container, and reload Caddy. One command to redeploy.
|
# rebuild/restart the API container, and reload Caddy. One command to redeploy.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -26,4 +26,4 @@ curl -fsS -o /dev/null -w ' logo-email.png → %{http_code} %{content_type}\n'
|
|||||||
https://upbeatbytes.com/logo-email.png \
|
https://upbeatbytes.com/logo-email.png \
|
||||||
|| echo " ⚠ logo-email.png is not being served — the digest masthead would break!"
|
|| echo " ⚠ logo-email.png is not being served — the digest masthead would break!"
|
||||||
|
|
||||||
echo "✓ Published Upbeat Bytes → https://upbeatbytes.com"
|
echo "✓ Published upbeatBytes → https://upbeatbytes.com"
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const PAGES = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
file: 'play.html', path: '/play',
|
file: 'play.html', path: '/play',
|
||||||
title: 'Play · Upbeat Bytes — calm daily games',
|
title: 'Play · upbeatBytes — calm daily games',
|
||||||
desc: 'A calm set of daily games — Daily Word, Word Search, Bloom, and Memory Match. ' +
|
desc: 'A calm set of daily games — Daily Word, Word Search, Bloom, and Memory Match. ' +
|
||||||
'A friendly little break from the doomscroll.',
|
'A friendly little break from the doomscroll.',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
font-display: swap;
|
font-display: swap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Upbeat Bytes calm design system.
|
/* upbeatBytes calm design system.
|
||||||
Sand, sea, and sun: warm paper surfaces, a vivid-azure accent, gold highlight,
|
Sand, sea, and sun: warm paper surfaces, a vivid-azure accent, gold highlight,
|
||||||
a serif voice for headlines, strong readable contrast, generous space.
|
a serif voice for headlines, strong readable contrast, generous space.
|
||||||
No urgency colors (no red). Built around the logo's #0083ad azure. */
|
No urgency colors (no red). Built around the logo's #0083ad azure. */
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
<meta name="description" content="A calmer, brighter corner of the internet: good news, daily art, small games, and little resets." />
|
<meta name="description" content="A calmer, brighter corner of the internet: good news, daily art, small games, and little resets." />
|
||||||
<title>upbeatBytes — a calmer, brighter corner of the internet</title>
|
<title>upbeatBytes — a calmer, brighter corner of the internet</title>
|
||||||
<link rel="canonical" href="https://upbeatbytes.com/" />
|
<link rel="canonical" href="https://upbeatbytes.com/" />
|
||||||
<meta property="og:site_name" content="Upbeat Bytes" />
|
<meta property="og:site_name" content="upbeatBytes" />
|
||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
<meta property="og:title" content="upbeatBytes — a calmer, brighter corner of the internet" />
|
<meta property="og:title" content="upbeatBytes — a calmer, brighter corner of the internet" />
|
||||||
<meta property="og:description" content="A calmer, brighter corner of the internet: good news, daily art, small games, and little resets. No ads, no paywalls, no doomscrolling." />
|
<meta property="og:description" content="A calmer, brighter corner of the internet: good news, daily art, small games, and little resets. No ads, no paywalls, no doomscrolling." />
|
||||||
@@ -128,9 +128,9 @@
|
|||||||
<div style="display: contents">%sveltekit.body%</div>
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
<div id="boot-fallback" role="alert" aria-live="polite">
|
<div id="boot-fallback" role="alert" aria-live="polite">
|
||||||
<div class="bf">
|
<div class="bf">
|
||||||
<img src="%sveltekit.assets%/logo.svg" alt="Upbeat Bytes" />
|
<img src="%sveltekit.assets%/logo.svg" alt="upbeatBytes" />
|
||||||
<p>We had a little trouble loading. A quick refresh usually sorts it out.</p>
|
<p>We had a little trouble loading. A quick refresh usually sorts it out.</p>
|
||||||
<button type="button" onclick="location.reload()">Refresh Upbeat Bytes</button>
|
<button type="button" onclick="location.reload()">Refresh upbeatBytes</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -59,7 +59,7 @@
|
|||||||
if (value) onaction?.(kind, value);
|
if (value) onaction?.(kind, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sharing: share the branded Upbeat Bytes card page (default), with copy-source.
|
// Sharing: share the branded upbeatBytes card page (default), with copy-source.
|
||||||
let shareOpen = $state(false);
|
let shareOpen = $state(false);
|
||||||
let copied = $state('');
|
let copied = $state('');
|
||||||
const canNativeShare = typeof navigator !== 'undefined' && !!navigator.share;
|
const canNativeShare = typeof navigator !== 'undefined' && !!navigator.share;
|
||||||
@@ -105,7 +105,7 @@
|
|||||||
</a>
|
</a>
|
||||||
{:else if usePlaceholder}
|
{:else if usePlaceholder}
|
||||||
<a class="media placeholder" href={summaryHref} onclick={opened} style="--c:{accentColor}" tabindex="-1" aria-hidden="true">
|
<a class="media placeholder" href={summaryHref} onclick={opened} style="--c:{accentColor}" tabindex="-1" aria-hidden="true">
|
||||||
<span class="ph-word">{humanize(article.topic) || 'upbeat bytes'}</span>
|
<span class="ph-word">{humanize(article.topic) || 'upbeatBytes'}</span>
|
||||||
</a>
|
</a>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
@@ -228,7 +228,7 @@
|
|||||||
const breakdown = Object.keys(byLen).sort((a, b) => b - a).map((l) => `${l}×${byLen[l]}`).join(' ');
|
const breakdown = Object.keys(byLen).sort((a, b) => b - a).map((l) => `${l}×${byLen[l]}`).join(' ');
|
||||||
const pang = found.some(isPangram) ? ' · pangram ✓' : '';
|
const pang = found.some(isPangram) ? ' · pangram ✓' : '';
|
||||||
const bloomV = mode === 'daily' ? 'daily' : (format === 'wild' ? 'free-wild' : 'free-center');
|
const bloomV = mode === 'daily' ? 'daily' : (format === 'wild' ? 'free-wild' : 'free-center');
|
||||||
const text = `Upbeat Bytes · Bloom ${date}\n${fullBloom ? 'Full Bloom 🌸' : tier.name} · ${found.length} words${pang}\n${breakdown}\n${gameShareUrl('bloom', bloomV)}`;
|
const text = `upbeatBytes · Bloom ${date}\n${fullBloom ? 'Full Bloom 🌸' : tier.name} · ${found.length} words${pang}\n${breakdown}\n${gameShareUrl('bloom', bloomV)}`;
|
||||||
if (navigator.share) navigator.share({ text }).then(() => trackGame('bloom', 'shared')).catch(() => {});
|
if (navigator.share) navigator.share({ text }).then(() => trackGame('bloom', 'shared')).catch(() => {});
|
||||||
else navigator.clipboard?.writeText(text).then(() => { trackGame('bloom', 'shared'); copied = true; setTimeout(() => (copied = false), 1500); });
|
else navigator.clipboard?.writeText(text).then(() => { trackGame('bloom', 'shared'); copied = true; setTimeout(() => (copied = false), 1500); });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
|
|
||||||
<header class="appbar">
|
<header class="appbar">
|
||||||
<div class="container bar">
|
<div class="container bar">
|
||||||
<a class="brand" href="/" aria-label="Upbeat Bytes — home">
|
<a class="brand" href="/" aria-label="upbeatBytes — home">
|
||||||
<img class="logo" src="/logo.svg" alt="Upbeat Bytes" width="586" height="196" />
|
<img class="logo" src="/logo.svg" alt="upbeatBytes" width="586" height="196" />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<nav class="utils" aria-label="Your controls">
|
<nav class="utils" aria-label="Your controls">
|
||||||
|
|||||||
@@ -133,7 +133,7 @@
|
|||||||
function share() {
|
function share() {
|
||||||
const label = `${TIER_LABEL[tier] || tier} · ${format === 'colors' ? 'colors' : 'icons'}`;
|
const label = `${TIER_LABEL[tier] || tier} · ${format === 'colors' ? 'colors' : 'icons'}`;
|
||||||
const when = isFree ? 'Free play' : date;
|
const when = isFree ? 'Free play' : date;
|
||||||
const text = `Upbeat Bytes · Memory Match (${label}) ${when}\nCleared in ${moves} moves\n${gameShareUrl('match', `${mode}-${format}-${tier}`)}`;
|
const text = `upbeatBytes · Memory Match (${label}) ${when}\nCleared in ${moves} moves\n${gameShareUrl('match', `${mode}-${format}-${tier}`)}`;
|
||||||
if (navigator.share) navigator.share({ text }).then(() => trackGame('match', 'shared')).catch(() => {});
|
if (navigator.share) navigator.share({ text }).then(() => trackGame('match', 'shared')).catch(() => {});
|
||||||
else navigator.clipboard?.writeText(text).then(() => { trackGame('match', 'shared'); copied = true; setTimeout(() => (copied = false), 1500); });
|
else navigator.clipboard?.writeText(text).then(() => { trackGame('match', 'shared'); copied = true; setTimeout(() => (copied = false), 1500); });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -181,7 +181,7 @@
|
|||||||
);
|
);
|
||||||
let viewSubtitle = $derived(
|
let viewSubtitle = $derived(
|
||||||
selected === 'today' ? localDateLabel(brief)
|
selected === 'today' ? localDateLabel(brief)
|
||||||
: selected === 'search' ? 'Results across Upbeat Bytes'
|
: selected === 'search' ? 'Results across upbeatBytes'
|
||||||
: selected.startsWith('source:') ? 'Latest from this source'
|
: selected.startsWith('source:') ? 'Latest from this source'
|
||||||
: selected === 'latest' ? 'Freshest calm reads — newest first'
|
: selected === 'latest' ? 'Freshest calm reads — newest first'
|
||||||
: selected === 'following' ? 'From the sources & topics you follow'
|
: selected === 'following' ? 'From the sources & topics you follow'
|
||||||
@@ -629,7 +629,7 @@
|
|||||||
moods = m; topics = c.topics;
|
moods = m; topics = c.topics;
|
||||||
await loadView(selected);
|
await loadView(selected);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!brief) error = 'Could not reach Upbeat Bytes.'; // keep painted content if a refresh failed
|
if (!brief) error = 'Could not reach upbeatBytes.'; // keep painted content if a refresh failed
|
||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
@@ -872,7 +872,7 @@
|
|||||||
|
|
||||||
{#if families.length}
|
{#if families.length}
|
||||||
<section id="explore" class="explore">
|
<section id="explore" class="explore">
|
||||||
<h2>Explore Upbeat Bytes</h2>
|
<h2>Explore upbeatBytes</h2>
|
||||||
<div class="families">
|
<div class="families">
|
||||||
{#each families as f (f.name)}
|
{#each families as f (f.name)}
|
||||||
{@const tags = f.tags.filter((t) => t.count > 0)}
|
{@const tags = f.tags.filter((t) => t.count > 0)}
|
||||||
@@ -895,7 +895,7 @@
|
|||||||
{#if !pwa.isStandalone && !pwa.dismissed && (pwa.canInstall || pwa.isIOS)}
|
{#if !pwa.isStandalone && !pwa.dismissed && (pwa.canInstall || pwa.isIOS)}
|
||||||
<aside class="install rise">
|
<aside class="install rise">
|
||||||
<div class="install-text">
|
<div class="install-text">
|
||||||
<strong>Keep Upbeat Bytes a tap away.</strong>
|
<strong>Keep upbeatBytes a tap away.</strong>
|
||||||
{#if pwa.canInstall}Add it to your home screen — it opens like an app, no store needed.
|
{#if pwa.canInstall}Add it to your home screen — it opens like an app, no store needed.
|
||||||
{:else}On iPhone: tap the <span class="ios-share">Share</span> button, then “Add to Home Screen.”{/if}
|
{:else}On iPhone: tap the <span class="ios-share">Share</span> button, then “Add to Home Screen.”{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
</p>
|
</p>
|
||||||
<button class="primary" onclick={onclose}>Done</button>
|
<button class="primary" onclick={onclose}>Done</button>
|
||||||
{:else}
|
{:else}
|
||||||
<h2>Sign in to Upbeat Bytes</h2>
|
<h2>Sign in to upbeatBytes</h2>
|
||||||
<p class="sub">
|
<p class="sub">
|
||||||
Save articles and keep your history across devices.
|
Save articles and keep your history across devices.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -167,7 +167,7 @@
|
|||||||
const label = variant === '6' ? 'Long Word' : 'Daily Word';
|
const label = variant === '6' ? 'Long Word' : 'Daily Word';
|
||||||
const score = status === 'won' ? guesses.length : 'X';
|
const score = status === 'won' ? guesses.length : 'X';
|
||||||
const grid = cols.map((cs) => cs.map((c) => EMOJI[c]).join('')).join('\n');
|
const grid = cols.map((cs) => cs.map((c) => EMOJI[c]).join('')).join('\n');
|
||||||
const text = `Upbeat Bytes · ${label} ${date}\n${score}/${maxGuesses}\n${grid}\n${gameShareUrl('word', variant)}`;
|
const text = `upbeatBytes · ${label} ${date}\n${score}/${maxGuesses}\n${grid}\n${gameShareUrl('word', variant)}`;
|
||||||
// Count a share only once it actually happens (sheet completed / clipboard wrote),
|
// Count a share only once it actually happens (sheet completed / clipboard wrote),
|
||||||
// never on a cancelled share sheet or denied clipboard.
|
// never on a cancelled share sheet or denied clipboard.
|
||||||
if (navigator.share) navigator.share({ text }).then(() => trackGame('word', 'shared')).catch(() => {});
|
if (navigator.share) navigator.share({ text }).then(() => trackGame('word', 'shared')).catch(() => {});
|
||||||
|
|||||||
@@ -223,7 +223,7 @@
|
|||||||
|
|
||||||
function share() {
|
function share() {
|
||||||
const label = { small: 'Small', med: 'Medium', large: 'Large' }[size] || '';
|
const label = { small: 'Small', med: 'Medium', large: 'Large' }[size] || '';
|
||||||
const text = `Upbeat Bytes · Word Search (${label}) ${date}\n${theme} — cleared in ${fmt(resultMs)}\n${gameShareUrl('wordsearch', size)}`;
|
const text = `upbeatBytes · Word Search (${label}) ${date}\n${theme} — cleared in ${fmt(resultMs)}\n${gameShareUrl('wordsearch', size)}`;
|
||||||
if (navigator.share) navigator.share({ text }).then(() => trackGame('wordsearch', 'shared')).catch(() => {});
|
if (navigator.share) navigator.share({ text }).then(() => trackGame('wordsearch', 'shared')).catch(() => {});
|
||||||
else navigator.clipboard?.writeText(text).then(() => { trackGame('wordsearch', 'shared'); copied = true; setTimeout(() => (copied = false), 1500); });
|
else navigator.clipboard?.writeText(text).then(() => { trackGame('wordsearch', 'shared'); copied = true; setTimeout(() => (copied = false), 1500); });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,7 +101,7 @@
|
|||||||
|
|
||||||
<header class="bar">
|
<header class="bar">
|
||||||
<div class="container inner">
|
<div class="container inner">
|
||||||
<a class="brand" href="/" aria-label="Upbeat Bytes — home"><img class="logo" src="/logo.svg" alt="Upbeat Bytes" /></a>
|
<a class="brand" href="/" aria-label="upbeatBytes — home"><img class="logo" src="/logo.svg" alt="upbeatBytes" /></a>
|
||||||
<div class="baractions">
|
<div class="baractions">
|
||||||
<button class="fb" onclick={openFeedback} aria-label="Share feedback" title="Share feedback">
|
<button class="fb" onclick={openFeedback} aria-label="Share feedback" title="Share feedback">
|
||||||
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><path d="M4 5h16v11H8l-4 3z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" /></svg>
|
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><path d="M4 5h16v11H8l-4 3z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" /></svg>
|
||||||
|
|||||||
@@ -675,7 +675,7 @@
|
|||||||
|
|
||||||
<header class="bar">
|
<header class="bar">
|
||||||
<div class="container inner">
|
<div class="container inner">
|
||||||
<a class="brand" href="/"><img class="logo" src="/logo.svg" alt="Upbeat Bytes" /></a>
|
<a class="brand" href="/"><img class="logo" src="/logo.svg" alt="upbeatBytes" /></a>
|
||||||
<a class="back" href="/account"><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>Account</a>
|
<a class="back" href="/account"><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>Account</a>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<h1>Couldn't sign you in</h1>
|
<h1>Couldn't sign you in</h1>
|
||||||
<p class="muted">{error}</p>
|
<p class="muted">{error}</p>
|
||||||
<a class="back" href="/">← Back to Upbeat Bytes</a>
|
<a class="back" href="/">← Back to upbeatBytes</a>
|
||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
@@ -273,7 +273,7 @@
|
|||||||
<!-- Canonical/OG/description for /play are baked into the static play.html at build
|
<!-- Canonical/OG/description for /play are baked into the static play.html at build
|
||||||
time (scripts/patch-static-heads.mjs) so non-JS social scrapers get them; we keep
|
time (scripts/patch-static-heads.mjs) so non-JS social scrapers get them; we keep
|
||||||
only the browser-tab title + dev-gate noindex here to avoid duplicate tags. -->
|
only the browser-tab title + dev-gate noindex here to avoid duplicate tags. -->
|
||||||
<title>Play · Upbeat Bytes — calm daily games</title>
|
<title>Play · upbeatBytes — calm daily games</title>
|
||||||
{#if isDevGated(game)}<meta name="robots" content="noindex" />{/if}
|
{#if isDevGated(game)}<meta name="robots" content="noindex" />{/if}
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
|
|||||||
@@ -51,13 +51,13 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>The Zen Den · Upbeat Bytes</title>
|
<title>The Zen Den · upbeatBytes</title>
|
||||||
{#if isDevGated('zen')}<meta name="robots" content="noindex" />{/if}
|
{#if isDevGated('zen')}<meta name="robots" content="noindex" />{/if}
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<header class="bar">
|
<header class="bar">
|
||||||
<div class="container inner">
|
<div class="container inner">
|
||||||
<a class="brand" href="/"><img class="logo" src="/logo.svg" alt="Upbeat Bytes" /></a>
|
<a class="brand" href="/"><img class="logo" src="/logo.svg" alt="upbeatBytes" /></a>
|
||||||
<a class="back" href="/play">
|
<a class="back" href="/play">
|
||||||
<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>Play
|
<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>Play
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "Upbeat Bytes",
|
"name": "upbeatBytes",
|
||||||
"short_name": "Upbeat Bytes",
|
"short_name": "upbeatBytes",
|
||||||
"description": "A calmer, brighter corner of the internet: good news, daily art, small games, and little resets.",
|
"description": "A calmer, brighter corner of the internet: good news, daily art, small games, and little resets.",
|
||||||
"start_url": "/",
|
"start_url": "/",
|
||||||
"scope": "/",
|
"scope": "/",
|
||||||
|
|||||||
+3
-3
@@ -767,7 +767,7 @@ def create_app() -> FastAPI:
|
|||||||
ok = _do_unsubscribe(u, t)
|
ok = _do_unsubscribe(u, t)
|
||||||
msg = (
|
msg = (
|
||||||
"You’re unsubscribed from the daily digest. No hard feelings — "
|
"You’re unsubscribed from the daily digest. No hard feelings — "
|
||||||
"Upbeat Bytes is always here when you want it."
|
"upbeatBytes is always here when you want it."
|
||||||
if ok else
|
if ok else
|
||||||
"That unsubscribe link looks invalid or expired. You can manage the "
|
"That unsubscribe link looks invalid or expired. You can manage the "
|
||||||
"digest from your account settings."
|
"digest from your account settings."
|
||||||
@@ -776,9 +776,9 @@ def create_app() -> FastAPI:
|
|||||||
'<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">'
|
'<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">'
|
||||||
'<div style="max-width:520px;margin:12vh auto;padding:0 24px;text-align:center;'
|
'<div style="max-width:520px;margin:12vh auto;padding:0 24px;text-align:center;'
|
||||||
'font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#16263a">'
|
'font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#16263a">'
|
||||||
'<h1 style="font-size:22px">Upbeat Bytes</h1>'
|
'<h1 style="font-size:22px">upbeatBytes</h1>'
|
||||||
f'<p style="font-size:16px;line-height:1.5;color:#3b4754">{msg}</p>'
|
f'<p style="font-size:16px;line-height:1.5;color:#3b4754">{msg}</p>'
|
||||||
'<p><a href="/" style="color:#0083ad;text-decoration:none">← Back to Upbeat Bytes</a></p></div>'
|
'<p><a href="/" style="color:#0083ad;text-decoration:none">← Back to upbeatBytes</a></p></div>'
|
||||||
)
|
)
|
||||||
return HTMLResponse(html)
|
return HTMLResponse(html)
|
||||||
|
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ def send_email(to: str, subject: str, text: str, html: str | None = None, reply_
|
|||||||
|
|
||||||
def send_feedback(to: str, category: str, message: str, contact: str | None, who: str) -> None:
|
def send_feedback(to: str, category: str, message: str, contact: str | None, who: str) -> None:
|
||||||
"""Notify the admin of new user feedback (plain text is plenty here)."""
|
"""Notify the admin of new user feedback (plain text is plenty here)."""
|
||||||
subject = f"Upbeat Bytes feedback · {category}"
|
subject = f"upbeatBytes feedback · {category}"
|
||||||
reply = contact or "(none given)"
|
reply = contact or "(none given)"
|
||||||
text = (
|
text = (
|
||||||
f"New feedback ({category})\n"
|
f"New feedback ({category})\n"
|
||||||
@@ -74,14 +74,14 @@ def send_feedback_reply(to: str, reply_text: str, reply_html: str | None, origin
|
|||||||
"""Reply to a reader's feedback from the admin inbox. Sends multipart
|
"""Reply to a reader's feedback from the admin inbox. Sends multipart
|
||||||
text/plain + text/html (the HTML is the pre-sanitized Markdown render). Quotes
|
text/plain + text/html (the HTML is the pre-sanitized Markdown render). Quotes
|
||||||
their original note for context; exposes no analytics/account details."""
|
their original note for context; exposes no analytics/account details."""
|
||||||
subject = "Re: Your Upbeat Bytes feedback"
|
subject = "Re: Your upbeatBytes feedback"
|
||||||
quoted = "\n".join("> " + line for line in (original_message or "").splitlines())
|
quoted = "\n".join("> " + line for line in (original_message or "").splitlines())
|
||||||
text = (
|
text = (
|
||||||
f"{reply_text}\n\n"
|
f"{reply_text}\n\n"
|
||||||
"—\n"
|
"—\n"
|
||||||
"In reply to your note to Upbeat Bytes:\n"
|
"In reply to your note to upbeatBytes:\n"
|
||||||
f"{quoted}\n\n"
|
f"{quoted}\n\n"
|
||||||
"Thanks for reaching out.\n— Upbeat Bytes\n"
|
"Thanks for reaching out.\n— upbeatBytes\n"
|
||||||
)
|
)
|
||||||
body_html = None
|
body_html = None
|
||||||
if reply_html:
|
if reply_html:
|
||||||
@@ -90,9 +90,9 @@ def send_feedback_reply(to: str, reply_text: str, reply_html: str | None, origin
|
|||||||
'<div style="font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;'
|
'<div style="font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;'
|
||||||
'color:#16263a;font-size:15px;line-height:1.5">'
|
'color:#16263a;font-size:15px;line-height:1.5">'
|
||||||
f"{reply_html}"
|
f"{reply_html}"
|
||||||
'<p style="color:#5d6b78;font-size:13px;margin-top:20px">In reply to your note to Upbeat Bytes:</p>'
|
'<p style="color:#5d6b78;font-size:13px;margin-top:20px">In reply to your note to upbeatBytes:</p>'
|
||||||
f'<blockquote style="color:#5d6b78;border-left:3px solid #e8e3d8;margin:0;padding-left:12px">{oq}</blockquote>'
|
f'<blockquote style="color:#5d6b78;border-left:3px solid #e8e3d8;margin:0;padding-left:12px">{oq}</blockquote>'
|
||||||
"<p>Thanks for reaching out.<br>— Upbeat Bytes</p></div>"
|
"<p>Thanks for reaching out.<br>— upbeatBytes</p></div>"
|
||||||
)
|
)
|
||||||
# Route the reader's reply to our chosen inbox (never back to the reader).
|
# Route the reader's reply to our chosen inbox (never back to the reader).
|
||||||
cfg = _cfg()
|
cfg = _cfg()
|
||||||
@@ -102,9 +102,9 @@ def send_feedback_reply(to: str, reply_text: str, reply_html: str | None, origin
|
|||||||
|
|
||||||
def send_magic_link(to: str, link: str) -> None:
|
def send_magic_link(to: str, link: str) -> None:
|
||||||
"""Send a calm, single-purpose sign-in email."""
|
"""Send a calm, single-purpose sign-in email."""
|
||||||
subject = "Your Upbeat Bytes sign-in link"
|
subject = "Your upbeatBytes sign-in link"
|
||||||
text = (
|
text = (
|
||||||
"Welcome back to Upbeat Bytes.\n\n"
|
"Welcome back to upbeatBytes.\n\n"
|
||||||
f"Tap to sign in:\n{link}\n\n"
|
f"Tap to sign in:\n{link}\n\n"
|
||||||
"This link works once and expires in 15 minutes.\n"
|
"This link works once and expires in 15 minutes.\n"
|
||||||
"If you didn't request it, you can safely ignore this email."
|
"If you didn't request it, you can safely ignore this email."
|
||||||
@@ -113,7 +113,7 @@ def send_magic_link(to: str, link: str) -> None:
|
|||||||
html = (
|
html = (
|
||||||
'<div style="font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;'
|
'<div style="font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;'
|
||||||
'color:#16263a;line-height:1.6">'
|
'color:#16263a;line-height:1.6">'
|
||||||
"<p>Welcome back to <strong>Upbeat Bytes</strong>.</p>"
|
"<p>Welcome back to <strong>upbeatBytes</strong>.</p>"
|
||||||
f'<p><a href="{safe}" style="display:inline-block;background:#0083ad;color:#fff;'
|
f'<p><a href="{safe}" style="display:inline-block;background:#0083ad;color:#fff;'
|
||||||
'text-decoration:none;padding:11px 20px;border-radius:999px;font-weight:600">'
|
'text-decoration:none;padding:11px 20px;border-radius:999px;font-weight:600">'
|
||||||
"Sign in</a></p>"
|
"Sign in</a></p>"
|
||||||
|
|||||||
+1
-1
@@ -78,7 +78,7 @@ _RESPONSE_FORMATS = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
SYSTEM_PROMPT = """You classify article metadata for Upbeat Bytes, a calm news digest.
|
SYSTEM_PROMPT = """You classify article metadata for upbeatBytes, a calm news digest.
|
||||||
|
|
||||||
The bar is NOT "is this happy?" — it is "will a reader finish this calm or a little better, never worse?" ACCEPT stories that are calm, neutral, insightful, or uplifting: they inform, teach, delight, or show progress or benefit. Neutral-but-absorbing is welcome — a discovery, a clear explainer, a clever build or gadget, a fascinating bit of science, space, nature, design, or culture, a genuinely useful insight — even when it isn't "feel-good."
|
The bar is NOT "is this happy?" — it is "will a reader finish this calm or a little better, never worse?" ACCEPT stories that are calm, neutral, insightful, or uplifting: they inform, teach, delight, or show progress or benefit. Neutral-but-absorbing is welcome — a discovery, a clear explainer, a clever build or gadget, a fascinating bit of science, space, nature, design, or culture, a genuinely useful insight — even when it isn't "feel-good."
|
||||||
|
|
||||||
|
|||||||
+13
-13
@@ -20,7 +20,7 @@ def _tag(name: str, content: str | None, attr: str = "property") -> str:
|
|||||||
def render_share_page(article: dict, base_url: str, summary: str | None = None,
|
def render_share_page(article: dict, base_url: str, summary: str | None = None,
|
||||||
explanation: dict | None = None) -> str:
|
explanation: dict | None = None) -> str:
|
||||||
aid = article["id"]
|
aid = article["id"]
|
||||||
title = (article.get("title") or "Upbeat Bytes").strip()
|
title = (article.get("title") or "upbeatBytes").strip()
|
||||||
why = (article.get("reason_text") or article.get("description")
|
why = (article.get("reason_text") or article.get("description")
|
||||||
or "A calm, constructive story worth your attention.").strip()
|
or "A calm, constructive story worth your attention.").strip()
|
||||||
source = (article.get("source_name") or "the source").strip()
|
source = (article.get("source_name") or "the source").strip()
|
||||||
@@ -39,7 +39,7 @@ def render_share_page(article: dict, base_url: str, summary: str | None = None,
|
|||||||
twitter_card = "summary_large_image" if image else "summary"
|
twitter_card = "summary_large_image" if image else "summary"
|
||||||
|
|
||||||
meta = "\n".join(filter(None, [
|
meta = "\n".join(filter(None, [
|
||||||
_tag("og:site_name", "Upbeat Bytes"),
|
_tag("og:site_name", "upbeatBytes"),
|
||||||
_tag("og:type", "article"),
|
_tag("og:type", "article"),
|
||||||
_tag("og:title", title),
|
_tag("og:title", title),
|
||||||
_tag("og:description", why),
|
_tag("og:description", why),
|
||||||
@@ -138,7 +138,7 @@ def render_share_page(article: dict, base_url: str, summary: str | None = None,
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>{escape(title)} · Upbeat Bytes</title>
|
<title>{escape(title)} · upbeatBytes</title>
|
||||||
<meta name="description" content="{escape(why)}">
|
<meta name="description" content="{escape(why)}">
|
||||||
<link rel="canonical" href="{escape(page_url)}">
|
<link rel="canonical" href="{escape(page_url)}">
|
||||||
<link rel="icon" href="/favicon.svg">
|
<link rel="icon" href="/favicon.svg">
|
||||||
@@ -194,7 +194,7 @@ def render_share_page(article: dict, base_url: str, summary: str | None = None,
|
|||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="bar"><div class="inner"><a href="/"><img src="/logo.svg" alt="Upbeat Bytes"></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>
|
<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>
|
||||||
<main class="wrap">
|
<main class="wrap">
|
||||||
<article class="card">
|
<article class="card">
|
||||||
{media}
|
{media}
|
||||||
@@ -207,9 +207,9 @@ def render_share_page(article: dict, base_url: str, summary: str | None = None,
|
|||||||
<div class="actions">
|
<div class="actions">
|
||||||
<a class="primary" href="{escape(src_url)}" target="_blank" rel="noopener" data-src-click>Read the full story at {escape(source)}</a>
|
<a class="primary" href="{escape(src_url)}" target="_blank" rel="noopener" data-src-click>Read the full story at {escape(source)}</a>
|
||||||
<button class="secondary" type="button" data-share>Copy link</button>
|
<button class="secondary" type="button" data-share>Copy link</button>
|
||||||
<a class="secondary" href="/news">Explore Upbeat Bytes →</a>
|
<a class="secondary" href="/news">Explore upbeatBytes →</a>
|
||||||
</div>
|
</div>
|
||||||
<p class="note">Upbeat Bytes summarizes in its own words and links to the original publisher — it doesn't host the article.</p>
|
<p class="note">upbeatBytes summarizes in its own words and links to the original publisher — it doesn't host the article.</p>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</main>
|
</main>
|
||||||
@@ -275,7 +275,7 @@ def render_digest(items: list[dict], base_url: str, brief_date: str | None) -> s
|
|||||||
)
|
)
|
||||||
|
|
||||||
meta = "\n".join(filter(None, [
|
meta = "\n".join(filter(None, [
|
||||||
_tag("og:site_name", "Upbeat Bytes"),
|
_tag("og:site_name", "upbeatBytes"),
|
||||||
_tag("og:type", "website"),
|
_tag("og:type", "website"),
|
||||||
_tag("og:title", "Today's good news, summarized"),
|
_tag("og:title", "Today's good news, summarized"),
|
||||||
_tag("og:description", intro),
|
_tag("og:description", intro),
|
||||||
@@ -292,7 +292,7 @@ def render_digest(items: list[dict], base_url: str, brief_date: str | None) -> s
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Today's good news, summarized · Upbeat Bytes</title>
|
<title>Today's good news, summarized · upbeatBytes</title>
|
||||||
<meta name="description" content="{escape(intro)}">
|
<meta name="description" content="{escape(intro)}">
|
||||||
<link rel="canonical" href="{page_url}">
|
<link rel="canonical" href="{page_url}">
|
||||||
<link rel="icon" href="/favicon.svg">
|
<link rel="icon" href="/favicon.svg">
|
||||||
@@ -324,12 +324,12 @@ def render_digest(items: list[dict], base_url: str, brief_date: str | None) -> s
|
|||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="bar"><div class="inner"><a href="/"><img src="/logo.svg" alt="Upbeat Bytes"></a></div></div>
|
<div class="bar"><div class="inner"><a href="/"><img src="/logo.svg" alt="upbeatBytes"></a></div></div>
|
||||||
<main class="wrap">
|
<main class="wrap">
|
||||||
<h1>Today's good news</h1>
|
<h1>Today's good news</h1>
|
||||||
<p class="lede">{escape(intro)}{f' · {escape(brief_date)}' if brief_date else ''}</p>
|
<p class="lede">{escape(intro)}{f' · {escape(brief_date)}' if brief_date else ''}</p>
|
||||||
{cards}
|
{cards}
|
||||||
<p class="more"><a href="/news">Browse more on Upbeat Bytes →</a></p>
|
<p class="more"><a href="/news">Browse more on upbeatBytes →</a></p>
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
</html>"""
|
</html>"""
|
||||||
@@ -339,7 +339,7 @@ def render_not_found(base_url: str) -> str:
|
|||||||
return f"""<!doctype html>
|
return f"""<!doctype html>
|
||||||
<html lang="en"><head><meta charset="utf-8">
|
<html lang="en"><head><meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Story not found · Upbeat Bytes</title><link rel="icon" href="/favicon.svg">
|
<title>Story not found · upbeatBytes</title><link rel="icon" href="/favicon.svg">
|
||||||
<style>
|
<style>
|
||||||
body {{ margin:0; min-height:100vh; display:flex; flex-direction:column; align-items:center;
|
body {{ margin:0; min-height:100vh; display:flex; flex-direction:column; align-items:center;
|
||||||
justify-content:center; gap:14px; text-align:center; padding:40px;
|
justify-content:center; gap:14px; text-align:center; padding:40px;
|
||||||
@@ -348,8 +348,8 @@ def render_not_found(base_url: str) -> str:
|
|||||||
a {{ color:#006b8e; }}
|
a {{ color:#006b8e; }}
|
||||||
</style></head>
|
</style></head>
|
||||||
<body>
|
<body>
|
||||||
<img src="/logo.svg" alt="Upbeat Bytes" style="height:44px">
|
<img src="/logo.svg" alt="upbeatBytes" style="height:44px">
|
||||||
<h1 style="font-family:Georgia,serif;font-weight:600">That story isn't here</h1>
|
<h1 style="font-family:Georgia,serif;font-weight:600">That story isn't here</h1>
|
||||||
<p style="color:#5d6b78">It may have moved on — the good news refreshes often.</p>
|
<p style="color:#5d6b78">It may have moved on — the good news refreshes often.</p>
|
||||||
<a href="/">← Back to Upbeat Bytes</a>
|
<a href="/">← Back to upbeatBytes</a>
|
||||||
</body></html>"""
|
</body></html>"""
|
||||||
|
|||||||
@@ -43,6 +43,6 @@ def test_reply_to_uses_env_inbox(monkeypatch):
|
|||||||
def test_reply_to_falls_back_to_from(monkeypatch):
|
def test_reply_to_falls_back_to_from(monkeypatch):
|
||||||
_arm(monkeypatch)
|
_arm(monkeypatch)
|
||||||
monkeypatch.delenv("GOODNEWS_REPLY_TO_EMAIL", raising=False)
|
monkeypatch.delenv("GOODNEWS_REPLY_TO_EMAIL", raising=False)
|
||||||
monkeypatch.setenv("GOODNEWS_SMTP_FROM", "Upbeat Bytes <hello@upbeatbytes.com>")
|
monkeypatch.setenv("GOODNEWS_SMTP_FROM", "upbeatBytes <hello@upbeatbytes.com>")
|
||||||
es.send_feedback_reply("reader@x.com", "hi", None, "o")
|
es.send_feedback_reply("reader@x.com", "hi", None, "o")
|
||||||
assert _FakeSMTP.last["Reply-To"] == "Upbeat Bytes <hello@upbeatbytes.com>"
|
assert _FakeSMTP.last["Reply-To"] == "upbeatBytes <hello@upbeatbytes.com>"
|
||||||
|
|||||||
Reference in New Issue
Block a user