Files
upbeatBytes/frontend/src/routes/play/+page.svelte
T
thejayman77 28b0ef6766 Word Search select: put Today's theme in a standout accent card
The theme was floating between the title and the size options; give it its own
soft accent-tinted card so it reads as the day's headline, distinct from the
size choices.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 09:23:11 -04:00

261 lines
12 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script>
import { onMount } from 'svelte';
import { goto, afterNavigate } from '$app/navigation';
import { page } from '$app/stores';
import { getJSON } from '$lib/api.js';
import WordGame from '$lib/components/WordGame.svelte';
import WordSearchGame from '$lib/components/WordSearchGame.svelte';
// Screen is derived from the URL so the device/browser Back button steps through
// Hub → Game Selection → Game (each screen is its own history entry), instead of
// jumping straight out of /play.
let sp = $derived($page.url.searchParams);
let game = $derived(sp.get('game') === 'wordsearch' ? 'wordsearch' : 'word');
let view = $derived(!sp.get('game') ? 'hub' : (sp.get('v') ? 'play' : 'select'));
let variant = $derived(['5', '6'].includes(sp.get('v')) ? sp.get('v') : '5');
let wsSize = $derived(['small', 'med', 'large'].includes(sp.get('v')) ? sp.get('v') : 'med');
let date = $state('');
let wordStatus = $state({ 5: null, 6: null });
let wsStatus = $state(null);
function readWord(v) {
try {
const s = JSON.parse(localStorage.getItem(`goodnews:word:${v}:${date}`) || 'null');
if (s && (s.status === 'won' || s.status === 'lost')) {
return { status: s.status, tries: (s.guesses || []).length, max: v === '6' ? 7 : 6 };
}
} catch { /* ignore */ }
return null;
}
function readWsSize(sz) {
try {
const s = JSON.parse(localStorage.getItem(`goodnews:wordsearch:${sz}:${date}`) || 'null');
if (s && Array.isArray(s.foundWords)) return { status: s.status, found: s.foundWords.length, ms: s.ms };
} catch { /* ignore */ }
return null;
}
function refreshStatus() {
wordStatus = { 5: readWord('5'), 6: readWord('6') };
let ws = null;
for (const sz of ['small', 'med', 'large']) {
const s = readWsSize(sz);
if (s && s.status === 'done') { ws = s; break; }
if (s && s.found > 0 && !ws) ws = s;
}
wsStatus = ws;
}
function fmtMs(ms) {
const s = Math.round(ms / 1000);
return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0');
}
// Hub card one-liners
function wordLabel() {
const a = wordStatus['5'], b = wordStatus['6'];
if (!a && !b) return 'Guess the days word';
const part = (s, mx) => s ? (s.status === 'won' ? `${s.tries}/${mx}` : 'X') : '';
return `Today · 5:${part(a, 6)} 6:${part(b, 7)}`;
}
function wsHubLabel() {
if (!wsStatus) return 'Find the days themed words';
if (wsStatus.status === 'done') return `Today: cleared${wsStatus.ms ? ' · ' + fmtMs(wsStatus.ms) : ''}`;
if (wsStatus.found > 0) return `Today: ${wsStatus.found} found`;
return 'Find the days themed words';
}
// Game-selection option statuses
function wordOpt(v) {
const s = wordStatus[v];
if (!s) return 'Play';
return s.status === 'won' ? `Solved ${s.tries}/${s.max}` : 'Out of guesses';
}
function wsOpt(sz) {
const s = readWsSize(sz);
if (!s) return 'Play';
if (s.status === 'done') return `Cleared${s.ms ? ' · ' + fmtMs(s.ms) : ''}`;
if (s.found > 0) return `${s.found} found`;
return 'Play';
}
// Forward navigations push a history entry (via goto). The in-app Back button
// pops it (history.back) when we have in-app history, but on a direct deep link
// (no in-app history) it navigates to the parent screen instead of leaving the
// site. Device Back stays browser-native either way.
let appNavDepth = 0;
function openGame(g) { appNavDepth++; goto('/play?game=' + g); }
function pick(v) { appNavDepth++; goto('/play?game=' + game + '&v=' + v); }
function back() {
if (appNavDepth > 0) { appNavDepth--; history.back(); }
else goto(view === 'play' ? '/play?game=' + game : '/play');
}
// Canonicalize shareable/bookmarked URLs: unknown game → hub; invalid v for the
// game → its default (replaceState, so it doesn't add a history entry).
$effect(() => {
const g = sp.get('game'), v = sp.get('v');
if (g && g !== 'word' && g !== 'wordsearch') { goto('/play', { replaceState: true }); return; }
if (g && v) {
const valid = g === 'word' ? ['5', '6'] : ['small', 'med', 'large'];
if (!valid.includes(v)) goto(`/play?game=${g}&v=${g === 'word' ? '5' : 'med'}`, { replaceState: true });
}
});
// Any puzzle on mobile = a focused viewport: lock scroll + hide footer. Cleanup
// ALWAYS removes the class (re-run or unmount), so leaving /play can't strand it.
$effect(() => {
if (typeof document === 'undefined') return;
if (view === 'play') {
document.documentElement.classList.add('playing-game');
return () => document.documentElement.classList.remove('playing-game');
}
});
const WS_OPTS = [
['small', 'Small', 'cozy · 8×8'],
['med', 'Medium', 'balanced · 11×11'],
['large', 'Large', 'a longer sit · 14×14'],
];
let wsTheme = $state('');
onMount(async () => {
try { date = (await getJSON('/api/puzzle/word?variant=5')).date; } catch { /* offline */ }
try { wsTheme = (await getJSON('/api/puzzle/wordsearch?variant=med')).theme; } catch { /* offline */ }
refreshStatus();
});
// Refresh hub/selection statuses whenever we land on a screen (incl. Back).
afterNavigate(() => refreshStatus());
</script>
<svelte:head><title>Play · Upbeat Bytes</title></svelte:head>
<header class="bar">
<div class="container inner">
<a class="brand" href="/"><img class="logo" src="/logo.svg" alt="Upbeat Bytes" /></a>
{#if view === 'hub'}
<a class="back" href="/"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M19 12H5M11 6l-6 6 6 6" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round"/></svg>News</a>
{:else}
<button class="back" onclick={back}>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M19 12H5M11 6l-6 6 6 6" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round"/></svg>{view === 'play' ? 'Game Selection' : 'Play Hub'}
</button>
{/if}
</div>
</header>
<main class="container page" class:gameview={view === 'play'}>
{#if view === 'hub'}
<h1>Play</h1>
<p class="sub">A small calm thing after the brief. One of each a day — no rush, no score to beat but your own.</p>
<div class="cards">
<button class="gamecard" onclick={() => openGame('word')}>
<div class="gc-icon"></div>
<div class="gc-body">
<h2>Daily Word</h2>
<p class="gc-sub">Guess the hopeful word · 5 or 6 letters</p>
<p class="gc-status" class:played={wordStatus['5'] || wordStatus['6']}>{wordLabel()}</p>
</div>
</button>
<button class="gamecard" onclick={() => openGame('wordsearch')}>
<div class="gc-icon"></div>
<div class="gc-body">
<h2>Word Search</h2>
<p class="gc-sub">Find the days themed words</p>
<p class="gc-status" class:played={wsStatus && (wsStatus.status === 'done' || wsStatus.found > 0)}>{wsHubLabel()}</p>
</div>
</button>
</div>
{:else if view === 'select'}
<h1 class="seltitle">{game === 'word' ? 'Daily Word' : 'Word Search'}</h1>
{#if game === 'wordsearch' && wsTheme}
<div class="themecard">
<span class="tc-label">Todays theme</span>
<span class="tc-name">{wsTheme}</span>
</div>
{/if}
<p class="sub">{game === 'word' ? 'Pick your length.' : 'Pick your size.'}</p>
<div class="opts">
{#if game === 'word'}
<button class="opt" onclick={() => pick('5')}>
<span class="opt-main"><strong>Daily Word</strong><span>5 letters · 6 guesses</span></span>
<span class="opt-go" class:done={wordStatus['5']}>{wordOpt('5')}</span>
</button>
<button class="opt" onclick={() => pick('6')}>
<span class="opt-main"><strong>Long Word</strong><span>6 letters · 7 guesses</span></span>
<span class="opt-go" class:done={wordStatus['6']}>{wordOpt('6')}</span>
</button>
{:else}
{#each WS_OPTS as [sz, name, desc] (sz)}
<button class="opt" onclick={() => pick(sz)}>
<span class="opt-main"><strong>{name}</strong><span>{desc}</span></span>
<span class="opt-go" class:done={readWsSize(sz)}>{wsOpt(sz)}</span>
</button>
{/each}
{/if}
</div>
{:else if view === 'play'}
{#if game === 'word'}
<WordGame {variant} onstatus={refreshStatus} />
{:else}
<WordSearchGame size={wsSize} onstatus={refreshStatus} />
{/if}
{/if}
</main>
<style>
header.bar { background: var(--surface); border-bottom: 1px solid var(--line); position: sticky; top: 0; z-index: 20; }
.inner { display: flex; align-items: center; justify-content: space-between; height: 64px; }
.logo { height: 40px; display: block; }
.back { color: var(--accent-deep); font-size: 0.9rem; display: inline-flex; align-items: center; gap: 5px;
background: none; border: none; font-family: inherit; cursor: pointer; }
.back svg { width: 17px; height: 17px; display: block; }
.page { padding: 22px 20px 70px; }
h1 { font-size: clamp(2rem, 5vw, 2.6rem); margin: 6px 0 6px; }
.seltitle { font-size: clamp(1.7rem, 4.5vw, 2.2rem); }
.sub { color: var(--muted); margin: 0 0 24px; max-width: 540px; }
.themecard { background: var(--accent-soft); border-radius: 16px; padding: 16px 22px;
margin: 8px 0 24px; max-width: 460px; text-align: center; box-shadow: var(--shadow); }
.tc-label { display: block; text-transform: uppercase; letter-spacing: 0.13em; font-size: 0.66rem;
font-family: var(--label); font-weight: 600; color: var(--accent-deep); margin-bottom: 4px; }
.tc-name { font-family: var(--serif); font-size: 1.7rem; color: var(--accent-deep); line-height: 1.15; }
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 16px; }
.gamecard {
display: flex; gap: 14px; align-items: center; text-align: left;
background: var(--surface); border: 1px solid var(--line); border-radius: 16px;
padding: 18px 20px; cursor: pointer; font: inherit; color: var(--ink);
box-shadow: var(--shadow); transition: border-color 0.14s ease, transform 0.14s ease;
}
.gamecard:hover { border-color: var(--accent); transform: translateY(-1px); }
.gc-icon { font-size: 2rem; color: var(--accent); line-height: 1; flex-shrink: 0; }
.gc-body h2 { font-size: 1.2rem; margin: 0 0 3px; }
.gc-sub { color: var(--muted); font-size: 0.86rem; margin: 0 0 8px; }
.gc-status { font-size: 0.84rem; color: var(--accent-deep); font-weight: 600; margin: 0; }
/* Game-selection options */
.opts { display: flex; flex-direction: column; gap: 12px; max-width: 460px; }
.opt {
display: flex; align-items: center; justify-content: space-between; gap: 14px;
background: var(--surface); border: 1px solid var(--line); border-radius: 14px;
padding: 16px 18px; cursor: pointer; font: inherit; color: var(--ink);
box-shadow: var(--shadow); transition: border-color 0.14s ease, transform 0.14s ease;
}
.opt:hover { border-color: var(--accent); transform: translateY(-1px); }
.opt-main { display: flex; flex-direction: column; text-align: left; }
.opt-main strong { font-size: 1.1rem; }
.opt-main span { color: var(--muted); font-size: 0.82rem; }
.opt-go { flex-shrink: 0; font-size: 0.82rem; font-weight: 600; color: #fff; background: var(--accent);
border-radius: 999px; padding: 6px 14px; }
.opt-go.done { background: var(--accent-soft); color: var(--accent-deep); }
/* Full-height game layout on mobile so the keyboard is always reachable. */
@media (max-width: 720px) {
.page.gameview {
height: calc(100dvh - 64px); padding: 10px 16px 0;
display: flex; flex-direction: column; overflow: hidden;
}
}
</style>