Files
upbeatBytes/frontend/src/routes/account/+page.svelte
T
thejayman77 89c0fbe1f6 Sync repo to deployed state: SEO recovery, Publishing Desk, Play games, emoji picker
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>
2026-06-18 11:32:27 -04:00

317 lines
15 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>