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 */} +
+

+ Quản trò +

+

+ Xổ số và theo dõi bảng lô tô +

+ + ← Về trang người chơi + +
+ + {/* 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;