refactor: single-page app — restore master tracking grid, drop /master route

Previous commit misread "remove master board" — restore the 11x9
ones-digit-aligned tracking grid (with circular tokens + draw-order
overlay) inside MasterPanel; remove the host's own player card
("Bảng của quản trò") instead. The host can use the player board
above it; no need for a duplicate card.

Delete /master route entirely (single-page app now). Cloudflare
Pages redirect added via static/_redirects: any unknown path
301-redirects to /, so old /master bookmarks land on the homepage.
Static assets are served first so the rule only fires on misses.

Storage keys loto_master_card_grid / loto_master_card_crossed are
no longer written but old saved data stays in users' localStorage
(harmless leftover).
This commit is contained in:
2026-04-27 01:31:26 +07:00
parent 0bc27f2897
commit 9933fb49e2
4 changed files with 102 additions and 62 deletions
+9 -13
View File
@@ -6,15 +6,14 @@
| File | Purpose |
|------|---------|
| `src/routes/+layout.svelte` | Root HTML layout. Sets Vietnamese lang, imports Geist font, applies global flex layout. |
| `src/routes/+page.svelte` | Player page (`/`). Header + SettingsButton, PlayerBoard, conditional MasterPanel (when `settings.masterMode`), PageFooter. Indigo→purple gradient branding. |
| `src/routes/master/+page.svelte` | Host page (`/master`). Thin shell: header + back link + MasterPanel + PageFooter. All host logic lives in `MasterPanel.svelte`. |
| `src/routes/+page.svelte` | Player page (`/`) — the ONLY page. Header + SettingsButton, PlayerBoard, conditional MasterPanel (when `settings.masterMode`), PageFooter. Indigo→purple gradient branding. |
### Shared Components
| File | Purpose |
|------|---------|
| `src/lib/PlayerBoard.svelte` | Reusable player card (9×9 grid rendered as 3 stacked 3×9 mini-cards: Tân Tân / An khang thịnh vượng / Tân Tân tốt nhất). Tall (3:5 on mobile; wider on sm+) cells with condensed bold black numbers (`tan-tan-num` font stack), white number cells, purple empty cells by default. Handles crossed state, bingo popup, "Chờ X" toast. Accepts `storagePrefix` prop for multi-card isolation. Empty cells use `--empty-cell-bg` CSS var from settings store. |
| `src/lib/SettingsButton.svelte` | Gear icon + modal. 4 fieldsets: Giao diện (theme auto/light/dark), Chế độ quản trò (master mode toggle), Tự động xổ (auto-call + speed 110s), Màu ô trống (10 Excel color swatches). Reset-to-default button. Mounted on both `/` and `/master` headers. |
| `src/lib/MasterPanel.svelte` | Host controls. New game / draw, "Số vừa xổ" hero token, "Thứ tự đã xổ" history list, host's own player card. No 11×9 tracking board (player card is enough). "Xổ số" / "Bắt đầu / Dừng" button bound to auto-call. Mounted conditionally on `/` when `settings.masterMode === true`, or directly on `/master`. |
| `src/lib/MasterPanel.svelte` | Host controls. New game / draw, "Số vừa xổ" hero token, "Thứ tự đã xổ" history list, 11×9 last-digit-aligned tracking grid (with circular tokens + draw-order overlay). No host's own player card (the player already has one above). "Xổ số" / "Bắt đầu / Dừng" button bound to auto-call. Mounted conditionally on `/` when `settings.masterMode === true`. |
| `src/lib/PageFooter.svelte` | Footer with tagline ("Made by miti99 with ❤️ SVG icon") + link. Mounted on `/` and `/master`. |
### Game Logic
@@ -44,6 +43,7 @@
| `jsconfig.json` | Path alias `$lib`, no checkJs. |
| `.gitignore` | Excludes node_modules, build, .env.local, etc. |
| `.env.example` | codeserver profile vars (CODESERVER_HOST, CODESERVER_PORT). |
| `static/_redirects` | Cloudflare Pages: `/* / 301` — every unknown path 301-redirects to homepage. Static assets are served first so this only fires on misses. |
## Key Data Structures
@@ -58,22 +58,18 @@
| `loto_grid` | Player's card numbers. |
| `loto_crossed` | Player's marked cells. |
| `loto_master` | Host's drawn/remaining numbers. |
| `loto_master_card_grid` | Host's player card numbers. |
| `loto_master_card_crossed` | Host's marked cells. |
| `loto_settings` | Global UI settings: `{ theme, masterMode, autoCallEnabled, autoCallSpeed, emptyCellColor }`. |
`loto_master_card_*` keys are no longer written (host's own player card removed) but old saved data is left untouched.
## Component Hierarchy
```
RootLayout
── HomePage (/)
├── PlayerBoard (storagePrefix="loto")
├── [if settings.masterMode]
│ └── MasterPanel (state, controls, draw history)
│ └── PageFooter
└── MasterPage (/master)
├── MasterPanel (state, controls, draw history)
├── Back link
── HomePage (/) ← single page; any other URL redirects to /
├── PlayerBoard (storagePrefix="loto")
├── [if settings.masterMode]
│ └── MasterPanel (controls, history, 11×9 tracking grid)
└── PageFooter
```
+89 -15
View File
@@ -1,6 +1,34 @@
<script module>
const STORAGE_KEY = "loto_master";
/**
* 11x9 board, aligned by ones-digit. Row = ones digit, col = tens digit.
* Col 0 holds 1..9, col 8 holds 80..90 (90 sits alone in row 10 col 8).
* Empty slots = 0.
* @returns {number[][]}
*/
function buildBoard() {
/** @type {number[][]} */
const board = [];
for (let row = 0; row < 11; row++) {
/** @type {number[]} */
const cells = [];
for (let col = 0; col < 9; col++) {
let num = 0;
if (row === 10) {
if (col === 8) num = 90;
} else if (row === 0) {
if (col > 0) num = col * 10;
} else {
num = col === 0 ? row : col * 10 + row;
}
cells.push(num);
}
board.push(cells);
}
return board;
}
/** @returns {{ called: number[], remaining: number[] }} */
function createFreshState() {
const all = Array.from({ length: 90 }, (_, i) => i + 1);
@@ -29,10 +57,12 @@
return null;
}
}
const BOARD = Object.freeze(buildBoard().map((row) => Object.freeze(row)));
const BOARD_FLAT = Object.freeze(BOARD.flatMap((r) => r));
</script>
<script>
import PlayerBoard from "$lib/PlayerBoard.svelte";
import { settings } from "$lib/settings-store.svelte.js";
let state = $state(
@@ -53,6 +83,11 @@
if (state) saveState(state);
});
// Map number -> 1-based draw order for fast Kinh! verification.
const callOrder = $derived(
new Map((state?.called ?? []).map((n, i) => [n, i + 1])),
);
// Auto-call interval. Single $effect that depends on autoRunning,
// settings.autoCallSpeed, AND settings.autoCallEnabled — Svelte tears
// down + re-arms cleanly whenever any of them change. Includes the
@@ -203,21 +238,60 @@
</div>
{/if}
{#if !state}
<!-- 11x9 master tracking board -->
{#if state}
<div
aria-label="Bảng theo dõi số đã xổ"
class="rounded-2xl overflow-hidden shadow-xl shadow-slate-200/50 dark:shadow-black/30 border border-slate-200 dark:border-slate-700"
>
<div class="master-grid">
{#each BOARD_FLAT as num, idx (idx)}
{@const hasNumber = num > 0}
{@const order = hasNumber ? callOrder.get(num) : undefined}
{@const isCalled = order !== undefined}
{@const isLast = isCalled && num === lastCalled}
{@const isLow = hasNumber && num <= 49}
<div
style:background-color={hasNumber ? null : "var(--empty-cell-bg)"}
class="relative flex items-center justify-center
aspect-square
border-r border-b border-slate-200/80 dark:border-slate-700/60
bg-white dark:bg-slate-800 select-none
{isLast ? 'z-10' : ''}"
>
{#if hasNumber}
<!-- Token: cream inner when called (pink ≤49 / green ≥50 ring),
gray-ringed and dim when uncalled. -->
<div
class="flex items-center justify-center
w-[82%] h-[82%] rounded-full
text-lg sm:text-xl font-black tabular-nums
border-[3px] transition-all
{!isCalled
? 'border-slate-300 dark:border-slate-600 bg-slate-50/40 dark:bg-slate-700/30 text-slate-400 dark:text-slate-500 opacity-70'
: isLow
? 'border-pink-500 dark:border-pink-400 bg-amber-50 dark:bg-amber-100 text-pink-500 dark:text-pink-400'
: 'border-emerald-500 dark:border-emerald-400 bg-amber-50 dark:bg-amber-100 text-emerald-500 dark:text-emerald-400'}
{isLast
? 'ring-2 ring-red-500 dark:ring-red-400 ring-offset-1 ring-offset-white dark:ring-offset-slate-800 scale-110 shadow-md'
: ''}"
>
{num}
</div>
{#if isCalled}
<span
class="absolute top-0.5 right-0.5 text-[9px] sm:text-[10px] font-semibold leading-none text-slate-500 dark:text-slate-400 tabular-nums"
>
{order}
</span>
{/if}
{/if}
</div>
{/each}
</div>
</div>
{:else}
<div class="text-center text-slate-400 dark:text-slate-500 py-10 text-sm">
Nhấn "Ván mới" để bắt đầu
</div>
{/if}
<!-- Master's own playing card -->
<div class="mt-10">
<div class="text-center mb-4">
<h3 class="text-lg font-bold text-slate-700 dark:text-slate-200">
Bảng của quản trò
</h3>
<p class="text-xs text-slate-400 dark:text-slate-500">
Quản trò cũng có thể chơi cùng
</p>
</div>
<PlayerBoard storagePrefix="loto_master_card" />
</div>
-34
View File
@@ -1,34 +0,0 @@
<script>
import { base } from "$app/paths";
import MasterPanel from "$lib/MasterPanel.svelte";
import PageFooter from "$lib/PageFooter.svelte";
import SettingsButton from "$lib/SettingsButton.svelte";
</script>
<div class="flex flex-col flex-1 items-center px-2 py-4 sm:px-3 sm:py-12">
<div class="w-full max-w-2xl">
<header class="relative text-center mb-6">
<div class="absolute right-0 top-0">
<SettingsButton />
</div>
<h1
class="text-3xl sm:text-4xl font-extrabold tracking-tight bg-gradient-to-r from-orange-500 to-red-500 bg-clip-text text-transparent"
>
Quản trò
</h1>
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">
Xổ số và theo dõi bảng lô tô
</p>
<a
href="{base}/"
class="mt-2 inline-block text-xs text-indigo-500 dark:text-indigo-400 hover:underline"
>
← Về trang người chơi
</a>
</header>
<MasterPanel />
<PageFooter />
</div>
</div>
+4
View File
@@ -0,0 +1,4 @@
# Cloudflare Pages: any unknown path → 301 redirect to homepage.
# Static asset files are served first, so this only fires on misses.
# Keeps old /master bookmarks (and any future stray URL) on the app.
/* / 301