diff --git a/app/globals.css b/app/globals.css
index 188d5fc..c2981b9 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -32,6 +32,12 @@ body {
gap: 0;
}
+.master-grid {
+ display: grid;
+ grid-template-columns: repeat(9, 1fr);
+ gap: 0;
+}
+
/* Crossed cell diagonal line */
.cell-crossed::after {
content: "";
diff --git a/app/master/page.tsx b/app/master/page.tsx
new file mode 100644
index 0000000..feb7045
--- /dev/null
+++ b/app/master/page.tsx
@@ -0,0 +1,255 @@
+"use client";
+
+import Link from "next/link";
+import { useCallback, useEffect, useState } from "react";
+
+const STORAGE_KEY = "loto_master";
+
+/** Build the 9x10 board: columns 0-8 map to number ranges 1-9, 10-19, ..., 80-90 */
+function buildBoard(): number[][] {
+ const board: number[][] = [];
+ for (let row = 0; row < 10; row++) {
+ const cells: number[] = [];
+ 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;
+}
+
+interface MasterState {
+ called: number[];
+ remaining: number[];
+}
+
+function createFreshState(): MasterState {
+ 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: MasterState): void {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
+}
+
+function loadState(): MasterState | null {
+ const data = localStorage.getItem(STORAGE_KEY);
+ if (!data) return null;
+ try {
+ return JSON.parse(data);
+ } catch {
+ return null;
+ }
+}
+
+const BOARD = buildBoard();
+
+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]);
+
+ const handleToggleNumber = useCallback(
+ (num: number) => {
+ if (!state) return;
+ if (calledSet.has(num)) {
+ // Uncall: remove from called, add back to remaining
+ setState({
+ called: state.called.filter((n) => n !== num),
+ remaining: [...state.remaining, num],
+ });
+ if (lastCalled === num) {
+ const newCalled = state.called.filter((n) => n !== num);
+ setLastCalled(newCalled.length > 0 ? newCalled[newCalled.length - 1] : null);
+ }
+ } else {
+ // Manual call: add to called, remove from remaining
+ setState({
+ called: [...state.called, num],
+ remaining: state.remaining.filter((n) => n !== num),
+ });
+ setLastCalled(num);
+ }
+ },
+ [state, calledSet, lastCalled]
+ );
+
+ return (
+
+
+ {/* Header */}
+
+
+ {/* Controls */}
+
+
+ {state && state.remaining.length > 0 && (
+
+ )}
+
+
+ {/* Current number display */}
+ {lastCalled && (
+
+
+ Số vừa xổ
+
+
+
+ {lastCalled}
+
+
+ {state && (
+
+ Đã xổ: {state.called.length}/90 · Còn lại: {state.remaining.length}
+
+ )}
+
+ )}
+
+ {/* Called numbers history */}
+ {state && state.called.length > 0 && (
+
+
+ Thứ tự đã xổ:
+
+
+ {state.called.map((num, i) => (
+
+ {num}
+
+ ))}
+
+
+ )}
+
+ {/* Master board 9x10 */}
+ {state ? (
+
+
+ {BOARD.flat().map((num, idx) => {
+ const hasNumber = num > 0;
+ const isCalled = hasNumber && calledSet.has(num);
+
+ return (
+
handleToggleNumber(num) : undefined}
+ 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-all select-none
+ ${hasNumber
+ ? isCalled
+ ? "bg-orange-500 dark:bg-orange-600 text-white cursor-pointer"
+ : "bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-200 cursor-pointer hover:bg-orange-50 dark:hover:bg-orange-950/20"
+ : "bg-slate-100 dark:bg-slate-900/60"
+ }
+ `}
+ >
+ {hasNumber ? num : ""}
+
+ );
+ })}
+
+
+ ) : (
+
+ Nhấn “Ván mới” để bắt đầu
+
+ )}
+
+ {/* Footer */}
+
+
+
+ );
+}
diff --git a/app/page.tsx b/app/page.tsx
index 75cd30d..783b7e9 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -1,5 +1,6 @@
"use client";
+import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
import {
generateGrid,
@@ -58,12 +59,21 @@ export default function Home() {
Lấy cảm hứng từ những buổi họp lớp thiếu giấy chơi lô tô
của TN1 (2014–2017)
-
+
+
+ |
+
+ Trang quản trò →
+
+
{/* Instructions */}
diff --git a/next.config.ts b/next.config.ts
index e20f856..1040bca 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -1,8 +1,10 @@
import type { NextConfig } from "next";
+const isProd = process.env.NODE_ENV === "production";
+
const nextConfig: NextConfig = {
output: "export",
- basePath: "/loto",
+ basePath: isProd ? "/loto" : "",
};
export default nextConfig;