Article sharing: branded /a/<id> page + share menu

- Server-rendered /a/<id> "pointer" page (FastAPI): OG/Twitter meta from the
  article's title/why/image, self-canonical (UB pages are the canonical for
  themselves), a prominent "Read the full story at {source}" button and a quiet
  "Explore more on Upbeat Bytes". No article body. Unknown/rejected/duplicate/
  malformed ids → a calm 404 (no stack traces). Text-card preview when no image.
- Caddy routes /a/* to the API.
- Card Share control → menu: native Share… (where available), Copy link (the UB
  card page), Copy source link. Boundary actions now hide-on-hover via a .mute
  class so Save/Replace/Share stay visible. 122 tests pass.

(Event tracking for shares/opens lands with the analytics step next.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-03 15:27:30 +00:00
parent a2765af3fc
commit 3d9900cdfc
4 changed files with 266 additions and 9 deletions
+64 -7
View File
@@ -41,6 +41,31 @@
function act(kind, value) {
if (value) onaction?.(kind, value);
}
// Sharing: share the branded Upbeat Bytes card page (default), with copy-source.
let shareOpen = $state(false);
let copied = $state('');
const canNativeShare = typeof navigator !== 'undefined' && !!navigator.share;
let shareUrl = $derived(
(typeof location !== 'undefined' ? location.origin : 'https://upbeatbytes.com') + '/a/' + article.id
);
async function nativeShare() {
try {
await navigator.share({ title: article.title, url: shareUrl });
} catch {
/* user cancelled */
}
shareOpen = false;
}
async function copy(text, label) {
try {
await navigator.clipboard.writeText(text);
copied = label;
setTimeout(() => (copied = ''), 1500);
} catch {
/* clipboard blocked */
}
}
</script>
<article
@@ -101,9 +126,26 @@
{article.paywalled ? 'Find one I can read' : 'Replace'}
</button>
{/if}
{#if article.topic}<button onclick={() => act('notToday', article.topic)}>Not today</button>{/if}
{#if article.flavor}<button onclick={() => act('lessLikeThis', article.flavor)}>Less like this</button>{/if}
{#if article.topic}<button onclick={() => act('alwaysHide', article.topic)}>Hide {article.topic}</button>{/if}
<span class="sharewrap">
<button class="share" onclick={() => (shareOpen = !shareOpen)} aria-expanded={shareOpen}>Share</button>
{#if shareOpen}
<button class="backdrop" aria-label="Close" onclick={() => (shareOpen = false)}></button>
<div class="sharemenu" role="menu">
{#if canNativeShare}
<button role="menuitem" onclick={nativeShare}>Share…</button>
{/if}
<button role="menuitem" onclick={() => copy(shareUrl, 'link')}>
{copied === 'link' ? 'Copied!' : 'Copy link'}
</button>
<button role="menuitem" onclick={() => copy(article.url, 'source')}>
{copied === 'source' ? 'Copied!' : 'Copy source link'}
</button>
</div>
{/if}
</span>
{#if article.topic}<button class="mute" onclick={() => act('notToday', article.topic)}>Not today</button>{/if}
{#if article.flavor}<button class="mute" onclick={() => act('lessLikeThis', article.flavor)}>Less like this</button>{/if}
{#if article.topic}<button class="mute" onclick={() => act('alwaysHide', article.topic)}>Hide {article.topic}</button>{/if}
</div>
</div>
</article>
@@ -181,11 +223,26 @@
color: var(--accent-deep); border-bottom-color: var(--accent-soft);
}
.actions .save.on { color: var(--accent); }
/* Save + Replace stay visible; the muter actions reveal on hover. */
.actions .share { color: var(--accent-deep); border-bottom-color: var(--accent-soft); }
.sharewrap { position: relative; display: inline-flex; }
.sharemenu {
position: absolute; bottom: 130%; left: 0; z-index: 12;
background: var(--surface); border: 1px solid var(--line); border-radius: 12px;
box-shadow: var(--shadow); padding: 6px; min-width: 150px;
display: flex; flex-direction: column;
}
.sharemenu button {
text-align: left; padding: 8px 10px; border: none; border-radius: 8px;
background: none; color: var(--ink); font-size: 0.82rem; cursor: pointer;
}
.sharemenu button:hover { background: var(--accent-soft); color: var(--accent-deep); }
.backdrop { position: fixed; inset: 0; z-index: 11; background: none; border: none; cursor: default; }
/* Save, Replace, Share stay visible; only the boundary actions reveal on hover. */
@media (hover: hover) {
.actions button:not(.replace):not(.save) { opacity: 0; transition: opacity 0.16s ease; }
article:hover .actions button:not(.replace):not(.save),
article:focus-within .actions button:not(.replace):not(.save) { opacity: 1; }
.actions .mute { opacity: 0; transition: opacity 0.16s ease; }
article:hover .actions .mute,
article:focus-within .actions .mute { opacity: 1; }
}
/* ---- Typographic editorial tile (every non-hero card) ---- */