38889f76e5
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>
377 lines
16 KiB
Svelte
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>
|