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 { page } from '$app/stores';
|
||||
import { getJSON, postJSON, delJSON } from '$lib/api.js';
|
||||
import { auth, refresh } from '$lib/auth.svelte.js';
|
||||
import EmojiPicker from '$lib/EmojiPicker.svelte';
|
||||
|
||||
let stats = $state(null);
|
||||
let feedback = $state([]);
|
||||
@@ -31,26 +32,192 @@
|
||||
// Load all panels in PARALLEL — these were sequential awaits, so the page
|
||||
// paid the (uncached, origin) round-trip six times back-to-back. One batch
|
||||
// instead of a chain.
|
||||
const [, fb, cand, wp, ce, ws] = await Promise.all([
|
||||
const [, fb, cand, wp, ce, ws, bq] = await Promise.all([
|
||||
loadStats(),
|
||||
getJSON('/api/admin/feedback'),
|
||||
getJSON('/api/admin/candidates'),
|
||||
getJSON('/api/admin/word/pool'),
|
||||
getJSON('/api/admin/client-errors'),
|
||||
getJSON('/api/admin/wordsearch/themes'),
|
||||
getJSON('/api/admin/bloom/reports').catch(() => null),
|
||||
]);
|
||||
feedback = fb;
|
||||
candidates = cand;
|
||||
wpPool = wp;
|
||||
clientErrors = ce;
|
||||
wsThemes = ws;
|
||||
if (bq) { bloomReports = bq.reports || []; bloomOverrides = bq.overrides || []; }
|
||||
} catch {
|
||||
error = "Couldn't load stats.";
|
||||
}
|
||||
});
|
||||
|
||||
// --- Publishing Desk: build the X share queue, write blurbs, open in X ---------
|
||||
let pubItems = $state([]); // active: queued/drafting/opened
|
||||
let pubArchived = $state([]); // recoverable tray: skipped/snoozed
|
||||
let pubBuilding = $state(false);
|
||||
let pubLast = $state(null); // last build summary {added, active, ranked_by}
|
||||
let pubError = $state(null); // last build error (e.g. non-model failure), surfaced to the user
|
||||
let pubLoaded = $state(false);
|
||||
let pubHandleInput = $state({}); // keyed "itemId:entity" → handle being saved
|
||||
let pubPollTimer = null;
|
||||
const pubDraftTimers = {};
|
||||
|
||||
async function loadPublish() {
|
||||
try {
|
||||
const r = await getJSON('/api/admin/publishing/queue?archived=true');
|
||||
pubBuilding = r.building; pubLast = r.last; pubError = r.error || null;
|
||||
const items = r.items || [];
|
||||
pubItems = items.filter((i) => ['queued', 'drafting', 'opened'].includes(i.status));
|
||||
pubArchived = items.filter((i) => ['skipped', 'snoozed'].includes(i.status));
|
||||
pubLoaded = true;
|
||||
} catch { /* leave as-is */ }
|
||||
}
|
||||
function pollPublish() {
|
||||
clearTimeout(pubPollTimer);
|
||||
pubPollTimer = setTimeout(async () => {
|
||||
await loadPublish();
|
||||
if (pubBuilding) pollPublish();
|
||||
}, 1500);
|
||||
}
|
||||
async function buildPublish() {
|
||||
pubBuilding = true;
|
||||
try { await postJSON('/api/admin/publishing/build', {}); pollPublish(); }
|
||||
catch { pubBuilding = false; }
|
||||
}
|
||||
function onDraftInput(item) {
|
||||
clearTimeout(pubDraftTimers[item.id]);
|
||||
pubDraftTimers[item.id] = setTimeout(() => {
|
||||
postJSON(`/api/admin/publishing/${item.id}/draft`, { draft_text: item.draft_text || '' }).catch(() => {});
|
||||
}, 700); // debounced autosave
|
||||
}
|
||||
async function pubSetStatus(item, status, extra = {}) {
|
||||
clearTimeout(pubDraftTimers[item.id]); // cancel a pending autosave so it can't land after the transition
|
||||
try { await postJSON(`/api/admin/publishing/${item.id}/status`, { status, ...extra }); await loadPublish(); }
|
||||
catch (e) { item._err = e?.message || 'Action failed.'; }
|
||||
}
|
||||
// "Posted" is a confirm step: the user may have edited the text inside X, so we let
|
||||
// them correct the saved text + paste the real post URL before recording it.
|
||||
async function pubConfirmPosted(item) {
|
||||
await pubSetStatus(item, 'posted', {
|
||||
final_text: item.draft_text || '',
|
||||
post_url: (item._postUrl || '').trim() || null,
|
||||
});
|
||||
}
|
||||
async function pubRestore(item) {
|
||||
try { await postJSON(`/api/admin/publishing/${item.id}/restore`, {}); await loadPublish(); } catch { /* ignore */ }
|
||||
}
|
||||
function markOpened(item) {
|
||||
// fire-and-forget so the X tab opens immediately from the click (no awaited call
|
||||
// before navigation, or the browser may block the popup)
|
||||
if (item.status !== 'opened') {
|
||||
postJSON(`/api/admin/publishing/${item.id}/status`, { status: 'opened' }).catch(() => {});
|
||||
item.status = 'opened';
|
||||
}
|
||||
}
|
||||
async function pubSaveHandle(item, entity) {
|
||||
const h = (pubHandleInput[`${item.id}:${entity}`] || '').trim();
|
||||
if (!h) return;
|
||||
try { await postJSON('/api/admin/publishing/handles', { entity_name: entity, handle: h }); await loadPublish(); }
|
||||
catch (e) { item._err = e?.message || 'Bad handle.'; }
|
||||
}
|
||||
function insertHandle(item, handle) {
|
||||
item.draft_text = ((item.draft_text || '').trimEnd() + ' ' + handle + ' ').trimStart();
|
||||
onDraftInput(item);
|
||||
}
|
||||
// Emoji picker: a full searchable set (EmojiPicker.svelte) inserted at the caret so it
|
||||
// drops in mid-sentence cleanly. The textarea also still accepts your OS picker.
|
||||
let pubEmojiOpen = $state(null); // id of the card whose palette is open
|
||||
const pubTextareas = {}; // id -> <textarea> element, for caret-aware insert
|
||||
function regTextarea(node, id) {
|
||||
pubTextareas[id] = node;
|
||||
return { destroy() { if (pubTextareas[id] === node) delete pubTextareas[id]; } };
|
||||
}
|
||||
function insertEmoji(item, emoji) {
|
||||
const el = pubTextareas[item.id];
|
||||
const cur = item.draft_text || '';
|
||||
if (el && typeof el.selectionStart === 'number') {
|
||||
const s = el.selectionStart, e = el.selectionEnd;
|
||||
item.draft_text = cur.slice(0, s) + emoji + cur.slice(e);
|
||||
const pos = s + emoji.length;
|
||||
requestAnimationFrame(() => { el.focus(); el.setSelectionRange(pos, pos); }); // caret after the emoji
|
||||
} else {
|
||||
item.draft_text = cur + emoji;
|
||||
}
|
||||
onDraftInput(item);
|
||||
}
|
||||
function snoozeDate(days) {
|
||||
return new Date(Date.now() + days * 86400000).toISOString().slice(0, 19).replace('T', ' ');
|
||||
}
|
||||
function intentURL(item) {
|
||||
return `https://x.com/intent/tweet?text=${encodeURIComponent(item.draft_text || '')}&url=${encodeURIComponent(item.share_url || '')}`;
|
||||
}
|
||||
function findOnX(entity) {
|
||||
return `https://x.com/search?q=${encodeURIComponent(entity)}&f=user`;
|
||||
}
|
||||
// Weighted length ≈ X's counting: any URL = 23. The share link X auto-appends adds
|
||||
// ~24 (a space + the 23-char t.co link), so the live budget reflects the real tweet.
|
||||
function weightedLen(t) {
|
||||
const urls = (t || '').match(/https?:\/\/\S+/g) || [];
|
||||
return (t || '').replace(/https?:\/\/\S+/g, '').length + urls.length * 23;
|
||||
}
|
||||
function pubRemaining(item) {
|
||||
return 280 - (weightedLen(item.draft_text) + 24);
|
||||
}
|
||||
// Named entities (for Find-on-X + save-a-handle); the verified ones already show as
|
||||
// chips above, so this is mostly the not-yet-known ones.
|
||||
function namedEntities(item) {
|
||||
return (item.entities || []).slice(0, 5);
|
||||
}
|
||||
|
||||
$effect(() => { if (section === 'publish' && !pubLoaded) loadPublish(); });
|
||||
|
||||
let clientErrors = $state([]);
|
||||
|
||||
// "X ago" from a UTC 'YYYY-MM-DD HH:MM:SS' created_at — so a morning glance shows
|
||||
// whether the newest error is fresh or just last night.
|
||||
function ago(ts) {
|
||||
if (!ts) return '—';
|
||||
const d = new Date(ts.replace(' ', 'T') + 'Z');
|
||||
const s = Math.max(0, (Date.now() - d.getTime()) / 1000);
|
||||
if (s < 90) return 'just now';
|
||||
if (s < 5400) return Math.round(s / 60) + 'm ago';
|
||||
if (s < 129600) return Math.round(s / 3600) + 'h ago';
|
||||
return Math.round(s / 86400) + 'd ago';
|
||||
}
|
||||
// Classify a beacon by layer so one scary count doesn't conflate a 30s HTML stall
|
||||
// (incident) with a 5s app boot or a post-deploy chunk miss (benign).
|
||||
function errType(e) {
|
||||
if (e.bot) return { k: 'bot', label: 'bot' };
|
||||
const r = e.reason || '';
|
||||
if (/preload|dynamically imported|failed to fetch/i.test(r)) return { k: 'preload', label: 'preload' };
|
||||
const m = r.match(/^boot-slow.*?\bhtml\s+(\d+)ms/i);
|
||||
if (m) return Number(m[1]) >= 3000 ? { k: 'html', label: 'html-slow' } : { k: 'app', label: 'app-slow' };
|
||||
if (/^boot-slow/i.test(r)) return { k: 'app', label: 'app-slow' };
|
||||
return { k: 'runtime', label: 'runtime' };
|
||||
}
|
||||
|
||||
// --- Games: Bloom word curation (reports queue + allow/block overrides) ---
|
||||
let bloomReports = $state([]);
|
||||
let bloomOverrides = $state([]);
|
||||
let bloomOvrWord = $state('');
|
||||
async function loadBloomQueue() {
|
||||
try { const r = await getJSON('/api/admin/bloom/reports'); bloomReports = r.reports || []; bloomOverrides = r.overrides || []; }
|
||||
catch { /* ignore */ }
|
||||
}
|
||||
async function resolveBloom(id, action) {
|
||||
try { await postJSON('/api/admin/bloom/reports/' + id, { action }); await loadBloomQueue(); } catch { /* ignore */ }
|
||||
}
|
||||
async function addBloomOverride(action) {
|
||||
const w = bloomOvrWord.trim().toLowerCase();
|
||||
if (!w) return;
|
||||
try { await postJSON('/api/admin/bloom/overrides', { word: w, action }); bloomOvrWord = ''; await loadBloomQueue(); }
|
||||
catch { /* ignore */ }
|
||||
}
|
||||
async function removeBloomOverride(w) {
|
||||
try { await delJSON('/api/admin/bloom/overrides/' + encodeURIComponent(w)); await loadBloomQueue(); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// --- Games: Daily Word pool ---
|
||||
let wpWord = $state('');
|
||||
let wpResult = $state(null); // lookup result for the current input
|
||||
@@ -168,6 +335,7 @@
|
||||
{ key: 'audience', label: 'Audience' },
|
||||
{ key: 'feedback', label: 'Feedback' },
|
||||
{ key: 'games', label: 'Games' },
|
||||
{ key: 'publish', label: 'Publishing' },
|
||||
];
|
||||
const VALID_SECTIONS = new Set(TABS.map((t) => t.key));
|
||||
// Unknown ?section= values fall back to Overview so the page never renders blank.
|
||||
@@ -282,7 +450,7 @@
|
||||
} catch (e) { s._artErr = e?.message || 'Could not load articles.'; }
|
||||
finally { s._artBusy = false; }
|
||||
}
|
||||
const ART_FILTERS = [['all', 'All'], ['accepted', 'Accepted'], ['rejected', 'Rejected'], ['no_image', 'No image'], ['duplicates', 'Duplicates']];
|
||||
const ART_FILTERS = [['all', 'All'], ['accepted', 'Accepted'], ['rejected', 'Rejected'], ['held', 'Held'], ['no_image', 'No image'], ['duplicates', 'Duplicates']];
|
||||
function pwBasis(sum) {
|
||||
if (!sum) return '';
|
||||
if (sum.paywall_override === 'free') return 'OFF (override)';
|
||||
@@ -309,6 +477,7 @@
|
||||
let addBusy = $state(false);
|
||||
let addErr = $state('');
|
||||
let pendingCandidates = $derived(candidates.filter((c) => c.status !== 'promoted' && c.status !== 'rejected'));
|
||||
let rejectedCandidates = $derived(candidates.filter((c) => c.status === 'rejected'));
|
||||
|
||||
async function addCandidate() {
|
||||
const url = newFeedUrl.trim();
|
||||
@@ -338,7 +507,7 @@
|
||||
try {
|
||||
// ~5-7s/item on the LAN model; bound the wait so a model stall can't pin
|
||||
// the button on "Deep-checking…" forever.
|
||||
const res = await postJSON(`/api/admin/candidates/${c.id}/preview?deep=true`, undefined, { timeout: 120000 });
|
||||
const res = await postJSON(`/api/admin/candidates/${c.id}/preview?deep=true`, undefined, { timeout: 180000 });
|
||||
Object.assign(c, res, { _deep: false });
|
||||
} catch (e) { c._err = e?.message || 'Deep preview failed.'; c._deep = false; }
|
||||
}
|
||||
@@ -365,6 +534,19 @@
|
||||
try { Object.assign(c, await postJSON(`/api/admin/candidates/${c.id}/reject`)); }
|
||||
catch { /* leave as-is */ }
|
||||
}
|
||||
// Codex's operating rule for the Deep-Preview access verdict, shown on hover.
|
||||
const ACC_HELP = {
|
||||
fine: 'Sample reads fine — judge mainly on content quality.',
|
||||
review: 'Click 2–3 example links before promoting — catches false-positive paywalls and bot-blocks (readable in a browser, blocked to us).',
|
||||
'reject-ready': 'Domain rule AND the sample agree it’s walled — usually reject, unless it’s a source you want and the examples open fine in your browser.',
|
||||
};
|
||||
// Send a rejected candidate back to staging for another look (status → suggested).
|
||||
async function restoreCandidate(c) {
|
||||
try {
|
||||
Object.assign(c, await postJSON(`/api/admin/candidates/${c.id}/restore`));
|
||||
candidates = [...candidates]; // nudge the derived queue/tray to recompute
|
||||
} catch { /* leave as-is */ }
|
||||
}
|
||||
|
||||
// Feedback inbox: filter + read/unread + delete.
|
||||
let fbCat = $state('all'); // 'all' | 'unread' | a category
|
||||
@@ -527,12 +709,13 @@
|
||||
</div>
|
||||
|
||||
{#if clientErrors.length}
|
||||
<h2>Recent load errors <span class="count">(last {clientErrors.length})</span></h2>
|
||||
<h2>Recent load errors <span class="count">(last {clientErrors.length} · newest {ago(clientErrors[0]?.created_at)})</span></h2>
|
||||
<ul class="cerrs">
|
||||
{#each clientErrors as e (e.created_at + e.reason)}
|
||||
{@const t = errType(e)}
|
||||
<li class:bot={e.bot}>
|
||||
<span class="ce-when">{fdate(e.created_at)}</span>
|
||||
<span class="ce-reason">{e.reason || '—'}{#if e.bot}<span class="ce-bot">bot</span>{/if}</span>
|
||||
<span class="ce-reason"><span class="ce-tag {t.k}">{t.label}</span>{e.reason || '—'}</span>
|
||||
<span class="ce-path">{e.path || '/'}</span>
|
||||
<span class="ce-ua">{e.user_agent}{#if e.app_version} · build {e.app_version}{/if}</span>
|
||||
</li>
|
||||
@@ -639,9 +822,19 @@
|
||||
<div class="curl">{c.feed_url}</div>
|
||||
{#if c.preview}
|
||||
<div class="cprev">
|
||||
{c.preview.accepted ?? 0}/{c.preview.sampled ?? 0} sampled would pass{#if c.preview.acceptance_rate != null} · {Math.round(c.preview.acceptance_rate * 100)}% accept{/if}{#if c.preview.recent_7d != null} · {c.preview.recent_7d} in last 7d{/if}
|
||||
{c.preview.accepted ?? 0}/{c.preview.sampled ?? 0} sampled would pass{#if c.preview.acceptance_rate != null} · {Math.round(c.preview.acceptance_rate * 100)}% accept{/if}{#if c.preview.non_english} · <span class="held" title="Non-English items are held (English-only feed for now), not counted as rejections">{c.preview.non_english} held · non-English</span>{/if}{#if c.preview.recent_7d != null} · {c.preview.recent_7d} in last 7d{/if}
|
||||
{#if c.preview.classified}<span class="vbadge model" title="Scored by the real classifier — the true acceptance view">model-checked</span>{:else}<span class="vbadge fast" title="Fast keyword heuristic — an estimate. Run Deep preview for the model's real verdict.">quick estimate</span>{/if}
|
||||
{#if c.preview.paywall_rule}<span class="vbadge wall" title="This domain is on the paywall list (a hint, not a verdict)">paywall domain</span>{/if}
|
||||
{#if c.preview.examples_accepted?.length}<div class="cex">e.g. {c.preview.examples_accepted.slice(0, 3).join(' · ')}</div>{/if}
|
||||
{#if c.preview.access}
|
||||
<div class="caccess">
|
||||
<span class="acc-verdict {c.preview.access_verdict}" title={ACC_HELP[c.preview.access_verdict] || ''}>{c.preview.access_verdict}</span>
|
||||
<span class="acc-counts">Access: {c.preview.access.readable} readable · {c.preview.access.paywalled} paywalled{#if c.preview.access.blocked} · <span title="Couldn't fetch — may be a bot-block (readable in a browser), not a reader paywall">{c.preview.access.blocked} blocked</span>{/if}{#if c.preview.access.unknown} · {c.preview.access.unknown} unknown{/if} <span class="acc-of">({c.preview.access.checked} sampled)</span></span>
|
||||
{#if c.preview.access.examples?.length}
|
||||
<div class="acc-ex-row">{#each c.preview.access.examples as ex (ex.url)}<a class="acc-ex {ex.access}" href={ex.url} target="_blank" rel="noopener">{ex.access}↗</a>{/each}</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if c._err}<p class="cerr">{c._err}</p>{/if}
|
||||
@@ -659,6 +852,29 @@
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if rejectedCandidates.length}
|
||||
<section>
|
||||
<details class="rejtray">
|
||||
<summary>Rejected candidates <span class="count">({rejectedCandidates.length})</span></summary>
|
||||
<ul class="candlist rejlist">
|
||||
{#each rejectedCandidates as c (c.id)}
|
||||
<li>
|
||||
<div class="chead">
|
||||
<span class="cname">{c.name || c.feed_url}</span>
|
||||
<span class="cstatus">rejected</span>
|
||||
</div>
|
||||
<div class="curl">{c.feed_url}</div>
|
||||
{#if c._err}<p class="cerr">{c._err}</p>{/if}
|
||||
<div class="cactions">
|
||||
<button class="csend" onclick={() => restoreCandidate(c)}>Send back to staging</button>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</details>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<h2>Sources <a class="exportlink" href="/api/admin/export/sources.csv" download>export CSV ↓</a></h2>
|
||||
<p class="sub2">{healthy} healthy · {resting} resting · {flagged} flagged · {paused} paused · {retired} retired · {sources.length} total</p>
|
||||
<div class="srctools">
|
||||
@@ -744,7 +960,7 @@
|
||||
{#if s._artSummary}
|
||||
<div class="artsum">
|
||||
<strong>{s._artSummary.total}</strong> ingested · {s._artSummary.accepted} accepted ·
|
||||
{s._artSummary.rejected} rejected · {s._artSummary.no_image} no image · {s._artSummary.duplicates} dup
|
||||
{s._artSummary.rejected} rejected{#if s._artSummary.non_english} · <span class="held">{s._artSummary.non_english} held · non-English</span>{/if} · {s._artSummary.no_image} no image · {s._artSummary.duplicates} dup
|
||||
</div>
|
||||
<div class="pwctl">
|
||||
<span>Paywall: <span class="pwrule" class:on={s._artSummary.paywalled}>{pwBasis(s._artSummary)}</span></span>
|
||||
@@ -770,7 +986,7 @@
|
||||
{#if typeof a.url === 'string' && /^https?:\/\//.test(a.url)}
|
||||
<a class="art-title" href={a.url} target="_blank" rel="noopener">{a.title}</a>
|
||||
{:else}<span class="art-title plain">{a.title}</span>{/if}
|
||||
{#if a.accepted === 1}<span class="badge ok">accepted</span>{:else if a.accepted === 0}<span class="badge no">rejected</span>{/if}
|
||||
{#if a.accepted === 1}<span class="badge ok">accepted</span>{:else if a.held}<span class="badge held">held · non-English</span>{:else if a.accepted === 0}<span class="badge no">rejected</span>{/if}
|
||||
{#if a.paywalled}<span class="pw" title="domain paywall rule">🔒</span>{/if}
|
||||
{#if !a.has_image}<span class="art-flag" title="no image extracted">no img</span>{/if}
|
||||
{#if a.duplicate}<span class="art-flag" title="marked duplicate">dup</span>{/if}
|
||||
@@ -958,7 +1174,46 @@
|
||||
{:else}<p class="muted">No feedback yet.</p>{/if}
|
||||
|
||||
{:else if section === 'games'}
|
||||
<h2>Daily Word pool</h2>
|
||||
<h2>Bloom words <span class="count">({bloomReports.length} to review)</span></h2>
|
||||
<p class="muted">Acceptance is broad (every valid dictionary word) — these are player
|
||||
“should this count?” reports. <strong>Approve</strong> allows the word everywhere
|
||||
(takes effect immediately, no deploy); <strong>Block</strong> hides it; <strong>Dismiss</strong>
|
||||
clears the report without a rule.</p>
|
||||
{#if bloomReports.length}
|
||||
<ul class="bloom-reports">
|
||||
{#each bloomReports as r (r.id)}
|
||||
<li>
|
||||
<span class="br-word">{r.word}</span>
|
||||
<span class="br-ctx">{r.format || ''}{r.letters ? ' · ' + r.letters.toUpperCase() : ''}{r.puzzle_date ? ' · ' + r.puzzle_date : ''}</span>
|
||||
<span class="br-acts">
|
||||
<button class="wp-add" onclick={() => resolveBloom(r.id, 'approve')}>Approve</button>
|
||||
<button class="act del" onclick={() => resolveBloom(r.id, 'block')}>Block</button>
|
||||
<button class="link" onclick={() => resolveBloom(r.id, 'dismiss')}>Dismiss</button>
|
||||
</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<p class="empty">No pending word reports.</p>
|
||||
{/if}
|
||||
|
||||
<div class="wp-lookup">
|
||||
<input type="text" bind:value={bloomOvrWord} maxlength="24" autocapitalize="off"
|
||||
autocomplete="off" spellcheck="false" placeholder="Manually allow/block a word…" />
|
||||
<button class="wp-add" onclick={() => addBloomOverride('allow')}>Allow</button>
|
||||
<button class="act del" onclick={() => addBloomOverride('block')}>Block</button>
|
||||
</div>
|
||||
{#if bloomOverrides.length}
|
||||
<div class="bloom-ovr">
|
||||
{#each bloomOverrides as o (o.word)}
|
||||
<span class="ovr-chip {o.action}">{o.word} · {o.action}
|
||||
<button class="ovr-x" aria-label="Remove override" onclick={() => removeBloomOverride(o.word)}>×</button>
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<h2 style="margin-top:32px">Daily Word pool</h2>
|
||||
<p class="muted">Look up a word to add or remove it from the answer pool. Only real, 5- or 6-letter
|
||||
words in the guess dictionary qualify, so the daily answer is always solvable. Removals take
|
||||
effect for future puzzles and can be restored any time.</p>
|
||||
@@ -1082,6 +1337,109 @@
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}<p class="muted small">No custom themes yet — the daily rotation uses the built-in ones.</p>{/if}
|
||||
|
||||
{:else if section === 'publish'}
|
||||
<h2>Publishing Desk <span class="count">({pubItems.length})</span></h2>
|
||||
<p class="sub2">Build a queue of share-worthy stories, write the blurb in your own voice, and open it in X. Posts your <code>/a/</code> share link (your card, drives to UB, still credits the source). Opening X can't confirm a post — you mark Posted.</p>
|
||||
<div class="pubtools">
|
||||
<button class="csend" onclick={buildPublish} disabled={pubBuilding}>{pubBuilding ? 'Building…' : 'Build queue'}</button>
|
||||
{#if pubLast}<span class="muted small">Last build: +{pubLast.added} · {pubLast.active} active · ranked {pubLast.ranked_by}</span>{/if}
|
||||
</div>
|
||||
{#if pubError}<p class="cerr">Build error: {pubError}</p>{/if}
|
||||
|
||||
{#if !pubItems.length && !pubBuilding}
|
||||
<p class="muted small">Queue is empty — hit “Build queue” to gather candidates.</p>
|
||||
{/if}
|
||||
|
||||
<ul class="publist">
|
||||
{#each pubItems as item (item.id)}
|
||||
{@const remaining = pubRemaining(item)}
|
||||
<li class="pubcard" class:opened={item.status === 'opened'}>
|
||||
<div class="pub-head">
|
||||
{#if item.image_url}<img class="pub-img" src={item.image_url} alt="" loading="lazy" />{/if}
|
||||
<div class="pub-meta">
|
||||
<a class="pub-title" href={item.share_url} target="_blank" rel="noopener">{item.title}</a>
|
||||
<span class="pub-src">{item.source_name}{#if item.social_score != null} · interest {item.social_score}/10{/if}{#if item.status === 'opened'} · <span class="pub-openedtag">opened</span>{/if}</span>
|
||||
</div>
|
||||
</div>
|
||||
{#if item.rationale}<p class="pub-why">{item.rationale}</p>{/if}
|
||||
{#if item.talking_points?.length}
|
||||
<ul class="pub-points">{#each item.talking_points as p}<li>{p}</li>{/each}</ul>
|
||||
{/if}
|
||||
{#if item.angle}<p class="pub-angle"><span class="hlbl">Angle:</span> {item.angle}</p>{/if}
|
||||
|
||||
{#if item.suggested_handles?.length}
|
||||
<div class="pub-handles">
|
||||
<span class="hlbl">Tag:</span>
|
||||
{#each item.suggested_handles as h}<button class="hchip" onclick={() => insertHandle(item, h.handle)} title="Insert into your post">{h.handle}</button>{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="pub-toolbar">
|
||||
<button type="button" class="emoji-toggle" class:on={pubEmojiOpen === item.id}
|
||||
onclick={() => (pubEmojiOpen = pubEmojiOpen === item.id ? null : item.id)}
|
||||
title="Insert emoji">😊 Emoji</button>
|
||||
{#if pubEmojiOpen === item.id}
|
||||
<div class="emoji-pop">
|
||||
<EmojiPicker onpick={(em) => insertEmoji(item, em)} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<textarea class="pub-draft" rows="3" placeholder="Write your post — in your voice…"
|
||||
use:regTextarea={item.id}
|
||||
bind:value={item.draft_text} oninput={() => onDraftInput(item)}></textarea>
|
||||
<div class="pub-count" class:over={remaining < 0}>{remaining} left <span class="muted">(+ your /a/ link)</span></div>
|
||||
|
||||
{#if namedEntities(item).length}
|
||||
<details class="pub-find">
|
||||
<summary>Find / save handles</summary>
|
||||
{#each namedEntities(item) as ent (ent)}
|
||||
<div class="pub-ent">
|
||||
<span class="pub-entname">{ent}</span>
|
||||
<a class="act mini" href={findOnX(ent)} target="_blank" rel="noopener">Find on X ↗</a>
|
||||
<input class="hin" placeholder="@handle" bind:value={pubHandleInput[`${item.id}:${ent}`]} />
|
||||
<button class="act mini" onclick={() => pubSaveHandle(item, ent)}>Save</button>
|
||||
</div>
|
||||
{/each}
|
||||
</details>
|
||||
{/if}
|
||||
|
||||
{#if item._err}<p class="cerr">{item._err}</p>{/if}
|
||||
{#if item._confirm}
|
||||
<div class="pub-confirm">
|
||||
<p class="muted small">If you tweaked the post inside X, edit the text above to match before confirming — it's saved as your final wording.</p>
|
||||
<input class="hin wide" placeholder="Post URL (optional)" bind:value={item._postUrl} />
|
||||
<div class="pub-actions">
|
||||
<button class="csend" onclick={() => pubConfirmPosted(item)}>Confirm posted</button>
|
||||
<button class="act" onclick={() => (item._confirm = false)}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="pub-actions">
|
||||
<a class="csend" href={intentURL(item)} target="_blank" rel="noopener" onclick={() => markOpened(item)}>Open in X ↗</a>
|
||||
<button class="act" onclick={() => (item._confirm = true)}>Posted ✓</button>
|
||||
<button class="act" onclick={() => pubSetStatus(item, 'snoozed', { snooze_until: snoozeDate(1) })}>Snooze 1d</button>
|
||||
<button class="act del" onclick={() => pubSetStatus(item, 'skipped')}>Skip</button>
|
||||
</div>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
{#if pubArchived.length}
|
||||
<details class="rejtray">
|
||||
<summary>Archived · skipped & snoozed <span class="count">({pubArchived.length})</span></summary>
|
||||
<ul class="candlist">
|
||||
{#each pubArchived as item (item.id)}
|
||||
<li>
|
||||
<div class="chead"><span class="cname">{item.title}</span><span class="cstatus">{item.status}{#if item.snooze_until} · until {item.snooze_until.slice(0, 16)}{/if}</span></div>
|
||||
<div class="curl">{item.source_name}</div>
|
||||
<div class="cactions"><button class="csend" onclick={() => pubRestore(item)}>Restore to queue</button></div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</details>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</main>
|
||||
@@ -1216,6 +1574,8 @@
|
||||
.addrow input { flex: 1 1 200px; box-sizing: border-box; font: inherit; font-size: 0.9rem; padding: 8px 11px; border: 1px solid var(--line); border-radius: 9px; background: var(--bg); color: var(--ink); }
|
||||
.addrow input:focus { outline: none; border-color: var(--accent); }
|
||||
ul.candlist { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
|
||||
.rejtray > summary { cursor: pointer; font-family: var(--label); font-weight: 600; color: var(--muted); margin-bottom: 10px; }
|
||||
.rejtray .rejlist { opacity: 0.85; } /* tucked-away, lower-emphasis tray */
|
||||
ul.candlist li { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 12px 14px; }
|
||||
.chead { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
|
||||
.chead .cname { font-weight: 600; color: var(--ink); }
|
||||
@@ -1229,11 +1589,26 @@
|
||||
.curl { font-size: 0.76rem; color: var(--muted); word-break: break-all; margin-top: 2px; }
|
||||
.cprev { font-size: 0.84rem; color: var(--ink); margin-top: 7px; }
|
||||
.cprev .cex { color: var(--muted); font-size: 0.8rem; margin-top: 2px; font-style: italic; }
|
||||
.held { color: #8a5a18; font-weight: 600; } /* language-held (not a rejection) */
|
||||
/* Heuristic-vs-model preview badge */
|
||||
.vbadge { display: inline-block; margin-left: 8px; padding: 1px 8px; border-radius: 999px;
|
||||
font-size: 0.68rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; cursor: help; }
|
||||
.vbadge.model { background: #e3efe4; color: #3f7048; }
|
||||
.vbadge.fast { background: var(--line); color: var(--muted); }
|
||||
.vbadge.wall { background: #fdf0d8; color: #8a5a18; }
|
||||
/* Deep-preview accessibility sample */
|
||||
.caccess { margin-top: 6px; font-size: 0.8rem; color: var(--ink); display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
||||
.acc-verdict { padding: 1px 8px; border-radius: 999px; font-size: 0.68rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
.acc-verdict.fine { background: #e3efe4; color: #3f7048; }
|
||||
.acc-verdict.review { background: #fdf0d8; color: #8a5a18; }
|
||||
.acc-verdict.reject-ready { background: #fbe3e3; color: #9a3b3b; }
|
||||
.acc-of { color: var(--muted); }
|
||||
.acc-ex-row { display: flex; gap: 6px; flex-wrap: wrap; flex-basis: 100%; }
|
||||
.acc-ex { font-size: 0.7rem; padding: 1px 7px; border-radius: 7px; text-decoration: none; }
|
||||
.acc-ex.readable { background: #e3efe4; color: #3f7048; }
|
||||
.acc-ex.paywalled { background: #fbe3e3; color: #9a3b3b; }
|
||||
.acc-ex.blocked { background: #eceff2; color: #67727d; }
|
||||
.acc-ex.unknown { background: var(--line); color: var(--muted); }
|
||||
.cactions { display: flex; gap: 9px; align-items: center; flex-wrap: wrap; margin-top: 10px; }
|
||||
.cactions .ccat { font: inherit; font-size: 0.8rem; padding: 5px 9px; border: 1px solid var(--line); border-radius: 8px; background: var(--bg); color: var(--ink); width: 150px; }
|
||||
.cactions .cchk { font-size: 0.8rem; color: var(--muted); display: inline-flex; align-items: center; gap: 5px; }
|
||||
@@ -1297,6 +1672,7 @@
|
||||
.art-row .badge { font-size: 0.66rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; padding: 1px 7px; border-radius: 999px; }
|
||||
.badge.ok { background: #e3efe4; color: #3f7048; }
|
||||
.badge.no { background: #f3e0e0; color: #9a3b3b; }
|
||||
.badge.held { background: #fdf0d8; color: #8a5a18; } /* language-held, not rejected */
|
||||
.art-row .pw { font-size: 0.78rem; }
|
||||
.art-flag { font-size: 0.7rem; color: var(--muted); border: 1px solid var(--line); border-radius: 999px; padding: 0 7px; }
|
||||
.art-cat { font-size: 0.72rem; color: var(--muted); text-transform: capitalize; }
|
||||
@@ -1400,6 +1776,15 @@
|
||||
font-size: 0.82rem; padding: 8px 12px; background: var(--surface); border: 1px solid var(--line); border-radius: 8px; }
|
||||
.ce-when { color: var(--muted); white-space: nowrap; }
|
||||
.ce-reason { font-family: var(--label); color: #9a3b3b; }
|
||||
/* Layer tag — html-slow is the only incident-grade one, so it reads loudest. */
|
||||
.ce-tag { display: inline-block; margin-right: 8px; padding: 1px 7px; border-radius: 999px;
|
||||
font-size: 0.66rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em;
|
||||
vertical-align: middle; }
|
||||
.ce-tag.html { background: #fbe3e3; color: #9a3b3b; }
|
||||
.ce-tag.app { background: #fdf0d8; color: #8a5a18; }
|
||||
.ce-tag.preload { background: var(--accent-soft); color: var(--accent-deep); }
|
||||
.ce-tag.runtime { background: #fbe3e3; color: #9a3b3b; }
|
||||
.ce-tag.bot { background: #eceff2; color: #67727d; }
|
||||
.cerrs li.bot { opacity: 0.6; }
|
||||
.cerrs li.bot .ce-reason { color: var(--muted); }
|
||||
.ce-bot { display: inline-block; margin-left: 8px; padding: 1px 8px; border-radius: 999px;
|
||||
@@ -1422,6 +1807,20 @@
|
||||
font: inherit; font-weight: 600; cursor: pointer; }
|
||||
.wp-add:hover { background: var(--accent-deep); }
|
||||
.wp-msg { color: var(--accent-deep); font-size: 0.9rem; margin: 6px 0 0; }
|
||||
.bloom-reports { list-style: none; padding: 0; margin: 10px 0 18px; display: flex; flex-direction: column; gap: 8px; }
|
||||
.bloom-reports li { display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
|
||||
background: var(--surface); border: 1px solid var(--line); border-radius: 10px; padding: 10px 14px; }
|
||||
.br-word { font-weight: 700; text-transform: capitalize; }
|
||||
.br-ctx { color: var(--muted); font-size: 0.82rem; }
|
||||
.br-acts { margin-left: auto; display: flex; align-items: center; gap: 10px; }
|
||||
.br-acts .wp-add { padding: 5px 13px; font-size: 0.85rem; }
|
||||
.bloom-ovr { display: flex; flex-wrap: wrap; gap: 8px; margin: 2px 0 8px; }
|
||||
.ovr-chip { display: inline-flex; align-items: center; gap: 6px; border-radius: 999px; padding: 4px 6px 4px 12px;
|
||||
font-size: 0.85rem; color: #fff; }
|
||||
.ovr-chip.allow { background: #2e8b57; }
|
||||
.ovr-chip.block { background: #b8553f; }
|
||||
.ovr-x { background: rgba(255,255,255,0.25); border: none; color: #fff; border-radius: 50%; width: 18px; height: 18px;
|
||||
line-height: 1; cursor: pointer; font-size: 0.9rem; }
|
||||
.wp-cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 22px; margin-top: 24px; }
|
||||
.wp-col h3 { margin: 0 0 2px; font-size: 1.05rem; }
|
||||
.wp-col .count { font-size: 0.85rem; }
|
||||
@@ -1474,4 +1873,45 @@
|
||||
border: 1px solid var(--line); border-radius: 12px; padding: 12px 16px; }
|
||||
.wt-name { font-weight: 600; }
|
||||
.wt-count { color: var(--muted); font-size: 0.84rem; margin-right: auto; }
|
||||
|
||||
/* Publishing Desk */
|
||||
.pubtools { display: flex; align-items: center; gap: 12px; margin: 6px 0 16px; }
|
||||
.publist { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 14px; }
|
||||
.pubcard { background: var(--surface); border: 1px solid var(--line); border-radius: 14px; padding: 14px 16px; }
|
||||
.pubcard.opened { border-color: var(--accent); }
|
||||
.pub-head { display: flex; gap: 12px; align-items: flex-start; }
|
||||
.pub-img { width: 88px; height: 64px; object-fit: cover; border-radius: 9px; flex-shrink: 0; }
|
||||
.pub-meta { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||
.pub-title { font-weight: 600; color: var(--ink); text-decoration: none; }
|
||||
.pub-title:hover { text-decoration: underline; }
|
||||
.pub-src { font-size: 0.8rem; color: var(--muted); }
|
||||
.pub-openedtag { color: var(--accent-deep); font-weight: 600; }
|
||||
.pub-why { margin: 10px 0 4px; font-style: italic; color: var(--ink); }
|
||||
.pub-points { margin: 4px 0; padding-left: 18px; color: var(--ink); font-size: 0.9rem; }
|
||||
.pub-points li { margin: 1px 0; }
|
||||
.pub-angle { margin: 4px 0; font-size: 0.88rem; color: var(--muted); }
|
||||
.hlbl { font-family: var(--label); font-weight: 600; font-size: 0.78rem; color: var(--muted); }
|
||||
.pub-handles { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin: 8px 0; }
|
||||
.hchip { font-size: 0.8rem; padding: 2px 9px; border: 1px solid var(--accent); border-radius: 999px;
|
||||
background: var(--accent-soft); color: var(--accent-deep); cursor: pointer; }
|
||||
.pub-draft { width: 100%; box-sizing: border-box; margin-top: 8px; padding: 9px 11px; font: inherit;
|
||||
border: 1px solid var(--line); border-radius: 10px; background: var(--bg); color: var(--ink); resize: vertical; }
|
||||
.pub-toolbar { position: relative; margin-top: 8px; }
|
||||
.emoji-toggle { font: inherit; font-size: 0.8rem; padding: 3px 10px; border: 1px solid var(--line);
|
||||
border-radius: 999px; background: var(--bg); color: var(--ink); cursor: pointer; }
|
||||
.emoji-toggle.on { border-color: var(--accent); background: var(--accent-soft); color: var(--accent-deep); }
|
||||
.emoji-pop { position: absolute; z-index: 20; top: calc(100% + 4px); left: 0; padding: 8px;
|
||||
border: 1px solid var(--line); border-radius: 10px; background: var(--card, var(--bg));
|
||||
box-shadow: 0 6px 20px rgba(0,0,0,0.18); }
|
||||
.pub-count { font-size: 0.78rem; color: var(--muted); text-align: right; margin-top: 2px; font-variant-numeric: tabular-nums; }
|
||||
.pub-count.over { color: #9a3b3b; font-weight: 700; }
|
||||
.pub-find { margin: 8px 0; font-size: 0.85rem; }
|
||||
.pub-find > summary { cursor: pointer; color: var(--accent-deep); }
|
||||
.pub-ent { display: flex; align-items: center; gap: 8px; margin: 6px 0; flex-wrap: wrap; }
|
||||
.pub-entname { min-width: 120px; }
|
||||
.hin { font: inherit; font-size: 0.82rem; padding: 4px 8px; border: 1px solid var(--line); border-radius: 8px;
|
||||
background: var(--bg); color: var(--ink); width: 130px; }
|
||||
.pub-actions { display: flex; gap: 9px; align-items: center; flex-wrap: wrap; margin-top: 12px; }
|
||||
.pub-confirm { margin-top: 10px; padding: 10px 12px; border: 1px solid var(--accent); border-radius: 10px; background: var(--accent-soft); }
|
||||
.hin.wide { width: 100%; box-sizing: border-box; margin: 4px 0; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user