Phase B2: grouping pills + Explore-by-family (frontend)
The "wander" layer for the multi-tag model, sitting beneath the brief: - Cards show up to 3 tappable grouping pills (the article's tags), falling back to the primary topic for articles the re-tag hasn't reached. Tap a pill → that tag's lane. Tags read as little doorways, not metadata confetti. - New tag-lane view (select 'tag:<slug>' → /api/feed?tag=) with a calm heading and the parent family's description as subtitle. - Replace the flat "Explore by topic" strip with four calm family bands (Discovery & Wonder / People & Kindness / Solutions & Progress / Mind & Craft) from /api/families; zero-count tags hide until tagging fills them in. - Mood nav stays the primary emotional layer; the brief stays the front door. - /api/families fetch is non-fatal so the page degrades gracefully when the B1 backend isn't deployed yet. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,12 @@
|
||||
<script>
|
||||
let { article, onaction, onreplace, hero = false } = $props();
|
||||
let { article, onaction, onreplace, ontag, hero = false } = $props();
|
||||
|
||||
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)
|
||||
);
|
||||
|
||||
// Reset image-failure when the article changes (cards swap in place).
|
||||
let failed = $state(false);
|
||||
@@ -34,8 +41,13 @@
|
||||
|
||||
<div class="body">
|
||||
<div class="tags">
|
||||
{#if article.topic}<span class="tag">{article.topic}</span>{/if}
|
||||
{#if article.flavor}<span class="tag soft">{article.flavor}</span>{/if}
|
||||
{#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 class="src">{article.source}</div>
|
||||
|
||||
@@ -79,13 +91,17 @@
|
||||
}
|
||||
|
||||
.body { padding: 16px 18px 14px; display: flex; flex-direction: column; gap: 8px; flex: 1; }
|
||||
.tags { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; font-size: 0.66rem; }
|
||||
.tags { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; }
|
||||
.tag {
|
||||
background: var(--accent); color: #fff; border-radius: 999px;
|
||||
padding: 3px 11px 2px; font-family: var(--label); font-weight: 300;
|
||||
text-transform: uppercase; letter-spacing: 0.055em;
|
||||
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; }
|
||||
/* Source on its own line below the tags, left-justified, for uniformity. */
|
||||
.src { color: var(--muted); font-size: 0.78rem; margin: -2px 0 2px; }
|
||||
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
|
||||
let moods = $state([]);
|
||||
let topics = $state([]);
|
||||
let selected = $state('today'); // 'today' | a mood key | a topic key
|
||||
let families = $state([]);
|
||||
let selected = $state('today'); // 'today' | a mood key | a topic key | 'tag:<slug>'
|
||||
let brief = $state(null);
|
||||
let feed = $state([]);
|
||||
let userPrefs = $state(P.blank());
|
||||
@@ -61,16 +62,28 @@
|
||||
}
|
||||
|
||||
const cap = (s) => (s ? s[0].toUpperCase() + s.slice(1) : s);
|
||||
const humanize = (s) => (s || '').replace(/-/g, ' ');
|
||||
let filtersOn = $derived(P.active(userPrefs));
|
||||
let currentMood = $derived(moods.find((m) => m.key === selected));
|
||||
let currentTopic = $derived(topics.find((t) => t.key === selected));
|
||||
let currentTag = $derived(selected.startsWith('tag:') ? selected.slice(4) : null);
|
||||
// The family a grouping tag belongs to — for the lane's calm subtitle.
|
||||
let tagFamily = $derived(
|
||||
currentTag ? families.find((f) => f.tags.some((t) => t.key === currentTag)) : null
|
||||
);
|
||||
let viewLabel = $derived(
|
||||
selected === 'today' ? 'Highlights from Today' : (currentMood?.label ?? cap(currentTopic?.key) ?? '')
|
||||
selected === 'today'
|
||||
? 'Highlights from Today'
|
||||
: currentTag
|
||||
? humanize(currentTag)
|
||||
: (currentMood?.label ?? cap(currentTopic?.key) ?? '')
|
||||
);
|
||||
let viewSubtitle = $derived(
|
||||
selected === 'today'
|
||||
? (brief?.brief_date ?? '')
|
||||
: (currentMood?.description ?? currentTopic?.description ?? '')
|
||||
: currentTag
|
||||
? (tagFamily?.description ?? '')
|
||||
: (currentMood?.description ?? currentTopic?.description ?? '')
|
||||
);
|
||||
let activeTab = $derived(showYou ? 'you' : selected === 'today' ? 'today' : 'browse');
|
||||
|
||||
@@ -108,6 +121,12 @@
|
||||
try {
|
||||
if (key === 'today') {
|
||||
await loadToday(fresh);
|
||||
} else if (key.startsWith('tag:')) {
|
||||
const tag = key.slice(4);
|
||||
const q = P.param(userPrefs);
|
||||
const ex = Array.from(dismissed).join(',');
|
||||
feed = (await getJSON(`/api/feed?limit=24&tag=${encodeURIComponent(tag)}${q ? '&' + q : ''}${ex ? '&exclude=' + ex : ''}`)).items;
|
||||
remember(feed);
|
||||
} else {
|
||||
const q = P.param(P.merge(userPrefs, viewFilter(key)));
|
||||
const ex = Array.from(dismissed).join(',');
|
||||
@@ -189,6 +208,10 @@
|
||||
try {
|
||||
moods = await getJSON('/api/moods');
|
||||
topics = (await getJSON('/api/categories')).topics;
|
||||
// Non-fatal: the groupings backend (B1) may not be deployed yet. If it
|
||||
// isn't, the Explore-by-family section simply stays hidden and cards fall
|
||||
// back to the topic pill — the rest of the page works unchanged.
|
||||
try { families = await getJSON('/api/families'); } catch { families = []; }
|
||||
await select('today');
|
||||
} catch (e) {
|
||||
error = 'Could not reach Upbeat Bytes.';
|
||||
@@ -258,11 +281,11 @@
|
||||
{#if selected === 'today'}
|
||||
{#if brief?.items?.length}
|
||||
<section class="rise">
|
||||
<ArticleCard article={brief.items[0]} hero onaction={applyAction} onreplace={replaceArticle} />
|
||||
<ArticleCard article={brief.items[0]} hero onaction={applyAction} onreplace={replaceArticle} ontag={(t) => select('tag:' + t)} />
|
||||
{#if brief.items.length > 1}
|
||||
<div class="grid rest">
|
||||
{#each brief.items.slice(1) as a (a.id)}
|
||||
<ArticleCard article={a} onaction={applyAction} onreplace={replaceArticle} />
|
||||
<ArticleCard article={a} onaction={applyAction} onreplace={replaceArticle} ontag={(t) => select('tag:' + t)} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -274,7 +297,7 @@
|
||||
{:else if feed.length}
|
||||
<div class="grid rise">
|
||||
{#each feed as a (a.id)}
|
||||
<ArticleCard article={a} onaction={applyAction} onreplace={replaceArticle} />
|
||||
<ArticleCard article={a} onaction={applyAction} onreplace={replaceArticle} ontag={(t) => select('tag:' + t)} />
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
@@ -282,12 +305,27 @@
|
||||
{/if}
|
||||
{/key}
|
||||
|
||||
{#if topics.length}
|
||||
{#if families.length}
|
||||
<section id="explore" class="explore">
|
||||
<h2>Explore by topic</h2>
|
||||
<div class="chips">
|
||||
{#each topics as t (t.key)}
|
||||
<button class="chip" class:active={selected === t.key} onclick={() => select(t.key)}>{cap(t.key)}</button>
|
||||
<h2>Explore Upbeat Bytes</h2>
|
||||
<div class="families">
|
||||
{#each families as f (f.name)}
|
||||
{@const tags = f.tags.filter((t) => t.count > 0)}
|
||||
{#if tags.length}
|
||||
<div class="family">
|
||||
<h3>{f.name}</h3>
|
||||
<p class="fdesc">{f.description}</p>
|
||||
<div class="chips">
|
||||
{#each tags as t (t.key)}
|
||||
<button
|
||||
class="chip"
|
||||
class:active={selected === 'tag:' + t.key}
|
||||
onclick={() => select('tag:' + t.key)}
|
||||
>{humanize(t.key)}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
@@ -308,17 +346,21 @@
|
||||
background: var(--accent); border-radius: 2px; margin-top: 14px; opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Explore by topic — a quiet secondary discovery section, not a nav row. */
|
||||
.explore { margin: 48px 0 8px; padding-top: 26px; border-top: 1px solid var(--line); }
|
||||
/* Explore — a quiet repository of groupings beneath the brief, not a nav row.
|
||||
Four calm families, each a doorway into its tags. */
|
||||
.explore { margin: 52px 0 8px; padding-top: 28px; border-top: 1px solid var(--line); }
|
||||
.explore h2 {
|
||||
font-size: 0.82rem; text-transform: uppercase; letter-spacing: 0.12em;
|
||||
color: var(--muted); font-family: var(--sans); font-weight: 700; margin: 0 0 12px;
|
||||
font-size: 0.74rem; text-transform: uppercase; letter-spacing: 0.14em;
|
||||
color: var(--muted); font-family: var(--label); font-weight: 400; margin: 0 0 22px;
|
||||
}
|
||||
.explore .chips { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.families { display: grid; grid-template-columns: repeat(auto-fit, minmax(248px, 1fr)); gap: 26px 32px; }
|
||||
.family h3 { font-size: 1.16rem; margin: 0 0 2px; }
|
||||
.family .fdesc { margin: 0 0 11px; color: var(--muted); font-size: 0.86rem; line-height: 1.45; }
|
||||
.explore .chips { display: flex; flex-wrap: wrap; gap: 7px; }
|
||||
.explore .chip {
|
||||
border: 1px solid var(--line); background: var(--surface); color: var(--ink);
|
||||
border-radius: 999px; padding: 7px 15px; font-size: 0.9rem; cursor: pointer; transition: all 0.14s ease;
|
||||
text-transform: capitalize;
|
||||
border-radius: 999px; padding: 5px 13px; font-size: 0.82rem; cursor: pointer;
|
||||
transition: all 0.14s ease; text-transform: capitalize;
|
||||
}
|
||||
.explore .chip:hover { border-color: var(--accent); color: var(--accent-deep); }
|
||||
.explore .chip.active { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
|
||||
Reference in New Issue
Block a user