Pool admin: delete any word (tombstones + restore) + bulk import
Daily Word pool curation, full add/delete/import — no redeploys to fix tone: - Remove ANY pool word, curated or admin-added, via a word_pool_removed tombstone table. Runtime pool = (static ∪ added) − removed, so even a baked-in word can be pulled on negative feedback. Reversible: a "Removed" list with one-tap Restore lifts the tombstone. Lookup now surfaces a Remove button when in-pool, Restore when removed. - Import a vetted list (paste or .txt/.csv upload, read client-side): validates each word (alpha · 5–6 · in guess dictionary), ignores duplicates, and reports rejects with reasons. Re-adding/importing a removed word lifts its tombstone. - Word Search theme delete already existed (Edit/Remove per theme) — verified. Pool stays the clean 251/224; today's noisy LLM enrichment is discarded. Tests: +tests/test_pool_admin.py, extended test_word_pool_admin. 222 pytest + 11 vitest green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -66,8 +66,52 @@
|
||||
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.'; }
|
||||
}
|
||||
// Refresh the live lookup tag after a pool mutation, so the button flips Remove↔Restore↔Add.
|
||||
async function refreshLookup() {
|
||||
const w = wpWord.trim();
|
||||
if (!w) { wpResult = null; return; }
|
||||
try { wpResult = await getJSON('/api/admin/word/lookup?word=' + encodeURIComponent(w)); }
|
||||
catch { wpResult = null; }
|
||||
}
|
||||
async function removeWord(w) {
|
||||
try { wpPool = await delJSON('/api/admin/word/pool/' + encodeURIComponent(w)); } catch { /* ignore */ }
|
||||
try {
|
||||
wpPool = await delJSON('/api/admin/word/pool/' + encodeURIComponent(w));
|
||||
wpMsg = `Removed “${w}”. It won’t appear as an answer — restore it any time below.`;
|
||||
await refreshLookup();
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
async function restoreWord(w) {
|
||||
try {
|
||||
wpPool = await postJSON('/api/admin/word/pool/restore', { word: w });
|
||||
wpMsg = `Restored “${w}” to the pool.`;
|
||||
await refreshLookup();
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// --- Games: bulk import (paste or .txt/.csv upload) ---
|
||||
let wpImportText = $state('');
|
||||
let wpImportResult = $state(null); // { counts, added[], duplicates[], rejected[] }
|
||||
let wpImporting = $state(false);
|
||||
function onImportFile(e) {
|
||||
const file = e.target.files && e.target.files[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const text = String(reader.result || '');
|
||||
wpImportText = wpImportText.trim() ? wpImportText.trim() + '\n' + text : text;
|
||||
};
|
||||
reader.readAsText(file);
|
||||
e.target.value = ''; // allow re-picking the same file
|
||||
}
|
||||
async function importWords() {
|
||||
if (!wpImportText.trim() || wpImporting) return;
|
||||
wpImporting = true; wpImportResult = null;
|
||||
try {
|
||||
const res = await postJSON('/api/admin/word/pool/import', { text: wpImportText });
|
||||
wpPool = res.pool; wpImportResult = res; wpImportText = '';
|
||||
await refreshLookup();
|
||||
} catch (e) { wpImportResult = { error: e.message || 'Import failed.' }; }
|
||||
finally { wpImporting = false; }
|
||||
}
|
||||
|
||||
// --- Games: Word Search themes ---
|
||||
@@ -783,7 +827,11 @@
|
||||
{#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>
|
||||
<span class="wp-tag ok">In the pool</span>
|
||||
<button class="act del" onclick={() => removeWord(wpResult.word)}>Remove</button>
|
||||
{:else if wpResult.removed}
|
||||
<span class="wp-tag bad">Removed</span>
|
||||
<button class="wp-add" onclick={() => restoreWord(wpResult.word)}>Restore</button>
|
||||
{: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>
|
||||
@@ -807,11 +855,52 @@
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}<p class="muted small">No words added yet — the curated pool is in use.</p>{/if}
|
||||
{#if wpPool[v].removed && wpPool[v].removed.length}
|
||||
<p class="muted small" style="margin-top:10px">Removed ({wpPool[v].removed.length}) — tap to restore:</p>
|
||||
<ul class="wp-added removed">
|
||||
{#each wpPool[v].removed as w (w)}
|
||||
<li>{w}<button class="x" onclick={() => restoreWord(w)} aria-label={'Restore ' + w}>↺</button></li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<h3 style="margin-top:24px">Import a vetted list</h3>
|
||||
<p class="muted small">Paste words (any separators) or upload a .txt/.csv. Each is checked —
|
||||
real word · 5–6 letters · in the guess dictionary — and duplicates are ignored.</p>
|
||||
<div class="wp-import">
|
||||
<textarea bind:value={wpImportText} rows="4"
|
||||
placeholder="clear, bloom, kindle, ripple… (or upload a file below)"></textarea>
|
||||
<div class="wp-import-row">
|
||||
<label class="act file">Choose file…
|
||||
<input type="file" accept=".txt,.csv,text/plain,text/csv" onchange={onImportFile} hidden />
|
||||
</label>
|
||||
<button class="wp-add" onclick={importWords} disabled={!wpImportText.trim() || wpImporting}>
|
||||
{wpImporting ? 'Importing…' : 'Import'}
|
||||
</button>
|
||||
</div>
|
||||
{#if wpImportResult}
|
||||
{#if wpImportResult.error}
|
||||
<p class="wp-msg">{wpImportResult.error}</p>
|
||||
{:else}
|
||||
<p class="wp-msg">Added {wpImportResult.counts.added} · ignored {wpImportResult.counts.duplicates} duplicate(s) · rejected {wpImportResult.counts.rejected}.</p>
|
||||
{#if wpImportResult.rejected.length}
|
||||
<details class="wp-rejects">
|
||||
<summary>{wpImportResult.rejected.length} rejected — see why</summary>
|
||||
<ul class="muted small">
|
||||
{#each wpImportResult.rejected as r (r.word)}
|
||||
<li>{r.word} — {r.reason}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</details>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<h2 style="margin-top:38px">Word Search themes</h2>
|
||||
<p class="muted">Add a theme and its words — the system splits them across the three sizes and places
|
||||
them. You need {WS_NEEDED}+ valid words (4–8 letters) to fill all three puzzles. Stuck? Let the AI
|
||||
@@ -1139,6 +1228,24 @@
|
||||
.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; }
|
||||
.wp-added.removed li { opacity: 0.7; border-style: dashed; }
|
||||
.wp-added.removed .x { font-size: 0.95rem; }
|
||||
.wp-added.removed .x:hover { color: var(--accent-deep); }
|
||||
.wp-lookup .act { font: inherit; font-size: 0.82rem; background: var(--surface); border: 1px solid var(--line);
|
||||
border-radius: 999px; padding: 7px 16px; cursor: pointer; color: #9a3b3b; }
|
||||
.wp-lookup .act:hover { border-color: #9a3b3b; }
|
||||
/* Bulk import */
|
||||
.wp-import { max-width: 560px; display: flex; flex-direction: column; gap: 10px; margin: 10px 0 6px; }
|
||||
.wp-import textarea { font: inherit; padding: 10px 14px; border: 1px solid var(--line); border-radius: 10px;
|
||||
background: var(--surface); color: var(--ink); resize: vertical; line-height: 1.6;
|
||||
text-transform: uppercase; letter-spacing: 0.03em; }
|
||||
.wp-import-row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||
.act.file { font: inherit; font-size: 0.84rem; background: var(--accent-soft); color: var(--accent-deep);
|
||||
border: none; border-radius: 999px; padding: 8px 16px; cursor: pointer; }
|
||||
.act.file:hover { filter: brightness(0.97); }
|
||||
.wp-rejects { margin: 4px 0 0; }
|
||||
.wp-rejects summary { cursor: pointer; color: var(--accent-deep); font-size: 0.84rem; }
|
||||
.wp-rejects ul { margin: 8px 0 0; padding-left: 18px; }
|
||||
|
||||
/* Word Search theme authoring */
|
||||
.ws-form { max-width: 560px; display: flex; flex-direction: column; gap: 10px; margin: 14px 0 6px; }
|
||||
|
||||
Reference in New Issue
Block a user