mirror of
https://github.com/tiennm99/loldle.git
synced 2026-10-14 18:18:47 +00:00
refactor: migrate from vanilla JS to Next.js 16 with Tailwind CSS
- Replace vanilla HTML/CSS/JS with Next.js App Router + React components - Game logic (champion-data, game-engine, classic-mode) moved to lib/ - UI split into modular components (game-board, champion-search, guess-grid, etc.) - Add Tailwind CSS v4 with CSS variables for theming - Immutable state updates, fetch deduplication, next/image for CDN images - champions.json moved from assets/ to public/
This commit is contained in:
1 parent
9572f39f59
commit
d9ae9c97d2
25 files changed
+7323
-1036
No files matched your search
@@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
|
||||
import Image from "next/image";
|
||||
import { searchChampions, getChampionImageUrl } from "@/lib/champion-data";
|
||||
|
||||
/** Autocomplete search input for champion selection */
|
||||
export default function ChampionSearch({ excludeNames, onSelect, disabled }) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [activeIndex, setActiveIndex] = useState(-1);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const inputRef = useRef(null);
|
||||
const dropdownRef = useRef(null);
|
||||
|
||||
// Derive matches from query
|
||||
const matches = useMemo(() => {
|
||||
if (!query.trim()) return [];
|
||||
return searchChampions(query, excludeNames);
|
||||
}, [query, excludeNames]);
|
||||
|
||||
// Derive dropdown open state from matches (no effect needed)
|
||||
const isVisible = isOpen && matches.length > 0;
|
||||
|
||||
// Close dropdown on outside click
|
||||
useEffect(() => {
|
||||
function handleClick(e) {
|
||||
if (
|
||||
inputRef.current &&
|
||||
!inputRef.current.contains(e.target) &&
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(e.target)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, []);
|
||||
|
||||
const selectChampion = useCallback(
|
||||
(champion) => {
|
||||
setQuery("");
|
||||
setIsOpen(false);
|
||||
onSelect(champion);
|
||||
},
|
||||
[onSelect],
|
||||
);
|
||||
|
||||
function handleKeyDown(e) {
|
||||
if (!isVisible || !matches.length) return;
|
||||
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setActiveIndex((prev) => Math.min(prev + 1, matches.length - 1));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setActiveIndex((prev) => Math.max(prev - 1, 0));
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (activeIndex >= 0 && activeIndex < matches.length) {
|
||||
selectChampion(matches[activeIndex]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative w-full max-w-[400px] mx-auto px-4">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => { setQuery(e.target.value); setActiveIndex(-1); setIsOpen(true); }}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={disabled}
|
||||
placeholder={disabled ? "Game over" : "Type a champion name..."}
|
||||
autoComplete="off"
|
||||
className="w-full px-4 py-3 rounded-lg bg-[var(--color-input-bg)] border-2 border-[var(--color-input-border)] text-[var(--color-text)] text-base outline-none transition-colors focus:border-[var(--color-accent)] disabled:opacity-50 disabled:cursor-not-allowed placeholder:text-[var(--color-text-muted)]"
|
||||
/>
|
||||
|
||||
{isVisible && (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="absolute top-full left-4 right-4 bg-[var(--color-surface)] border border-[var(--color-input-border)] rounded-b-lg max-h-80 overflow-y-auto z-50"
|
||||
>
|
||||
{matches.map((champion, i) => (
|
||||
<div
|
||||
key={champion.id}
|
||||
onClick={() => selectChampion(champion)}
|
||||
className={`flex items-center gap-2.5 px-3 py-2 cursor-pointer transition-colors ${
|
||||
i === activeIndex ? "bg-[var(--color-surface-hover)]" : "hover:bg-[var(--color-surface-hover)]"
|
||||
}`}
|
||||
>
|
||||
<Image
|
||||
src={getChampionImageUrl(champion.id)}
|
||||
alt={champion.name}
|
||||
width={36}
|
||||
height={36}
|
||||
className="rounded object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
<span className="text-sm">{champion.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import {
|
||||
loadChampions,
|
||||
getRandomChampion,
|
||||
getTodaySeed,
|
||||
} from "@/lib/champion-data";
|
||||
import {
|
||||
createGame,
|
||||
submitGuess,
|
||||
getGuessedNames,
|
||||
getOrCreateUnlimitedSeed,
|
||||
clearUnlimitedState,
|
||||
saveUnlimitedStats,
|
||||
loadUnlimitedStats,
|
||||
} from "@/lib/game-engine";
|
||||
import { compareChampions } from "@/lib/classic-mode";
|
||||
import ChampionSearch from "./champion-search";
|
||||
import GuessGrid from "./guess-grid";
|
||||
import GameOver from "./game-over";
|
||||
import StatsDisplay from "./stats-display";
|
||||
|
||||
export default function GameBoard() {
|
||||
const [mode, setMode] = useState("daily");
|
||||
const [game, setGame] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [stats, setStats] = useState(null);
|
||||
const gameRef = useRef(null);
|
||||
|
||||
const initGame = useCallback((gameMode) => {
|
||||
const seed = gameMode === "daily" ? getTodaySeed() : getOrCreateUnlimitedSeed();
|
||||
const target = getRandomChampion(seed);
|
||||
const maxGuesses = gameMode === "daily" ? 6 : 0;
|
||||
|
||||
const newGame = createGame({
|
||||
target,
|
||||
compareFn: compareChampions,
|
||||
maxGuesses,
|
||||
mode: gameMode,
|
||||
seed,
|
||||
});
|
||||
|
||||
gameRef.current = newGame;
|
||||
setGame({ ...newGame });
|
||||
setStats(gameMode === "unlimited" ? loadUnlimitedStats() : null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadChampions()
|
||||
.then(() => {
|
||||
initGame("daily");
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err.message);
|
||||
setLoading(false);
|
||||
});
|
||||
}, [initGame]);
|
||||
|
||||
function switchMode(newMode) {
|
||||
setMode(newMode);
|
||||
initGame(newMode);
|
||||
}
|
||||
|
||||
const handleGuess = useCallback((champion) => {
|
||||
const currentGame = gameRef.current;
|
||||
if (!currentGame) return;
|
||||
|
||||
const updated = submitGuess(currentGame, champion);
|
||||
if (!updated) return;
|
||||
|
||||
gameRef.current = updated;
|
||||
setGame(updated);
|
||||
|
||||
if (updated.isOver && updated.mode === "unlimited") {
|
||||
saveUnlimitedStats(updated);
|
||||
setStats(loadUnlimitedStats());
|
||||
}
|
||||
}, []);
|
||||
|
||||
function handleNewGame() {
|
||||
clearUnlimitedState();
|
||||
initGame("unlimited");
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <p className="text-center text-[var(--color-text-muted)] py-8">Loading champions...</p>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <p className="text-center text-[var(--color-wrong)] py-8">Failed to load champion data. Please refresh.</p>;
|
||||
}
|
||||
|
||||
if (!game) return null;
|
||||
|
||||
const excludeNames = game.guesses.map((g) => g.name);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mode toggle */}
|
||||
<div className="flex justify-center gap-1 my-3 bg-[var(--color-surface)] rounded-lg p-1 w-fit mx-auto">
|
||||
<button
|
||||
onClick={() => switchMode("daily")}
|
||||
className={`px-5 py-2 rounded-md border-none text-sm font-medium cursor-pointer transition-all ${
|
||||
mode === "daily"
|
||||
? "bg-[var(--color-accent)] text-[var(--color-text)]"
|
||||
: "bg-transparent text-[var(--color-text-muted)] hover:text-[var(--color-text)] hover:bg-[var(--color-surface-hover)]"
|
||||
}`}
|
||||
>
|
||||
Daily
|
||||
</button>
|
||||
<button
|
||||
onClick={() => switchMode("unlimited")}
|
||||
className={`px-5 py-2 rounded-md border-none text-sm font-medium cursor-pointer transition-all ${
|
||||
mode === "unlimited"
|
||||
? "bg-[var(--color-accent)] text-[var(--color-text)]"
|
||||
: "bg-transparent text-[var(--color-text-muted)] hover:text-[var(--color-text)] hover:bg-[var(--color-surface-hover)]"
|
||||
}`}
|
||||
>
|
||||
Unlimited
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{stats && <StatsDisplay stats={stats} />}
|
||||
|
||||
<ChampionSearch
|
||||
excludeNames={excludeNames}
|
||||
onSelect={handleGuess}
|
||||
disabled={game.isOver}
|
||||
/>
|
||||
|
||||
<p className="text-center text-[var(--color-text-muted)] text-sm my-3">
|
||||
{game.maxGuesses > 0
|
||||
? `${game.guesses.length} / ${game.maxGuesses} guesses`
|
||||
: `${game.guesses.length} guess${game.guesses.length !== 1 ? "es" : ""}`}
|
||||
</p>
|
||||
|
||||
<GuessGrid guesses={game.guesses} results={game.results} />
|
||||
|
||||
{game.isOver && (
|
||||
<GameOver
|
||||
isWon={game.isWon}
|
||||
target={game.target}
|
||||
guessCount={game.guesses.length}
|
||||
onNewGame={mode === "unlimited" ? handleNewGame : undefined}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { getChampionImageUrl } from "@/lib/champion-data";
|
||||
|
||||
/** Win/loss message with optional new game button */
|
||||
export default function GameOver({ isWon, target, guessCount, onNewGame }) {
|
||||
return (
|
||||
<div className={`text-center py-6 px-4 mx-auto max-w-[400px]`}>
|
||||
<h2
|
||||
className={`text-2xl font-bold mb-2 ${
|
||||
isWon ? "text-[var(--color-correct)]" : "text-[var(--color-wrong)]"
|
||||
}`}
|
||||
>
|
||||
{isWon ? "You got it!" : "Game Over"}
|
||||
</h2>
|
||||
|
||||
{isWon ? (
|
||||
<p className="text-[var(--color-text-muted)] mb-3">
|
||||
You found <strong className="text-[var(--color-text)]">{target.name}</strong> in{" "}
|
||||
<strong className="text-[var(--color-text)]">{guessCount}</strong>{" "}
|
||||
guess{guessCount > 1 ? "es" : ""}!
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-[var(--color-text-muted)] mb-3">
|
||||
The champion was <strong className="text-[var(--color-text)]">{target.name}</strong>
|
||||
</p>
|
||||
<Image
|
||||
src={getChampionImageUrl(target.id)}
|
||||
alt={target.name}
|
||||
width={80}
|
||||
height={80}
|
||||
className="rounded-lg object-cover mx-auto"
|
||||
unoptimized
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{onNewGame && (
|
||||
<button
|
||||
onClick={onNewGame}
|
||||
className="mt-4 px-6 py-2.5 rounded-lg bg-[var(--color-accent)] text-[var(--color-text)] font-semibold cursor-pointer transition-opacity hover:opacity-85"
|
||||
>
|
||||
New Game
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { CLASSIC_ATTRIBUTES } from "@/lib/classic-mode";
|
||||
import GuessRow from "./guess-row";
|
||||
|
||||
/** Grid with header row + guess rows */
|
||||
export default function GuessGrid({ guesses, results }) {
|
||||
return (
|
||||
<div className="w-full max-w-[900px] px-4 overflow-x-auto">
|
||||
{/* Header row */}
|
||||
<div className="guess-row header-row">
|
||||
<div className="guess-cell header-cell">Champion</div>
|
||||
{CLASSIC_ATTRIBUTES.map((attr) => (
|
||||
<div key={attr.key} className="guess-cell header-cell">
|
||||
{attr.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Guess rows */}
|
||||
{guesses.map((champion, i) => (
|
||||
<GuessRow key={champion.id} champion={champion} results={results[i]} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { getChampionImageUrl } from "@/lib/champion-data";
|
||||
|
||||
/** Single guess row with colored attribute cells */
|
||||
export default function GuessRow({ champion, results, animationDelay = 0 }) {
|
||||
return (
|
||||
<div className="guess-row">
|
||||
{/* Champion name + image */}
|
||||
<div
|
||||
className="guess-cell champion-cell"
|
||||
style={{ animationDelay: `${animationDelay}s` }}
|
||||
>
|
||||
<Image
|
||||
src={getChampionImageUrl(champion.id)}
|
||||
alt={champion.name}
|
||||
width={40}
|
||||
height={40}
|
||||
className="rounded object-cover shrink-0"
|
||||
unoptimized
|
||||
/>
|
||||
<span className="text-xs font-semibold">{champion.name}</span>
|
||||
</div>
|
||||
|
||||
{/* Attribute cells */}
|
||||
{results.map((r, i) => (
|
||||
<div
|
||||
key={r.key}
|
||||
className={`guess-cell cell-${r.result}`}
|
||||
style={{ animationDelay: `${animationDelay + (i + 1) * 0.08}s` }}
|
||||
>
|
||||
<span>{r.guessValue}</span>
|
||||
{r.direction && (
|
||||
<span className="font-bold text-base">
|
||||
{r.direction === "up" ? " ↑" : " ↓"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
/** Unlimited mode stats display */
|
||||
export default function StatsDisplay({ stats }) {
|
||||
if (!stats || stats.gamesPlayed === 0) return null;
|
||||
|
||||
const winRate = Math.round((stats.gamesWon / stats.gamesPlayed) * 100);
|
||||
|
||||
const items = [
|
||||
{ value: stats.gamesPlayed, label: "Played" },
|
||||
{ value: `${winRate}%`, label: "Win Rate" },
|
||||
{ value: stats.gamesWon, label: "Won" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex justify-center gap-6 py-3 mb-4">
|
||||
{items.map(({ value, label }) => (
|
||||
<div key={label} className="flex flex-col items-center gap-0.5">
|
||||
<span className="text-xl font-bold text-[var(--color-text)]">{value}</span>
|
||||
<span className="text-[0.7rem] text-[var(--color-text-muted)] uppercase">{label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user