refactor: rewrite from Next.js + React to SvelteKit + Svelte 5

Full stack swap to enable future extension (more pages / load functions /
backend) while keeping JSDoc-only code style.

Stack:
- SvelteKit 2 + adapter-static
- Svelte 5 runes ($state, $derived, $effect, $props)
- Vite 7 + @sveltejs/vite-plugin-svelte 6
- Tailwind 4 (Vite plugin)
- ESLint 9 (flat) + eslint-plugin-svelte
- Pure JS + JSDoc, no TypeScript

Source moves:
- app/page.jsx              → src/routes/+page.svelte
- app/master/page.jsx       → src/routes/master/+page.svelte
- app/layout.jsx            → src/routes/+layout.svelte (+ +layout.js)
- components/player-board.jsx → src/lib/PlayerBoard.svelte
- lib/game-logic.js         → src/lib/game-logic.js (verbatim)
- next.config.mjs           → svelte.config.js + vite.config.js
- app/globals.css           → src/app.css
- (new)                     → src/app.html

Behavior preserved: PlayerBoard with bingo "Kinh!" popup + waiting "Chờ X"
toast, master 9x10 tracking board with shuffled draw, host's own player
card via storagePrefix="loto_master_card", localStorage prefix model
(loto_*, loto_master, loto_master_card_*), basePath dual mode (CF default
empty, BUILD_PROFILE=gh → /loto, codeserver dev → /absproxy/{port}).

A11y kept from prior hardening: role-correct buttons, aria-pressed on
cells, role=dialog modal with Escape, role=status toast.

Plans: ts-to-jsdoc plan marked completed; sveltekit-refactor plan tracks
the work above. Docs under ./docs/ rewritten by docs-manager subagent to
match the SvelteKit terminology.
This commit is contained in:
tiennm99 committed 2026-04-26 21:03:41 +07:00
1 parent bbdd2b449b
commit ee48e162d2
38 files changed
+3622 -5561

No files matched your search

+2 -3
View File
@@ -1,8 +1,7 @@
.vscode/
node_modules/
.next/
out/
.svelte-kit/
build/
.env.local
.env*.local
tsconfig.tsbuildinfo
repomix-output.xml
+11 -6
View File
@@ -1,14 +1,16 @@
# Lô tô
Bàn số của trò chơi "Lô tô" — Next.js app.
Bàn số của trò chơi "Lô tô" — SvelteKit app.
Two pages: `/` for players, `/master` for the host (quản trò) — calls numbers, shows a tracking board, and has its own player card to play along.
Two routes: `/` for players, `/master` for the host (quản trò) — calls
numbers, shows a tracking board, and has its own player card to play along.
See `docs/` for project overview, architecture, code standards, and deployment.
See `docs/` for architecture, code standards, and deployment.
## Development
```bash
npm install
npm run dev
```
@@ -22,12 +24,15 @@ npm run dev:codeserver
Open `https://<CODESERVER_HOST>/absproxy/<CODESERVER_PORT>/`.
Use `/absproxy/{port}/`, **not** `/proxy/{port}/` — the latter strips the path prefix and breaks Next's `basePath`. HMR may not survive the proxy; refresh manually if it disconnects.
Use `/absproxy/{port}/`, **not** `/proxy/{port}/` — the latter strips the
path prefix and breaks the SvelteKit base path.
## Build
```bash
npm run build
npm run build # default — root basePath, for Cloudflare Pages
npm run build:gh # /loto basePath, for tiennm99.github.io/loto manual export
```
Static export to `out/`. Deployed to Cloudflare Pages from `master` (set up via the CF dashboard — see `docs/deployment-guide.md`).
Static export to `build/`. Deployed to Cloudflare Pages from `master`
(set up via the CF dashboard — see `docs/deployment-guide.md`).
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

