mirror of
https://github.com/tiennm99/loto.git
synced 2026-10-15 00:17:46 +00:00
After the JS+JSDoc conversion, some TS-flavored bits lingered. Removed:
- // @ts-check directives (TS-specific pragma)
- JSDoc annotations referencing TS-defined types: import('next').NextConfig,
React.MutableRefObject, React.Dispatch, React.SetStateAction
- jsconfig.json (TS-server-flavored config; only kept it for the @/* alias)
@/* imports replaced with relative paths so jsconfig is no longer needed.
Remaining JSDoc is plain @param / @returns — vanilla JS, no TS dependency.
Build, lint, dev profiles unchanged.
249 lines
8.2 KiB
React
249 lines
8.2 KiB
React
"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 · 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 “Ván mới” để 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>
|
|
);
|
|
}
|