Admin: source Articles inspector (verify metrics against real evidence)
New per-row "Articles" button on the Sources table expands a read-only inline
panel of the source's ACTUAL ingested articles — so the automated metrics
(paywall/image/acceptance/duplicate) can be verified against evidence instead of
trusted blind. Distinct from "Check" (which re-samples the LIVE feed for
would-pass quality); this shows what's already in the DB, which is what the table
metrics are computed from.
- Backend: GET /api/admin/sources/{id}/articles?filter=&limit=&offset= (admin,
read-only). queries.source_articles + source_articles_summary — per article:
title, url, date, accepted, reason (the "why"), topic/flavor, paywalled
(domain rule), has_image, duplicate. Summary = counts + source-level paywall
rule.
- Frontend: expandable panel with a summary header ("27 ingested · 18 accepted
· … · paywall rule: ON (domain)"), filter chips (All/Accepted/Rejected/No
image/Duplicates), compact rows with title→link + badges + reason, Load more.
So "100% paywall" or "0% images" becomes clickable evidence: open two articles
to tell a real paywall from a mis-flagged domain, or a true image gap from an
enrichment failure. Test: test_source_articles_inspector. 241 pytest + 11 vitest.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -193,7 +193,7 @@
|
||||
{#each Array(length) as _, c (c)}
|
||||
{@const ch = g ? g[c] : (r === guesses.length ? current[c] : '')}
|
||||
<div class="tile {cs ? cs[c] : ''}" class:filled={!!ch}
|
||||
style={cs ? `animation-delay:${c * 0.08}s` : ''}>{(ch || '').toUpperCase()}</div>
|
||||
style={cs ? `--d:${c * 0.08}s` : ''}>{(ch || '').toUpperCase()}</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
@@ -253,22 +253,53 @@
|
||||
aspect-ratio: 1; display: flex; align-items: center; justify-content: center;
|
||||
border: 2px solid var(--line); border-radius: 8px; font-family: var(--label);
|
||||
font-weight: 700; font-size: 1.5rem; color: var(--ink); text-transform: uppercase;
|
||||
background: var(--surface);
|
||||
background: var(--surface); position: relative; overflow: hidden;
|
||||
}
|
||||
.tile.filled { border-color: #b7c0cb; }
|
||||
.tile.correct { background: #4a9d6e; border-color: #4a9d6e; color: #fff; }
|
||||
.tile.present { background: #d8b24a; border-color: #d8b24a; color: #fff; }
|
||||
.tile.absent { background: #9aa6b2; border-color: #9aa6b2; color: #fff; }
|
||||
/* Judged tiles set like glazed enamel: a soft top-light gradient over the
|
||||
colour, an inner bevel, and a little lift off the board. Pending tiles stay
|
||||
flat on purpose — depth marks the moment a letter is settled. */
|
||||
.tile.correct, .tile.present, .tile.absent { color: #fff; text-shadow: 0 1px 2px rgba(20, 30, 25, 0.22); }
|
||||
.tile.correct {
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.2), rgba(255, 255, 255, 0) 45%),
|
||||
linear-gradient(165deg, #56ac7c, #4a9d6e 55%, #3e8a5e);
|
||||
border-color: #3e8a5e;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.32), inset 0 -3px 5px rgba(22, 64, 42, 0.2),
|
||||
0 2px 5px rgba(58, 125, 86, 0.28);
|
||||
}
|
||||
.tile.present {
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.2), rgba(255, 255, 255, 0) 45%),
|
||||
linear-gradient(165deg, #e2c163, #d8b24a 55%, #c29c38);
|
||||
border-color: #c29c38;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.35), inset 0 -3px 5px rgba(122, 92, 22, 0.2),
|
||||
0 2px 5px rgba(184, 148, 58, 0.28);
|
||||
}
|
||||
.tile.absent {
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.16), rgba(255, 255, 255, 0) 45%),
|
||||
linear-gradient(165deg, #a7b2bd, #9aa6b2 55%, #87939f);
|
||||
border-color: #87939f;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.26), inset 0 -3px 5px rgba(50, 60, 70, 0.16),
|
||||
0 2px 4px rgba(110, 122, 134, 0.24);
|
||||
}
|
||||
/* One quiet glint sweeps each tile just after its flip lands — once, not a loop. */
|
||||
.tile.correct::after, .tile.present::after, .tile.absent::after {
|
||||
content: ''; position: absolute; inset: 0; pointer-events: none;
|
||||
background: linear-gradient(115deg, transparent 38%, rgba(255, 255, 255, 0.38) 50%, transparent 62%);
|
||||
transform: translateX(-130%);
|
||||
animation: sheen 0.65s ease-out both;
|
||||
animation-delay: calc(var(--d, 0s) + 0.32s);
|
||||
}
|
||||
|
||||
/* Juice: a tile pops as you type; the row reveals with a staggered bounce when
|
||||
you submit; the row shakes on an invalid word. */
|
||||
.tile.filled:not(.correct):not(.present):not(.absent) { animation: pop 0.13s ease; }
|
||||
.tile.correct, .tile.present, .tile.absent { animation: reveal 0.34s ease both; }
|
||||
.tile.correct, .tile.present, .tile.absent { animation: reveal 0.34s ease both; animation-delay: var(--d, 0s); }
|
||||
.row.shake { animation: shake 0.4s ease; }
|
||||
@keyframes pop { 0% { transform: scale(1); } 45% { transform: scale(1.09); } 100% { transform: scale(1); } }
|
||||
@keyframes reveal { 0% { transform: scale(0.5); opacity: 0.3; } 55% { transform: scale(1.12); } 100% { transform: scale(1); opacity: 1; } }
|
||||
@keyframes sheen { to { transform: translateX(130%); } }
|
||||
@keyframes shake { 0%, 100% { transform: translateX(0); } 20% { transform: translateX(-7px); } 40% { transform: translateX(7px); } 60% { transform: translateX(-5px); } 80% { transform: translateX(5px); } }
|
||||
@media (prefers-reduced-motion: reduce) { .tile, .row { animation: none !important; } }
|
||||
@media (prefers-reduced-motion: reduce) { .tile, .tile::after, .row { animation: none !important; } }
|
||||
.flash {
|
||||
text-align: center; background: var(--ink); color: #fff; border-radius: 8px;
|
||||
padding: 7px 14px; width: fit-content; margin: 0 auto 12px; font-size: 0.86rem;
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
let foundWords = $state([]); // {word, cells:[[r,c]], ci}
|
||||
let sel = $state([]); // current selection cells
|
||||
let selecting = false;
|
||||
let startTime = 0;
|
||||
let playedMs = 0; // accumulated ACTIVE play time (closed segments)
|
||||
let segStart = 0; // wall-clock start of the open segment (0 = paused)
|
||||
let resultMs = $state(0);
|
||||
let best = $state(0);
|
||||
let loading = $state(true);
|
||||
@@ -63,16 +64,44 @@
|
||||
return m;
|
||||
});
|
||||
|
||||
// --- the clock counts ACTIVE play only -----------------------------------
|
||||
// Wall-clock timing made "finish in one sitting" feel mandatory — the
|
||||
// opposite of calm. The clock runs only while the puzzle is on screen
|
||||
// (tab visible, window focused, game unfinished); stepping away pauses it,
|
||||
// coming back resumes it, and several sittings simply add up.
|
||||
function playedNow() { return playedMs + (segStart ? Date.now() - segStart : 0); }
|
||||
function pauseClock(save = true) {
|
||||
if (!segStart) return;
|
||||
playedMs += Date.now() - segStart; segStart = 0;
|
||||
if (save) persist(); // don't lose the segment if the tab dies
|
||||
}
|
||||
function resumeClock() {
|
||||
if (!segStart && !loading && status === 'playing' && !document.hidden) segStart = Date.now();
|
||||
}
|
||||
$effect(() => {
|
||||
const onVis = () => (document.hidden ? pauseClock() : resumeClock());
|
||||
const onAway = () => pauseClock();
|
||||
document.addEventListener('visibilitychange', onVis);
|
||||
window.addEventListener('pagehide', onAway);
|
||||
window.addEventListener('blur', onAway);
|
||||
window.addEventListener('focus', onVis);
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', onVis);
|
||||
window.removeEventListener('pagehide', onAway);
|
||||
window.removeEventListener('blur', onAway);
|
||||
window.removeEventListener('focus', onVis);
|
||||
};
|
||||
});
|
||||
|
||||
async function load() {
|
||||
const seq = ++loadSeq; // stale-load guard for rapid size switches
|
||||
loading = true; ready = false;
|
||||
foundWords = []; sel = []; resultMs = 0; startTime = 0;
|
||||
foundWords = []; sel = []; resultMs = 0; playedMs = 0; segStart = 0;
|
||||
try {
|
||||
const p = await getJSON('/api/puzzle/wordsearch?variant=' + size);
|
||||
if (seq !== loadSeq) return; // a newer size was selected — abandon
|
||||
theme = p.theme; words = p.words; grid = p.grid; date = p.date;
|
||||
restore();
|
||||
if (!startTime) startTime = Date.now();
|
||||
try { best = JSON.parse(localStorage.getItem(bestKey) || '0'); } catch { best = 0; }
|
||||
} catch {
|
||||
if (seq !== loadSeq) return;
|
||||
@@ -80,6 +109,7 @@
|
||||
}
|
||||
if (seq !== loadSeq) return;
|
||||
loading = false;
|
||||
resumeClock();
|
||||
requestAnimationFrame(() => (ready = true));
|
||||
// Reconcile with the server in the background (signed-in only): pull any
|
||||
// progress from another device, and pull the cross-device best time.
|
||||
@@ -103,14 +133,14 @@
|
||||
const s = JSON.parse(localStorage.getItem(stateKey) || 'null');
|
||||
if (s && Array.isArray(s.foundWords)) {
|
||||
foundWords = validFinds(s.foundWords);
|
||||
startTime = s.startTime || 0;
|
||||
playedMs = s.played || 0; // pre-"active clock" saves restart at 0:00 — the kind direction
|
||||
resultMs = foundWords.length === words.length ? (s.ms || 0) : 0;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
onstatus?.(summary());
|
||||
}
|
||||
function persist() {
|
||||
try { localStorage.setItem(stateKey, JSON.stringify({ foundWords, startTime, ms: resultMs, status })); }
|
||||
try { localStorage.setItem(stateKey, JSON.stringify({ foundWords, played: playedNow(), ms: resultMs, status })); }
|
||||
catch { /* ignore */ }
|
||||
onstatus?.(summary());
|
||||
}
|
||||
@@ -125,13 +155,17 @@
|
||||
}
|
||||
// renumber colours by find order so overlap blends stay consistent
|
||||
foundWords = foundWords.map((fw, i) => ({ ...fw, ci: i % PALETTE.length }));
|
||||
if (merged.startTime && (!startTime || merged.startTime < startTime)) startTime = merged.startTime;
|
||||
// another device may have accumulated more active time — credit the larger
|
||||
if ((merged.played || 0) > playedNow()) {
|
||||
playedMs = merged.played;
|
||||
if (segStart) segStart = Date.now();
|
||||
}
|
||||
if (foundWords.length === words.length && merged.ms) resultMs = Math.min(resultMs || merged.ms, merged.ms);
|
||||
persist();
|
||||
}
|
||||
async function syncNow() {
|
||||
const d = date, sz = size; // pin against a size switch mid-flight
|
||||
const merged = await pushGameState('wordsearch', sz, d, { foundWords, startTime, ms: resultMs });
|
||||
const merged = await pushGameState('wordsearch', sz, d, { foundWords, played: playedNow(), ms: resultMs });
|
||||
if (d === date && sz === size) adopt(merged); // ignore if the user switched away
|
||||
}
|
||||
function syncSoon() { clearTimeout(syncTimer); syncTimer = setTimeout(syncNow, 1200); }
|
||||
@@ -144,7 +178,7 @@
|
||||
function down(e) {
|
||||
if (status === 'done') return;
|
||||
selecting = true;
|
||||
if (!startTime) startTime = Date.now();
|
||||
resumeClock(); // safety net if a focus event was missed
|
||||
sel = [cellAt(e)];
|
||||
gridEl.setPointerCapture?.(e.pointerId);
|
||||
e.preventDefault();
|
||||
@@ -171,7 +205,8 @@
|
||||
}
|
||||
|
||||
function finish() {
|
||||
resultMs = startTime ? Date.now() - startTime : 0;
|
||||
pauseClock(false); // close the open segment; persist follows in evaluate()
|
||||
resultMs = playedMs;
|
||||
if (resultMs && (!best || resultMs < best)) {
|
||||
best = resultMs;
|
||||
try { localStorage.setItem(bestKey, JSON.stringify(best)); } catch { /* ignore */ }
|
||||
|
||||
@@ -263,6 +263,27 @@
|
||||
}
|
||||
function dismissCheck(s) { s._check = null; s._checkErr = ''; }
|
||||
|
||||
// --- Source article inspector: the real articles behind the metrics ---
|
||||
async function toggleArticles(s) {
|
||||
if (s._showArts) { s._showArts = false; return; }
|
||||
s._showArts = true;
|
||||
if (!s._arts) await loadArticles(s, 'all', true);
|
||||
}
|
||||
async function loadArticles(s, filter, reset) {
|
||||
s._artBusy = true; s._artErr = '';
|
||||
if (reset) { s._artFilter = filter; s._artOffset = 0; s._arts = []; }
|
||||
try {
|
||||
const q = `filter=${s._artFilter}&limit=25&offset=${s._artOffset}`;
|
||||
const r = await getJSON(`/api/admin/sources/${s.id}/articles?${q}`);
|
||||
s._arts = reset ? r.articles : [...(s._arts || []), ...r.articles];
|
||||
if (r.summary) s._artSummary = r.summary;
|
||||
s._artMore = r.has_more;
|
||||
s._artOffset += r.articles.length;
|
||||
} 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']];
|
||||
|
||||
// --- Source candidates: supervised "add a source" pipeline ---
|
||||
let candidates = $state([]);
|
||||
let newFeedUrl = $state('');
|
||||
@@ -677,6 +698,7 @@
|
||||
<button class="act" onclick={() => (s.review_flag ? clearReview(s) : openFlag(s))}>{s.review_flag ? 'Clear' : 'Flag'}</button>
|
||||
<button class="act" onclick={() => toggleVisible(s)}>{s.content_visible ? 'Hide' : 'Show'}</button>
|
||||
<button class="act" title="Read-only spot-check of the live feed" onclick={() => checkSource(s)} disabled={s._checking}>{s._checking ? 'Checking…' : 'Check'}</button>
|
||||
<button class="act" title="Inspect this source's real ingested articles" onclick={() => toggleArticles(s)}>{s._showArts ? 'Hide' : 'Articles'}</button>
|
||||
</td>
|
||||
</tr>
|
||||
{#if s._checking || s._check || s._checkErr}
|
||||
@@ -698,6 +720,49 @@
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
{#if s._showArts}
|
||||
<tr class="artrow">
|
||||
<td colspan="10">
|
||||
{#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 ·
|
||||
paywall rule: <span class="pwrule" class:on={s._artSummary.paywalled}>{s._artSummary.paywalled ? 'ON (domain)' : 'off'}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="artfilters">
|
||||
{#each ART_FILTERS as [key, label] (key)}
|
||||
<button class="chip sm" class:on={(s._artFilter || 'all') === key} onclick={() => loadArticles(s, key, true)}>{label}</button>
|
||||
{/each}
|
||||
<button class="act" onclick={() => (s._showArts = false)}>close</button>
|
||||
</div>
|
||||
{#if s._artErr}<p class="cerr">{s._artErr}</p>{/if}
|
||||
{#if s._arts?.length}
|
||||
<ul class="artlist">
|
||||
{#each s._arts as a (a.id)}
|
||||
<li>
|
||||
<div class="art-row">
|
||||
<a class="art-title" href={a.url} target="_blank" rel="noopener">{a.title}</a>
|
||||
{#if a.accepted === 1}<span class="badge ok">accepted</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}
|
||||
{#if a.topic}<span class="art-cat">{a.topic}</span>{/if}
|
||||
<span class="art-when">{a.published_at ? fdate(a.published_at) : ''}</span>
|
||||
</div>
|
||||
{#if a.reason}<div class="art-reason">{a.reason}</div>{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{#if s._artMore}<button class="act more" onclick={() => loadArticles(s, s._artFilter, false)} disabled={s._artBusy}>{s._artBusy ? 'Loading…' : 'Load more'}</button>{/if}
|
||||
{:else if !s._artBusy}
|
||||
<p class="muted small">No articles{s._artFilter && s._artFilter !== 'all' ? ' match this filter' : ' yet'}.</p>
|
||||
{/if}
|
||||
{#if s._artBusy && !s._arts?.length}<p class="muted small">Loading articles…</p>{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
{:else}
|
||||
<tr><td colspan="10" class="srcempty">{srcSearch.trim() ? `No sources match “${srcSearch.trim()}”.` : 'No sources in this view.'}</td></tr>
|
||||
{/each}
|
||||
@@ -1188,6 +1253,28 @@
|
||||
.chkex { margin-top: 5px; color: var(--ink); }
|
||||
.chkex .chklbl { color: var(--muted); }
|
||||
.chkex.chkrej { color: var(--muted); }
|
||||
|
||||
/* Source article inspector */
|
||||
.srctable tr.artrow td { background: var(--bg); font-size: 0.84rem; padding: 10px 12px; }
|
||||
.artsum { color: var(--ink); margin-bottom: 8px; }
|
||||
.artsum .pwrule { color: var(--muted); font-weight: 600; }
|
||||
.artsum .pwrule.on { color: #9a3b3b; }
|
||||
.artfilters { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; margin-bottom: 8px; }
|
||||
.chip.sm { font-size: 0.74rem; padding: 3px 10px; }
|
||||
.artlist { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 7px; max-height: 360px; overflow-y: auto; }
|
||||
.artlist li { border-bottom: 1px solid var(--line); padding-bottom: 6px; }
|
||||
.art-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.art-title { color: var(--accent-deep); font-weight: 600; text-decoration: none; }
|
||||
.art-title:hover { text-decoration: underline; }
|
||||
.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; }
|
||||
.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; }
|
||||
.art-when { font-size: 0.72rem; color: var(--muted); margin-left: auto; white-space: nowrap; }
|
||||
.art-reason { font-size: 0.76rem; color: var(--muted); font-style: italic; margin-top: 2px; }
|
||||
.act.more { margin-top: 8px; }
|
||||
.srctable .rowactions { white-space: nowrap; }
|
||||
.srctable .rowactions .act {
|
||||
background: none; border: 1px solid var(--line); color: var(--accent-deep);
|
||||
|
||||
Reference in New Issue
Block a user