-20
View File
@@ -1,20 +0,0 @@
import { Geist } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
export const metadata = {
title: "Lô tô",
description: "Bàn số của trò chơi Lô tô",
};
export default function RootLayout({ children }) {
return (
<html lang="vi" className={`${geistSans.variable} h-full antialiased`}>
<body className="min-h-full flex flex-col">{children}</body>
</html>
);
}
-248
View File
@@ -1,248 +0,0 @@
"use client";
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
import PlayerBoard from "../../components/player-board";
const STORAGE_KEY = "loto_master";
/**
* Master draw state shape:
* { called: number[], remaining: number[] }
*
* Build the 9x10 board: columns 0-8 map to number ranges 1-9, 10-19, ..., 80-90.
*/
function buildBoard() {
const board = [];
for (let row = 0; row < 10; row++) {
const cells = [];
for (let col = 0; col < 9; col++) {
const num = col === 0 ? row + 1 : col * 10 + row;
// Column 0: 1-9 (row 9 is empty), Columns 1-8: 10-19, ..., 80-89 (row 9 has 90 for col 8)
if (col === 0 && row === 9) {
cells.push(0); // no number
} else if (col === 8 && row === 9) {
cells.push(90);
} else if (col > 0 && row === 9) {
cells.push(0); // no number
} else {
cells.push(num);
}
}
board.push(cells);
}
return board;
}
function createFreshState() {
const all = Array.from({ length: 90 }, (_, i) => i + 1);
// Shuffle
for (let i = all.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[all[i], all[j]] = [all[j], all[i]];
}
return { called: [], remaining: all };
}
function saveState(state) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
}
function loadState() {
const data = localStorage.getItem(STORAGE_KEY);
if (!data) return null;
try {
return JSON.parse(data);
} catch {
return null;
}
}
const BOARD = Object.freeze(buildBoard().map((row) => Object.freeze(row)));
const BOARD_FLAT = Object.freeze(BOARD.flatMap((r) => r));
export default function MasterPage() {
const [state, setState] = useState(null);
const [lastCalled, setLastCalled] = useState(null);
useEffect(() => {
const saved = loadState();
if (saved && saved.called.length > 0) {
setState(saved);
setLastCalled(saved.called[saved.called.length - 1]);
}
}, []);
useEffect(() => {
if (state) saveState(state);
}, [state]);
const calledSet = new Set(state?.called ?? []);
const handleNewGame = useCallback(() => {
if (state && !confirm("Bạn có muốn tạo ván mới không?")) return;
const fresh = createFreshState();
setState(fresh);
setLastCalled(null);
}, [state]);
const handleDrawNext = useCallback(() => {
if (!state || state.remaining.length === 0) return;
const next = state.remaining[0];
setState({
called: [...state.called, next],
remaining: state.remaining.slice(1),
});
setLastCalled(next);
}, [state]);
return (
<div className="flex flex-col flex-1 items-center px-3 py-8 sm:py-12">
<div className="w-full max-w-2xl">
{/* Header */}
<header className="text-center mb-6">
<h1 className="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 className="mt-1 text-sm text-slate-500 dark:text-slate-400">
Xổ số và theo dõi bảng lô tô
</p>
<Link
href="/"
className="mt-2 inline-block text-xs text-indigo-500 dark:text-indigo-400 hover:underline"
>
← Về trang người chơi
</Link>
</header>
{/* Controls */}
<div className="flex justify-center gap-3 mb-6">
<button
onClick={handleNewGame}
className="px-8 py-4 rounded-full font-semibold text-white text-lg
bg-gradient-to-r from-orange-500 to-red-500
hover:from-orange-600 hover:to-red-600
active:scale-95 transition-all shadow-lg shadow-orange-500/25"
>
Ván mới
</button>
{state && state.remaining.length > 0 && (
<button
onClick={handleDrawNext}
className="px-10 py-4 rounded-full font-semibold text-white text-lg
bg-gradient-to-r from-emerald-500 to-teal-500
hover:from-emerald-600 hover:to-teal-600
active:scale-95 transition-all shadow-lg shadow-emerald-500/25"
>
Xổ số
</button>
)}
</div>
{/* Current number display */}
{lastCalled && (
<div className="flex flex-col items-center mb-6">
<div className="text-xs uppercase tracking-widest text-slate-400 dark:text-slate-500 mb-1">
Số vừa xổ
</div>
<div className="w-20 h-20 sm:w-24 sm:h-24 rounded-2xl bg-gradient-to-br from-orange-500 to-red-500 flex items-center justify-center shadow-xl shadow-orange-500/30">
<span className="text-4xl sm:text-5xl font-black text-white">
{lastCalled}
</span>
</div>
{state && (
<div className="mt-2 text-xs text-slate-400 dark:text-slate-500">
Đã xổ: {state.called.length}/90 &middot; Còn lại: {state.remaining.length}
</div>
)}
</div>
)}
{/* Called numbers history */}
{state && state.called.length > 0 && (
<div className="mb-6 px-1">
<div className="text-xs text-slate-400 dark:text-slate-500 mb-1">
Thứ tự đã xổ:
</div>
<div className="flex flex-wrap gap-1.5">
{state.called.map((num, i) => (
<span
key={i}
className="inline-flex items-center justify-center w-7 h-7 text-xs font-bold rounded-md bg-orange-100 dark:bg-orange-950/40 text-orange-700 dark:text-orange-300"
>
{num}
</span>
))}
</div>
</div>
)}
{/* Master board 9x10 */}
{state ? (
<div
aria-label="Bảng theo dõi số đã xổ"
className="rounded-2xl overflow-hidden shadow-xl shadow-slate-200/50 dark:shadow-black/30 border border-slate-200 dark:border-slate-700"
>
<div className="master-grid">
{BOARD_FLAT.map((num, idx) => {
const hasNumber = num > 0;
const isCalled = hasNumber && calledSet.has(num);
return (
<div
key={idx}
className={`
relative flex items-center justify-center
aspect-square text-sm sm:text-base font-bold
border-r border-b border-slate-200/80 dark:border-slate-700/60
transition-colors select-none
${hasNumber
? isCalled
? "bg-orange-500 dark:bg-orange-600 text-white"
: "bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-200"
: "bg-slate-100 dark:bg-slate-900/60"
}
`}
>
{hasNumber ? num : ""}
</div>
);
})}
</div>
</div>
) : (
<div className="text-center text-slate-400 dark:text-slate-500 py-20 text-sm">
Nhấn &ldquo;Ván mới&rdquo; để bắt đầu
</div>
)}
{/* Master's own playing card */}
<div className="mt-10">
<div className="text-center mb-4">
<h2 className="text-lg font-bold text-slate-700 dark:text-slate-200">
Bảng của quản trò
</h2>
<p className="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>
{/* Footer */}
<footer className="mt-10 text-center text-xs text-slate-400 dark:text-slate-600">
Made with ❤️ by{" "}
<a
href="https://miti99.com"
target="_blank"
rel="noopener noreferrer"
className="text-indigo-500 hover:underline"
>
miti99
</a>
</footer>
</div>
</div>
);
}
-74
View File
@@ -1,74 +0,0 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import PlayerBoard from "../components/player-board";
export default function Home() {
const [showInstructions, setShowInstructions] = useState(false);
return (
<div className="flex flex-col flex-1 items-center px-3 py-8 sm:py-12">
<div className="w-full max-w-lg">
{/* Header */}
<header className="text-center mb-8">
<h1 className="text-4xl sm:text-5xl font-extrabold tracking-tight bg-gradient-to-r from-indigo-500 to-purple-500 bg-clip-text text-transparent">
Lô tô
</h1>
<p className="mt-2 text-sm text-slate-500 dark:text-slate-400">
Lấy cảm hứng từ những buổi họp lớp thiếu giấy chơi lô tô
<br className="hidden sm:block" /> của TN1 (2014–2017)
</p>
<div className="mt-3 flex items-center justify-center gap-3 text-xs">
<button
onClick={() => setShowInstructions((v) => !v)}
className="text-indigo-500 dark:text-indigo-400 hover:underline"
>
{showInstructions ? "Ẩn hướng dẫn" : "Hướng dẫn"}
</button>
<span className="text-slate-300 dark:text-slate-600">|</span>
<Link
href="/master"
className="text-orange-500 dark:text-orange-400 hover:underline"
>
Trang quản trò →
</Link>
</div>
</header>
{/* Instructions */}
{showInstructions && (
<div className="mb-6 rounded-xl bg-indigo-50 dark:bg-indigo-950/30 border border-indigo-100 dark:border-indigo-900 p-4 text-sm text-slate-600 dark:text-slate-400">
<ul className="space-y-1 list-disc list-inside">
<li>
Nhấn{" "}
<strong className="text-slate-800 dark:text-slate-200">
Tạo bảng mới
</strong>{" "}
để tạo bảng
</li>
<li>Nhấn vào ô số để đánh dấu khi số được xổ</li>
<li>Nhấn lại để bỏ đánh dấu</li>
<li>Bảng được lưu tự động</li>
</ul>
</div>
)}
<PlayerBoard />
{/* Footer */}
<footer className="mt-10 text-center text-xs text-slate-400 dark:text-slate-600">
Made with ❤️ by{" "}
<a
href="https://miti99.com"
target="_blank"
rel="noopener noreferrer"
className="text-indigo-500 hover:underline"
>
miti99
</a>
</footer>
</div>
</div>
);
}
-267
View File
@@ -1,267 +0,0 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
generateGrid,
getWaitingNumber,
isRowComplete,
loadCrossedState,
loadGrid,
saveCrossedState,
saveGrid,
} from "../lib/game-logic";
/**
* Reusable player card component.
*
* @param {object} props
* @param {string} [props.storagePrefix] localStorage key prefix; allows
* multiple independent boards (e.g. user vs master).
*/
export default function PlayerBoard({ storagePrefix = "loto" } = {}) {
const [grid, setGrid] = useState(null);
const [crossed, setCrossed] = useState([]);
const [showCongrats, setShowCongrats] = useState(false);
const [congratsRow, setCongratsRow] = useState(-1);
const [toast, setToast] = useState(null);
const toastTimer = useRef(null);
const celebratedRows = useRef(new Set());
const notifiedWaitingRows = useRef(new Set());
const dismissToast = useCallback(() => {
setToast(null);
if (toastTimer.current) {
clearTimeout(toastTimer.current);
toastTimer.current = null;
}
}, []);
const showToast = useCallback(
(msg) => {
dismissToast();
setToast(msg);
toastTimer.current = setTimeout(() => setToast(null), 5000);
},
[dismissToast]
);
useEffect(() => {
const savedGrid = loadGrid(storagePrefix);
if (savedGrid) {
setGrid(savedGrid);
const savedCrossed =
loadCrossedState(storagePrefix) ??
savedGrid.map((row) => row.map(() => false));
setCrossed(savedCrossed);
celebratedRows.current.clear();
notifiedWaitingRows.current.clear();
for (let i = 0; i < savedGrid.length; i++) {
if (isRowComplete(savedGrid, savedCrossed, i)) {
celebratedRows.current.add(i);
}
if (getWaitingNumber(savedGrid, savedCrossed, i) !== null) {
notifiedWaitingRows.current.add(i);
}
}
}
}, [storagePrefix]);
useEffect(() => {
if (crossed.length > 0) saveCrossedState(crossed, storagePrefix);
}, [crossed, storagePrefix]);
// Detect newly completed rows and waiting rows
useEffect(() => {
if (!grid || crossed.length === 0) return;
// Pass 1: at most one bingo popup per render
for (let i = 0; i < grid.length; i++) {
if (!celebratedRows.current.has(i) && isRowComplete(grid, crossed, i)) {
celebratedRows.current.add(i);
notifiedWaitingRows.current.add(i);
setCongratsRow(i + 1);
setShowCongrats(true);
break;
}
}
// Pass 2: update waiting state for every non-celebrated row
for (let i = 0; i < grid.length; i++) {
if (celebratedRows.current.has(i)) continue;
const waitNum = getWaitingNumber(grid, crossed, i);
if (waitNum !== null && !notifiedWaitingRows.current.has(i)) {
notifiedWaitingRows.current.add(i);
showToast(`Chờ ${waitNum}`);
} else if (waitNum === null && notifiedWaitingRows.current.has(i)) {
notifiedWaitingRows.current.delete(i);
}
}
}, [grid, crossed, showToast]);
// Precompute per-row completeness so we don't call isRowComplete 81×/render
const rowCompleteness = useMemo(() => {
if (!grid || crossed.length === 0) return [];
return grid.map((_, row) => isRowComplete(grid, crossed, row));
}, [grid, crossed]);
const handleGenerate = useCallback(() => {
if (grid && !confirm("Bạn có muốn tạo lại bảng không?")) return;
const newGrid = generateGrid();
const newCrossed = newGrid.map((row) => row.map(() => false));
setGrid(newGrid);
setCrossed(newCrossed);
saveGrid(newGrid, storagePrefix);
saveCrossedState(newCrossed, storagePrefix);
celebratedRows.current.clear();
notifiedWaitingRows.current.clear();
dismissToast();
}, [grid, dismissToast, storagePrefix]);
const handleCellClick = useCallback((row, col) => {
setCrossed((prev) => {
const next = prev.map((r) => [...r]);
next[row][col] = !next[row][col];
return next;
});
}, []);
return (
<>
<div className="flex justify-center mb-6">
<button
onClick={handleGenerate}
className="px-8 py-3 rounded-full font-semibold text-white
bg-gradient-to-r from-indigo-500 to-purple-500
hover:from-indigo-600 hover:to-purple-600
active:scale-95 transition-all shadow-lg shadow-indigo-500/25"
>
Tạo bảng mới
</button>
</div>
{grid ? (
<div className="relative">
<div
aria-label="Bảng lô tô"
className="rounded-2xl overflow-hidden shadow-xl shadow-slate-200/50 dark:shadow-black/30 border border-slate-200 dark:border-slate-700"
>
<div className="loto-grid">
{grid.flat().map((num, idx) => {
const row = Math.floor(idx / 9);
const col = idx % 9;
const hasNumber = num > 0;
const isCrossed = hasNumber && !!crossed[row]?.[col];
const rowComplete = hasNumber && rowCompleteness[row];
if (!hasNumber) {
return (
<div
key={idx}
aria-hidden="true"
className="relative flex items-center justify-center aspect-square border-r border-b border-slate-200/80 dark:border-slate-700/60 bg-slate-50 dark:bg-slate-900/60"
/>
);
}
return (
<button
key={idx}
type="button"
aria-label={`Số ${num}${isCrossed ? ", đã đánh dấu" : ""}`}
aria-pressed={isCrossed}
onClick={() => handleCellClick(row, col)}
className={`
relative flex items-center justify-center
aspect-square text-base sm:text-xl font-bold
border-r border-b border-slate-200/80 dark:border-slate-700/60
transition-all select-none cursor-pointer
focus:outline-none focus:ring-2 focus:ring-inset focus:ring-indigo-400
${
isCrossed
? rowComplete
? "cell-crossed bg-emerald-100 dark:bg-emerald-900/40 text-emerald-500 dark:text-emerald-400"
: "cell-crossed bg-red-50 dark:bg-red-950/30 text-red-400 dark:text-red-500"
: "bg-white dark:bg-slate-800 text-slate-800 dark:text-slate-100 hover:bg-indigo-50 dark:hover:bg-indigo-950/30 hover:text-indigo-600 dark:hover:text-indigo-400"
}
`}
>
{num}
</button>
);
})}
</div>
</div>
{toast && (
<div
role="status"
aria-live="polite"
onClick={dismissToast}
className="absolute inset-0 flex items-center justify-center pointer-events-auto cursor-pointer z-10"
>
<div className="px-6 py-3 rounded-2xl bg-amber-500/90 dark:bg-amber-600/90 text-white text-xl sm:text-2xl font-black shadow-xl animate-toast">
{toast}
</div>
</div>
)}
</div>
) : (
<div className="text-center text-slate-400 dark:text-slate-500 py-20 text-sm">
Nhấn &ldquo;Tạo bảng mới&rdquo; để bắt đầu chơi
</div>
)}
{showCongrats && (
<div
role="dialog"
aria-modal="true"
aria-labelledby="congrats-title"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm animate-fade-in"
onClick={() => setShowCongrats(false)}
onKeyDown={(e) => {
if (e.key === "Escape") setShowCongrats(false);
}}
tabIndex={-1}
>
<div
className="relative mx-4 max-w-sm w-full rounded-3xl bg-white dark:bg-slate-800 p-8 text-center shadow-2xl animate-pop-in"
onClick={(e) => e.stopPropagation()}
>
<div className="absolute -top-6 left-1/2 -translate-x-1/2 text-6xl animate-bounce-slow">
🎉
</div>
<div className="absolute top-2 left-4 text-2xl animate-spin-slow">
✨
</div>
<div className="absolute top-2 right-4 text-2xl animate-spin-slow-reverse">
🎊
</div>
<h2
id="congrats-title"
className="mt-6 text-3xl font-black bg-gradient-to-r from-amber-500 via-pink-500 to-purple-500 bg-clip-text text-transparent"
>
Kinh!
</h2>
<p className="mt-3 text-lg text-slate-600 dark:text-slate-300">
Hàng <span className="font-bold text-pink-500">{congratsRow}</span>{" "}
đã đầy đủ!
</p>
<p className="mt-1 text-sm text-slate-400 dark:text-slate-500">
Hãy hô to &ldquo;Kinh!&rdquo; 🎶
</p>
<button
onClick={() => setShowCongrats(false)}
className="mt-6 px-8 py-2.5 rounded-full font-semibold text-white
bg-gradient-to-r from-pink-500 to-purple-500
hover:from-pink-600 hover:to-purple-600
active:scale-95 transition-all shadow-lg"
>
Tuyệt vời! 🥳
</button>
</div>
</div>
)}
</>
);
}
+57 -29
View File
@@ -2,30 +2,33 @@
## File Naming & Structure
- **Kebab-case** for all files: `player-board.jsx`, `game-logic.js`.
- **Kebab-case** for all files: `PlayerBoard.svelte`, `game-logic.js`.
- **Descriptive names**: Long names are preferred for self-documentation. Avoid ambiguity.
- **Single responsibility**: Each file has one primary export (component or utilities).
- **Max 200 lines per file**: Split larger components into smaller focused ones.
## React & JavaScript Conventions
## Svelte 5 Runes & JavaScript
### Hooks
- **useState**: For local UI state (grid, crossed, form inputs).
- **useEffect**: For side effects (load from localStorage, save to localStorage, detect changes).
- **useCallback**: For event handlers to stabilize function identity across renders.
- **useRef**: For mutable values that don't trigger renders (timers, celebratedRows set).
### State & Reactivity
- **$state**: For mutable local UI state (grid, crossed, booleans). `let grid = $state(...)`
- **$derived**: For computed values that auto-update when dependencies change. `const rowCompleteness = $derived(grid && crossed.length ? [...] : [])`
- **$effect**: For side effects (localStorage sync, detecting completed rows). Replaces React useEffect.
- **$props**: For destructuring component props. `let { storagePrefix = "loto" } = $props()`
### Plain References (No Reactivity)
- Use regular `let` or `const` for timers, Sets, Maps (not reactive). Example: `let toastTimer = null; const celebratedRows = new Set();`
### Typing (JSDoc)
- Author types in JSDoc comments — `jsconfig.json` has `checkJs: true`, so `tsc --noEmit` validates them.
- Component props: `@typedef {Object} Props { ... }` followed by `@param {Props} props` on the function.
- Layout children: `@param {{ children: React.ReactNode }} props`.
- Use JSDoc `@typedef {Object}` for prop types, JSDoc type annotations for functions.
- `jsconfig.json` has `checkJs: false` (not enabled), so types are documentation-only.
- Component props: `@typedef {Object} Props { ... }` then `/** @type {Props} */ let { ... } = $props()`
- Avoid `any`; use precise types (e.g., `number[][]` for grid).
- Generics: `@template T` then reference `T` in `@param` / `@returns`.
- Generics: `@template T` for utility functions.
### Client-Only Constraint
- All interactive pages must have `"use client"` at the top.
- No server-side data fetching; use localStorage instead.
- No async Server Components.
### Client-Only Architecture
- SvelteKit's `ssr: false` in `+layout.js` disables server rendering.
- All pages are client-only; no server-side data fetching.
- Use localStorage exclusively for persistence.
## CSS & Tailwind 4 Patterns
@@ -81,7 +84,7 @@ function loadGrid(prefix = "loto") {
## Error Handling
- **Silent fallback**: JSON parse errors return null; caller checks for null.
- **No try-catch in render**: Keep logic in useEffect or event handlers.
- **No try-catch in render**: Keep logic in $effect or event handlers.
- **Confirmation dialogs**: `confirm("Bạn có muốn...")` for destructive actions.
## Naming Conventions
@@ -91,13 +94,13 @@ function loadGrid(prefix = "loto") {
| camelCase | `handleCellClick`, `storagePrefix` | variables, functions, props |
| PascalCase | `PlayerBoard`, `MasterPage` | components, types |
| UPPER_SNAKE | `STORAGE_KEY`, `NUM_ROWS` | constants |
| kebab-case | `player-board.jsx` | file names |
| kebab-case | `PlayerBoard.svelte` | file names |
## Comment Style
- Document **why**, not **what**. The code shows what it does.
- Use `/** JSDoc */` for exported functions.
- Inline comments for complex logic (e.g., weighted random selection in `lib/game-logic.js:19–28`).
- Inline comments for complex logic (e.g., weighted random selection in `src/lib/game-logic.js:19–28`).
### Example
```js
@@ -116,37 +119,62 @@ function randomANumberInRow(weights) {
}
```
## Internal Navigation
- Use `import { base } from '$app/paths'` for internal links to preserve basePath across deployments.
- Example: `<a href="{base}/master">Host page</a>` works on root (`""`) and subpath (`/loto`) equally.
## Import Organization
1. React/Next imports
1. SvelteKit imports (`$app/paths`, `$lib/...`)
2. Third-party imports
3. Local component/utility imports
```js
import { useCallback, useState } from "react";
import Link from "next/link";
import PlayerBoard from "../components/player-board";
import { generateGrid } from "../lib/game-logic";
import { base } from "$app/paths";
import { generateGrid, isRowComplete } from "$lib/game-logic.js";
import PlayerBoard from "$lib/PlayerBoard.svelte";
```
## Component Patterns
### Props Pattern
```svelte
<script>
/**
* @typedef {Object} Props
* @property {string} [storagePrefix]
*/
/** @type {Props} */
let { storagePrefix = "loto" } = $props();
</script>
```
### Event Handlers
Use inline event handlers (`onclick`, `onkeydown`). Svelte 5 handles click delegation. Event objects automatically passed:
```svelte
<button onclick={() => handleCellClick(row, col)}>Click</button>
<div onkeydown={(e) => e.key === "Escape" && dismiss()}>Dialog</div>
```
## Testing (Not Currently Implemented)
Future tests should follow:
- Unit: test game logic (generateGrid, isRowComplete, getWaitingNumber) in isolation.
- Component: mock localStorage, render PlayerBoard with different props.
- Component: render PlayerBoard, mock localStorage, verify toast/popup.
- E2E: player flow (generate → click → bingo).
## Configuration
### Environment Variables
- **NEXT_DEV_PROFILE**: "codeserver" triggers proxy config.
- **CODESERVER_HOST**: Hostname for HMR (required if NEXT_DEV_PROFILE=codeserver).
### Environment Variables (code-server only)
- **VITE_DEV_PROFILE**: "codeserver" triggers proxy config.
- **CODESERVER_HOST**: Hostname for HMR.
- **CODESERVER_PORT**: Port (defaults to 3000).
Set in `.env.local` (not committed).
### Build Targets
- **output: "export"**: Static HTML export (no Node.js server needed).
- **basePath**: Configurable per deployment environment.
- **adapter-static**: Generates static HTML + JS in `build/`.
- **basePath**: Dual-mode: `""` (Cloudflare, dev) or `/loto` (GitHub Pages via `BUILD_PROFILE=gh`).
Last reviewed: 2026-04-26
+18 -16
View File
@@ -5,33 +5,35 @@
### Routing & Layout
| File | Purpose |
|------|---------|
| `app/layout.jsx` | Root HTML layout. Sets Vietnamese lang, imports Geist font, applies global flex layout. |
| `app/page.jsx` | Player page (`/`). Instructions toggle, PlayerBoard component, indigo gradient branding. |
| `app/master/page.jsx` | Host page (`/master`). Controls (new game, draw number), 9×10 master board, host's player card. |
| `src/routes/+layout.svelte` | Root HTML layout. Sets Vietnamese lang, imports Geist font, applies global flex layout. |
| `src/routes/+page.svelte` | Player page (`/`). Instructions toggle, PlayerBoard component, indigo gradient branding. |
| `src/routes/master/+page.svelte` | Host page (`/master`). Controls (new game, draw number), 9×10 master board, host's player card. |
### Shared Components
| File | Purpose |
|------|---------|
| `components/player-board.jsx` | Reusable player card (9×9 grid). Handles crossed state, bingo popup, "Chờ X" toast. Accepts `storagePrefix` prop for multi-card isolation. |
| `src/lib/PlayerBoard.svelte` | Reusable player card (9×9 grid). Handles crossed state, bingo popup, "Chờ X" toast. Accepts `storagePrefix` prop for multi-card isolation. |
### Game Logic
| File | Purpose |
|------|---------|
| `lib/game-logic.js` | Stateless utilities: generateGrid (weighted column selection), saveGrid, loadGrid, saveCrossedState, loadCrossedState, isRowComplete, getWaitingNumber. |
| `src/lib/game-logic.js` | Stateless utilities: generateGrid (weighted column selection), saveGrid, loadGrid, saveCrossedState, loadCrossedState, isRowComplete, getWaitingNumber. |
### Styling
| File | Purpose |
|------|---------|
| `app/globals.css` | Root styles: Tailwind @import, CSS variables (light/dark), `.loto-grid` & `.master-grid` (9-col), animations (fade-in, pop-in, bounce-slow, spin-slow, toast), `.cell-crossed` diagonal. |
| `src/app.css` | Root styles: Tailwind @import, CSS variables (light/dark), `.loto-grid` & `.master-grid` (9-col), animations (fade-in, pop-in, bounce-slow, spin-slow, toast), `.cell-crossed` diagonal. |
### Configuration
| File | Purpose |
|------|---------|
| `next.config.mjs` | Dual basePath: prod `/loto`, codeserver `/absproxy/{PORT}`. Exports static HTML. HMR-aware. |
| `package.json` | Next 16.2.2, React 19.2.4, Tailwind 4. Scripts: dev, dev:codeserver, build, start, lint. |
| `eslint.config.mjs` | ESLint 9 config (Next.js preset). |
| `.gitignore` | Excludes node_modules, .next, .env.local, etc. |
| `.env.example` | Template for env vars (currently none required for runtime; codeserver profile reads CODESERVER_HOST/PORT). |
| `svelte.config.js` | adapter-static (HTML export), dual basePath via BUILD_PROFILE env. |
| `vite.config.js` | Tailwind + SvelteKit plugins. codeserver HMR config (port, allowedHosts, hmr). |
| `package.json` | SvelteKit 2, Svelte 5 (runes), Tailwind 4, Vite. Scripts: dev, dev:codeserver, build, build:gh, lint. |
| `eslint.config.mjs` | ESLint 9 flat config (@eslint/js + eslint-plugin-svelte). |
| `jsconfig.json` | Path alias `$lib`, no checkJs. |
| `.gitignore` | Excludes node_modules, build, .env.local, etc. |
| `.env.example` | codeserver profile vars (CODESERVER_HOST, CODESERVER_PORT). |
## Key Data Structures
@@ -66,10 +68,10 @@ RootLayout
| Function | Location | Effect |
|----------|----------|--------|
| `generateGrid()` | game-logic.js:52 | Creates 9×9 with weighted column selection (5 nums/row). |
| `isRowComplete()` | game-logic.js:108 | Boolean: all non-zero cells in row crossed? |
| `getWaitingNumber()` | game-logic.js:120 | Returns the single uncrossed number in row, or null. |
| `handleCellClick()` | player-board.jsx:112 | Toggle crossed[row][col]. |
| `handleDrawNext()` | master/page.jsx:88 | Pop first number from remaining, add to called. |
| `generateGrid()` | game-logic.js:74 | Creates 9×9 with weighted column selection (5 nums/row). |
| `isRowComplete()` | game-logic.js:200 | Boolean: all non-zero cells in row crossed? |
| `getWaitingNumber()` | game-logic.js:218 | Returns the single uncrossed number in row, or null. |
| `handleCellClick()` | PlayerBoard.svelte:127 | Toggle crossed[row][col]. |
| `saveGrid()` / `loadGrid()` | game-logic.js:149–167 | localStorage with prefix-based keys. |
Last reviewed: 2026-04-26
+29 -28
View File
@@ -7,9 +7,11 @@
| `npm run build` | `""` (root) | Cloudflare Pages → `https://loto.miti99.com` |
| `npm run build:gh` | `/loto` | GitHub Pages → `https://tiennm99.github.io/loto` (manual fallback) |
Implementation: `next.config.mjs` reads `BUILD_PROFILE`. Default is empty
basePath; `BUILD_PROFILE=gh` switches to `/loto`. `NEXT_BASE_PATH`
overrides everything for one-off custom-domain builds.
Implementation: `svelte.config.js` reads `BUILD_PROFILE` env. Default is empty
basePath; `BUILD_PROFILE=gh npm run build` switches to `/loto`.
Internal links use `import { base } from '$app/paths'` so they survive
either profile without code changes.
## Production Deployment — Cloudflare Pages
@@ -19,9 +21,9 @@ pushes to `master` trigger automatic builds + deploys.
1. dash.cloudflare.com → Workers & Pages → Create → Pages → Connect to Git
→ pick the repo
2. Build settings:
- Framework preset: `Next.js (Static HTML Export)`
- Framework preset: SvelteKit
- Build command: `npm run build`
- Build output directory: `out`
- Build output directory: `build`
- Production branch: `master`
3. After first deploy, add the custom domain:
Project → Custom domains → `loto.miti99.com`. Cloudflare gives DNS
@@ -36,7 +38,7 @@ If you ever want to push a build to GitHub Pages by hand:
```bash
npm run build:gh
# upload out/ to the GH Pages target
# upload build/ to the GH Pages target
```
The repo no longer auto-deploys to GH Pages — there's no GitHub Actions
@@ -65,6 +67,7 @@ code-server --no-auth
**2. Create `.env.local`** in project root:
```
VITE_DEV_PROFILE=codeserver
CODESERVER_HOST=your-machine.example.com
CODESERVER_PORT=3000
```
@@ -76,7 +79,7 @@ Replace `your-machine.example.com` with your actual hostname/IP (must match the
npm run dev:codeserver
```
This sets `NEXT_DEV_PROFILE=codeserver`, triggering the code-server config path in `next.config.mjs`.
This reads `vite.config.js` codeserver config (basePath `/absproxy/{PORT}`, HMR proxy).
**4. Access via browser**:
Navigate to:
@@ -87,7 +90,7 @@ https://your-codeserver-host/absproxy/3000/
**Key Points**:
- `/absproxy/{port}` (NOT `/proxy/{port}`) preserves basePath through the proxy.
- HMR socket connects to `CODESERVER_HOST` for live reload.
- If HMR fails, manually refresh the page (CSS/JS changes still apply server-side).
- If HMR fails, manually refresh the page (Vite HMR still compiles server-side).
### Manual Refresh Workaround
If HMR over proxy is unreliable:
@@ -105,27 +108,28 @@ npm run build
```
Generates:
- `out/` — Complete static HTML export
- `.next/` — Build cache (not needed for deployment)
- `build/` — Complete static HTML + JS export
- `.svelte-kit/` — Build cache (not needed for deployment)
### Export Settings
- `output: "export"` in `next.config.mjs`
- No server-side rendering
- `adapter-static` in `svelte.config.js`
- No server-side rendering (SSR disabled via `ssr: false`)
- All pages pre-rendered to HTML + JS bundles
### Asset Hosting
- `assetPrefix` matches `basePath` (prod: `/loto`, dev/codeserver: empty or `/absproxy/{port}`)
- `base` path matches deployment target (prod: `""`, GH: `/loto`, codeserver: `/absproxy/{port}`)
- CSS, JS, fonts all prefixed correctly
- GitHub Pages serves from repository root, so `/loto` paths resolve correctly
## Environment Variables
### Required (code-server only)
### Development (code-server only)
- `VITE_DEV_PROFILE` — set to "codeserver" to enable proxy mode
- `CODESERVER_HOST` — hostname for HMR proxy
- `CODESERVER_PORT` — port (default 3000)
### Optional
- `NEXT_DEV_PROFILE` — set to "codeserver" to enable proxy mode (usually set by `npm run dev:codeserver`)
### Build-Time
- `BUILD_PROFILE` — set to "gh" for GitHub Pages build (basePath `/loto`). Default empty (Cloudflare).
### Not Used at Runtime
- No database URL, API keys, or secrets (all client-side, localStorage)
@@ -135,7 +139,7 @@ Generates:
| Issue | Cause | Fix |
|-------|-------|-----|
| 404 on subpages after deploy | basePath mismatch | Verify `basePath="/loto"` in prod; local dev should be empty |
| 404 on subpages after deploy | basePath mismatch | Verify `BUILD_PROFILE=gh` for GitHub Pages; default for Cloudflare |
| HMR not connecting (code-server) | CODESERVER_HOST not set | Add `CODESERVER_HOST=...` to `.env.local` |
| Assets 404 (code-server) | Wrong proxy URL | Use `/absproxy/{port}`, not `/proxy/{port}` |
| Page blank after refresh | State not persisted | Check browser localStorage is enabled |
@@ -143,23 +147,20 @@ Generates:
## CI/CD Pipeline
`.github/workflows/deploy.yml`:
- Triggers on `push` to `master`
- Installs dependencies (`npm install`)
- Builds app (`npm run build`)
- Deploys `out/` folder to GitHub Pages
No manual steps required; push to master and GitHub Pages updates automatically.
Cloudflare Pages handles CI/CD automatically:
- GitHub Pages deployment removed (Cloudflare is primary).
- Manual fallback: `npm run build:gh && git push` (if needed).
- No GitHub Actions workflow for automatic GH Pages deploy.
## Performance Checklist
- [x] Static export (no server overhead)
- [x] Tailwind purged for production size
- [x] Static export via adapter-static (no server overhead)
- [x] Tailwind 4 purged for production size
- [x] localStorage reduces bundle—no API calls
- [x] Images minimal (mostly CSS gradients)
- [x] Images minimal (mostly CSS gradients + emojis)
- [x] Fonts: Geist via Google Fonts CDN
Bundle analysis: Run `npm run build && ls -lh out/` to inspect file sizes.
Bundle analysis: Run `npm run build && ls -lh build/` to inspect file sizes.
## Security Considerations
+2 -2
View File
@@ -30,7 +30,7 @@
- Text: slate-600 (light) / slate-400 (dark)
### Dark Mode
All colors have corresponding `dark:` variants. Use Tailwind's `prefers-color-scheme: dark` media query (set in `globals.css`).
All colors have corresponding `dark:` variants. Tailwind's `prefers-color-scheme: dark` media query is enabled (set in `app.css`).
## Typography
@@ -114,7 +114,7 @@ All colors have corresponding `dark:` variants. Use Tailwind's `prefers-color-sc
- **spin-slow-reverse** (3s): 🎊 emoji (counter-rotation)
### Ephemeral
- **toast** (5s): "Chờ X" notification (scale 0.8→1.05→1, fade out)
- **toast** (5s): "Chờ X" notification (scale 0.8→1.05→1, fade build)
### Interactive
- **active:scale-95**: Button press response
+10 -9
View File
@@ -39,7 +39,7 @@ Host and players connect via WebSocket to sync called numbers and state. Require
- Remote tournaments
- Master board auto-updates all players in real-time
**Consideration**: Out of scope for static export. Requires major refactor (Next.js API routes + WebSocket server). Deferred indefinitely.
**Consideration**: Out of scope for static export. Requires major refactor (SvelteKit API routes + WebSocket server). Deferred indefinitely.
### i18n Beyond Vietnamese
Internationalization (English, Chinese, etc.). Requires extraction of all Vietnamese strings and i18n library (next-intl). **Status**: Considered (low demand for non-Vietnamese users)
@@ -53,17 +53,17 @@ Full undo/redo stack with history navigation. Adds complexity to state managemen
- Game logic: `generateGrid()`, `isRowComplete()`, `getWaitingNumber()`
- localStorage helpers: `saveGrid()`, `loadGrid()`, etc.
**Tech**: Jest + @testing-library/react
**Tech**: Vitest (Vite's native test runner)
### Component Tests
- PlayerBoard with mocked localStorage
- PlayerBoard with mocked localStorage, $state/$derived verification
- Master page with different game states
### E2E Tests
- Player flow: generate card → click cells → verify bingo popup
- Host flow: new game → draw numbers → verify board state
**Tech**: Playwright or Cypress
**Tech**: Playwright
## Future Enhancements (Speculative)
@@ -88,11 +88,12 @@ Support 8×8 or 10×10 grids. Major refactor (NUM_ROWS, NUM_COLS constants, weig
## Decision Rationale
All decisions follow **YAGNI** (You Aren't Gonna Need It):
- No multiplayer sync → adds server dependency, breaks static export model
- No i18n → Vietnamese-only community, localizing adds complexity without demand
- No testing → small codebase, manual testing covers critical paths; add tests when code grows or bugs surface
- No undo/redo → simple game, mistakes are part of play experience
All decisions follow **YAGNI** (You Aren't Gonna Need It), **KISS** (Keep It Simple), **DRY** (Don't Repeat Yourself):
- No multiplayer sync → adds server dependency, breaks static export (Cloudflare Pages)
- No i18n → Vietnamese-only community, localizing adds complexity
- No testing → small codebase (<300 LOC), manual testing covers critical paths; add when code grows
- No undo/redo → simple game, mistakes are play experience
- Svelte 5 runes → cleaner reactivity than hooks, smaller mental model
Future work gates on **real user demand**, not speculation.
+5 -4
View File
@@ -4,7 +4,7 @@
Lô tô is a traditional Vietnamese bingo game. The app replicates the game digitally for players to generate their own 9×9 number cards and mark cells as a host calls numbers from 1–90. First player to complete an entire row wins and shouts "Kinh!" (the game's victory cheer).
The inspiration comes from TN1 class reunions (2014–2017) where players often ran out of physical bingo cards.
The inspiration comes from TN1 class reunions (2014–2017) where players often ran build of physical bingo cards.
## Core Mechanics
@@ -14,10 +14,11 @@ The inspiration comes from TN1 class reunions (2014–2017) where players often
## Tech Stack
- **Framework**: Next.js 16.2.2 (App Router, SSG via `output: "export"`)
- **Runtime**: React 19.2.4 (Hooks: useState, useEffect, useCallback, useRef)
- **Framework**: SvelteKit 2 with Svelte 5 (runes mode)
- **Runtime**: Svelte 5 runes ($state, $derived, $effect, $props)
- **Styling**: Tailwind CSS 4 (utility-first, animations)
- **Persistence**: localStorage (no backend)
- **Deploy**: Cloudflare Pages (root domain), GitHub Pages fallback (`/loto`)
- **Dev Profile**: code-server compatible via `/absproxy/{port}` basePath + HMR proxy config
## Architecture Overview
@@ -32,7 +33,7 @@ State is entirely client-side. Each page/card instance uses a unique localStorag
- **Production**: GitHub Pages auto-deploys from `master` branch via `.github/workflows/deploy.yml`. App is served at `/loto` basePath.
- **Development**: `npm run dev` (local), `npm run dev:codeserver` (code-server via proxy).
- **Build**: `npm run build` generates static export to `out/` directory.
- **Build**: `npm run build` generates static export to `build/` directory.
## Key Acceptance Criteria
+18 -20
View File
@@ -80,38 +80,36 @@ Access: https://<proxy>/absproxy/3000/
## Client-Only Architecture
All pages use `"use client"` because:
- `output: "export"` disables server components
All pages are client-only (no SSR) because:
- `ssr: false` in `+layout.js` disables server-side rendering
- localStorage is unavailable on server
- Hydration requires client-only state initialization
- State initialization (grid, crossed) must run in browser
Files with `"use client"`:
- `app/page.jsx`
- `app/master/page.jsx`
- `components/player-board.jsx`
Files that are client-only:
- `src/routes/+page.svelte` (player page)
- `src/routes/master/+page.svelte` (host page)
- `src/lib/PlayerBoard.svelte` (shared component)
## Data Flow: Mark a Cell
```
1. User clicks <div> in PlayerBoard
1. User clicks button in PlayerBoard
2. handleCellClick(row, col) fires
3. setCrossed(prev => [...]) toggles crossed[row][col]
4. useEffect listens to crossed → saveCrossedState()
3. crossed = crossed.map(...) toggled state
4. $effect listens to crossed → saveCrossedState()
5. localStorage updated with new crossed state
6. Render cycle checks isRowComplete() + getWaitingNumber()
6. $derived updates rowCompleteness matrix
7. If row complete → bingo popup; if waiting → toast
```
## Data Flow: Draw a Number
## Data Flow: Initial Load
```
1. User clicks "Xổ số" button on master page
2. handleDrawNext() runs
3. Sets state: { called: [...old, next], remaining: [...old].slice(1) }
4. useEffect listens to state → saveState() → localStorage["loto_master"]
5. Master grid re-renders: cells with numbers in called set turn orange
6. lastCalled updates the big display
7. Host can see their card update in real-time (separate component)
1. Component mounts
2. $effect runs initial loadGrid(storagePrefix)
3. If found, load crossed state (or initialize as all false)
4. Pre-populate celebratedRows + notifiedWaitingRows sets
5. Pass grid + crossed to UI render
```
## Animations
@@ -123,7 +121,7 @@ Files with `"use client"`:
| bounce-slow | 1.5s infinite | Emoji on bingo popup |
| spin-slow | 3s infinite | ✨ on bingo popup |
| spin-slow-reverse | 3s infinite reverse | 🎊 on bingo popup |
| toast | 5s forwards | "Chờ X" notification fade in/out |
| toast | 5s forwards | "Chờ X" notification fade in/build |
| cell-crossed::after | instant | Red diagonal line in marked cells |
## Offline Capability
+23 -8
View File
@@ -1,9 +1,24 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import js from "@eslint/js";
import svelte from "eslint-plugin-svelte";
import globals from "globals";
const eslintConfig = defineConfig([
...nextVitals,
globalIgnores([".next/**", "out/**", "build/**"]),
]);
export default eslintConfig;
export default [
js.configs.recommended,
...svelte.configs["flat/recommended"],
{
languageOptions: {
ecmaVersion: "latest",
sourceType: "module",
globals: { ...globals.browser, ...globals.node },
},
},
{
ignores: [
"build/**",
".svelte-kit/**",
"node_modules/**",
"plans/**",
"docs/**",
],
},
];
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"module": "esnext",
"moduleResolution": "bundler",
"target": "es2022",
"allowJs": true,
"checkJs": false,
"paths": {
"$lib": ["./src/lib"],
"$lib/*": ["./src/lib/*"]
}
},
"include": ["src/**/*", "vite.config.js", "svelte.config.js"]
}
-43
View File
@@ -1,43 +0,0 @@
const isCodeserver = process.env.NEXT_DEV_PROFILE === "codeserver";
// Build profiles select basePath for the deploy target:
// (default) — Cloudflare Pages, served at loto.miti99.com (root)
// gh — GitHub Pages, served at tiennm99.github.io/loto
const buildProfile = process.env.BUILD_PROFILE;
// In dev under code-server's reverse proxy, basePath/assetPrefix must match
// the proxy URL so links, assets, and the HMR socket all resolve.
// Use /absproxy/{port} (NOT /proxy/{port}) — /proxy strips the prefix before
// forwarding; /absproxy preserves it so basePath matches the incoming request.
// CODESERVER_HOST and CODESERVER_PORT are loaded from .env.local by Next.
function codeserverConfig() {
const host = process.env.CODESERVER_HOST;
if (!host) {
throw new Error(
"CODESERVER_HOST is required when NEXT_DEV_PROFILE=codeserver (set it in .env.local)"
);
}
const port = process.env.CODESERVER_PORT ?? "3000";
return { basePath: `/absproxy/${port}`, allowedDevOrigins: [host] };
}
const cs = isCodeserver ? codeserverConfig() : null;
// basePath resolution order (first non-null wins):
// 1. NEXT_BASE_PATH — explicit override (escape hatch for forks / custom domains)
// 2. codeserver dev profile
// 3. BUILD_PROFILE=gh — explicit GH Pages target
// 4. default — empty (CF Pages / local dev / any root-served host)
const basePath =
process.env.NEXT_BASE_PATH ??
cs?.basePath ??
(buildProfile === "gh" ? "/loto" : "");
const nextConfig = {
output: "export",
basePath,
assetPrefix: basePath || undefined,
...(cs?.allowedDevOrigins ? { allowedDevOrigins: cs.allowedDevOrigins } : {}),
};
export default nextConfig;
+1634 -4725
View File
File diff suppressed because it is too large. Load diff
+17 -14
View File
@@ -2,23 +2,26 @@
"name": "loto",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev",
"dev:codeserver": "NEXT_DEV_PROFILE=codeserver next dev -H 0.0.0.0",
"build": "next build",
"build:gh": "BUILD_PROFILE=gh next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"next": "16.2.2",
"react": "19.2.4",
"react-dom": "19.2.4"
"dev": "vite dev",
"dev:codeserver": "VITE_DEV_PROFILE=codeserver vite dev --host 0.0.0.0",
"build": "vite build",
"build:gh": "BUILD_PROFILE=gh vite build",
"preview": "vite preview",
"lint": "eslint ."
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@eslint/js": "^9",
"@sveltejs/adapter-static": "^3",
"@sveltejs/kit": "^2",
"@sveltejs/vite-plugin-svelte": "^6",
"@tailwindcss/vite": "^4",
"eslint": "^9",
"eslint-config-next": "16.2.2",
"tailwindcss": "^4"
"eslint-plugin-svelte": "^2",
"globals": "^15",
"svelte": "^5",
"tailwindcss": "^4",
"vite": "^7"
}
}
@@ -1,7 +1,8 @@
---
slug: ts-to-jsdoc-refactor
created: 2026-04-26
status: planned
status: completed
completedAt: 2026-04-26
mode: fast
blockedBy: []
blocks: []
@@ -0,0 +1,219 @@
---
phase: 1
title: Scaffold SvelteKit project + tooling
priority: high
effort: M
status: planned
---
# Phase 1 — Scaffold SvelteKit + tooling
In-place rewrite. Replace the Next-flavored top-level config and source dirs
with SvelteKit equivalents.
## Steps
1. **Delete Next artefacts** (preserve `docs/`, `plans/`, `.gitignore`,
`.env.example`, `README.md`):
- `package.json` will be rewritten — back up scripts mentally:
`dev`, `dev:codeserver`, `build`, `build:gh`, `start`, `lint`
- Remove: `next.config.mjs`, `app/`, `components/`, `lib/`,
`eslint.config.mjs` (will be rewritten), `postcss.config.mjs`
- Keep `.env.local` (gitignored)
2. **`package.json` — new dependencies:**
```json
{
"name": "loto",
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev",
"dev:codeserver": "VITE_DEV_PROFILE=codeserver vite dev --host 0.0.0.0",
"build": "vite build",
"build:gh": "BUILD_PROFILE=gh vite build",
"preview": "vite preview",
"lint": "eslint ."
},
"devDependencies": {
"@sveltejs/adapter-static": "^3",
"@sveltejs/kit": "^2",
"@sveltejs/vite-plugin-svelte": "^4",
"@tailwindcss/vite": "^4",
"eslint": "^9",
"eslint-plugin-svelte": "^2",
"svelte": "^5",
"tailwindcss": "^4",
"vite": "^5"
}
}
```
Note: SvelteKit's `dev` defaults to localhost; codeserver profile binds
`0.0.0.0`. Read latest versions during install (`npm install`).
3. **`svelte.config.js`** at root:
```js
import adapter from '@sveltejs/adapter-static';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
export default {
preprocess: vitePreprocess(),
kit: {
adapter: adapter({
pages: 'build',
assets: 'build',
fallback: undefined,
precompress: false,
strict: true,
}),
paths: {
// Filled by Phase 5; default empty (CF Pages root)
base: process.env.BUILD_PROFILE === 'gh' ? '/loto' : '',
},
},
};
```
4. **`vite.config.js`** at root:
```js
import { sveltekit } from '@sveltejs/kit/vite';
import tailwindcss from '@tailwindcss/vite';
const isCodeserver = process.env.VITE_DEV_PROFILE === 'codeserver';
const host = process.env.CODESERVER_HOST;
const port = Number(process.env.CODESERVER_PORT ?? 3000);
export default {
plugins: [tailwindcss(), sveltekit()],
server: isCodeserver
? {
port,
host: true,
allowedHosts: host ? [host] : true,
hmr: host
? {
host,
protocol: 'wss',
clientPort: 443,
path: `/absproxy/${port}/`,
}
: true,
}
: { port: 3000 },
};
```
Codeserver profile mirrors `sokoban/vite/config.codeserver.mjs` —
absproxy preserves the prefix, HMR client connects through `wss://...:443`.
5. **`jsconfig.json`** — JSDoc-aware path aliases (SvelteKit auto-generates
one referencing `./.svelte-kit/tsconfig.json`, but we don't want TS
inheritance; write a minimal version):
```json
{
"compilerOptions": {
"checkJs": false,
"module": "esnext",
"moduleResolution": "bundler",
"target": "es2022",
"allowJs": true,
"paths": { "$lib": ["./src/lib"], "$lib/*": ["./src/lib/*"] }
},
"include": ["src/**/*", "vite.config.js", "svelte.config.js"]
}
```
`checkJs: false` because user wants no TS-flavored validation. SvelteKit
may warn about an auto-generated `.svelte-kit/tsconfig.json` — ignore;
it lives inside the gitignored build cache.
6. **`eslint.config.mjs`** — minimal flat config:
```js
import js from '@eslint/js';
import svelte from 'eslint-plugin-svelte';
import globals from 'globals';
export default [
js.configs.recommended,
...svelte.configs['flat/recommended'],
{
languageOptions: {
globals: { ...globals.browser, ...globals.node },
},
},
{ ignores: ['build/', '.svelte-kit/', 'node_modules/'] },
];
```
Add `globals` and `@eslint/js` to devDeps as discovered during install.
7. **Tailwind 4 setup** — add `src/app.css` with:
```css
@import 'tailwindcss';
```
Replace the Next-side `app/globals.css`. Custom keyframes (`fade-in`,
`pop-in`, `bounce-slow`, `spin-slow`, `toast`) and `.cell-crossed`
diagonal class will move here in Phase 4 — verbatim copy.
8. **`.gitignore` additions:**
```
.svelte-kit
build
```
Remove now-stale entries: `.next/`, `out/`, `next-env.d.ts`,
`tsconfig.tsbuildinfo`, `repomix-output.xml` (keep — still relevant).
9. **`src/app.html`** — minimal SvelteKit HTML shell:
```html
<!doctype html>
<html lang="vi">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Lô tô</title>
<meta name="description" content="Bàn số của trò chơi Lô tô" />
%sveltekit.head%
</head>
<body class="min-h-full flex flex-col">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
```
Vietnamese `lang="vi"` matches the Next layout. Geist font import will
move to `app.css` (`@import` from `next/font/google` doesn't exist
outside Next; either bundle Geist via `@fontsource/geist-sans` or use
the system stack and accept a font swap).
10. **`npm install`** to materialize the new dep tree. Verify
`@sveltejs/kit`, `svelte`, `@tailwindcss/vite` resolve.
## Files affected
- delete: `next.config.mjs`, `app/`, `components/`, `lib/`, `postcss.config.mjs`
- create: `svelte.config.js`, `vite.config.js`, `src/app.html`, `src/app.css`,
`jsconfig.json`, `eslint.config.mjs` (rewritten)
- modify: `package.json`, `.gitignore`
## Verify
- `ls package.json svelte.config.js vite.config.js src/app.html src/app.css`
all present
- `npm install` completes with 0 errors
- No leftover `app/` or `components/` or `lib/` from Next at repo root
- `grep -r "next\|react" package.json` returns nothing
## Out of scope
Source code (Phases 2-4), deploy wiring (Phase 5), docs (Phase 6).
## Status: planned
@@ -0,0 +1,78 @@
---
phase: 2
title: Port game logic to src/lib
priority: high
effort: S
status: planned
---
# Phase 2 — Port game logic
Move the pure functions verbatim. They have no React dependency, so this is
a near-copy with one path move.
## Source
Existing: `lib/game-logic.js` (~205 lines, JSDoc). Already React-free.
## Target
`src/lib/game-logic.js` — same content, no edits beyond removing dead
markers if any.
## Steps
1. Create `src/lib/`.
2. Copy file:
```bash
cp lib/game-logic.js src/lib/game-logic.js # or git mv after Phase 1 deletes
```
If Phase 1 already deleted `lib/`, restore from `git show HEAD:lib/game-logic.js > src/lib/game-logic.js`.
3. **Verify exports unchanged.** Public API consumed by Phase 3:
- `generateGrid()`
- `saveGrid(grid, prefix?)`
- `loadGrid(prefix?)`
- `saveCrossedState(crossed, prefix?)`
- `loadCrossedState(prefix?)`
- `isRowComplete(grid, crossed, row)`
- `getWaitingNumber(grid, crossed, row)`
4. **Imports** — file uses no imports. Pure JS. Done.
5. **JSDoc** — already present, vanilla JSDoc style. No changes.
6. **localStorage guard** — file already wraps `localStorage.setItem` /
`getItem` in try/catch for quota and disabled-storage cases. SvelteKit
SSR check needed? `adapter-static` prerenders at build time and the
game logic is only called from browser-mounted components, so SSR
doesn't reach it. But add a `typeof window !== 'undefined'` guard inside
each storage function to be safe — it's a one-line cheap defense:
```js
export function saveGrid(grid, prefix = 'loto') {
if (typeof localStorage === 'undefined') return;
try { localStorage.setItem(`${prefix}_grid`, JSON.stringify(grid)); }
catch { /* see notes */ }
}
```
Apply to all four storage functions.
## Files affected
- create: `src/lib/game-logic.js`
- delete (in Phase 1, if not yet): `lib/game-logic.js`
## Verify
- `node --input-type=module -e "import('./src/lib/game-logic.js').then(m => { const g = m.generateGrid(); console.log('rows:', g.length, 'cols:', g[0].length); })"`
- Output: `rows: 9 cols: 9`
## Out of scope
UI components, persistence wiring (Phase 3).
## Status: planned
@@ -0,0 +1,266 @@
---
phase: 3
title: Port PlayerBoard component to Svelte 5 runes
priority: high
effort: M
status: planned
---
# Phase 3 — PlayerBoard component
Port `components/player-board.jsx` (224 lines, React + JSDoc) to
`src/lib/PlayerBoard.svelte` (Svelte 5 runes). This is the most state-heavy
component and where runes pay off vs. React.
## React → runes mapping
| React | Svelte 5 |
|---|---|
| `useState(null)` | `let grid = $state(null)` |
| `useState([])` | `let crossed = $state([])` |
| `useRef(new Set())` | `let celebratedRows = new Set()` (mutable, no rune needed — refs aren't reactive) |
| `useMemo(() => grid.map(isRowComplete), [grid, crossed])` | `let rowCompleteness = $derived(grid ? grid.map((_, r) => isRowComplete(grid, crossed, r)) : [])` |
| `useEffect(() => { ... save }, [crossed])` | `$effect(() => { if (crossed.length) saveCrossedState(crossed, storagePrefix); })` |
| `useCallback(handler, [deps])` | Plain function — Svelte tracks deps via `$state` reads automatically |
## Component skeleton
```svelte
<script>
import {
generateGrid,
getWaitingNumber,
isRowComplete,
loadCrossedState,
loadGrid,
saveCrossedState,
saveGrid,
} from '$lib/game-logic.js';
/**
* @typedef {Object} Props
* @property {string} [storagePrefix]
*/
/** @type {Props} */
let { storagePrefix = 'loto' } = $props();
let grid = $state(/** @type {number[][] | null} */ (null));
let crossed = $state(/** @type {boolean[][]} */ ([]));
let showCongrats = $state(false);
let congratsRow = $state(-1);
let toast = $state(/** @type {string | null} */ (null));
// Refs: mutable, non-reactive
let toastTimer = null;
const celebratedRows = new Set();
const notifiedWaitingRows = new Set();
// Derived: precompute row completeness once per render
const rowCompleteness = $derived(
grid && crossed.length ? grid.map((_, r) => isRowComplete(grid, crossed, r)) : []
);
function dismissToast() {
toast = null;
if (toastTimer) {
clearTimeout(toastTimer);
toastTimer = null;
}
}
function showToast(msg) {
dismissToast();
toast = msg;
toastTimer = setTimeout(() => { toast = null; }, 5000);
}
// Initial load — runs once on mount per storagePrefix change
$effect(() => {
const savedGrid = loadGrid(storagePrefix);
if (!savedGrid) return;
grid = savedGrid;
crossed = loadCrossedState(storagePrefix) ?? savedGrid.map((row) => row.map(() => false));
celebratedRows.clear();
notifiedWaitingRows.clear();
for (let i = 0; i < savedGrid.length; i++) {
if (isRowComplete(savedGrid, crossed, i)) celebratedRows.add(i);
if (getWaitingNumber(savedGrid, crossed, i) !== null) notifiedWaitingRows.add(i);
}
});
// Persist crossed state
$effect(() => {
if (crossed.length > 0) saveCrossedState(crossed, storagePrefix);
});
// Detect newly completed and waiting rows. Two passes prevent skipped resets.
$effect(() => {
if (!grid || crossed.length === 0) return;
for (let i = 0; i < grid.length; i++) {
if (!celebratedRows.has(i) && isRowComplete(grid, crossed, i)) {
celebratedRows.add(i);
notifiedWaitingRows.add(i);
congratsRow = i + 1;
showCongrats = true;
break;
}
}
for (let i = 0; i < grid.length; i++) {
if (celebratedRows.has(i)) continue;
const waitNum = getWaitingNumber(grid, crossed, i);
if (waitNum !== null && !notifiedWaitingRows.has(i)) {
notifiedWaitingRows.add(i);
showToast(`Chờ ${waitNum}`);
} else if (waitNum === null && notifiedWaitingRows.has(i)) {
notifiedWaitingRows.delete(i);
}
}
});
function handleGenerate() {
if (grid && !confirm('Bạn có muốn tạo lại bảng không?')) return;
const newGrid = generateGrid();
grid = newGrid;
crossed = newGrid.map((row) => row.map(() => false));
saveGrid(newGrid, storagePrefix);
saveCrossedState(crossed, storagePrefix);
celebratedRows.clear();
notifiedWaitingRows.clear();
dismissToast();
}
function handleCellClick(row, col) {
crossed = crossed.map((r, ri) =>
ri === row ? r.map((v, ci) => (ci === col ? !v : v)) : r
);
}
</script>
<!-- Generate button -->
<div class="flex justify-center mb-6">
<button
onclick={handleGenerate}
class="px-8 py-3 rounded-full font-semibold text-white
bg-gradient-to-r from-indigo-500 to-purple-500
hover:from-indigo-600 hover:to-purple-600
active:scale-95 transition-all shadow-lg shadow-indigo-500/25"
>
Tạo bảng mới
</button>
</div>
{#if grid}
<div class="relative">
<div
aria-label="Bảng lô tô"
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="loto-grid">
{#each grid.flat() as num, idx}
{@const row = Math.floor(idx / 9)}
{@const col = idx % 9}
{@const hasNumber = num > 0}
{@const isCrossed = hasNumber && !!crossed[row]?.[col]}
{@const rowComplete = hasNumber && rowCompleteness[row]}
{#if !hasNumber}
<div aria-hidden="true" class="cell-empty" />
{:else}
<button
type="button"
aria-label="Số {num}{isCrossed ? ', đã đánh dấu' : ''}"
aria-pressed={isCrossed}
onclick={() => handleCellClick(row, col)}
class="cell-num"
class:cell-crossed={isCrossed}
class:cell-completed={rowComplete}
>
{num}
</button>
{/if}
{/each}
</div>
</div>
{#if toast}
<div
role="status"
aria-live="polite"
onclick={dismissToast}
class="absolute inset-0 flex items-center justify-center pointer-events-auto cursor-pointer z-10"
>
<div class="px-6 py-3 rounded-2xl bg-amber-500/90 dark:bg-amber-600/90 text-white text-xl sm:text-2xl font-black shadow-xl animate-toast">
{toast}
</div>
</div>
{/if}
</div>
{:else}
<div class="text-center text-slate-400 dark:text-slate-500 py-20 text-sm">
Nhấn "Tạo bảng mới" để bắt đầu chơi
</div>
{/if}
{#if showCongrats}
<!-- bingo modal — same markup as React version with svelte:on:keydown for Escape -->
...
{/if}
<style>
.cell-empty { @apply relative flex items-center justify-center aspect-square border-r border-b border-slate-200/80 dark:border-slate-700/60 bg-slate-50 dark:bg-slate-900/60; }
.cell-num { @apply relative flex items-center justify-center aspect-square text-base sm:text-xl font-bold border-r border-b border-slate-200/80 dark:border-slate-700/60 transition-all select-none cursor-pointer focus:outline-none focus:ring-2 focus:ring-inset focus:ring-indigo-400 bg-white dark:bg-slate-800 text-slate-800 dark:text-slate-100 hover:bg-indigo-50 dark:hover:bg-indigo-950/30 hover:text-indigo-600 dark:hover:text-indigo-400; }
.cell-crossed { @apply bg-red-50 dark:bg-red-950/30 text-red-400 dark:text-red-500; }
.cell-completed { @apply bg-emerald-100 dark:bg-emerald-900/40 text-emerald-500 dark:text-emerald-400; }
</style>
```
The full bingo-modal block follows the React structure 1:1 — `role="dialog"`,
`aria-labelledby="congrats-title"`, click-outside-to-dismiss, Escape key
handler. Use Svelte's `onkeydown` and a `tabindex={-1}` wrapper.
## Behavior parity checklist (map to original fixes)
- [ ] Bingo popup fires only once per row — guarded by `celebratedRows` Set
- [ ] Two-pass effect prevents skipped reset for higher-index rows — same
structure as React fix
- [ ] `isRowComplete` requires ≥1 numbered cell — already in `game-logic.js`
- [ ] Memoized `rowCompleteness` via `$derived` (was `useMemo`)
- [ ] localStorage shape validation via `safeParse` — in `game-logic.js`
- [ ] Real `<button>` cells with `aria-label`, `aria-pressed`, focus ring
- [ ] Modal: `role="dialog"`, `aria-labelledby`, Escape closes
- [ ] Toast: `role="status"`, `aria-live="polite"`
## Cell click immutability
React used `prev.map((r) => [...r])` for crossed toggle. Svelte 5 with `$state`
deeply tracks objects, so simpler:
```js
crossed[row][col] = !crossed[row][col];
```
…would also work because runes proxy nested writes. But explicit immutable
update (as in skeleton above) keeps reactivity behavior predictable across
older Svelte 5 versions. Pick whichever, document the choice.
## Files affected
- create: `src/lib/PlayerBoard.svelte`
## Verify
- `npm run dev` — visit `/`, generate card, click cells, see crossed state
- Click 4 of 5 in a row — see "Chờ X" toast
- Click the 5th — see "Kinh!" popup
- Reload — state persists for the default `loto` prefix
- (After Phase 4) `/master` shows host card with `storagePrefix="loto_master_card"`,
storage stays separate from `/`
## Out of scope
Routes, layout (Phase 4). Codeserver verification (Phase 5).
## Status: planned
@@ -0,0 +1,175 @@
---
phase: 4
title: Port routes + layout
priority: high
effort: M
status: planned
---
# Phase 4 — Routes + layout
Set up the SvelteKit route tree and port the two pages.
## Target tree
```
src/
├── app.html
├── app.css
├── routes/
│ ├── +layout.svelte ← global wrapper (replaces app/layout.tsx)
│ ├── +layout.js ← export const prerender = true
│ ├── +page.svelte ← player page (replaces app/page.tsx)
│ └── master/
│ └── +page.svelte ← host page (replaces app/master/page.tsx)
└── lib/
├── game-logic.js ← from Phase 2
└── PlayerBoard.svelte ← from Phase 3
```
## `+layout.js` — enable static prerendering for every route
```js
export const prerender = true;
export const ssr = false; // pure SPA, no HTML pre-render of dynamic state
export const trailingSlash = 'never';
```
`ssr = false` is the right move because the game state is entirely
client-side. Without it, SvelteKit tries to prerender HTML and the
localStorage code paths get awkward guards.
## `+layout.svelte` — global shell
```svelte
<script>
import '../app.css';
let { children } = $props();
</script>
{@render children()}
```
That's the whole layout. The Next-side `app/layout.tsx` set `<html lang="vi">`
with the Geist font — that lives in `src/app.html` now (Phase 1).
## `+page.svelte` — player route
Replicate `app/page.jsx` 1:1:
```svelte
<script>
import PlayerBoard from '$lib/PlayerBoard.svelte';
let showInstructions = $state(false);
</script>
<div class="flex flex-col flex-1 items-center px-3 py-8 sm:py-12">
<div class="w-full max-w-lg">
<header class="text-center mb-8">
<h1 class="text-4xl sm:text-5xl font-extrabold tracking-tight bg-gradient-to-r from-indigo-500 to-purple-500 bg-clip-text text-transparent">
Lô tô
</h1>
<p class="mt-2 text-sm text-slate-500 dark:text-slate-400">
Lấy cảm hứng từ những buổi họp lớp thiếu giấy chơi lô tô
<br class="hidden sm:block" /> của TN1 (2014–2017)
</p>
<div class="mt-3 flex items-center justify-center gap-3 text-xs">
<button
onclick={() => (showInstructions = !showInstructions)}
class="text-indigo-500 dark:text-indigo-400 hover:underline"
>
{showInstructions ? 'Ẩn hướng dẫn' : 'Hướng dẫn'}
</button>
<span class="text-slate-300 dark:text-slate-600">|</span>
<a href="/master" class="text-orange-500 dark:text-orange-400 hover:underline">
Trang quản trò →
</a>
</div>
</header>
{#if showInstructions}
<div class="mb-6 rounded-xl bg-indigo-50 dark:bg-indigo-950/30 border border-indigo-100 dark:border-indigo-900 p-4 text-sm text-slate-600 dark:text-slate-400">
<ul class="space-y-1 list-disc list-inside">
<li>Nhấn <strong class="text-slate-800 dark:text-slate-200">Tạo bảng mới</strong> để tạo bảng</li>
<li>Nhấn vào ô số để đánh dấu khi số được xổ</li>
<li>Nhấn lại để bỏ đánh dấu</li>
<li>Bảng được lưu tự động</li>
</ul>
</div>
{/if}
<PlayerBoard />
<footer class="mt-10 text-center text-xs text-slate-400 dark:text-slate-600">
Made with ❤️ by
<a href="https://miti99.com" target="_blank" rel="noopener noreferrer" class="text-indigo-500 hover:underline">miti99</a>
</footer>
</div>
</div>
```
## `master/+page.svelte` — host route
Replicate `app/master/page.jsx`. Key differences from React:
- `useState` → `$state` runes
- `useEffect(load on mount)` → `$effect(...)` (runs once at mount because
no reactive deps tracked inside)
- `useEffect(save on change)` → `$effect(() => { saveState(state); })`
- `useCallback` → plain functions
- `BOARD = Object.freeze(...)` — module-scope, declared in `<script module>`
so it computes once across all instances:
```svelte
<script module>
const STORAGE_KEY = 'loto_master';
function buildBoard() { /* same body */ }
const BOARD = Object.freeze(buildBoard().map(Object.freeze));
const BOARD_FLAT = Object.freeze(BOARD.flatMap((r) => r));
</script>
<script>
import PlayerBoard from '$lib/PlayerBoard.svelte';
/* state, handlers, effects */
</script>
<!-- markup -->
```
The "host's own player card" piece becomes:
```svelte
<PlayerBoard storagePrefix="loto_master_card" />
```
— same prop pattern.
## Behavior to preserve
- "Ván mới" button confirms before reset if game in progress
- "Xổ số" button hidden when `remaining.length === 0`
- Last drawn number badge with size animation
- Called numbers history strip (chip-style)
- 9×10 master board lighting up on draw
- Master's own player card below, with separate localStorage prefix
## Files affected
- create: `src/routes/+layout.svelte`, `src/routes/+layout.js`,
`src/routes/+page.svelte`, `src/routes/master/+page.svelte`
## Verify
- `npm run dev` — both `/` and `/master` render without errors
- Tab switching between routes preserves localStorage independently
(player at `loto_*`, master state at `loto_master`, master's card at
`loto_master_card_*`)
- Browser back/forward navigates correctly (SvelteKit client router)
- View source on prerendered build (Phase 5 verifies) shows the static
HTML shell with `ssr: false` skipping content render
## Out of scope
Codeserver dev profile (Phase 5), CF deploy (Phase 5), docs (Phase 6).
## Status: planned
@@ -0,0 +1,118 @@
---
phase: 5
title: Codeserver dev profile + CF Pages deploy
priority: high
effort: S
status: planned
---
# Phase 5 — Codeserver dev + CF Pages deploy
Wire the two operational profiles already proven on Next:
1. `npm run dev:codeserver` working through `/absproxy/{port}/`
2. `npm run build` producing static export for CF Pages at `loto.miti99.com`
## Codeserver dev profile
Already drafted in Phase 1 `vite.config.js`. Verify by running:
```bash
echo "CODESERVER_HOST=codeserver.sg.miti99.com" > .env.local
echo "CODESERVER_PORT=3000" >> .env.local
npm run dev:codeserver
```
Open `https://codeserver.sg.miti99.com/absproxy/3000/`. Page should load,
HMR socket should upgrade (Vite HMR config maps client → wss:443/...).
**Differences vs Next implementation:**
- Vite handles HMR explicitly (Phase 1 config). Next 16's HMR was
finicky over the proxy; Vite's named-host config works because we tell
the client exactly which URL to connect on (matches sokoban's setup).
- SvelteKit's `paths.base` config takes the basePath, not Vite's. Bridge
with the same `BUILD_PROFILE` env var pattern, but route through
`svelte.config.js`:
```js
// svelte.config.js
const profile = process.env.BUILD_PROFILE;
const isCodeserver = process.env.VITE_DEV_PROFILE === 'codeserver';
let base = '';
if (isCodeserver) {
const port = process.env.CODESERVER_PORT ?? '3000';
base = `/absproxy/${port}`;
} else if (profile === 'gh') {
base = '/loto';
}
// else: empty (CF Pages root or local dev)
export default {
preprocess: vitePreprocess(),
kit: {
adapter: adapter({ pages: 'build', assets: 'build', strict: true }),
paths: { base },
},
};
```
The dev server reads `VITE_DEV_PROFILE` early (set by `npm run dev:codeserver`),
so `paths.base` populates correctly during dev.
**Internal links** in `+page.svelte` etc. should use `base`:
```svelte
<script>
import { base } from '$app/paths';
</script>
<a href="{base}/master">Trang quản trò →</a>
```
This keeps links working under any of the three modes (root, /loto,
/absproxy/3000).
## CF Pages deploy
Already covered by current setup. Just update the dashboard build command
when the project is rebuilt:
| Setting | Value |
|---|---|
| Framework preset | SvelteKit |
| Build command | `npm run build` |
| Build output directory | `build` (NOT `out` — SvelteKit + adapter-static default) |
| Production branch | `master` |
| Environment variables | none (root basePath is the default) |
Custom domain `loto.miti99.com` step unchanged.
## Manual GH Pages export (the optional path)
```bash
npm run build:gh # BUILD_PROFILE=gh sets paths.base = '/loto'
# Upload build/ to GitHub Pages
```
## Files affected
- modify: `svelte.config.js` (paths.base logic)
- modify: `src/routes/+page.svelte` and `src/routes/master/+page.svelte`
(use `$app/paths` `base` for internal links)
- create/keep: `.env.local` (gitignored; user fills in)
## Verify
1. **Local dev** — `npm run dev` → `http://localhost:3000/`, links work.
2. **Codeserver** — `npm run dev:codeserver` → page loads at the proxy URL,
HMR works (no `wss` errors in console).
3. **CF Pages build** — `npm run build` → `build/` populated, asset URLs
start with `/_app/...` (root-relative).
4. **GH Pages build** — `npm run build:gh` → asset URLs start with
`/loto/_app/...`.
## Out of scope
Docs sync (Phase 6).
## Status: planned
@@ -0,0 +1,137 @@
---
phase: 6
title: Update docs, verify end-to-end, commit
priority: high
effort: S
status: planned
---
# Phase 6 — Finalize
End-to-end verification, docs sync, commit + push.
## Steps
1. **End-to-end smoke test (manual)**
```bash
npm run dev
```
- Visit `/`, generate a card, click 4 cells in row 0 → see "Chờ X" toast
- Click the 5th → see "Kinh!" popup with "Hàng 1 đã đầy đủ!"
- Press Esc — popup dismisses
- Reload — card persists
- Visit `/master`, click "Ván mới", click "Xổ số" several times — numbers
light up on the 9×10 board, history strip grows
- Generate a card on `/master` (the host's own card) — confirm it's a
different card than `/`'s (separate localStorage prefixes)
2. **Build check**
```bash
npm run build
```
Expect `build/` populated, no errors. `build/index.html`,
`build/master.html` (or `build/master/index.html` depending on
`trailingSlash` config).
3. **Codeserver dev smoke**
```bash
npm run dev:codeserver
```
Open the proxy URL; verify page + HMR.
4. **Lint**
```bash
npm run lint
```
No new errors.
5. **Update docs.** Files to rewrite (re-generate from new code state, don't
edit the Next-era versions):
- `docs/codebase-summary.md` — file table now lists `src/routes/...`,
`src/lib/...`, `svelte.config.js`, `vite.config.js`
- `docs/system-architecture.md` — diagram of SvelteKit route flow,
Svelte 5 runes pattern, drop "use client" client-only architecture
section (no longer relevant — `ssr: false` in `+layout.js`)
- `docs/code-standards.md` — Svelte 5 runes (`$state`, `$derived`,
`$effect`), `<script>` vs `<script module>`, JSDoc style for `$props()`
- `docs/design-guidelines.md` — Tailwind classes unchanged, but the
"use Tailwind in className" examples become `class=`. Animation CSS
stays in `app.css`.
- `docs/deployment-guide.md` — output dir `build/` (not `out/`),
framework preset SvelteKit
- `docs/development-roadmap.md` — drop any roadmap items that
SvelteKit unlocks (e.g. "consider migration to lighter framework"
if listed)
- `docs/project-overview-pdr.md` — tech stack section: Next.js → SvelteKit
Set `Last reviewed: 2026-04-26` on each.
6. **Update `README.md`** — entry point sentences:
```md
# Lô tô
Bàn số của trò chơi "Lô tô" — SvelteKit app.
Two routes: `/` for players, `/master` for the host.
See `docs/` for architecture, code standards, and deployment.
```
7. **Update active plan + sync ts-to-jsdoc plan if not already.**
- `plans/260426-1934-ts-to-jsdoc-refactor/plan.md` — already marked
`completed` (done in Phase 0 of this refactor).
- `plans/260426-2033-sveltekit-refactor/plan.md` — change `status:
planned` → `status: completed` after this phase finishes.
8. **Commit on `dev`** in coherent slices:
```
refactor: scaffold sveltekit, drop next/react
Replace Next.js 16 + React 19 with SvelteKit + Svelte 5 runes. New
svelte.config.js / vite.config.js / src/app.html, all Next config files
removed. Adapter-static for CF Pages compatibility.
refactor: port game logic + player board to svelte
src/lib/game-logic.js — verbatim copy of the JSDoc-only utilities.
src/lib/PlayerBoard.svelte — runes-based reactivity replaces useState/
useRef/useMemo/useEffect ceremony. Bingo and waiting detection use a
two-pass $effect to mirror the React-side fix. <button> cells with
aria-label/aria-pressed/focus ring kept.
refactor: port routes; wire deploy profiles
src/routes/+layout.{svelte,js} sets prerender + ssr=false. + page.svelte
and master/+page.svelte mirror the Next pages 1:1, using $app/paths base
so internal links survive /loto and /absproxy/{port} rewrites.
svelte.config.js routes BUILD_PROFILE=gh and codeserver dev to the right
basePath; default empty for CF Pages.
docs: refresh for sveltekit
```
Or one squashed commit if you prefer — your call.
9. **Push** — `git push`. Already tracking `origin/dev`.
## Acceptance gates (must pass before commit)
- [ ] Manual smoke covered all paths
- [ ] `npm run build` succeeds
- [ ] `npm run lint` no new errors
- [ ] Both dev profiles boot
- [ ] Zero references to Next, React, or `app/`/`components/`/`lib/` in
source
- [ ] `docs/` updated to SvelteKit terminology
- [ ] No leftover `next.config.*`, `next-env.d.ts`, `tsconfig.tsbuildinfo`,
`out/` directory
## Rollback
`git revert` of the phase commits restores Next state. Old code preserved
on `master` branch + earlier `dev` commits.
## Status: planned
@@ -0,0 +1,113 @@
---
slug: sveltekit-refactor
created: 2026-04-26
status: planned
mode: fast
blockedBy: []
blocks: []
---
# Refactor Next.js → SvelteKit
Replace the Next.js 16 + React 19 stack with SvelteKit + Svelte 5 runes. User
chose SvelteKit over plain Svelte to leave room for future routes / load
functions / a possible backend. Static export only for now (no SSR target).
## Why (per user request)
- Bundle size — Svelte compiles, no runtime; ~75% smaller payload
- Reactivity ergonomics — runes (`$state`, `$derived`, `$effect`) replace the
React `useRef<Set>` / two-pass `useEffect` / `useMemo` ceremony in
`components/player-board.jsx`
- Consistency with `sokoban` / `rplace` (already Svelte 5)
- Room to extend — file-based routing, layouts, future server endpoints
## Constraints (must preserve)
- **JSDoc only** — no TypeScript (explicit user preference, just shipped)
- **Static export** to Cloudflare Pages at `loto.miti99.com` (root basePath)
- **Codeserver dev profile** equivalent to current `npm run dev:codeserver`
- **Gameplay 1:1** — bingo popup ("Kinh!"), waiting toast ("Chờ X"), master
9×10 tracking board, host's own player card via `storagePrefix`
- **Visual identity** — indigo→purple for player, orange→red for host,
emerald for completed rows, amber for waiting toast
- **Tailwind 4** — keep
- **Two routes** — `/` and `/master`
- **localStorage prefix pattern** — `loto_grid` / `loto_crossed` /
`loto_master` / `loto_master_card_*`
- **Plans + docs preserved** — update content; don't delete
## Stack target
| Concern | Choice |
|---|---|
| Framework | SvelteKit (latest stable) |
| Language | Svelte 5 (runes mode) + JS + JSDoc |
| Adapter | `@sveltejs/adapter-static` (CF Pages target) |
| Styling | Tailwind 4 (Vite plugin) |
| Build | Vite (under SvelteKit) |
| Routing | SvelteKit file-based (`src/routes/+page.svelte`) |
| State | Svelte 5 `$state` runes; localStorage in `$effect` |
| Lint | ESLint 9 + `eslint-plugin-svelte` (no TS rules) |
## Out of scope (deliberately deferred)
- Tests — none exist; not adding now
- New features (sound effects, undo, multiplayer, theme switcher) — see
`docs/development-roadmap.md`
- TypeScript — explicitly rejected
- Backend / API — extension surface, not this refactor
## Risk callouts
| Risk | Mitigation |
|---|---|
| Rewrite reopens fixed bugs (`isRowComplete` empty-row, two-pass row scan) | Phase 5 has explicit checklist mapping each Next-side fix to its Svelte equivalent |
| Codeserver proxy in Vite uses different config keys than Next | Copy proven config from sibling `sokoban` repo verbatim |
| `adapter-static` requires `prerender = true` on every page | One-line `export const prerender = true` in root `+layout.js` covers all routes |
| Tailwind 4 + SvelteKit setup is newer; less StackOverflow coverage | Use the Tailwind official Vite plugin; pinned in package.json |
| Plans dir + reports inside repo make `git mv` from old paths messy | Don't move plans; the new SvelteKit tree replaces `app/`, `components/`, `lib/` |
## Phases
| # | Phase | File | Effort |
|---|---|---|---|
| 1 | Scaffold + tooling | `phase-01-scaffold.md` | M |
| 2 | Port game logic | `phase-02-game-logic.md` | S |
| 3 | Port player-board component | `phase-03-player-board.md` | M |
| 4 | Port routes + layout | `phase-04-routes.md` | M |
| 5 | Wire codeserver + CF deploy | `phase-05-deploy-profiles.md` | S |
| 6 | Update docs + verify + commit | `phase-06-finalize.md` | S |
Total: ~3-4h focused.
## Acceptance criteria
- [ ] `npm run dev` boots SvelteKit dev server, `/` and `/master` render
- [ ] `npm run build` produces static export in `build/` (SvelteKit default)
- [ ] `npm run dev:codeserver` works under `/absproxy/{port}`
- [ ] Generate card → click cells → bingo popup fires once per completed row
- [ ] Waiting toast ("Chờ X") fires on 4/5 and clears on row completion
- [ ] localStorage state survives reload for both player and master cards
- [ ] Master draws numbers, tracking board lights up cells, master's player
card plays independently
- [ ] No `.tsx`, `.ts`, `.jsx`, `next.config.*`, `next-env.d.ts`, or
`app/`, `components/`, `lib/` dirs remain
- [ ] No `react`, `react-dom`, `next` in `package.json`
- [ ] `docs/` updated to describe SvelteKit structure
- [ ] `plans/260426-2033-sveltekit-refactor/` complete; old plan dir
preserved as historical record
- [ ] CF Pages dashboard build command updated to match the new output dir
## Rollback
`git revert` of the squash-merge commit (or the series of phase commits)
restores Next state. Old code is preserved in `master` branch + earlier `dev`
commits.
## Reference projects
- `tiennm99/sokoban` — Svelte 5 + Vite + codeserver dev config (template for
Phase 5)
- `tiennm99/rplace` — Svelte 5 + Hono on Cloudflare Workers (extension hint
for future backend work; not used here)
-7
View File
@@ -1,7 +0,0 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+9 -37
View File
@@ -5,13 +5,6 @@
--foreground: #1e293b;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0f172a;
@@ -22,7 +15,6 @@
body {
background: var(--background);
color: var(--foreground);
font-family: var(--font-sans), Arial, Helvetica, sans-serif;
}
/* Equal-width grid cells */
@@ -38,34 +30,10 @@ body {
gap: 0;
}
/* Congratulations popup animations */
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes pop-in {
0% { opacity: 0; transform: scale(0.5); }
70% { transform: scale(1.05); }
100% { opacity: 1; transform: scale(1); }
}
@keyframes bounce-slow {
0%, 100% { transform: translateX(-50%) translateY(0); }
50% { transform: translateX(-50%) translateY(-12px); }
}
@keyframes spin-slow {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.animate-fade-in { animation: fade-in 0.2s ease-out; }
.animate-pop-in { animation: pop-in 0.4s cubic-bezier(0.16, 1, 0.3, 1); }
.animate-bounce-slow { animation: bounce-slow 1.5s ease-in-out infinite; }
.animate-spin-slow { animation: spin-slow 3s linear infinite; }
.animate-spin-slow-reverse { animation: spin-slow 3s linear infinite reverse; }
@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } }
@keyframes pop-in { 0% { opacity: 0; transform: scale(0.5); } 70% { transform: scale(1.05); } 100% { opacity: 1; transform: scale(1); } }
@keyframes bounce-slow { 0%, 100% { transform: translateX(-50%) translateY(0); } 50% { transform: translateX(-50%) translateY(-12px); } }
@keyframes spin-slow { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
@keyframes toast {
0% { opacity: 0; transform: scale(0.8); }
10% { opacity: 1; transform: scale(1.05); }
@@ -74,9 +42,13 @@ body {
100% { opacity: 0; }
}
.animate-fade-in { animation: fade-in 0.2s ease-out; }
.animate-pop-in { animation: pop-in 0.4s cubic-bezier(0.16, 1, 0.3, 1); }
.animate-bounce-slow { animation: bounce-slow 1.5s ease-in-out infinite; }
.animate-spin-slow { animation: spin-slow 3s linear infinite; }
.animate-spin-slow-reverse { animation: spin-slow 3s linear infinite reverse; }
.animate-toast { animation: toast 5s ease-in-out forwards; }
/* Crossed cell diagonal line */
.cell-crossed::after {
content: "";
position: absolute;
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="vi" class="h-full antialiased">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Lô tô</title>
<meta name="description" content="Bàn số của trò chơi Lô tô" />
%sveltekit.head%
</head>
<body class="min-h-full flex flex-col">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+267
View File
@@ -0,0 +1,267 @@
<script>
import {
generateGrid,
getWaitingNumber,
isRowComplete,
loadCrossedState,
loadGrid,
saveCrossedState,
saveGrid,
} from "$lib/game-logic.js";
/**
* @typedef {Object} Props
* @property {string} [storagePrefix] localStorage key prefix; allows multiple
* independent boards (e.g. user vs master)
*/
/** @type {Props} */
let { storagePrefix = "loto" } = $props();
let grid = $state(/** @type {number[][] | null} */ (null));
let crossed = $state(/** @type {boolean[][]} */ ([]));
let showCongrats = $state(false);
let congratsRow = $state(-1);
let toast = $state(/** @type {string | null} */ (null));
// Plain refs (not reactive)
/** @type {ReturnType<typeof setTimeout> | null} */
let toastTimer = null;
const celebratedRows = new Set();
const notifiedWaitingRows = new Set();
// Memoized per-row completeness — avoid 81×/render isRowComplete calls
const rowCompleteness = $derived(
grid && crossed.length
? grid.map((_, r) => isRowComplete(grid, crossed, r))
: []
);
function dismissToast() {
toast = null;
if (toastTimer) {
clearTimeout(toastTimer);
toastTimer = null;
}
}
/** @param {string} msg */
function showToast(msg) {
dismissToast();
toast = msg;
toastTimer = setTimeout(() => {
toast = null;
}, 5000);
}
// Initial load from localStorage. Re-runs if storagePrefix changes.
$effect(() => {
const savedGrid = loadGrid(storagePrefix);
if (!savedGrid) return;
grid = savedGrid;
const savedCrossed =
loadCrossedState(storagePrefix) ??
savedGrid.map((row) => row.map(() => false));
crossed = savedCrossed;
celebratedRows.clear();
notifiedWaitingRows.clear();
for (let i = 0; i < savedGrid.length; i++) {
if (isRowComplete(savedGrid, savedCrossed, i)) celebratedRows.add(i);
if (getWaitingNumber(savedGrid, savedCrossed, i) !== null)
notifiedWaitingRows.add(i);
}
});
// Persist crossed state on change
$effect(() => {
if (crossed.length > 0) saveCrossedState(crossed, storagePrefix);
});
// Detect newly completed and waiting rows. Two passes prevent skipped resets.
$effect(() => {
if (!grid || crossed.length === 0) return;
// Pass 1: at most one bingo popup per render
for (let i = 0; i < grid.length; i++) {
if (!celebratedRows.has(i) && isRowComplete(grid, crossed, i)) {
celebratedRows.add(i);
notifiedWaitingRows.add(i);
congratsRow = i + 1;
showCongrats = true;
break;
}
}
// Pass 2: update waiting state for every non-celebrated row
for (let i = 0; i < grid.length; i++) {
if (celebratedRows.has(i)) continue;
const waitNum = getWaitingNumber(grid, crossed, i);
if (waitNum !== null && !notifiedWaitingRows.has(i)) {
notifiedWaitingRows.add(i);
showToast(`Chờ ${waitNum}`);
} else if (waitNum === null && notifiedWaitingRows.has(i)) {
notifiedWaitingRows.delete(i);
}
}
});
function handleGenerate() {
if (grid && !confirm("Bạn có muốn tạo lại bảng không?")) return;
const newGrid = generateGrid();
const newCrossed = newGrid.map((row) => row.map(() => false));
grid = newGrid;
crossed = newCrossed;
saveGrid(newGrid, storagePrefix);
saveCrossedState(newCrossed, storagePrefix);
celebratedRows.clear();
notifiedWaitingRows.clear();
dismissToast();
}
/**
* @param {number} row
* @param {number} col
*/
function handleCellClick(row, col) {
crossed = crossed.map((r, ri) =>
ri === row ? r.map((v, ci) => (ci === col ? !v : v)) : r
);
}
/** @param {KeyboardEvent} e */
function onModalKeydown(e) {
if (e.key === "Escape") showCongrats = false;
}
</script>
<div class="flex justify-center mb-6">
<button
onclick={handleGenerate}
class="px-8 py-3 rounded-full font-semibold text-white
bg-gradient-to-r from-indigo-500 to-purple-500
hover:from-indigo-600 hover:to-purple-600
active:scale-95 transition-all shadow-lg shadow-indigo-500/25"
>
Tạo bảng mới
</button>
</div>
{#if grid}
<div class="relative">
<div
aria-label="Bảng lô tô"
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="loto-grid">
{#each grid.flat() as num, idx (idx)}
{@const row = Math.floor(idx / 9)}
{@const col = idx % 9}
{@const hasNumber = num > 0}
{@const isCrossed = hasNumber && !!crossed[row]?.[col]}
{@const rowComplete = hasNumber && rowCompleteness[row]}
{#if !hasNumber}
<div
aria-hidden="true"
class="relative flex items-center justify-center aspect-square border-r border-b border-slate-200/80 dark:border-slate-700/60 bg-slate-50 dark:bg-slate-900/60"
></div>
{:else}
<button
type="button"
aria-label="Số {num}{isCrossed ? ', đã đánh dấu' : ''}"
aria-pressed={isCrossed}
onclick={() => handleCellClick(row, col)}
class="relative flex items-center justify-center
aspect-square text-base sm:text-xl font-bold
border-r border-b border-slate-200/80 dark:border-slate-700/60
transition-all select-none cursor-pointer
focus:outline-none focus:ring-2 focus:ring-inset focus:ring-indigo-400
{isCrossed
? rowComplete
? 'cell-crossed bg-emerald-100 dark:bg-emerald-900/40 text-emerald-500 dark:text-emerald-400'
: 'cell-crossed bg-red-50 dark:bg-red-950/30 text-red-400 dark:text-red-500'
: 'bg-white dark:bg-slate-800 text-slate-800 dark:text-slate-100 hover:bg-indigo-50 dark:hover:bg-indigo-950/30 hover:text-indigo-600 dark:hover:text-indigo-400'}"
>
{num}
</button>
{/if}
{/each}
</div>
</div>
{#if toast}
<div
role="status"
aria-live="polite"
class="absolute inset-0 flex items-center justify-center pointer-events-none z-10"
>
<button
type="button"
onclick={dismissToast}
aria-label="Đóng thông báo"
class="pointer-events-auto px-6 py-3 rounded-2xl bg-amber-500/90 dark:bg-amber-600/90 text-white text-xl sm:text-2xl font-black shadow-xl animate-toast"
>
{toast}
</button>
</div>
{/if}
</div>
{:else}
<div class="text-center text-slate-400 dark:text-slate-500 py-20 text-sm">
Nhấn "Tạo bảng mới" để bắt đầu chơi
</div>
{/if}
{#if showCongrats}
<div
role="dialog"
aria-modal="true"
aria-labelledby="congrats-title"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm animate-fade-in"
>
<button
type="button"
aria-label="Đóng"
onclick={() => (showCongrats = false)}
onkeydown={onModalKeydown}
class="absolute inset-0 cursor-default"
></button>
<div
class="relative mx-4 max-w-sm w-full rounded-3xl bg-white dark:bg-slate-800 p-8 text-center shadow-2xl animate-pop-in"
>
<div
class="absolute -top-6 left-1/2 -translate-x-1/2 text-6xl animate-bounce-slow"
>
🎉
</div>
<div class="absolute top-2 left-4 text-2xl animate-spin-slow">✨</div>
<div class="absolute top-2 right-4 text-2xl animate-spin-slow-reverse">
🎊
</div>
<h2
id="congrats-title"
class="mt-6 text-3xl font-black bg-gradient-to-r from-amber-500 via-pink-500 to-purple-500 bg-clip-text text-transparent"
>
Kinh!
</h2>
<p class="mt-3 text-lg text-slate-600 dark:text-slate-300">
Hàng <span class="font-bold text-pink-500">{congratsRow}</span> đã đầy đủ!
</p>
<p class="mt-1 text-sm text-slate-400 dark:text-slate-500">
Hãy hô to "Kinh!" 🎶
</p>
<button
onclick={() => (showCongrats = false)}
class="mt-6 px-8 py-2.5 rounded-full font-semibold text-white
bg-gradient-to-r from-pink-500 to-purple-500
hover:from-pink-600 hover:to-purple-600
active:scale-95 transition-all shadow-lg"
>
Tuyệt vời! 🥳
</button>
</div>
</div>
{/if}
File renamed without changes.
+3
View File
@@ -0,0 +1,3 @@
export const prerender = true;
export const ssr = false;
export const trailingSlash = "never";
+7
View File
@@ -0,0 +1,7 @@
<script>
import "../app.css";
let { children } = $props();
</script>
{@render children()}
+71
View File
@@ -0,0 +1,71 @@
<script>
import { base } from "$app/paths";
import PlayerBoard from "$lib/PlayerBoard.svelte";
let showInstructions = $state(false);
</script>
<div class="flex flex-col flex-1 items-center px-3 py-8 sm:py-12">
<div class="w-full max-w-lg">
<!-- Header -->
<header class="text-center mb-8">
<h1
class="text-4xl sm:text-5xl font-extrabold tracking-tight bg-gradient-to-r from-indigo-500 to-purple-500 bg-clip-text text-transparent"
>
Lô tô
</h1>
<p class="mt-2 text-sm text-slate-500 dark:text-slate-400">
Lấy cảm hứng từ những buổi họp lớp thiếu giấy chơi lô tô
<br class="hidden sm:block" /> của TN1 (2014–2017)
</p>
<div class="mt-3 flex items-center justify-center gap-3 text-xs">
<button
onclick={() => (showInstructions = !showInstructions)}
class="text-indigo-500 dark:text-indigo-400 hover:underline"
>
{showInstructions ? "Ẩn hướng dẫn" : "Hướng dẫn"}
</button>
<span class="text-slate-300 dark:text-slate-600">|</span>
<a
href="{base}/master"
class="text-orange-500 dark:text-orange-400 hover:underline"
>
Trang quản trò →
</a>
</div>
</header>
{#if showInstructions}
<div
class="mb-6 rounded-xl bg-indigo-50 dark:bg-indigo-950/30 border border-indigo-100 dark:border-indigo-900 p-4 text-sm text-slate-600 dark:text-slate-400"
>
<ul class="space-y-1 list-disc list-inside">
<li>
Nhấn <strong class="text-slate-800 dark:text-slate-200"
>Tạo bảng mới</strong
> để tạo bảng
</li>
<li>Nhấn vào ô số để đánh dấu khi số được xổ</li>
<li>Nhấn lại để bỏ đánh dấu</li>
<li>Bảng được lưu tự động</li>
</ul>
</div>
{/if}
<PlayerBoard />
<footer
class="mt-10 text-center text-xs text-slate-400 dark:text-slate-600"
>
Made with ❤️ by{" "}
<a
href="https://miti99.com"
target="_blank"
rel="noopener noreferrer"
class="text-indigo-500 hover:underline"
>
miti99
</a>
</footer>
</div>
</div>
+244
View File
@@ -0,0 +1,244 @@
<script module>
const STORAGE_KEY = "loto_master";
/**
* Build the 9x10 board: columns 0-8 map to number ranges 1-9, 10-19, ..., 80-90.
* @returns {number[][]}
*/
function buildBoard() {
/** @type {number[][]} */
const board = [];
for (let row = 0; row < 10; row++) {
/** @type {number[]} */
const cells = [];
for (let col = 0; col < 9; col++) {
const num = col === 0 ? row + 1 : col * 10 + row;
if (col === 0 && row === 9) cells.push(0);
else if (col === 8 && row === 9) cells.push(90);
else if (col > 0 && row === 9) cells.push(0);
else cells.push(num);
}
board.push(cells);
}
return board;
}
/** @returns {{ called: number[], remaining: number[] }} */
function createFreshState() {
const all = Array.from({ length: 90 }, (_, i) => i + 1);
for (let i = all.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[all[i], all[j]] = [all[j], all[i]];
}
return { called: [], remaining: all };
}
/** @param {{ called: number[], remaining: number[] }} state */
function saveState(state) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch {
/* ignore */
}
}
function loadState() {
try {
const data = localStorage.getItem(STORAGE_KEY);
if (!data) return null;
return JSON.parse(data);
} catch {
return null;
}
}
const BOARD = Object.freeze(buildBoard().map((row) => Object.freeze(row)));
const BOARD_FLAT = Object.freeze(BOARD.flatMap((r) => r));
</script>
<script>
import { base } from "$app/paths";
import PlayerBoard from "$lib/PlayerBoard.svelte";
let state = $state(/** @type {{called: number[], remaining: number[]} | null} */ (null));
let lastCalled = $state(/** @type {number | null} */ (null));
// Load on mount
$effect(() => {
const saved = loadState();
if (saved && saved.called.length > 0) {
state = saved;
lastCalled = saved.called[saved.called.length - 1];
}
});
// Persist on change
$effect(() => {
if (state) saveState(state);
});
const calledSet = $derived(new Set(state?.called ?? []));
function handleNewGame() {
if (state && !confirm("Bạn có muốn tạo ván mới không?")) return;
state = createFreshState();
lastCalled = null;
}
function handleDrawNext() {
if (!state || state.remaining.length === 0) return;
const next = state.remaining[0];
state = {
called: [...state.called, next],
remaining: state.remaining.slice(1),
};
lastCalled = next;
}
</script>
<div class="flex flex-col flex-1 items-center px-3 py-8 sm:py-12">
<div class="w-full max-w-2xl">
<!-- Header -->
<header class="text-center mb-6">
<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>
<!-- Controls -->
<div class="flex justify-center gap-3 mb-6">
<button
onclick={handleNewGame}
class="px-8 py-4 rounded-full font-semibold text-white text-lg
bg-gradient-to-r from-orange-500 to-red-500
hover:from-orange-600 hover:to-red-600
active:scale-95 transition-all shadow-lg shadow-orange-500/25"
>
Ván mới
</button>
{#if state && state.remaining.length > 0}
<button
onclick={handleDrawNext}
class="px-10 py-4 rounded-full font-semibold text-white text-lg
bg-gradient-to-r from-emerald-500 to-teal-500
hover:from-emerald-600 hover:to-teal-600
active:scale-95 transition-all shadow-lg shadow-emerald-500/25"
>
Xổ số
</button>
{/if}
</div>
<!-- Current number -->
{#if lastCalled}
<div class="flex flex-col items-center mb-6">
<div
class="text-xs uppercase tracking-widest text-slate-400 dark:text-slate-500 mb-1"
>
Số vừa xổ
</div>
<div
class="w-20 h-20 sm:w-24 sm:h-24 rounded-2xl bg-gradient-to-br from-orange-500 to-red-500 flex items-center justify-center shadow-xl shadow-orange-500/30"
>
<span class="text-4xl sm:text-5xl font-black text-white">
{lastCalled}
</span>
</div>
{#if state}
<div class="mt-2 text-xs text-slate-400 dark:text-slate-500">
Đã xổ: {state.called.length}/90 &middot; Còn lại: {state.remaining
.length}
</div>
{/if}
</div>
{/if}
<!-- Called history -->
{#if state && state.called.length > 0}
<div class="mb-6 px-1">
<div class="text-xs text-slate-400 dark:text-slate-500 mb-1">
Thứ tự đã xổ:
</div>
<div class="flex flex-wrap gap-1.5">
{#each state.called as num, i (i)}
<span
class="inline-flex items-center justify-center w-7 h-7 text-xs font-bold rounded-md bg-orange-100 dark:bg-orange-950/40 text-orange-700 dark:text-orange-300"
>
{num}
</span>
{/each}
</div>
</div>
{/if}
<!-- Master 9x10 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 isCalled = hasNumber && calledSet.has(num)}
<div
class="relative flex items-center justify-center
aspect-square text-sm sm:text-base font-bold
border-r border-b border-slate-200/80 dark:border-slate-700/60
transition-colors select-none
{hasNumber
? isCalled
? 'bg-orange-500 dark:bg-orange-600 text-white'
: 'bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-200'
: 'bg-slate-100 dark:bg-slate-900/60'}"
>
{hasNumber ? num : ""}
</div>
{/each}
</div>
</div>
{:else}
<div class="text-center text-slate-400 dark:text-slate-500 py-20 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">
<h2 class="text-lg font-bold text-slate-700 dark:text-slate-200">
Bảng của quản trò
</h2>
<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>
<footer
class="mt-10 text-center text-xs text-slate-400 dark:text-slate-600"
>
Made with ❤️ by{" "}
<a
href="https://miti99.com"
target="_blank"
rel="noopener noreferrer"
class="text-indigo-500 hover:underline"
>
miti99
</a>
</footer>
</div>
</div>
+34
View File
@@ -0,0 +1,34 @@
import adapter from "@sveltejs/adapter-static";
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
const profile = process.env.BUILD_PROFILE;
const isCodeserver = process.env.VITE_DEV_PROFILE === "codeserver";
// basePath resolution:
// 1. NEXT_BASE_PATH (kept name for consistency with prior config; treated as opaque override)
// 2. codeserver dev → /absproxy/{port}
// 3. BUILD_PROFILE=gh → /loto
// 4. default (CF Pages root, local dev) → ""
function resolveBase() {
if (process.env.NEXT_BASE_PATH != null) return process.env.NEXT_BASE_PATH;
if (isCodeserver) {
const port = process.env.CODESERVER_PORT ?? "3000";
return `/absproxy/${port}`;
}
if (profile === "gh") return "/loto";
return "";
}
export default {
preprocess: vitePreprocess(),
kit: {
adapter: adapter({
pages: "build",
assets: "build",
fallback: undefined,
precompress: false,
strict: true,
}),
paths: { base: resolveBase() },
},
};
+26
View File
@@ -0,0 +1,26 @@
import { sveltekit } from "@sveltejs/kit/vite";
import tailwindcss from "@tailwindcss/vite";
const isCodeserver = process.env.VITE_DEV_PROFILE === "codeserver";
const host = process.env.CODESERVER_HOST;
const port = Number(process.env.CODESERVER_PORT ?? 3000);
const codeserverServer = host
? {
port,
host: true,
strictPort: true,
allowedHosts: [host],
hmr: {
host,
protocol: "wss",
clientPort: 443,
path: `/absproxy/${port}/`,
},
}
: { port: 3000, host: true };
export default {
plugins: [tailwindcss(), sveltekit()],
server: isCodeserver ? codeserverServer : { port: 3000 },
};