feat: convert to Next.js with TypeScript and Tailwind CSS

Replace vanilla HTML/JS/CSS with Next.js App Router, TypeScript,
and Tailwind CSS. Responsive design with dark mode support.
All original game logic preserved: grid generation, click-to-cross,
localStorage persistence.
This commit is contained in:
2026-04-04 21:51:07 +07:00
parent 761744cdc6
commit 7f25a8e462
18 changed files with 7026 additions and 321 deletions
+3
View File
@@ -1 +1,4 @@
.vscode/
node_modules/
.next/
out/
+13 -1
View File
@@ -1,3 +1,15 @@
# Lô tô
Bàn số của trò chơi "Lô tô"
Bàn số của trò chơi "Lô tô" — Next.js app.
## Development
```bash
npm run dev
```
## Build
```bash
npm run build
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+44
View File
@@ -0,0 +1,44 @@
@import "tailwindcss";
:root {
--background: #fafaf9;
--foreground: #1c1917;
}
@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: #0c0a09;
--foreground: #e7e5e4;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: var(--font-sans), Arial, Helvetica, sans-serif;
}
/* Crossed cell diagonal line */
.cell-crossed {
position: relative;
}
.cell-crossed::after {
content: "";
position: absolute;
inset: 4px;
background-image: linear-gradient(
to bottom right,
transparent calc(50% - 1.5px),
currentColor,
transparent calc(50% + 1.5px)
);
pointer-events: none;
}
+25
View File
@@ -0,0 +1,25 @@
import type { Metadata } from "next";
import { Geist } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Lô tô",
description: "Bàn số của trò chơi Lô tô",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="vi" className={`${geistSans.variable} h-full antialiased`}>
<body className="min-h-full flex flex-col">{children}</body>
</html>
);
}
+108
View File
@@ -0,0 +1,108 @@
/** Number ranges for each column (0-8) in the lô tô grid */
const NUM_IN_COL: number[][] = [
[1, 2, 3, 4, 5, 6, 7, 8, 9],
[10, 11, 12, 13, 14, 15, 16, 17, 18, 19],
[20, 21, 22, 23, 24, 25, 26, 27, 28, 29],
[30, 31, 32, 33, 34, 35, 36, 37, 38, 39],
[40, 41, 42, 43, 44, 45, 46, 47, 48, 49],
[50, 51, 52, 53, 54, 55, 56, 57, 58, 59],
[60, 61, 62, 63, 64, 65, 66, 67, 68, 69],
[70, 71, 72, 73, 74, 75, 76, 77, 78, 79],
[80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90],
];
const NUM_ROWS = 9;
const NUM_COLS = 9;
const NUM_PER_ROW = 5;
/** Weighted random selection of a column index */
function randomANumberInRow(weights: number[]): number {
const tempWeight = [...weights];
for (let i = 1; i < tempWeight.length; i++) {
tempWeight[i] += tempWeight[i - 1];
}
const rand = Math.floor(Math.random() * tempWeight[tempWeight.length - 1]);
for (let i = 0; i < tempWeight.length; i++) {
if (rand < tempWeight[i]) return i;
}
return 0;
}
/** Select NUM_PER_ROW columns for a row using weighted random */
function randomARow(baseWeight: number[]): number[] {
const tempWeight = [...baseWeight];
const selectedCols: number[] = [];
for (let i = 0; i < NUM_PER_ROW; i++) {
const col = randomANumberInRow(tempWeight);
selectedCols.push(col);
tempWeight[col] = 0;
baseWeight[col]--;
}
return selectedCols;
}
/** Pick random numbers from a column's range */
function randomNumbersInCol(num: number, col: number): number[] {
const arr = [...NUM_IN_COL[col]];
arr.sort(() => 0.5 - Math.random());
return arr.slice(0, num);
}
/** Generate a 9x9 lô tô grid. Returns cell values (0 = empty, >0 = number). */
export function generateGrid(): number[][] {
const cell = Array.from({ length: NUM_ROWS }, () =>
new Array(NUM_COLS).fill(0)
);
const countNumPerCol = new Array(NUM_COLS).fill(0);
const baseWeight = new Array(NUM_COLS).fill(6);
for (let i = 0; i < NUM_ROWS; i++) {
const newRow = randomARow(baseWeight);
newRow.forEach((col) => {
countNumPerCol[col]++;
cell[i][col] = -1;
});
}
for (let i = 0; i < NUM_COLS; i++) {
const selectedNum = randomNumbersInCol(countNumPerCol[i], i);
for (let j = 0; j < NUM_ROWS; j++) {
if (cell[j][i] === -1) {
cell[j][i] = selectedNum.shift() ?? 0;
}
}
}
return cell;
}
const STORAGE_KEY_GRID = "loto_grid";
const STORAGE_KEY_CROSSED = "loto_crossed";
export function saveGrid(grid: number[][]): void {
localStorage.setItem(STORAGE_KEY_GRID, JSON.stringify(grid));
}
export function loadGrid(): number[][] | null {
const data = localStorage.getItem(STORAGE_KEY_GRID);
if (!data) return null;
try {
return JSON.parse(data);
} catch {
return null;
}
}
export function saveCrossedState(crossed: boolean[][]): void {
localStorage.setItem(STORAGE_KEY_CROSSED, JSON.stringify(crossed));
}
export function loadCrossedState(): boolean[][] | null {
const data = localStorage.getItem(STORAGE_KEY_CROSSED);
if (!data) return null;
try {
return JSON.parse(data);
} catch {
return null;
}
}
+163
View File
@@ -0,0 +1,163 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import {
generateGrid,
loadCrossedState,
loadGrid,
saveCrossedState,
saveGrid,
} from "./loto-game-logic";
export default function Home() {
const [grid, setGrid] = useState<number[][] | null>(null);
const [crossed, setCrossed] = useState<boolean[][]>([]);
const [showInstructions, setShowInstructions] = useState(false);
// Load saved state on mount
useEffect(() => {
const savedGrid = loadGrid();
if (savedGrid) {
setGrid(savedGrid);
const savedCrossed = loadCrossedState();
setCrossed(
savedCrossed ??
savedGrid.map((row) => row.map(() => false))
);
}
}, []);
// Persist crossed state on change
useEffect(() => {
if (crossed.length > 0) {
saveCrossedState(crossed);
}
}, [crossed]);
const handleGenerate = useCallback(() => {
if (grid && !confirm("Bạn có muốn tạo lại bảng không?")) return;
const newGrid = generateGrid();
setGrid(newGrid);
const newCrossed = newGrid.map((row) => row.map(() => false));
setCrossed(newCrossed);
saveGrid(newGrid);
saveCrossedState(newCrossed);
}, [grid]);
const handleCellClick = useCallback(
(row: number, col: number) => {
setCrossed((prev) => {
const next = prev.map((r) => [...r]);
next[row][col] = !next[row][col];
return next;
});
},
[]
);
return (
<div className="flex flex-col flex-1 items-center px-4 py-6 sm:py-10">
<div className="w-full max-w-2xl">
{/* Header */}
<header className="text-center mb-6">
<h1 className="text-3xl sm:text-4xl font-bold tracking-tight mb-2">
</h1>
<p className="text-sm sm:text-base text-stone-500 dark:text-stone-400 mb-3">
Tạo bảng chơi , lấy cảm hứng từ những buổi họp lớp thiếu
giấy chơi của TN1 (2014-2017)
</p>
<button
onClick={() => setShowInstructions((v) => !v)}
className="text-sm text-blue-600 dark:text-blue-400 hover:underline"
>
{showInstructions ? "Ẩn hướng dẫn" : "Hướng dẫn"}
</button>
</header>
{/* Instructions */}
{showInstructions && (
<div className="mb-6 rounded-lg border border-stone-200 dark:border-stone-700 bg-stone-50 dark:bg-stone-900 p-4 text-sm">
<h2 className="font-semibold mb-2">Hướng dẫn</h2>
<ul className="list-disc list-inside space-y-1 text-stone-600 dark:text-stone-400">
<li>
Nhấn <strong>Tạo bảng mới</strong> đ tạo bảng mới
</li>
<li>Nhấn vào ô số đ đánh dấu khi số đưc xổ</li>
<li>Nhấn lại ô đã đánh dấu đ bỏ đánh dấu</li>
<li>Bảng trạng thái đưc lưu tự đng</li>
</ul>
</div>
)}
{/* Generate button */}
<div className="flex justify-center mb-6">
<button
onClick={handleGenerate}
className="px-6 py-2.5 rounded-lg bg-blue-600 text-white font-medium
hover:bg-blue-700 active:bg-blue-800
transition-colors shadow-sm"
>
Tạo bảng mới
</button>
</div>
{/* Grid */}
{grid && (
<div className="overflow-x-auto rounded-xl border border-stone-200 dark:border-stone-700 shadow-sm">
<table className="w-full border-collapse">
<tbody>
{grid.map((row, i) => (
<tr key={i}>
{row.map((num, j) => {
const hasNumber = num > 0;
const isCrossed =
hasNumber && crossed[i]?.[j];
return (
<td
key={j}
onClick={
hasNumber
? () => handleCellClick(i, j)
: undefined
}
className={`
relative text-center border border-stone-200 dark:border-stone-700
h-10 sm:h-12 text-sm sm:text-lg font-medium
transition-colors select-none
${
hasNumber
? isCrossed
? "cell-crossed bg-amber-100 dark:bg-amber-900/40 text-amber-800 dark:text-amber-300 cursor-pointer"
: "bg-amber-50 dark:bg-amber-950/30 text-stone-800 dark:text-stone-200 cursor-pointer hover:bg-amber-100 dark:hover:bg-amber-900/30"
: "bg-stone-50 dark:bg-stone-900/50"
}
`}
>
{hasNumber ? num : ""}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Empty state */}
{!grid && (
<div className="text-center text-stone-400 dark:text-stone-500 py-16">
Nhấn &ldquo;Tạo bảng mới&rdquo; đ bắt đu chơi
</div>
)}
{/* Footer */}
<footer className="mt-8 text-center text-xs text-stone-400 dark:text-stone-500">
made by miti99
</footer>
</div>
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
-44
View File
@@ -1,44 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="style.css" />
<title>Lô tô (made by miti99)</title>
</head>
<body onload="start()">
<h1>Lô tô</h1>
Tạo bảng chơi lô tô, 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)
<a href="javascript:toggle('instructions');">Hướng dẫn</a>
<noscript>
<div class="error">
<strong>Lỗi:</strong>
<p>
<em> JavaScript không được bật</em>
</p>
</div>
</noscript>
<div id="instructions">
<h2>Hướng dẫn</h2>
<ul>
<noscript>
<li>Bật JavaScript</li>
</noscript>
<li>Nhấn <strong>Tạo</strong> để tạo bảng mới</li>
</ul>
</div>
<form>
<input type="button" value="Tạo" onclick="generate();" />
</form>
<h2>Bảng</h2>
<div id="grid"></div>
<script src="script.js"></script>
</body>
</html>
-23
View File
@@ -1,23 +0,0 @@
function randomNumbers(num, from, to) {
let arr = Array.from({
length: to - from
}, (_, i) => from + i);
arr.sort(() => 0.5 - Math.random());
return arr.slice(0, num);
}
let arr = randomNumbers(90, 1, 91);
let i = 0;
const readline = require('readline');
readline.emitKeypressEvents(process.stdin);
process.stdin.setRawMode(true);
process.stdin.on('keypress', (str, key) => {
if (key.ctrl && key.name === 'c') {
console.log(JSON.stringify);
process.exit();
} else {
console.log(`Next number: ${arr[i++]}\n`);
}
});
console.log('Press any key...');
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+7
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;
+6572
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "nextjs-temp",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"next": "16.2.2",
"react": "19.2.4",
"react-dom": "19.2.4"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.2",
"tailwindcss": "^4",
"typescript": "^5"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
-192
View File
@@ -1,192 +0,0 @@
let numInCol = [
[1, 2, 3, 4, 5, 6, 7, 8, 9],
[10, 11, 12, 13, 14, 15, 16, 17, 18, 19],
[20, 21, 22, 23, 24, 25, 26, 27, 28, 29],
[30, 31, 32, 33, 34, 35, 36, 37, 38, 39],
[40, 41, 42, 43, 44, 45, 46, 47, 48, 49],
[50, 51, 52, 53, 54, 55, 56, 57, 58, 59],
[60, 61, 62, 63, 64, 65, 66, 67, 68, 69],
[70, 71, 72, 73, 74, 75, 76, 77, 78, 79],
[80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90]
];
let numRows = 9;
let numCols = 9;
let numPerRow = 5;
let maxNumPerCol = 6;
let lotoKey = "loto_";
function randomANumberInRow(weights) {
let tempWeight = [...weights];
for (let i = 1; i < tempWeight.length; i++) {
tempWeight[i] += tempWeight[i - 1];
}
let rand = Math.floor(Math.random() * tempWeight[tempWeight.length - 1]);
for (let i = 0; i < tempWeight.length; i++) {
if (rand < tempWeight[i]) return i;
}
}
function randomARow(baseWeight) {
console.log("Before", JSON.stringify(baseWeight));
let tempWeight = [...baseWeight];
let selectedNum = [];
for (let i = 0; i < numPerRow; i++) {
let num = randomANumberInRow(tempWeight);
selectedNum.push(num);
tempWeight[num] = 0;
baseWeight[num]--;
}
console.log("After", JSON.stringify(baseWeight));
return selectedNum;
}
function randomNumbersInCol(num, col) {
if (col < 0 || col >= numInCol.length) return;
let arr = numInCol[col];
arr.sort(() => 0.5 - Math.random());
return arr.slice(0, num);
}
// function randomSelectCols() {
// let arr = Array.from({
// length: 9
// }, (_, i) => i);
// arr.sort(() => 0.5 - Math.random());
// return arr;
// }
function generate() {
let node = document.getElementById("grid");
if (node.innerHTML && !confirm("Bạn có muốn tạo lại bảng không?"))
return;
let cell = new Array(numRows).fill(0).map(() => new Array(numCols).fill(0));
let countNumPerCol = new Array(numCols).fill(0);
// //Random cac cot co so trong tung dong
// for (let i = 0; i < numRows; i++) {
// let selectedCol = randomSelectCols(numPerRow, 0, 9);
// let count = 0;
// for (let j = 0; j < selectedCol.length; j++) {
// let col = selectedCol[j];
// if (countNumPerCol[col] == maxNumPerCol) continue;
// countNumPerCol[col] += 1;
// cell[i][col] = -1;
// count++;
// if (count == numPerRow) break;
// }
// }
let baseWeight = new Array(numCols).fill(6);
for (let i = 0; i < numRows; i++) {
let newRow = randomARow(baseWeight);
newRow.forEach(col => {
countNumPerCol[col]++;
cell[i][col] = -1;
});
}
for (let i = 0; i < numCols; i++) {
let selectedNum = randomNumbersInCol(countNumPerCol[i], i);
for (let j = 0; j < numRows; j++) {
if (cell[j][i] == -1) {
cell[j][i] = selectedNum.shift();
}
}
}
let html = '<table border="0">';
for (let i = 0; i < numRows; i++) {
html += "<tr>";
for (let j = 0; j < numCols; j++) {
let num = cell[i][j];
let isEnabled = num > 0;
html += `<td id="${i}_${j}" class="${isEnabled ? 'hightlight' : ''}" onClick="cellClicked('${i}_${j}')" style="text-align: center; pointer-events: ${isEnabled ? 'auto' : 'none'};">${isEnabled ? num : ""}</td>`;
}
html += "</tr >";
}
html += "</table>";
node.innerHTML = html;
save(lotoKey, html);
saveGameState();
}
function supports_html5_storage() {
try {
return "localStorage" in window && window["localStorage"] !== null;
} catch (e) {
return false;
}
}
function cellClicked(id) {
let elem = document.getElementById(id);
let isChecked = elem.classList.contains("crossed");
if (isChecked)
elem.classList.remove("crossed");
else
elem.classList.add("crossed");
saveGameState();
}
function saveGameState() {
for (let i = 0; i < numRows; i++) {
for (let j = 0; j < numCols; j++) {
let id = i + "_" + j;
let elem = document.getElementById(id);
if (elem.innerHTML == "") continue;
let isChecked = elem.classList.contains("crossed");
save(lotoKey + id, !isChecked);
}
}
}
function loadGameState() {
for (let i = 0; i < numRows; i++) {
for (let j = 0; j < numCols; j++) {
let id = i + "_" + j;
let elem = document.getElementById(id);
if (elem.innerHTML == "") continue;
let isChecked = load(lotoKey + id) == "true";
if (isChecked)
elem.classList.remove("crossed");
else
elem.classList.add("crossed");
}
}
}
function toggle(id) {
let elem = document.getElementById(id);
if (elem.style.display == "block")
elem.style.display = "none";
else
elem.style.display = "block";
}
save = function (key, value) {};
load = function (key) {
return null;
};
function start() {
if (supports_html5_storage()) {
save = function (key, value) {
localStorage.setItem(key, value);
};
load = function (key) {
return localStorage.getItem(key);
};
} else {
alert("Trình duyệt của bạn không hỗ trợ localStorage!");
return;
}
let grid = load(lotoKey);
if (grid) {
console.log("Load bảng đã được tạo sẵn");
let node = document.getElementById("grid");
node.innerHTML = grid;
loadGameState();
}
}
-61
View File
@@ -1,61 +0,0 @@
h2 {
background: blueviolet;
text-align: center;
padding: 10px;
border: 1px solid blue;
}
body {
font-family: Verdana, Geneva, Tahoma, sans-serif;
margin-left: 20px;
margin-right: 20px;
}
table {
width: 100%;
}
td {
padding: 10px 20px;
border-top: 1px solid lightgray;
border-left: 1px solid lightgray;
border-bottom: 1px solid darkgray;
border-right: 1px solid darkgray;
}
.error {
background-color: orange;
color: red;
padding: 5px;
margin: 20px 50px;
border: 1px solid red;
}
.hightlight {
background: yellow;
}
a {
color: blue;
}
td label {
cursor: pointer;
}
form {
margin: 5px;
padding: 5px;
color: blue;
}
#instructions {
display: none;
border: 1px solid blue;
margin: 10px;
padding: 20px;
}
table td.crossed {
background-image: linear-gradient(to bottom right, transparent calc(50% - 2px), black, transparent calc(50% + 2px));
}
+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}