Customizable nav lanes: pin moods / topics / discovery tags
Readers can now choose which quick-access lanes sit above the feed; "Today" stays pinned. The pool (goodnews/lanes.py, served at /api/lanes) is one source of truth over three lane kinds the feed already renders: moods, primary topics, and high-volume Discovery tags. Selection lives in the existing prefs blob (localStorage + /api/prefs sync); the filter parser ignores the new `lanes` field, so it rides along harmlessly. Default = today's moods, unchanged. Food/Space stay grouping tags rather than primary topics (per review): `space` already existed; added `food` to the Mind & Craft family so the classifier assigns it, and seeded the Food lane by re-tagging the two food sources. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
<script>
|
||||
// pool: { pinned, default, groups:[{name, lanes:[{key,label,description,count?}]}] }
|
||||
// selected: array of currently-pinned lane keys (excludes the always-on 'today')
|
||||
let { pool, selected, onsave, onclose } = $props();
|
||||
|
||||
// Work on a local copy; only commit on Done.
|
||||
let chosen = $state(new Set(selected ?? []));
|
||||
|
||||
function toggle(key) {
|
||||
if (chosen.has(key)) chosen.delete(key);
|
||||
else chosen.add(key);
|
||||
chosen = new Set(chosen); // reassign so Svelte re-renders
|
||||
}
|
||||
function resetDefault() {
|
||||
chosen = new Set(pool?.default ?? []);
|
||||
}
|
||||
function done() {
|
||||
// Preserve each group's natural order so the rail reads predictably.
|
||||
const order = [];
|
||||
for (const g of pool?.groups ?? []) for (const l of g.lanes) order.push(l.key);
|
||||
onsave?.(order.filter((k) => chosen.has(k)));
|
||||
onclose?.();
|
||||
}
|
||||
function onkey(e) {
|
||||
if (e.key === 'Escape') onclose?.();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onkey} />
|
||||
|
||||
<div class="overlay" onclick={onclose} role="presentation">
|
||||
<div class="sheet rise" role="dialog" aria-modal="true" aria-label="Customize lanes" onclick={(e) => e.stopPropagation()}>
|
||||
<button class="x" onclick={onclose} aria-label="Close">×</button>
|
||||
|
||||
<h2>Your lanes</h2>
|
||||
<p class="sub">Pick the quick-access lanes above the feed. <strong>Today</strong> always stays — choose the rest.</p>
|
||||
|
||||
<div class="pinned-row">
|
||||
<span class="chip pinned" title="Always shown">Today <span class="lock">📌</span></span>
|
||||
</div>
|
||||
|
||||
{#each pool?.groups ?? [] as g (g.name)}
|
||||
{#if g.lanes.length}
|
||||
<div class="group">
|
||||
<span class="label">{g.name}</span>
|
||||
<div class="chips">
|
||||
{#each g.lanes as l (l.key)}
|
||||
<button
|
||||
type="button"
|
||||
class="chip"
|
||||
class:on={chosen.has(l.key)}
|
||||
title={l.description ?? ''}
|
||||
onclick={() => toggle(l.key)}
|
||||
>
|
||||
{l.label}{#if l.count > 0}<small>{l.count}</small>{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<div class="actions">
|
||||
<button class="reset" onclick={resetDefault}>Reset to default</button>
|
||||
<button class="primary" onclick={done}>Done</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.overlay {
|
||||
position: fixed; inset: 0; background: rgba(10, 22, 38, 0.32);
|
||||
display: flex; align-items: center; justify-content: center; padding: 20px; z-index: 60;
|
||||
}
|
||||
.sheet {
|
||||
background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius);
|
||||
box-shadow: var(--shadow); width: 100%; max-width: 480px; padding: 26px 24px 20px;
|
||||
position: relative; max-height: 86vh; overflow-y: auto;
|
||||
}
|
||||
.x { position: absolute; top: 12px; right: 14px; background: none; border: none; font-size: 1.5rem; line-height: 1; color: var(--muted); cursor: pointer; }
|
||||
h2 { font-size: 1.4rem; margin: 0 0 6px; }
|
||||
.sub { margin: 0 0 16px; color: var(--muted); font-size: 0.92rem; }
|
||||
.pinned-row { margin-bottom: 14px; }
|
||||
.group { margin-bottom: 16px; }
|
||||
.label {
|
||||
display: block; font-size: 0.78rem; text-transform: uppercase;
|
||||
letter-spacing: 0.07em; color: var(--muted); margin-bottom: 9px; font-weight: 600;
|
||||
}
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.chip {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
border: 1px solid var(--line); background: var(--bg); color: var(--ink);
|
||||
border-radius: 999px; padding: 6px 14px; font-size: 0.88rem; cursor: pointer;
|
||||
transition: all 0.14s ease;
|
||||
}
|
||||
.chip:hover { border-color: var(--accent); }
|
||||
.chip.on { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
.chip small { font-size: 0.72rem; opacity: 0.7; }
|
||||
.chip.pinned { background: var(--accent-soft); color: var(--accent-deep); cursor: default; border-color: transparent; }
|
||||
.chip .lock { font-size: 0.8rem; }
|
||||
.actions { display: flex; align-items: center; justify-content: space-between; margin-top: 18px; }
|
||||
.reset { background: none; border: none; color: var(--muted); font-size: 0.82rem; text-decoration: underline; cursor: pointer; }
|
||||
.primary {
|
||||
font: inherit; font-weight: 600; background: var(--accent); color: #fff; border: none;
|
||||
border-radius: 999px; padding: 10px 22px; cursor: pointer;
|
||||
}
|
||||
.primary:hover { background: var(--accent-deep); }
|
||||
</style>
|
||||
@@ -1,13 +1,18 @@
|
||||
<script>
|
||||
let { moods, selected, onselect } = $props();
|
||||
// `lanes` is the resolved display list ({key, label, description}), with
|
||||
// Today already pinned first. `oncustomize` opens the lane picker.
|
||||
let { lanes, selected, onselect, oncustomize } = $props();
|
||||
</script>
|
||||
|
||||
<nav class="moods">
|
||||
{#each moods as m}
|
||||
{#each lanes as m (m.key)}
|
||||
<button class:active={selected === m.key} title={m.description} onclick={() => onselect(m.key)}>
|
||||
{m.label}
|
||||
</button>
|
||||
{/each}
|
||||
<button class="customize" title="Choose your lanes" aria-label="Customize lanes" onclick={oncustomize}>
|
||||
✎
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<style>
|
||||
@@ -16,6 +21,7 @@
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 4px 0 6px;
|
||||
}
|
||||
button {
|
||||
@@ -34,4 +40,11 @@
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 14px rgba(47, 125, 91, 0.25);
|
||||
}
|
||||
.customize {
|
||||
padding: 8px 12px;
|
||||
color: var(--muted);
|
||||
opacity: 0.7;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.customize:hover { opacity: 1; }
|
||||
</style>
|
||||
|
||||
@@ -7,6 +7,10 @@ export function blank() {
|
||||
include_topics: [], include_flavors: [],
|
||||
mute_topics: [], mute_flavors: [],
|
||||
avoid_terms: [], pauses: [], max_cortisol: null,
|
||||
// UI-only: which nav lanes the reader has pinned (keys from /api/lanes).
|
||||
// [] means "not customized" — the feed falls back to the default lane set.
|
||||
// The backend filter parser ignores unknown keys, so this rides along safely.
|
||||
lanes: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user