Admin: Word Search theme authoring + tidy word-pool chips
* New "Word Search themes" panel in the Games tab: enter a theme name + words, with live validation (4–8 letters, alpha, deduped) and a count vs the 28 needed to fill all three sizes. An "✨ Suggest a word" button asks the LLM for one fresh word that fits the theme. Save/edit/remove; authored themes join the daily fallback rotation alongside the curated ones (wordsearch_themes table). The system still handles word distribution across sizes + placement. * Daily Word pool's added-word chips now scroll within a bounded area so the console stays tidy as the list grows. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -33,6 +33,7 @@
|
||||
candidates = await getJSON('/api/admin/candidates');
|
||||
wpPool = await getJSON('/api/admin/word/pool');
|
||||
clientErrors = await getJSON('/api/admin/client-errors');
|
||||
wsThemes = await getJSON('/api/admin/wordsearch/themes');
|
||||
} catch {
|
||||
error = "Couldn't load stats.";
|
||||
}
|
||||
@@ -69,6 +70,43 @@
|
||||
try { wpPool = await delJSON('/api/admin/word/pool/' + encodeURIComponent(w)); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// --- Games: Word Search themes ---
|
||||
const WS_NEEDED = 28;
|
||||
let wsThemes = $state([]);
|
||||
let wsTheme = $state('');
|
||||
let wsWordsText = $state('');
|
||||
let wsEditId = $state(null);
|
||||
let wsMsg = $state('');
|
||||
let wsSuggesting = $state(false);
|
||||
|
||||
function parseWords(text) {
|
||||
return [...new Set((text || '').split(/[\s,]+/).map((w) => w.trim().toUpperCase()).filter(Boolean))];
|
||||
}
|
||||
let wsParsed = $derived(parseWords(wsWordsText));
|
||||
let wsValid = $derived(wsParsed.filter((w) => /^[A-Z]{4,8}$/.test(w)));
|
||||
let wsInvalid = $derived(wsParsed.filter((w) => !/^[A-Z]{4,8}$/.test(w)));
|
||||
|
||||
async function loadWsThemes() { try { wsThemes = await getJSON('/api/admin/wordsearch/themes'); } catch { /* ignore */ } }
|
||||
async function suggestWsWord() {
|
||||
if (!wsTheme.trim()) { wsMsg = 'Enter a theme first.'; return; }
|
||||
wsSuggesting = true; wsMsg = '';
|
||||
try {
|
||||
const r = await postJSON('/api/admin/wordsearch/suggest', { theme: wsTheme.trim(), existing: wsValid });
|
||||
wsWordsText = (wsWordsText.trim() + ' ' + r.word).trim();
|
||||
} catch (e) { wsMsg = e.message || 'No suggestion available.'; }
|
||||
finally { wsSuggesting = false; }
|
||||
}
|
||||
async function saveWsTheme() {
|
||||
try {
|
||||
const res = await postJSON('/api/admin/wordsearch/themes', { theme: wsTheme.trim(), words: wsValid, id: wsEditId });
|
||||
wsThemes = res.themes; wsMsg = `Saved “${wsTheme.trim()}” (${res.count} words).`;
|
||||
wsTheme = ''; wsWordsText = ''; wsEditId = null;
|
||||
} catch (e) { wsMsg = e.message || 'Could not save.'; }
|
||||
}
|
||||
function editWsTheme(t) { wsEditId = t.id; wsTheme = t.theme; wsWordsText = t.words.join(' '); wsMsg = ''; }
|
||||
function cancelWsEdit() { wsEditId = null; wsTheme = ''; wsWordsText = ''; wsMsg = ''; }
|
||||
async function removeWsTheme(t) { try { wsThemes = await delJSON('/api/admin/wordsearch/themes/' + t.id); } catch { /* ignore */ } }
|
||||
|
||||
const TABS = [
|
||||
{ key: 'overview', label: 'Overview' },
|
||||
{ key: 'content', label: 'Content' },
|
||||
@@ -773,6 +811,45 @@
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<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
|
||||
suggest one that fits.</p>
|
||||
|
||||
<div class="ws-form">
|
||||
<input class="ws-name" type="text" bind:value={wsTheme} maxlength="40"
|
||||
placeholder="Theme name (e.g. In the kitchen)" />
|
||||
<textarea class="ws-words" bind:value={wsWordsText} rows="4"
|
||||
placeholder="Words, separated by spaces or commas…"></textarea>
|
||||
<div class="ws-row">
|
||||
<span class="ws-count" class:ok={wsValid.length >= WS_NEEDED}>
|
||||
{wsValid.length} / {WS_NEEDED} valid{#if wsInvalid.length} · {wsInvalid.length} skipped{/if}
|
||||
</span>
|
||||
<button class="ws-ai" onclick={suggestWsWord} disabled={wsSuggesting}>
|
||||
{wsSuggesting ? 'Thinking…' : '✨ Suggest a word'}
|
||||
</button>
|
||||
<button class="wp-add" onclick={saveWsTheme} disabled={wsValid.length < WS_NEEDED || !wsTheme.trim()}>
|
||||
{wsEditId ? 'Update theme' : 'Save theme'}
|
||||
</button>
|
||||
{#if wsEditId}<button class="act" onclick={cancelWsEdit}>Cancel</button>{/if}
|
||||
</div>
|
||||
{#if wsInvalid.length}<p class="muted small">Skipped (not 4–8 letters): {wsInvalid.join(', ')}</p>{/if}
|
||||
</div>
|
||||
{#if wsMsg}<p class="wp-msg">{wsMsg}</p>{/if}
|
||||
|
||||
{#if wsThemes.length}
|
||||
<ul class="ws-themes">
|
||||
{#each wsThemes as t (t.id)}
|
||||
<li>
|
||||
<span class="wt-name">{t.theme}</span>
|
||||
<span class="wt-count">{t.count} words</span>
|
||||
<button class="act" onclick={() => editWsTheme(t)}>Edit</button>
|
||||
<button class="act del" onclick={() => removeWsTheme(t)}>Remove</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}<p class="muted small">No custom themes yet — the daily rotation uses the built-in ones.</p>{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</main>
|
||||
@@ -1052,7 +1129,8 @@
|
||||
.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 { list-style: none; display: flex; flex-wrap: wrap; gap: 8px; padding: 0; margin: 10px 0 0;
|
||||
max-height: 230px; overflow-y: auto; }
|
||||
.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;
|
||||
@@ -1061,4 +1139,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; }
|
||||
|
||||
/* Word Search theme authoring */
|
||||
.ws-form { max-width: 560px; display: flex; flex-direction: column; gap: 10px; margin: 14px 0 6px; }
|
||||
.ws-name { font: inherit; font-size: 1.05rem; padding: 10px 14px; border: 1px solid var(--line);
|
||||
border-radius: 10px; background: var(--surface); color: var(--ink); }
|
||||
.ws-words { 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; }
|
||||
.ws-row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||
.ws-count { font-size: 0.86rem; font-weight: 600; color: var(--muted); }
|
||||
.ws-count.ok { color: var(--accent-deep); }
|
||||
.ws-ai { background: var(--accent-soft); color: var(--accent-deep); border: none; border-radius: 999px;
|
||||
padding: 8px 16px; font: inherit; font-weight: 600; cursor: pointer; }
|
||||
.ws-ai:hover:not(:disabled) { filter: brightness(0.97); }
|
||||
.ws-ai:disabled, .wp-add:disabled { opacity: 0.5; cursor: default; }
|
||||
.ws-themes { list-style: none; padding: 0; margin: 14px 0 0; display: flex; flex-direction: column; gap: 8px; max-width: 560px; }
|
||||
.ws-themes li { display: flex; align-items: center; gap: 12px; background: var(--surface);
|
||||
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; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user