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:
@@ -4,6 +4,7 @@
|
||||
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';
|
||||
@@ -25,9 +26,24 @@
|
||||
{ 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() {
|
||||
@@ -112,6 +128,25 @@
|
||||
<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} />
|
||||
|
||||
@@ -226,6 +261,22 @@
|
||||
.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);
|
||||
|
||||
Reference in New Issue
Block a user