89c0fbe1f6
The deploy pipeline runs from the working tree, so a wave of shipped features
had never been committed. This snapshots git to what's actually running.
SEO impression recovery (live + verified):
- Duplicate /a/{id} now 301-redirect to their canonical twin instead of 404
(a hard 404 silently dropped already-indexed URLs and tanked impressions).
- Dedup representative selection reworked: accepted/serveable -> established
rep (URL stability) -> quality score, so an accepted page never retires to a
rejected rep and an indexed canonical doesn't churn when a newer twin arrives.
- HEAD /a/{id} returns the same status as GET (api_route GET+HEAD) instead of
falling through to the static mount and 404ing.
- `dedup --force-recluster`: cycle-locked, model-free re-cluster to re-apply the
policy to the existing corpus (shared cycle_lock context manager).
- CLI honors GOODNEWS_DB for its default --db (was silently ignored).
Publishing Desk (admin tool to post highlights to X via Web Intents):
- publishing.py queue/rank/handle-resolution; admin UI; full searchable emoji
picker (bundled data, no CDN) for the blurb editor.
Play games + site:
- Bloom (word-wheel), Memory Match, daily ritual set, Zen Den (dev-gated).
- English-only language gate; source prospecting; paywall + dedup hardening.
Tests: full suite green (349). Ignores tightened (node_modules, data/*.db).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
317 lines
15 KiB
Svelte
317 lines
15 KiB
Svelte
<script>
|
||
import { onMount } from 'svelte';
|
||
import { page } from '$app/stores';
|
||
import { getJSON, postJSON } from '$lib/api.js';
|
||
import { auth, savedIds, refresh, toggleFollow } from '$lib/auth.svelte.js';
|
||
import { prefs, initPrefs, persistPrefs } from '$lib/prefs.svelte.js';
|
||
import { RITUAL_ITEMS, ritualKeys, DEFAULT_KEYS } from '$lib/ritual.js';
|
||
import { history, initHistory, loadServerHistory, removeOne, clearAll, record } from '$lib/history.svelte.js';
|
||
import { track } from '$lib/analytics.js';
|
||
import { openFeedback } from '$lib/feedback.svelte.js';
|
||
import AccountPanel from '$lib/components/AccountPanel.svelte';
|
||
import BoundariesPanel from '$lib/components/BoundariesPanel.svelte';
|
||
import LanePicker from '$lib/components/LanePicker.svelte';
|
||
import ArticleCard from '$lib/components/ArticleCard.svelte';
|
||
|
||
let section = $derived($page.url.searchParams.get('section') || 'profile');
|
||
let historyItems = $derived(auth.user ? history.server : history.device);
|
||
|
||
let savedItems = $state([]);
|
||
let savedReady = $state(false);
|
||
let lanePool = $state(null);
|
||
|
||
const SECTIONS = [
|
||
{ key: 'profile', label: 'Profile' },
|
||
{ key: 'saved', label: 'Saved' },
|
||
{ key: 'following', label: 'Following' },
|
||
{ key: 'history', label: 'History' },
|
||
{ key: 'lanes', label: 'Lanes' },
|
||
{ key: 'calmset', label: 'Calm set' },
|
||
{ key: 'boundaries', label: 'Boundaries' },
|
||
];
|
||
|
||
// Calm set: which daily items make up the reader's gentle daily ritual.
|
||
let calmEnabled = $derived(new Set(ritualKeys(prefs.data.ritual)));
|
||
function toggleCalmItem(key) {
|
||
const cur = new Set(ritualKeys(prefs.data.ritual));
|
||
cur.has(key) ? cur.delete(key) : cur.add(key);
|
||
const keys = RITUAL_ITEMS.map((i) => i.key).filter((k) => cur.has(k));
|
||
// null = "follow the curated default" (only when the selection IS the default);
|
||
// otherwise store the explicit chosen list (frozen — future games won't auto-join).
|
||
const isDefault = keys.length === DEFAULT_KEYS.length && DEFAULT_KEYS.every((k) => cur.has(k));
|
||
prefs.data.ritual = isDefault ? null : keys;
|
||
prefs.data = { ...prefs.data };
|
||
persistPrefs();
|
||
}
|
||
|
||
let follows = $state([]);
|
||
let followsReady = $state(false);
|
||
async function loadFollowsList() {
|
||
try { follows = await getJSON('/api/follows'); } catch { follows = []; }
|
||
}
|
||
async function removeFollow(f) {
|
||
follows = follows.filter((x) => !(x.kind === f.kind && x.value === f.value)); // optimistic
|
||
await toggleFollow(f.kind, f.value); // syncs the shared followKeys store too
|
||
}
|
||
|
||
onMount(async () => {
|
||
if (!auth.ready) await refresh();
|
||
initPrefs();
|
||
initHistory();
|
||
if (auth.user) loadServerHistory();
|
||
try { lanePool = await getJSON('/api/lanes'); } catch { lanePool = null; }
|
||
});
|
||
|
||
let pinnedLaneKeys = $derived(
|
||
prefs.data.lanes?.length ? prefs.data.lanes : (lanePool?.default ?? [])
|
||
);
|
||
function saveLanes(keys) {
|
||
prefs.data.lanes = keys;
|
||
persistPrefs();
|
||
}
|
||
|
||
let digestBusy = $state(false);
|
||
async function toggleDigest() {
|
||
if (!auth.user || digestBusy) return;
|
||
digestBusy = true;
|
||
try {
|
||
await postJSON('/api/account/digest', { enabled: !auth.user.digest_enabled });
|
||
await refresh(); // re-pull auth.user with the new digest_enabled
|
||
} catch {
|
||
/* leave as-is */
|
||
} finally {
|
||
digestBusy = false;
|
||
}
|
||
}
|
||
|
||
// Load the saved grid when entering that section while signed in.
|
||
$effect(() => {
|
||
if (section === 'saved' && auth.user && !savedReady) {
|
||
savedReady = true;
|
||
getJSON('/api/saved').then((r) => (savedItems = r.items)).catch(() => {});
|
||
}
|
||
if (section === 'following' && auth.user && !followsReady) {
|
||
followsReady = true;
|
||
loadFollowsList();
|
||
}
|
||
});
|
||
let savedShown = $derived(savedItems.filter((a) => savedIds.has(a.id)));
|
||
</script>
|
||
|
||
<header class="bar">
|
||
<div class="container inner">
|
||
<a class="brand" href="/" aria-label="Upbeat Bytes — home"><img class="logo" src="/logo.svg" alt="Upbeat Bytes" /></a>
|
||
<div class="baractions">
|
||
<button class="fb" onclick={openFeedback} aria-label="Share feedback" title="Share feedback">
|
||
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><path d="M4 5h16v11H8l-4 3z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" /></svg>
|
||
</button>
|
||
<a class="back" href="/"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M19 12H5M11 6l-6 6 6 6" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round"/></svg>Back</a>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<main class="container page">
|
||
<h1>You</h1>
|
||
|
||
<nav class="tabs" aria-label="Account sections">
|
||
{#each SECTIONS as s (s.key)}
|
||
<a href={'/account?section=' + s.key} class:active={section === s.key}>{s.label}</a>
|
||
{/each}
|
||
{#if auth.user?.is_admin}<a href="/admin" class="admin">Admin dashboard</a>{/if}
|
||
</nav>
|
||
|
||
<div class="content">
|
||
{#if section === 'lanes'}
|
||
{#if lanePool}
|
||
<LanePicker inline pool={lanePool} selected={pinnedLaneKeys} onsave={saveLanes} />
|
||
{:else}
|
||
<p class="muted">Loading…</p>
|
||
{/if}
|
||
|
||
{:else if section === 'calmset'}
|
||
<section class="panel">
|
||
<h2>Your calm set</h2>
|
||
<p class="dnote">The gentle daily loop shown on Highlights and Play — a finite "today's set"
|
||
you can finish and feel caught up. Choose what fills yours, like building a workout. No
|
||
pressure, no streaks; uncheck anything you'd rather not see there.</p>
|
||
<ul class="calmpick">
|
||
{#each RITUAL_ITEMS as it (it.key)}
|
||
<li>
|
||
<button class="cpitem" class:on={calmEnabled.has(it.key)} onclick={() => toggleCalmItem(it.key)}
|
||
aria-pressed={calmEnabled.has(it.key)}>
|
||
<span class="cpmark" aria-hidden="true"></span>{it.label}
|
||
</button>
|
||
</li>
|
||
{/each}
|
||
</ul>
|
||
{#if calmEnabled.size === 0}<p class="empty">Your calm set is empty — it won't show until you add something.</p>{/if}
|
||
</section>
|
||
|
||
{:else if section === 'boundaries'}
|
||
<BoundariesPanel prefs={prefs.data} onchange={persistPrefs} />
|
||
|
||
{:else if section === 'history'}
|
||
<section class="panel">
|
||
<div class="phead">
|
||
<h2>History</h2>
|
||
{#if historyItems.length}<button class="link" onclick={clearAll}>Clear all</button>{/if}
|
||
</div>
|
||
<p class="reassure">Stories you've opened or swapped away — so an accidental Replace stays
|
||
recoverable. {auth.user ? 'Synced across your devices.' : 'Kept on this device.'}</p>
|
||
{#if historyItems.length}
|
||
<ul class="hist">
|
||
{#each historyItems as a (a.id)}
|
||
<li>
|
||
<a href={a.url} target="_blank" rel="noopener" onclick={() => track('full_story', a.id)}>{a.title}</a>
|
||
<span class="hsrc">{a.source}</span>
|
||
<button class="hx" aria-label="Remove" onclick={() => removeOne(a.id)}>×</button>
|
||
</li>
|
||
{/each}
|
||
</ul>
|
||
{:else}
|
||
<p class="empty">Nothing yet — stories you open or swap away will appear here.</p>
|
||
{/if}
|
||
</section>
|
||
|
||
{:else if section === 'saved'}
|
||
{#if !auth.user}
|
||
<p class="gate">Sign in to save articles and find them here.</p>
|
||
{:else if savedShown.length}
|
||
<div class="grid">
|
||
{#each savedShown as a (a.id)}
|
||
<ArticleCard article={a} thumb onview={record} />
|
||
{/each}
|
||
</div>
|
||
{:else if savedReady}
|
||
<p class="empty">Nothing saved yet — tap <strong>Save</strong> on a story to keep it here.</p>
|
||
{:else}
|
||
<p class="muted">Loading…</p>
|
||
{/if}
|
||
|
||
{:else if section === 'following'}
|
||
{#if !auth.user}
|
||
<p class="gate">Sign in, then follow sources and topics to build your own calm lane.</p>
|
||
{:else}
|
||
<h2>Following</h2>
|
||
<p class="dnote">Sources and topics you follow feed your <a href="/?view=following">Following</a> lane. Open any source or grouping and tap <strong>Follow</strong> to add it.</p>
|
||
{#if follows.length}
|
||
<ul class="follows">
|
||
{#each follows as f (f.kind + ':' + f.value)}
|
||
<li>
|
||
<span class="fmeta"><span class="fkind">{f.kind}</span> <span class="fname">{f.kind === 'tag' ? f.name.replace(/-/g, ' ') : f.name}</span></span>
|
||
<button class="funfollow" onclick={() => removeFollow(f)}>Unfollow</button>
|
||
</li>
|
||
{/each}
|
||
</ul>
|
||
{:else if followsReady}
|
||
<p class="empty">You're not following anything yet.</p>
|
||
{:else}
|
||
<p class="muted">Loading…</p>
|
||
{/if}
|
||
{/if}
|
||
|
||
{:else}
|
||
<!-- profile -->
|
||
{#if auth.user}
|
||
<AccountPanel onclose={() => {}} />
|
||
<section class="panel digest">
|
||
<h2>Daily digest</h2>
|
||
<p class="dnote">A calm morning email — today's handful of good things, with a one-tap unsubscribe. No streaks, no noise; just the brief in your inbox.</p>
|
||
<button class="dtoggle" class:on={auth.user.digest_enabled} onclick={toggleDigest} disabled={digestBusy} aria-pressed={auth.user.digest_enabled}>
|
||
{#if digestBusy}…{:else if auth.user.digest_enabled}✓ On — you'll get tomorrow's brief{:else}Get the daily digest{/if}
|
||
</button>
|
||
</section>
|
||
{:else}
|
||
<p class="gate">Sign in from the home page to manage your profile, saved articles, and devices.</p>
|
||
{/if}
|
||
{/if}
|
||
</div>
|
||
</main>
|
||
|
||
<style>
|
||
.bar { background: var(--surface); border-bottom: 1px solid var(--line); position: sticky; top: 0; z-index: 20; }
|
||
.inner { display: flex; align-items: center; justify-content: space-between; height: 64px; }
|
||
.logo { height: 40px; display: block; }
|
||
.back { color: var(--accent-deep); font-size: 0.9rem; display: inline-flex; align-items: center; gap: 5px; }
|
||
.back svg { width: 17px; height: 17px; display: block; }
|
||
.baractions { display: flex; align-items: center; gap: 14px; }
|
||
.baractions .fb { background: none; border: none; color: var(--accent-deep); cursor: pointer; display: inline-flex; padding: 2px; }
|
||
.page { padding: 20px 20px 70px; }
|
||
h1 { font-size: clamp(2rem, 5vw, 2.6rem); margin: 6px 0 16px; }
|
||
|
||
/* Desktop: sidebar to the left of the content. Mobile: a top tab strip. */
|
||
.tabs { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 22px; }
|
||
.tabs a {
|
||
border: 1px solid var(--line); background: var(--surface); color: var(--ink);
|
||
border-radius: 999px; padding: 7px 15px; font-size: 0.9rem;
|
||
}
|
||
.tabs a:hover { border-color: var(--accent); color: var(--accent-deep); }
|
||
.tabs a.active { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||
.tabs a.admin { margin-left: auto; border-color: var(--accent); color: var(--accent-deep); }
|
||
|
||
@media (min-width: 760px) {
|
||
.page { display: grid; grid-template-columns: 180px 1fr; column-gap: 32px; align-items: start; }
|
||
h1 { grid-column: 1 / -1; }
|
||
.tabs { flex-direction: column; gap: 4px; position: sticky; top: 80px; }
|
||
.tabs a { text-align: left; border-color: transparent; }
|
||
.tabs a.admin { margin-left: 0; margin-top: 10px; }
|
||
}
|
||
|
||
.panel { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); padding: 20px 22px; }
|
||
.digest { margin-top: 16px; }
|
||
.digest h2 { font-size: 1.1rem; margin: 0 0 6px; }
|
||
.dnote { color: var(--muted); font-size: 0.9rem; margin: 0 0 14px; line-height: 1.5; }
|
||
.calmpick { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; max-width: 360px; }
|
||
.cpitem {
|
||
display: flex; align-items: center; gap: 10px; width: 100%; text-align: left;
|
||
background: var(--surface); border: 1px solid var(--line); border-radius: 12px;
|
||
padding: 12px 16px; font: inherit; font-weight: 600; color: var(--muted); cursor: pointer;
|
||
transition: border-color 0.14s ease, color 0.14s ease;
|
||
}
|
||
.cpitem.on { color: var(--ink); border-color: var(--accent); }
|
||
.cpitem:hover { border-color: var(--accent); }
|
||
.cpmark { width: 18px; height: 18px; border-radius: 50%; border: 1.5px solid var(--line); flex-shrink: 0;
|
||
transition: background 0.14s ease, border-color 0.14s ease; }
|
||
.cpitem.on .cpmark {
|
||
background: var(--accent); border-color: var(--accent);
|
||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M5 12l5 5 9-10' fill='none' stroke='white' stroke-width='2.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
||
background-size: 14px; background-repeat: no-repeat; background-position: center;
|
||
}
|
||
.dtoggle {
|
||
font: inherit; font-size: 0.9rem; border-radius: 999px; padding: 9px 18px; cursor: pointer;
|
||
border: 1px solid var(--accent); background: var(--surface); color: var(--accent-deep);
|
||
}
|
||
.dtoggle:hover { border-color: var(--accent-deep); }
|
||
.dtoggle.on { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||
.dtoggle:disabled { opacity: 0.6; cursor: default; }
|
||
ul.follows { list-style: none; margin: 14px 0 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
|
||
ul.follows li {
|
||
display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
||
background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 11px 14px;
|
||
}
|
||
.fmeta { min-width: 0; }
|
||
.fkind { font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.07em; color: var(--muted); }
|
||
.fname { font-weight: 600; color: var(--ink); text-transform: capitalize; margin-left: 6px; }
|
||
.funfollow {
|
||
flex-shrink: 0; background: none; border: 1px solid var(--line); color: var(--muted);
|
||
border-radius: 999px; padding: 5px 13px; font-size: 0.8rem; cursor: pointer;
|
||
}
|
||
.funfollow:hover { border-color: #9a3b3b; color: #9a3b3b; }
|
||
.phead { display: flex; align-items: baseline; justify-content: space-between; }
|
||
.phead h2 { font-size: 1.3rem; }
|
||
.link { background: none; border: none; color: var(--accent-deep); font-size: 0.85rem; text-decoration: underline; cursor: pointer; }
|
||
.reassure { margin: 4px 0 14px; color: var(--muted); font-size: 0.85rem; }
|
||
.hist { list-style: none; margin: 0; padding: 0; }
|
||
.hist li { padding: 8px 0; border-bottom: 1px solid var(--line); display: flex; gap: 12px; align-items: baseline; }
|
||
.hist li:last-child { border-bottom: none; }
|
||
.hist a { color: var(--ink); }
|
||
.hist a:hover { color: var(--accent-deep); }
|
||
.hsrc { margin-left: auto; color: var(--muted); font-size: 0.78rem; white-space: nowrap; }
|
||
.hx { background: none; border: none; color: var(--muted); font-size: 1.15rem; line-height: 1; cursor: pointer; padding: 0 2px; }
|
||
.hx:hover { color: var(--accent-deep); }
|
||
.empty { margin: 0; color: var(--muted); font-style: italic; font-size: 0.9rem; }
|
||
.muted { color: var(--muted); }
|
||
.gate { color: var(--muted); font-size: 1rem; padding: 8px 0; }
|
||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(238px, 1fr)); gap: 18px; }
|
||
</style>
|