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>
This commit is contained in:
@@ -16,6 +16,7 @@
|
||||
import { initHistory, deviceIds, record, loadServerHistory } from '$lib/history.svelte.js';
|
||||
import { trackVisit, track } from '$lib/analytics.js';
|
||||
import { pwa, installApp, dismissPwa } from '$lib/pwa.svelte.js';
|
||||
import { ritualState, markBriefSeen } from '$lib/ritual.js';
|
||||
|
||||
let moods = $state([]);
|
||||
let topics = $state([]);
|
||||
@@ -28,6 +29,7 @@
|
||||
// /?view=<mood|topic>, or bare / for Highlights.
|
||||
function parseView(url) {
|
||||
const p = url.searchParams;
|
||||
if ((p.get('q') || '').trim()) return 'search';
|
||||
if (p.get('source')) return 'source:' + p.get('source');
|
||||
if (p.get('tag')) return 'tag:' + p.get('tag');
|
||||
return p.get('view') || 'today';
|
||||
@@ -39,9 +41,25 @@
|
||||
return '/?view=' + encodeURIComponent(key);
|
||||
}
|
||||
let selected = $derived(parseView($page.url));
|
||||
let searchQuery = $derived(($page.url.searchParams.get('q') || '').trim());
|
||||
let searchOpen = $state(false);
|
||||
let searchText = $state('');
|
||||
function toggleSearch() { searchOpen = !searchOpen; if (searchOpen) searchText = searchQuery; }
|
||||
function runSearch() {
|
||||
const q = searchText.trim();
|
||||
goto(q ? '/?q=' + encodeURIComponent(q) : '/');
|
||||
}
|
||||
let sourceNames = $state({}); // source id -> name, for an instant header label
|
||||
let brief = $state(null);
|
||||
let heroIdx = $state(0);
|
||||
// Daily Ritual ("today's calm set") — derived from local game state + a
|
||||
// brief-seen flag, keyed on the brief's SERVER date (never the browser's).
|
||||
let ritual = $state({ items: [], count: 0, total: 0 });
|
||||
let endcapEl = $state(null);
|
||||
function refreshRitual() {
|
||||
const d = brief?.brief_date;
|
||||
if (d) ritual = ritualState(d, prefs.data.ritual);
|
||||
}
|
||||
let feed = $state([]);
|
||||
let feedDone = $state(false); // no more pages for the current feed view
|
||||
let loadingMore = $state(false);
|
||||
@@ -136,6 +154,7 @@
|
||||
);
|
||||
let viewLabel = $derived(
|
||||
selected === 'today' ? 'Highlights from Today'
|
||||
: selected === 'search' ? `Search: “${searchQuery}”`
|
||||
: selected.startsWith('source:') ? (sourceNames[selected.slice(7)] ?? feed[0]?.source ?? 'Source')
|
||||
: selected === 'latest' ? 'Latest'
|
||||
: selected === 'following' ? 'Following'
|
||||
@@ -144,6 +163,7 @@
|
||||
);
|
||||
let viewSubtitle = $derived(
|
||||
selected === 'today' ? localDateLabel(brief)
|
||||
: selected === 'search' ? 'Results across Upbeat Bytes'
|
||||
: selected.startsWith('source:') ? 'Latest from this source'
|
||||
: selected === 'latest' ? 'Freshest calm reads — newest first'
|
||||
: selected === 'following' ? 'From the sources & topics you follow'
|
||||
@@ -262,6 +282,10 @@
|
||||
function feedUrl(key, offset) {
|
||||
const ex = Array.from(dismissed).join(',');
|
||||
const exq = ex ? `&exclude=${ex}` : '';
|
||||
if (key === 'search') {
|
||||
const q = P.param(prefs.data);
|
||||
return `/api/search?q=${encodeURIComponent(searchQuery)}&limit=${PAGE}&offset=${offset}${q ? '&' + q : ''}`;
|
||||
}
|
||||
if (key === 'latest') {
|
||||
const q = P.param(prefs.data);
|
||||
return `/api/feed?sort=latest&limit=${PAGE}&offset=${offset}${q ? '&' + q : ''}${exq}`;
|
||||
@@ -340,9 +364,25 @@
|
||||
// an accurate count. Clamped at 0, so an out-of-app landing stays app-safe.
|
||||
if (nav.type === 'goto' || nav.type === 'link') appNavDepth += 1;
|
||||
else if (nav.type === 'popstate') appNavDepth = Math.max(0, appNavDepth + (nav.delta ?? -1));
|
||||
if (selected === 'search') { searchText = searchQuery; searchOpen = true; } // prefill on shared/back links
|
||||
loadView(selected);
|
||||
});
|
||||
|
||||
// The brief is "enjoyed" only when its end-cap actually scrolls into view (the
|
||||
// finite read), not on mere page-open. When it does, mark it for today and
|
||||
// refresh the calm-set — which also picks up any Word / Word Search played in
|
||||
// this session. The block lives inside the end-cap, so by the time the reader
|
||||
// sees it, the brief tick has already settled.
|
||||
$effect(() => {
|
||||
if (!endcapEl || !brief?.brief_date) return;
|
||||
const date = brief.brief_date;
|
||||
const io = new IntersectionObserver((entries) => {
|
||||
if (entries.some((e) => e.isIntersecting)) { markBriefSeen(date); refreshRitual(); }
|
||||
}, { threshold: 0.4 });
|
||||
io.observe(endcapEl);
|
||||
return () => io.disconnect();
|
||||
});
|
||||
|
||||
// "Load more" for any feed view (Latest, topics, tags, moods): fetch the next
|
||||
// page at the current length and append, de-duping against what's shown.
|
||||
async function loadMore() {
|
||||
@@ -449,6 +489,7 @@
|
||||
dismissed = new Set(P.loadJSON(DISMISSED_KEY, []));
|
||||
refreshAuth();
|
||||
trackVisit();
|
||||
if (selected === 'search') { searchText = searchQuery; searchOpen = true; } // prefill on direct/shared link
|
||||
// Instant paint: render the last saved Today brief immediately and refresh
|
||||
// it behind the scenes, so the first view never blocks on a (personalized,
|
||||
// origin-bound) /api/brief request. "Gathering the good news…" then only
|
||||
@@ -517,6 +558,9 @@
|
||||
{#if viewSubtitle}<p class="sub">{viewSubtitle}</p>{/if}
|
||||
</div>
|
||||
<div class="vh-actions">
|
||||
<button class="searchtoggle" class:on={searchOpen || selected === 'search'} onclick={toggleSearch} aria-label="Search articles" title="Search articles">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="7" fill="none" stroke="currentColor" stroke-width="2"/><path d="M21 21l-4.4-4.4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
||||
</button>
|
||||
{#if auth.user && followTarget}
|
||||
<button class="followbtn" class:on={isFollowing(followTarget.kind, followTarget.value)}
|
||||
onclick={() => toggleFollow(followTarget.kind, followTarget.value)}>
|
||||
@@ -532,6 +576,16 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if searchOpen || selected === 'search'}
|
||||
<div class="searchbar rise">
|
||||
<input type="search" bind:value={searchText} placeholder="Search articles, topics, or a source…"
|
||||
autocapitalize="off" autocomplete="off" spellcheck="false"
|
||||
onkeydown={(e) => (e.key === 'Enter' ? runSearch() : e.key === 'Escape' ? (searchOpen = false) : null)} />
|
||||
<button class="searchgo" onclick={runSearch}>Search</button>
|
||||
{#if selected === 'search'}<button class="searchclear" onclick={() => { searchOpen = false; goto('/'); }}>Clear</button>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if selected === 'today'}
|
||||
{#if sinceCount > 0 && !sinceDismissed}
|
||||
<div class="welcomeback rise">
|
||||
@@ -563,9 +617,24 @@
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
<div class="endcap rise">
|
||||
<div class="endcap rise" bind:this={endcapEl}>
|
||||
<p class="endmark">✦ that's the good news for today ✦</p>
|
||||
<p class="endsub">You're caught up for now.</p>
|
||||
{#if ritual.total}
|
||||
<div class="calmset">
|
||||
<p class="cs-head">Today's calm set</p>
|
||||
<ul class="cs-items">
|
||||
{#each ritual.items as it (it.key)}
|
||||
<li class="cs-item" class:done={it.done}>
|
||||
<span class="cs-mark" aria-hidden="true"></span>{#if it.done || it.key === 'brief'}{it.label}{:else}<a href={it.href}>{it.label}</a>{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<p class="cs-foot">
|
||||
{ritual.count === ritual.total ? `All ${ritual.total} enjoyed today` : `${ritual.count} of ${ritual.total} enjoyed today`} · fresh set tomorrow · <a class="cs-edit" href="/account?section=calmset">make it yours</a>
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
{#if auth.user?.digest_enabled}
|
||||
<p class="digestnote">Tomorrow's brief is headed to your inbox ☕</p>
|
||||
{:else}
|
||||
@@ -592,6 +661,8 @@
|
||||
{:else}
|
||||
<p class="endcap rise">✦ you're all caught up ✦</p>
|
||||
{/if}
|
||||
{:else if selected === 'search'}
|
||||
<p class="muted center pad">No articles found for “{searchQuery}”. Try a different word, or a source name like “Nature”.</p>
|
||||
{:else if selected === 'following'}
|
||||
<p class="muted center pad">
|
||||
{#if auth.user}Nothing here yet — open a source or a grouping and tap <strong>Follow</strong> to fill this lane with what you care about.
|
||||
@@ -660,6 +731,21 @@
|
||||
.viewback:hover { border-color: var(--accent); }
|
||||
.viewback svg { width: 16px; height: 16px; display: block; }
|
||||
.vh-actions { flex-shrink: 0; display: flex; align-items: center; gap: 8px; margin-top: 8px; }
|
||||
.searchtoggle { display: inline-flex; align-items: center; justify-content: center; width: 34px; height: 34px;
|
||||
background: none; border: 1px solid var(--line); color: var(--accent-deep); border-radius: 999px;
|
||||
cursor: pointer; transition: border-color 0.14s ease, background 0.14s ease; }
|
||||
.searchtoggle:hover { border-color: var(--accent); }
|
||||
.searchtoggle.on { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
.searchtoggle svg { width: 17px; height: 17px; display: block; }
|
||||
.searchbar { display: flex; gap: 8px; margin: 0 0 18px; }
|
||||
.searchbar input { flex: 1; min-width: 0; font: inherit; font-size: 1rem; padding: 10px 14px;
|
||||
border: 1px solid var(--line); border-radius: 10px; background: var(--surface); color: var(--ink); }
|
||||
.searchbar input:focus { outline: none; border-color: var(--accent); }
|
||||
.searchgo { background: var(--accent); color: #fff; border: none; border-radius: 10px; padding: 0 18px;
|
||||
font: inherit; font-weight: 600; cursor: pointer; }
|
||||
.searchgo:hover { background: var(--accent-deep); }
|
||||
.searchclear { background: none; border: 1px solid var(--line); color: var(--muted); border-radius: 10px;
|
||||
padding: 0 14px; font: inherit; cursor: pointer; }
|
||||
.followbtn {
|
||||
display: inline-flex; align-items: center; gap: 5px; white-space: nowrap;
|
||||
background: none; border: 1px solid var(--accent); color: var(--accent-deep);
|
||||
@@ -713,6 +799,36 @@
|
||||
.endcap .digestcta:hover { background: var(--accent-deep); }
|
||||
.endcap .digestcta:disabled { opacity: 0.6; cursor: default; }
|
||||
|
||||
/* Daily Ritual — "today's calm set". Gentle, non-instrumental: a soft check
|
||||
for what's been enjoyed, no streak, no pressure to finish the rest. */
|
||||
.calmset {
|
||||
margin: 20px auto 4px; max-width: 320px; padding-top: 16px;
|
||||
border-top: 1px solid var(--line); font-style: normal; font-family: var(--label);
|
||||
}
|
||||
.cs-head {
|
||||
margin: 0 0 10px; text-transform: uppercase; letter-spacing: 0.13em;
|
||||
font-size: 0.66rem; font-weight: 600; color: var(--accent-deep);
|
||||
}
|
||||
.cs-items {
|
||||
list-style: none; margin: 0; padding: 0; display: flex; gap: 16px;
|
||||
justify-content: center; flex-wrap: wrap;
|
||||
}
|
||||
.cs-item { display: inline-flex; align-items: center; gap: 7px; font-size: 0.9rem; color: var(--muted); }
|
||||
.cs-item a { color: inherit; text-decoration: none; }
|
||||
.cs-item a:hover { color: var(--accent-deep); }
|
||||
.cs-item.done { color: var(--ink); }
|
||||
.cs-mark {
|
||||
width: 16px; height: 16px; border-radius: 50%; border: 1.5px solid var(--line);
|
||||
flex-shrink: 0; transition: background 0.16s ease, border-color 0.16s ease;
|
||||
}
|
||||
.cs-item.done .cs-mark {
|
||||
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: 13px; background-repeat: no-repeat; background-position: center;
|
||||
}
|
||||
.cs-foot { margin: 12px 0 0; font-size: 0.82rem; color: var(--muted); }
|
||||
.cs-edit { color: var(--accent-deep); text-decoration: underline; white-space: nowrap; }
|
||||
|
||||
/* "Since you last visited" — a calm welcome-back cue on Highlights */
|
||||
.welcomeback {
|
||||
display: flex; align-items: center; gap: 12px; justify-content: space-between;
|
||||
|
||||
Reference in New Issue
Block a user