39d682f353
- Word Search: unfound words were tinted (accent-soft background) like found ones, so the remaining words were hard to spot as the board filled. Unfound chips are now plain (transparent + a light outline); found words keep their grid colour. Easy to see what's left. - Auth: a refresh briefly flashed the signed-out header until /api/auth/me returned. Now the last-known user is cached and hydrated immediately, so the signed-in UI paints at once; the session is still revalidated every load (a stale/expired one corrects within a beat) and the cache is cleared on logout. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
258 lines
11 KiB
Svelte
258 lines
11 KiB
Svelte
<script>
|
||
import { getJSON } from '$lib/api.js';
|
||
import { lineFrom, matchWord, cellFromPoint } from '$lib/wordsearch.js';
|
||
|
||
let { size = 'med', onstatus } = $props();
|
||
|
||
// A calm, distinct palette — each found word gets its own colour.
|
||
const PALETTE = ['#a7d3c2', '#f4cf88', '#a9c8ec', '#eab0c0', '#cdb8e6',
|
||
'#f3a98a', '#c2d99a', '#e3b0d0', '#9fd0d8', '#d9c79a'];
|
||
|
||
let theme = $state('');
|
||
let words = $state([]);
|
||
let grid = $state([]); // array of row strings
|
||
let date = $state('');
|
||
let foundWords = $state([]); // {word, cells:[[r,c]], ci}
|
||
let sel = $state([]); // current selection cells
|
||
let selecting = false;
|
||
let startTime = 0;
|
||
let resultMs = $state(0);
|
||
let best = $state(0);
|
||
let loading = $state(true);
|
||
let ready = $state(false);
|
||
let okFlash = $state(false);
|
||
let copied = $state(false);
|
||
let gridEl = $state(null);
|
||
let loadSeq = 0;
|
||
|
||
const n = $derived(grid.length);
|
||
const stateKey = $derived(`goodnews:wordsearch:${size}:${date}`);
|
||
const bestKey = $derived(`goodnews:wordsearch:best:${size}`);
|
||
const found = $derived(foundWords.map((w) => w.word));
|
||
const status = $derived(words.length && foundWords.length === words.length ? 'done' : 'playing');
|
||
const selSet = $derived(new Set(sel.map(([r, c]) => r + ',' + c)));
|
||
const cellColor = $derived.by(() => {
|
||
const m = new Map();
|
||
for (const w of foundWords) for (const [r, c] of w.cells) m.set(r + ',' + c, w.ci);
|
||
return m;
|
||
});
|
||
const wordColor = $derived.by(() => {
|
||
const m = new Map();
|
||
for (const w of foundWords) m.set(w.word, w.ci);
|
||
return m;
|
||
});
|
||
|
||
async function load() {
|
||
const seq = ++loadSeq; // stale-load guard for rapid size switches
|
||
loading = true; ready = false;
|
||
foundWords = []; sel = []; resultMs = 0; startTime = 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;
|
||
theme = ''; words = [];
|
||
}
|
||
if (seq !== loadSeq) return;
|
||
loading = false;
|
||
requestAnimationFrame(() => (ready = true));
|
||
}
|
||
|
||
function restore() {
|
||
try {
|
||
const s = JSON.parse(localStorage.getItem(stateKey) || 'null');
|
||
if (s && Array.isArray(s.foundWords)) {
|
||
// Keep only finds whose stored cells still spell their word in the CURRENT
|
||
// grid — guards against stale highlights if the day's puzzle changed.
|
||
const valid = s.foundWords.filter((fw) =>
|
||
fw && Array.isArray(fw.cells) && words.includes(fw.word) &&
|
||
fw.cells.map(([r, c]) => (grid[r] && grid[r][c]) || '').join('') === fw.word);
|
||
foundWords = valid;
|
||
startTime = s.startTime || 0;
|
||
resultMs = valid.length === words.length ? (s.ms || 0) : 0;
|
||
}
|
||
} catch { /* ignore */ }
|
||
onstatus?.(summary());
|
||
}
|
||
function persist() {
|
||
try { localStorage.setItem(stateKey, JSON.stringify({ foundWords, startTime, ms: resultMs, status })); }
|
||
catch { /* ignore */ }
|
||
onstatus?.(summary());
|
||
}
|
||
function summary() { return { game: 'wordsearch', size, date, status, found: foundWords.length, total: words.length, ms: resultMs }; }
|
||
|
||
function cellAt(e) {
|
||
return cellFromPoint(gridEl.getBoundingClientRect(), e.clientX, e.clientY, n);
|
||
}
|
||
|
||
function down(e) {
|
||
if (status === 'done') return;
|
||
selecting = true;
|
||
if (!startTime) startTime = Date.now();
|
||
sel = [cellAt(e)];
|
||
gridEl.setPointerCapture?.(e.pointerId);
|
||
e.preventDefault();
|
||
}
|
||
function move(e) {
|
||
if (!selecting) return;
|
||
sel = lineFrom(sel[0], cellAt(e), n);
|
||
}
|
||
function up() {
|
||
if (!selecting) return;
|
||
selecting = false;
|
||
evaluate(sel);
|
||
sel = [];
|
||
}
|
||
|
||
function evaluate(cells) {
|
||
const hit = matchWord(cells, grid, words, found);
|
||
if (!hit) return;
|
||
foundWords = [...foundWords, { word: hit, cells: cells.map((c) => [...c]), ci: foundWords.length % PALETTE.length }];
|
||
okFlash = true; setTimeout(() => (okFlash = false), 500);
|
||
if (foundWords.length === words.length) finish();
|
||
persist();
|
||
}
|
||
|
||
function finish() {
|
||
resultMs = startTime ? Date.now() - startTime : 0;
|
||
if (resultMs && (!best || resultMs < best)) {
|
||
best = resultMs;
|
||
try { localStorage.setItem(bestKey, JSON.stringify(best)); } catch { /* ignore */ }
|
||
}
|
||
}
|
||
|
||
function fmt(ms) {
|
||
const s = Math.round(ms / 1000);
|
||
return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0');
|
||
}
|
||
|
||
function share() {
|
||
const label = { small: 'Small', med: 'Medium', large: 'Large' }[size] || '';
|
||
const text = `Upbeat Bytes · Word Search (${label}) ${date}\n${theme} — cleared in ${fmt(resultMs)}\nupbeatbytes.com/play`;
|
||
if (navigator.share) navigator.share({ text }).catch(() => {});
|
||
else navigator.clipboard?.writeText(text).then(() => { copied = true; setTimeout(() => (copied = false), 1500); });
|
||
}
|
||
|
||
// Load on mount and whenever the size changes. Tracks ONLY `size`.
|
||
$effect(() => { size; load(); });
|
||
</script>
|
||
|
||
<div class="wordsearch" class:ready>
|
||
{#if loading}
|
||
<p class="muted">Loading today’s word search…</p>
|
||
{:else if !words.length}
|
||
<p class="muted">Could not load today’s word search.</p>
|
||
{:else}
|
||
<p class="theme">{theme}</p>
|
||
|
||
<div class="gridwrap">
|
||
<div class="grid" class:ok={okFlash} class:done={status === 'done'} bind:this={gridEl} style="--n:{n}"
|
||
role="application" aria-label="Word search grid — drag across letters to find words"
|
||
onpointerdown={down} onpointermove={move} onpointerup={up} onpointercancel={up}>
|
||
{#each grid as rowStr, r (r)}
|
||
{#each rowStr.split('') as ch, c (c)}
|
||
{@const key = r + ',' + c}
|
||
<div class="cell" class:sel={selSet.has(key)}
|
||
style={cellColor.has(key) && !selSet.has(key) ? `background:${PALETTE[cellColor.get(key)]};color:#2a2f36` : ''}>{ch}</div>
|
||
{/each}
|
||
{/each}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="palette">
|
||
<p class="plabel">Find these · {foundWords.length}/{words.length}</p>
|
||
<ul class="words">
|
||
{#each words as w (w)}
|
||
<li class:got={found.includes(w)}
|
||
style={wordColor.has(w) ? `background:${PALETTE[wordColor.get(w)]};color:#2a2f36` : ''}>{w}</li>
|
||
{/each}
|
||
</ul>
|
||
</div>
|
||
|
||
{#if status === 'done'}
|
||
<div class="result rise">
|
||
<p class="rmark">✦ all found ✦</p>
|
||
<div class="times">
|
||
<div><span class="n">{fmt(resultMs)}</span><span class="l">your time</span></div>
|
||
{#if best}<div><span class="n">{fmt(best)}</span><span class="l">best</span></div>{/if}
|
||
</div>
|
||
<button class="share" onclick={share}>{copied ? 'Copied!' : 'Share result'}</button>
|
||
</div>
|
||
{:else}
|
||
<p class="hint">{foundWords.length} / {words.length} found · drag across the letters</p>
|
||
{/if}
|
||
{/if}
|
||
</div>
|
||
|
||
<style>
|
||
.wordsearch { max-width: 520px; margin: 0 auto; opacity: 0; transform: translateY(6px); }
|
||
.wordsearch.ready { opacity: 1; transform: none; transition: opacity 0.3s ease, transform 0.3s ease; }
|
||
.theme { text-align: center; font-family: var(--serif); font-weight: 600; font-size: 1.6rem;
|
||
color: var(--accent-deep); margin: 0 0 14px; }
|
||
.gridwrap { display: flex; justify-content: center; }
|
||
.grid {
|
||
/* Cells are ~32px on a wide screen (board grows with the grid) and shrink via
|
||
1fr to fit a narrow phone — capped by max-width so it can never overflow. */
|
||
display: grid; grid-template-columns: repeat(var(--n), 1fr); gap: 2px;
|
||
width: 100%; max-width: calc(var(--n) * 32px + 16px); margin: 0 auto 16px;
|
||
touch-action: none; user-select: none; -webkit-user-select: none;
|
||
border-radius: 10px; padding: 6px; background: var(--surface); border: 1px solid var(--line);
|
||
}
|
||
.grid.done { opacity: 0.9; }
|
||
.cell {
|
||
aspect-ratio: 1; display: flex; align-items: center; justify-content: center;
|
||
font-family: var(--label); font-weight: 600; font-size: clamp(0.7rem, 2.6vw, 1.05rem);
|
||
color: var(--ink); border-radius: 5px; background: transparent; text-transform: uppercase;
|
||
}
|
||
.cell.sel { background: var(--accent) !important; color: #fff !important; }
|
||
/* Scale each letter to ~42% of its cell so the letter-to-spacing ratio is uniform
|
||
across Small/Med/Large (Large is the reference). Guarded so older browsers keep
|
||
the clamp() fallback above. */
|
||
@supports (width: 1cqw) {
|
||
.grid { container-type: inline-size; }
|
||
.cell { font-size: calc(100cqw / var(--n) * 0.42); }
|
||
}
|
||
.palette { background: var(--surface); border: 1px solid var(--line); border-radius: 14px;
|
||
padding: 12px 14px 14px; margin: 0 auto 16px; max-width: 440px; box-shadow: var(--shadow); }
|
||
.plabel { text-transform: uppercase; letter-spacing: 0.12em; font-size: 0.62rem; font-family: var(--label);
|
||
color: var(--muted); text-align: center; margin: 0 0 10px; }
|
||
.words { list-style: none; display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; padding: 0; margin: 0; }
|
||
/* Unfound words are plain (transparent + outline) so it's easy to see what's
|
||
left as the board fills; found words get their grid colour via inline style. */
|
||
.words li { font-family: var(--label); font-size: 0.82rem; letter-spacing: 0.04em; color: var(--ink);
|
||
padding: 4px 11px; border-radius: 999px; background: transparent;
|
||
border: 1px solid var(--line); transition: background 0.2s ease, border-color 0.2s ease; }
|
||
.words li.got { border-color: transparent; }
|
||
.hint { text-align: center; color: var(--muted); font-size: 0.84rem; margin: 0; }
|
||
.result { text-align: center; }
|
||
.rmark { font-family: var(--serif); font-style: italic; color: var(--accent-deep); font-size: 1.2rem; margin: 0 0 12px; }
|
||
.times { display: flex; justify-content: center; gap: 26px; margin: 0 0 16px; }
|
||
.times .n { display: block; font-size: 1.5rem; font-weight: 700; font-family: var(--label); }
|
||
.times .l { color: var(--muted); font-size: 0.78rem; }
|
||
.share { background: var(--accent); color: #fff; border: none; border-radius: 999px;
|
||
padding: 11px 26px; font: inherit; font-weight: 600; cursor: pointer; }
|
||
.share:hover { background: var(--accent-deep); }
|
||
.muted { color: var(--muted); text-align: center; }
|
||
|
||
/* Mobile: a focused game screen (matches Daily Word). The grid sizes to the
|
||
largest square that fits the space left between the theme and the palette,
|
||
so the whole puzzle is on screen with no page scroll. */
|
||
@media (max-width: 720px) {
|
||
.wordsearch { display: flex; flex-direction: column; height: 100%; max-width: 100%; }
|
||
.theme { flex-shrink: 0; font-size: 1.5rem; margin: 4px 0 8px; }
|
||
.gridwrap { flex: 1; min-height: 0; container-type: size; align-items: center; padding: 2px 0; }
|
||
.grid { width: min(100%, 100cqh, calc(var(--n) * 40px)); max-width: none; margin: 0; }
|
||
.palette { flex-shrink: 0; margin: 10px auto 0; padding: 9px 12px 10px; max-width: 100%;
|
||
width: 100%; box-sizing: border-box; }
|
||
.plabel { margin-bottom: 7px; }
|
||
.words { gap: 6px; }
|
||
.words li { font-size: 0.78rem; padding: 3px 9px; }
|
||
.hint { flex-shrink: 0; padding: 9px 0 calc(env(safe-area-inset-bottom) + 6px); }
|
||
.result { flex-shrink: 0; padding-bottom: calc(env(safe-area-inset-bottom) + 6px); }
|
||
}
|
||
</style>
|