Admin: Daily Word pool curation (lookup + add/remove)
First games admin tool. A "Games" tab in the operator console for the Daily Word answer pool. * Lookup: is a word real (in the guess dictionary), the right length (5/6), and already in the pool — instant as you type. * Add: appends to the pool, enforcing the invariant (alpha · 5/6 letters · in the guess dict) so the daily answer is always guessable. Remove: drops admin-added words (curated static ones stay). * Additions persist in a new word_pool table (survives redeploys, unlike the baked-in JSON); the daily picker reads static pool ∪ DB additions. Guess dicts shipped with the package (goodnews/data/words-5/6.json) for server-side validation. Admin-gated endpoints + tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -31,17 +31,48 @@
|
||||
await loadStats();
|
||||
feedback = await getJSON('/api/admin/feedback');
|
||||
candidates = await getJSON('/api/admin/candidates');
|
||||
wpPool = await getJSON('/api/admin/word/pool');
|
||||
} catch {
|
||||
error = "Couldn't load stats.";
|
||||
}
|
||||
});
|
||||
|
||||
// --- Games: Daily Word pool ---
|
||||
let wpWord = $state('');
|
||||
let wpResult = $state(null); // lookup result for the current input
|
||||
let wpPool = $state(null); // { '5': {curated, added[], total}, '6': {...} }
|
||||
let wpMsg = $state('');
|
||||
let wpTimer;
|
||||
const canAddWord = $derived(!!wpResult && !!wpResult.variant && wpResult.in_dictionary && !wpResult.in_pool);
|
||||
|
||||
function onWpInput() {
|
||||
wpMsg = '';
|
||||
clearTimeout(wpTimer);
|
||||
const w = wpWord.trim();
|
||||
if (!w) { wpResult = null; return; }
|
||||
wpTimer = setTimeout(async () => {
|
||||
try { wpResult = await getJSON('/api/admin/word/lookup?word=' + encodeURIComponent(w)); }
|
||||
catch { wpResult = null; }
|
||||
}, 200);
|
||||
}
|
||||
async function addWord() {
|
||||
if (!canAddWord) return;
|
||||
try {
|
||||
const res = await postJSON('/api/admin/word/pool', { word: wpWord.trim() });
|
||||
wpPool = res.pool; wpWord = ''; wpResult = null; wpMsg = `Added “${res.word}” to the ${res.variant}-letter pool.`;
|
||||
} catch (e) { wpMsg = e.message || 'Could not add that word.'; }
|
||||
}
|
||||
async function removeWord(w) {
|
||||
try { wpPool = await delJSON('/api/admin/word/pool/' + encodeURIComponent(w)); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const TABS = [
|
||||
{ key: 'overview', label: 'Overview' },
|
||||
{ key: 'content', label: 'Content' },
|
||||
{ key: 'sources', label: 'Sources' },
|
||||
{ key: 'audience', label: 'Audience' },
|
||||
{ key: 'feedback', label: 'Feedback' },
|
||||
{ key: 'games', label: 'Games' },
|
||||
];
|
||||
const VALID_SECTIONS = new Set(TABS.map((t) => t.key));
|
||||
// Unknown ?section= values fall back to Overview so the page never renders blank.
|
||||
@@ -679,6 +710,47 @@
|
||||
</ul>
|
||||
{:else}<p class="muted">Nothing in this filter.</p>{/if}
|
||||
{:else}<p class="muted">No feedback yet.</p>{/if}
|
||||
|
||||
{:else if section === 'games'}
|
||||
<h2>Daily Word pool</h2>
|
||||
<p class="muted">Look up a word and add it to the answer pool. Only real, 5- or 6-letter
|
||||
words in the guess dictionary qualify, so the daily answer is always solvable.</p>
|
||||
|
||||
<div class="wp-lookup">
|
||||
<input type="text" bind:value={wpWord} oninput={onWpInput} maxlength="6" autocapitalize="off"
|
||||
autocomplete="off" spellcheck="false" placeholder="Type a word to check…" />
|
||||
{#if wpResult && wpResult.word}
|
||||
{#if !wpResult.variant}
|
||||
<span class="wp-tag bad">Must be 5 or 6 letters</span>
|
||||
{:else if wpResult.in_pool}
|
||||
<span class="wp-tag ok">Already in the pool</span>
|
||||
{:else if wpResult.in_dictionary}
|
||||
<span class="wp-tag ok">Valid {wpResult.variant}-letter word</span>
|
||||
<button class="wp-add" onclick={addWord}>Add to pool</button>
|
||||
{:else}
|
||||
<span class="wp-tag bad">Not in the guess dictionary</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{#if wpMsg}<p class="wp-msg">{wpMsg}</p>{/if}
|
||||
|
||||
{#if wpPool}
|
||||
<div class="wp-cols">
|
||||
{#each ['5', '6'] as v (v)}
|
||||
<div class="wp-col">
|
||||
<h3>{v === '6' ? 'Long Word' : 'Daily Word'} <span class="count">· {v} letters · {wpPool[v].total} words</span></h3>
|
||||
<p class="muted small">{wpPool[v].curated} curated + {wpPool[v].added.length} added by you</p>
|
||||
{#if wpPool[v].added.length}
|
||||
<ul class="wp-added">
|
||||
{#each wpPool[v].added as w (w)}
|
||||
<li>{w}<button class="x" onclick={() => removeWord(w)} aria-label={'Remove ' + w}>×</button></li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}<p class="muted small">No words added yet — the curated pool is in use.</p>{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</main>
|
||||
@@ -929,4 +1001,31 @@
|
||||
}
|
||||
.factions .act:hover { color: var(--accent-deep); border-bottom-color: var(--accent); }
|
||||
.factions .act.del:hover { color: #9a3b3b; border-bottom-color: #9a3b3b; }
|
||||
|
||||
/* Games — Daily Word pool */
|
||||
.wp-lookup { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin: 14px 0 6px; }
|
||||
.wp-lookup input {
|
||||
font: inherit; font-size: 1.05rem; padding: 10px 14px; border: 1px solid var(--line); border-radius: 10px;
|
||||
text-transform: uppercase; letter-spacing: 0.06em; width: 210px; background: var(--surface); color: var(--ink);
|
||||
}
|
||||
.wp-tag { font-size: 0.84rem; font-weight: 600; padding: 4px 11px; border-radius: 999px; }
|
||||
.wp-tag.ok { background: var(--accent-soft); color: var(--accent-deep); }
|
||||
.wp-tag.bad { background: #f3e0e0; color: #9a3b3b; }
|
||||
.wp-add { background: var(--accent); color: #fff; border: none; border-radius: 999px; padding: 8px 18px;
|
||||
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; }
|
||||
.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; }
|
||||
.small { font-size: 0.82rem; }
|
||||
.wp-added { list-style: none; display: flex; flex-wrap: wrap; gap: 8px; padding: 0; margin: 10px 0 0; }
|
||||
.wp-added li {
|
||||
display: inline-flex; align-items: center; gap: 4px; background: var(--surface); border: 1px solid var(--line);
|
||||
border-radius: 999px; padding: 4px 6px 4px 12px; font-family: var(--label); text-transform: uppercase;
|
||||
font-size: 0.82rem; letter-spacing: 0.04em; color: var(--ink);
|
||||
}
|
||||
.wp-added .x { background: none; border: none; color: var(--muted); cursor: pointer; font-size: 1.15rem;
|
||||
line-height: 1; padding: 0 5px; border-radius: 50%; }
|
||||
.wp-added .x:hover { color: #9a3b3b; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user