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:
@@ -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) ---- */
|
||||
|
||||
Reference in New Issue
Block a user