Files
upbeatBytes/frontend/src/lib/components/ArticleCard.svelte
T
thejayman77 38889f76e5 Source feeds: click a source to see its publication feed
Click a source name on any card → a feed of just that source's articles,
newest-first, still accepted / non-duplicate / boundary-filtered (the calm
promise isn't bypassed). A natural way to follow a publication's feel.

* queries.feed + /api/feed: source_id filter; Article output gains source_id.
* Frontend: source label is a button → transient 'source:<id>' view (like
  'tag:<slug>'), rendered in the feed grid with Load more, header = source name.
* Ad-hoc, not a pinned lane. Foundation for a future source page (metadata) +
  Follow; shareable /source/<slug> route and source_view analytics come then.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 08:30:33 -04:00

377 lines
16 KiB
Svelte

<script>
import { auth, savedIds, toggleSave } from '$lib/auth.svelte.js';
import { track } from '$lib/analytics.js';
let { article, onaction, onreplace, ontag, onsource, onimageerror, onview, hero = false, thumb = false } = $props();
function opened() {
// Records history; the /a page itself fires the summary_viewed event on load.
onview?.(article);
}
let isSaved = $derived(savedIds.has(article.id));
const humanize = (s) => (s || '').replace(/-/g, ' ');
// Grouping tags are the doorways; cap at 3 so cards stay calm. Fall back to
// the primary topic for articles the re-tag hasn't reached yet.
let pills = $derived(
(article.tags?.length ? article.tags : article.topic ? [article.topic] : []).slice(0, 3)
);
// The accent line is color-coded by primary topic — a quiet bit of variety
// across the grid. Muted tones in the sand/sea/sun family; calm, not loud.
const TOPIC_COLOR = {
science: '#0083ad', // azure (sea)
technology: '#5f72a8', // muted indigo
environment: '#5a9d3e', // leaf green (clearly distinct from health's teal)
health: '#1f9e95', // teal
community: '#c79a3a', // warm gold
culture: '#c4795a', // terracotta
animals: '#9a8246', // earthy tan
learning: '#8a6da3', // soft violet
};
let accentColor = $derived(TOPIC_COLOR[article.topic] ?? 'var(--accent)');
// Reset image-failure when the article changes (cards swap in place).
let failed = $state(false);
$effect(() => {
void article.image_url;
failed = false;
});
let hasImg = $derived(!!article.image_url && !failed);
// Hero shows a large image. A `thumb` card (brief rest cards) ALWAYS carries a
// banner so the grid stays uniform: a real image when it has one, otherwise a
// calm topic-colored placeholder. Every other card stays typographic.
let showImage = $derived(hasImg && (hero || thumb));
let isThumb = $derived(thumb && !hero);
let usePlaceholder = $derived(isThumb && !hasImg);
let safeHref = $derived(
typeof article.url === 'string' && /^https?:\/\//.test(article.url) ? article.url : '#'
);
// Default click → our summary page (the calm gist). Source is one tap further.
let summaryHref = $derived('/a/' + article.id);
function openedSource() {
onview?.(article);
track('full_story', article.id);
if (article.paywalled) track('paywalled_source_open', article.id);
}
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 });
track('native_share', article.id);
} catch {
/* user cancelled */
}
shareOpen = false;
}
// Kick off summary generation when the user signals intent to share, so it's
// cached by the time a recipient opens the link.
function warmSummary() {
fetch(`/api/summary/${article.id}`).catch(() => {});
}
async function copy(text, label) {
try {
await navigator.clipboard.writeText(text);
copied = label;
setTimeout(() => (copied = ''), 1500);
} catch {
/* clipboard blocked */
}
}
</script>
<article
class:hero={hero && hasImg}
class:herotype={hero && !hasImg}
class:tile={!hero}
class:hasthumb={isThumb}
data-topic={article.topic ?? ''}
>
{#if showImage}
<a class="media" href={summaryHref} onclick={opened}>
<img src={article.image_url} alt="" loading="lazy" referrerpolicy="no-referrer"
onerror={() => { failed = true; onimageerror?.(); }} />
</a>
{:else if usePlaceholder}
<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>
</a>
{/if}
<div class="body">
<div class="cardhead">
<span class="accent" style="background:{accentColor}"></span>
<div class="pillrow">
{#each pills as t, i (t)}
{#if ontag}
<button type="button" class="tag" class:soft={i > 0} onclick={() => ontag(t)}>{humanize(t)}</button>
{:else}
<span class="tag" class:soft={i > 0}>{humanize(t)}</span>
{/if}
{/each}
</div>
</div>
<div class="src">
{#if onsource && article.source_id}
<button type="button" class="srclink" onclick={() => onsource(article.source_id, article.source)}>{article.source}</button>
{:else}
{article.source}
{/if}
</div>
<h3><a href={summaryHref} onclick={opened}>{article.title}</a></h3>
{#if article.summary}
<p class="summary">{article.summary}</p>
{:else if hero && article.description}
<p class="desc">{article.description}</p>
{/if}
{#if article.paywalled}
<p class="paywall">May need a subscription to read</p>
{/if}
<div class="actions">
{#if auth.user}
<button class="save" class:on={isSaved} onclick={() => toggleSave(article.id)}
aria-pressed={isSaved} title={isSaved ? 'Saved' : 'Save for later'}>
<svg viewBox="0 0 24 24" aria-hidden="true" width="13" height="13">
<path d="M6 3h12v18l-6-4-6 4z" fill={isSaved ? 'currentColor' : 'none'}
stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" />
</svg>
{isSaved ? 'Saved' : 'Save'}
</button>
{/if}
<a class="source" href={safeHref} target="_blank" rel="noopener" onclick={openedSource}>
{article.paywalled ? 'Full story 🔒' : 'Full story'}
</a>
{#if onreplace}
<button class="replace" onclick={() => onreplace(article)}>Replace</button>
{/if}
<span class="sharewrap">
<button class="share" onclick={() => { shareOpen = !shareOpen; if (shareOpen) warmSummary(); }} 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'); track('share_ub', article.id); }}>
{copied === 'link' ? 'Copied!' : 'Copy link'}
</button>
<button role="menuitem" onclick={() => { copy(article.url, 'source'); track('copy_source', article.id); }}>
{copied === 'source' ? 'Copied!' : 'Copy source link'}
</button>
</div>
{/if}
</span>
{#if onaction && article.topic}<button class="mute" onclick={() => act('notToday', article.topic)}>Not today</button>{/if}
{#if onaction && article.flavor}<button class="mute" onclick={() => act('lessLikeThis', article.flavor)}>Less like this</button>{/if}
{#if onaction && article.topic}<button class="mute" onclick={() => act('alwaysHide', article.topic)}>Hide {article.topic}</button>{/if}
</div>
</div>
</article>
<style>
article {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
overflow: hidden;
box-shadow: var(--shadow);
display: flex;
flex-direction: column;
height: 100%;
}
.body { padding: 16px 18px 14px; display: flex; flex-direction: column; gap: 8px; flex: 1; }
.cardhead { display: flex; align-items: center; }
.pillrow { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; }
.tag {
background: var(--accent); color: #fff; border: none; border-radius: 999px;
padding: 3px 11px 2px; font-family: var(--label); font-weight: 300; font-size: 0.66rem;
text-transform: uppercase; letter-spacing: 0.055em; line-height: 1.5;
transition: background 0.14s ease, color 0.14s ease;
}
.tag.soft { background: var(--accent-soft); color: var(--accent-deep); }
button.tag { cursor: pointer; }
button.tag:hover { background: var(--accent-deep); color: #fff; }
button.tag.soft:hover { background: var(--accent); color: #fff; }
/* On tiles, accent + pills + divider are ONE header unit with a fixed height,
so the centered band is exactly the band the eye measures (accent line →
divider). The accent is the top grid row; the pill row centers in the rest,
giving even margins for 1-, 2-, and 3-pill cards with everything aligned. */
.tile .cardhead {
display: grid; grid-template-rows: 3px 1fr; height: 94px;
padding-top: 18px; border-bottom: 1px solid var(--line);
}
.tile .accent {
width: 30px; height: 3px; border-radius: 2px; background: var(--accent); opacity: 0.85;
}
.tile .pillrow { align-self: center; }
/* Source on its own line below the tags, left-justified, for uniformity. */
.src { color: var(--muted); font-size: 0.78rem; margin: -2px 0 2px; }
.srclink {
background: none; border: none; padding: 0; font: inherit; color: var(--muted);
cursor: pointer; border-bottom: 1px dotted transparent;
}
.srclink:hover { color: var(--accent-deep); border-bottom-color: var(--accent-soft); }
h3 { font-size: 1.18rem; }
h3 a:hover { color: var(--accent-deep); }
.desc { margin: 2px 0 0; color: #3b4754; }
.summary { margin: 2px 0 0; color: var(--ink); font-size: 0.92rem; }
.tile .summary {
display: -webkit-box; -webkit-box-orient: vertical; overflow: hidden; -webkit-line-clamp: 5;
}
.hero .summary, .herotype .summary { font-size: 1rem; }
.paywall {
margin: 0; font-size: 0.78rem; color: var(--gold);
display: inline-flex; align-items: center; gap: 5px;
}
.paywall::before { content: '🔒'; font-size: 0.72rem; filter: grayscale(0.3); }
/* Keep heights even: clamp variable-length text on tiles. */
.tile h3 {
display: -webkit-box; -webkit-box-orient: vertical; overflow: hidden; -webkit-line-clamp: 3;
}
.hero .desc, .herotype .desc {
display: -webkit-box; -webkit-box-orient: vertical; overflow: hidden; -webkit-line-clamp: 6;
}
.actions { margin-top: auto; padding-top: 12px; display: flex; gap: 14px; flex-wrap: wrap; }
.actions button {
background: none; border: none; padding: 0; color: var(--muted);
font-size: 0.76rem; border-bottom: 1px dotted var(--line);
}
.actions button:hover { color: var(--accent-deep); border-bottom-color: var(--accent); }
.actions .replace { color: var(--accent-deep); border-bottom-color: var(--accent-soft); }
.actions .save {
display: inline-flex; align-items: center; gap: 5px;
color: var(--accent-deep); border-bottom-color: var(--accent-soft);
}
.actions .save.on { color: var(--accent); }
.actions .source {
color: var(--accent-deep); text-decoration: none; font-size: 0.76rem;
border-bottom: 1px dotted var(--accent-soft);
}
.actions .source:hover { border-bottom-color: var(--accent); }
.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 .mute { opacity: 0; transition: opacity 0.16s ease; }
article:hover .actions .mute,
article:focus-within .actions .mute { opacity: 1; }
}
/* ---- Compact top image for brief rest cards (thumb) ---- */
/* A modest, fixed-ratio banner — selective image confidence, not a media wall.
Light desaturation for calm cohesion; never a heavy tint. */
.tile .media {
display: block; position: relative; width: 100%;
aspect-ratio: 16 / 9; overflow: hidden; border-bottom: 1px solid var(--line);
background: linear-gradient(135deg, var(--accent-soft), #f1ece0);
}
.tile .media img {
width: 100%; height: 100%; object-fit: cover; display: block;
filter: saturate(0.9);
}
/* Image-less cards get a calm placeholder banner in the card's topic color
(--c), so every card in the grid is the same size — uniform, not lacking. */
.tile .media.placeholder {
display: flex; align-items: center; justify-content: center; text-decoration: none;
/* Flat fill in the card's topic color. */
background: color-mix(in srgb, var(--c) 14%, var(--surface));
}
.tile .media.placeholder .ph-word {
font-family: var(--serif); font-size: 2rem; line-height: 1;
/* A deep shade of the topic color so the word reads clearly (health vs
environment easy to tell apart). */
color: color-mix(in srgb, var(--c) 64%, var(--ink)); opacity: 0.92;
text-transform: lowercase; letter-spacing: -0.01em;
}
/* A thumb card's banner carries the topic identity, so drop the faint corner
watermark there to avoid a doubled word. */
.tile.hasthumb::after { display: none; }
/* The "why" now lives only on the article page, so the summary gets the room. */
.tile.hasthumb .summary { -webkit-line-clamp: 4; }
/* ---- Typographic editorial tile (every non-hero card) ---- */
.tile { position: relative; }
/* The .cardhead owns the top padding (above the accent); avoid doubling it. */
.tile .body { padding-top: 0; }
.tile::after {
content: attr(data-topic);
position: absolute; right: 10px; bottom: -8px;
font-family: var(--serif); font-size: 3.4rem; line-height: 1;
color: var(--accent); opacity: 0.05; text-transform: lowercase; letter-spacing: -0.02em;
pointer-events: none; user-select: none;
}
.tile .cardhead, .tile .src, .tile h3, .tile .actions { position: relative; }
/* ---- Hero WITH image: two columns, with an atmospheric overlay ---- */
.hero { display: grid; grid-template-columns: 1.1fr 1fr; }
.hero .media {
position: relative; display: block; height: 100%; min-height: 300px;
background: linear-gradient(135deg, var(--accent-soft), #f1ece0);
}
.hero .media img { width: 100%; height: 100%; object-fit: cover; }
/* Soft feed images read as atmospheric, not broken. */
.hero .media::after {
content: ''; position: absolute; inset: 0; pointer-events: none;
background: linear-gradient(180deg, rgba(10, 22, 38, 0) 55%, rgba(10, 22, 38, 0.18));
}
.hero .body { padding: 30px 32px; justify-content: center; gap: 12px; }
.hero h3 { font-size: 1.95rem; }
/* ---- Hero WITHOUT image: fully typographic lead ---- */
.herotype {
position: relative; overflow: hidden;
background:
radial-gradient(120% 140% at 100% 0%, var(--accent-soft) 0%, transparent 55%),
var(--surface);
}
.herotype::after {
content: attr(data-topic);
position: absolute; right: 12px; bottom: -18px;
font-family: var(--serif); font-size: clamp(4rem, 12vw, 8rem); line-height: 1;
color: var(--accent); opacity: 0.07; text-transform: lowercase; letter-spacing: -0.02em;
pointer-events: none; user-select: none;
}
.herotype .body { position: relative; padding: 40px 36px; gap: 12px; }
.herotype h3 { font-size: 2.15rem; }
.herotype .desc { font-size: 1.05rem; }
@media (max-width: 640px) {
.hero { grid-template-columns: 1fr; }
.hero .media { min-height: 200px; }
.hero h3, .herotype h3 { font-size: 1.6rem; }
.herotype .body { padding: 30px 24px; }
}
</style>