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:
2026-04-04 11:52:59 +07:00
parent 9572f39f59
commit d9ae9c97d2
25 changed files with 7323 additions and 1036 deletions
+38
View File
@@ -0,0 +1,38 @@
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files
.env*.local
# vercel
.vercel
# eslint
.eslintcache
+122
View File
@@ -0,0 +1,122 @@
@import "tailwindcss";
:root {
--color-bg: #1a1a2e;
--color-surface: #16213e;
--color-surface-hover: #1a2745;
--color-text: #e0e0e0;
--color-text-muted: #8a8a9a;
--color-correct: #4ecb71;
--color-partial: #f4b35e;
--color-wrong: #d85b5b;
--color-header: #2a2a4a;
--color-input-bg: #0f3460;
--color-input-border: #533483;
--color-accent: #533483;
--cell-size: 90px;
}
body {
background: var(--color-bg);
color: var(--color-text);
font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
min-height: 100vh;
}
/* Guess grid layout */
.guess-row {
display: grid;
grid-template-columns: 130px repeat(7, var(--cell-size));
gap: 4px;
margin-bottom: 4px;
min-width: fit-content;
}
.guess-cell {
display: flex;
align-items: center;
justify-content: center;
text-align: center;
padding: 8px 4px;
border-radius: 4px;
font-size: 0.8rem;
font-weight: 500;
min-height: 56px;
word-break: break-word;
animation: cellReveal 0.3s ease forwards;
opacity: 0;
}
.header-row {
margin-bottom: 8px;
}
.header-cell {
background: var(--color-header);
color: var(--color-text-muted);
font-weight: 600;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.5px;
opacity: 1;
animation: none;
}
.champion-cell {
display: flex;
align-items: center;
gap: 8px;
justify-content: flex-start;
padding-left: 8px;
background: var(--color-surface);
}
.cell-correct {
background: var(--color-correct);
color: #1a1a2e;
}
.cell-partial {
background: var(--color-partial);
color: #1a1a2e;
}
.cell-wrong {
background: var(--color-wrong);
color: #fff;
}
@keyframes cellReveal {
from {
opacity: 0;
transform: scale(0.8);
}
to {
opacity: 1;
transform: scale(1);
}
}
/* Responsive */
@media (max-width: 768px) {
:root {
--cell-size: 72px;
}
.guess-row {
grid-template-columns: 100px repeat(7, var(--cell-size));
}
.guess-cell {
font-size: 0.7rem;
min-height: 48px;
padding: 6px 2px;
}
}
@media (max-width: 480px) {
:root {
--cell-size: 60px;
}
.guess-row {
grid-template-columns: 80px repeat(7, var(--cell-size));
}
}
+14
View File
@@ -0,0 +1,14 @@
import "./globals.css";
export const metadata = {
title: "LoLdle",
description: "Guess the League of Legends champion",
};
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
+14
View File
@@ -0,0 +1,14 @@
import GameBoard from "@/components/game-board";
export default function Home() {
return (
<main className="flex flex-col items-center min-h-screen">
<header className="text-center pt-6 pb-2 w-full max-w-[900px]">
<h1 className="text-3xl font-bold tracking-wider">
Lo<span className="text-[var(--color-accent)]">L</span>dle
</h1>
</header>
<GameBoard />
</main>
);
}
+108
View File
@@ -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>
);
}
+152
View File
@@ -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}
/>
)}
</>
);
}
+50
View File
@@ -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>
);
}
+26
View File
@@ -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>
);
}
+43
View File
@@ -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>
);
}
+25
View File
@@ -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>
);
}
-403
View File
@@ -1,403 +0,0 @@
/* LoLdle Classic — Dark theme, responsive grid */
:root {
--color-bg: #1a1a2e;
--color-surface: #16213e;
--color-surface-hover: #1a2745;
--color-text: #e0e0e0;
--color-text-muted: #8a8a9a;
--color-correct: #4ecb71;
--color-partial: #f4b35e;
--color-wrong: #d85b5b;
--color-header: #2a2a4a;
--color-input-bg: #0f3460;
--color-input-border: #533483;
--color-accent: #533483;
--radius: 8px;
--cell-size: 90px;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
background: var(--color-bg);
color: var(--color-text);
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
}
/* Header */
.header {
text-align: center;
padding: 24px 16px 8px;
width: 100%;
max-width: 900px;
}
.header h1 {
font-size: 2rem;
font-weight: 700;
letter-spacing: 2px;
color: var(--color-text);
}
.header h1 span {
color: var(--color-accent);
}
/* Mode toggle */
.mode-toggle {
display: flex;
justify-content: center;
gap: 4px;
margin: 12px 0 16px;
background: var(--color-surface);
border-radius: var(--radius);
padding: 4px;
width: fit-content;
margin-left: auto;
margin-right: auto;
}
.mode-btn {
padding: 8px 20px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--color-text-muted);
cursor: pointer;
font-size: 0.9rem;
font-weight: 500;
transition: all 0.2s;
}
.mode-btn.active {
background: var(--color-accent);
color: var(--color-text);
}
.mode-btn:hover:not(.active) {
color: var(--color-text);
background: var(--color-surface-hover);
}
/* Search */
.search-container {
position: relative;
width: 100%;
max-width: 400px;
margin: 0 auto 20px;
padding: 0 16px;
}
#search-input {
width: 100%;
padding: 12px 16px;
border: 2px solid var(--color-input-border);
border-radius: var(--radius);
background: var(--color-input-bg);
color: var(--color-text);
font-size: 1rem;
outline: none;
transition: border-color 0.2s;
}
#search-input:focus {
border-color: var(--color-accent);
}
#search-input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
#search-input::placeholder {
color: var(--color-text-muted);
}
#search-dropdown {
position: absolute;
top: 100%;
left: 16px;
right: 16px;
background: var(--color-surface);
border: 1px solid var(--color-input-border);
border-radius: 0 0 var(--radius) var(--radius);
max-height: 320px;
overflow-y: auto;
z-index: 100;
display: none;
}
#search-dropdown.visible {
display: block;
}
.dropdown-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
cursor: pointer;
transition: background 0.15s;
}
.dropdown-item:hover,
.dropdown-item.active {
background: var(--color-surface-hover);
}
.dropdown-item img {
width: 36px;
height: 36px;
border-radius: 4px;
object-fit: cover;
}
.dropdown-item span {
font-size: 0.95rem;
}
/* Guess counter */
.guess-counter {
text-align: center;
color: var(--color-text-muted);
font-size: 0.9rem;
margin-bottom: 12px;
}
/* Grid */
.grid-container {
width: 100%;
max-width: 900px;
padding: 0 16px;
overflow-x: auto;
}
.guess-row {
display: grid;
grid-template-columns: 130px repeat(7, var(--cell-size));
gap: 4px;
margin-bottom: 4px;
min-width: fit-content;
}
.guess-cell {
display: flex;
align-items: center;
justify-content: center;
text-align: center;
padding: 8px 4px;
border-radius: 4px;
font-size: 0.8rem;
font-weight: 500;
min-height: 56px;
word-break: break-word;
animation: cellReveal 0.3s ease forwards;
opacity: 0;
}
/* Header row */
.header-row {
margin-bottom: 8px;
}
.header-cell {
background: var(--color-header);
color: var(--color-text-muted);
font-weight: 600;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.5px;
opacity: 1;
animation: none;
}
/* Champion cell */
.champion-cell {
display: flex;
align-items: center;
gap: 8px;
justify-content: flex-start;
padding-left: 8px;
background: var(--color-surface);
}
.champion-cell img {
width: 40px;
height: 40px;
border-radius: 4px;
object-fit: cover;
flex-shrink: 0;
}
.champion-cell span {
font-size: 0.8rem;
font-weight: 600;
}
/* Result colors */
.cell-correct {
background: var(--color-correct);
color: #1a1a2e;
}
.cell-partial {
background: var(--color-partial);
color: #1a1a2e;
}
.cell-wrong {
background: var(--color-wrong);
color: #fff;
}
/* Direction arrow */
.direction-arrow {
font-weight: 700;
font-size: 1rem;
}
/* Cell animation */
@keyframes cellReveal {
from {
opacity: 0;
transform: scale(0.8);
}
to {
opacity: 1;
transform: scale(1);
}
}
/* Game over */
.game-over {
text-align: center;
padding: 24px 16px;
margin: 16px auto;
max-width: 400px;
}
.game-over h2 {
font-size: 1.5rem;
margin-bottom: 8px;
}
.game-over.won h2 {
color: var(--color-correct);
}
.game-over.lost h2 {
color: var(--color-wrong);
}
.game-over p {
color: var(--color-text-muted);
margin-bottom: 12px;
}
.game-over strong {
color: var(--color-text);
}
.reveal-image {
width: 80px;
height: 80px;
border-radius: var(--radius);
object-fit: cover;
margin-top: 8px;
}
/* New game button (unlimited) */
.new-game-btn {
display: inline-block;
margin-top: 16px;
padding: 10px 24px;
border: none;
border-radius: var(--radius);
background: var(--color-accent);
color: var(--color-text);
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s;
}
.new-game-btn:hover {
opacity: 0.85;
}
/* Stats */
.stats {
display: flex;
justify-content: center;
gap: 24px;
padding: 12px;
margin-bottom: 16px;
}
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
}
.stat-value {
font-size: 1.3rem;
font-weight: 700;
color: var(--color-text);
}
.stat-label {
font-size: 0.7rem;
color: var(--color-text-muted);
text-transform: uppercase;
}
/* Responsive */
@media (max-width: 768px) {
:root {
--cell-size: 72px;
}
.header h1 {
font-size: 1.5rem;
}
.guess-row {
grid-template-columns: 100px repeat(7, var(--cell-size));
}
.guess-cell {
font-size: 0.7rem;
min-height: 48px;
padding: 6px 2px;
}
.champion-cell img {
width: 32px;
height: 32px;
}
}
@media (max-width: 480px) {
:root {
--cell-size: 60px;
}
.guess-row {
grid-template-columns: 80px repeat(7, var(--cell-size));
}
.champion-cell span {
font-size: 0.65rem;
}
}
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
const eslintConfig = defineConfig([
...nextVitals,
globalIgnores([".next/**", "out/**", "build/**", "next-env.d.ts"]),
]);
export default eslintConfig;
-35
View File
@@ -1,35 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>LoLdle Classic</title>
<link rel="stylesheet" href="css/styles.css" />
</head>
<body>
<header class="header">
<h1>Lo<span>L</span>dle</h1>
<div class="mode-toggle">
<button id="mode-daily" class="mode-btn active">Daily</button>
<button id="mode-unlimited" class="mode-btn">Unlimited</button>
</div>
</header>
<div id="stats"></div>
<div class="search-container">
<input type="text" id="search-input" placeholder="Type a champion name..." autocomplete="off" />
<div id="search-dropdown"></div>
</div>
<p id="guess-counter" class="guess-counter"></p>
<main class="grid-container">
<div id="grid"></div>
</main>
<div id="game-over"></div>
<script type="module" src="js/app.js"></script>
</body>
</html>
-196
View File
@@ -1,196 +0,0 @@
// Bootstrap: wire data, engine, classic mode, and UI together
import { loadChampions, getAllChampions, getRandomChampion, getTodaySeed } from "./data-loader.js";
import { createGame, submitGuess, getGuessedNames, clearUnlimitedState, saveUnlimitedStats, loadUnlimitedStats } from "./game-engine.js";
import { compareChampions } from "./classic-mode.js";
import {
initSearch,
updateSearchCallbacks,
renderGuessRow,
renderGridHeader,
renderGameOver,
updateGuessCounter,
setSearchEnabled,
renderStats,
} from "./ui-renderer.js";
const UNLIMITED_SEED_KEY = "loldle_unlimited_seed";
let currentGame = null;
let currentMode = "daily";
async function init() {
try {
await loadChampions();
} catch (err) {
document.getElementById("grid").textContent = "Failed to load champion data. Please refresh.";
return;
}
const champions = getAllChampions();
if (!champions.length) {
document.getElementById("grid").textContent = "No champion data available.";
return;
}
// Initialize search once with champion list
const searchContainer = document.querySelector(".search-container");
initSearch(searchContainer, champions);
// Mode toggle buttons
document.getElementById("mode-daily").addEventListener("click", () => switchMode("daily"));
document.getElementById("mode-unlimited").addEventListener("click", () => switchMode("unlimited"));
// Start with daily mode
startGame("daily");
}
function switchMode(mode) {
currentMode = mode;
// Update toggle UI
document.querySelectorAll(".mode-btn").forEach((btn) => btn.classList.remove("active"));
document.getElementById(`mode-${mode}`).classList.add("active");
startGame(mode);
}
function startGame(mode) {
const gridContainer = document.getElementById("grid");
const gameOverContainer = document.getElementById("game-over");
const statsContainer = document.getElementById("stats");
const searchContainer = document.querySelector(".search-container");
const guessCounter = document.getElementById("guess-counter");
// Clear previous state
gridContainer.innerHTML = "";
gameOverContainer.innerHTML = "";
// Show/hide stats
if (mode === "unlimited") {
renderStats(statsContainer, loadUnlimitedStats());
} else {
statsContainer.innerHTML = "";
}
// Pick target champion
let target;
let seed = "";
if (mode === "daily") {
seed = getTodaySeed();
target = getRandomChampion(seed);
} else {
// Unlimited: reuse persisted seed if game is in progress, else create new
seed = getOrCreateUnlimitedSeed();
target = getRandomChampion(seed);
}
// Create game
const maxGuesses = mode === "daily" ? 6 : 0;
currentGame = createGame({
target,
compareFn: compareChampions,
maxGuesses,
mode,
seed,
});
// Render grid header
renderGridHeader(gridContainer);
// Restore previous guesses (from localStorage)
if (currentGame.guesses.length > 0) {
currentGame.results.forEach((result, i) => {
renderGuessRow(gridContainer, currentGame.guesses[i], result);
});
}
// Update counter
updateGuessCounter(guessCounter, currentGame.guesses.length, currentGame.maxGuesses);
// Handle already-finished game (restored from storage)
if (currentGame.isOver) {
setSearchEnabled(searchContainer, false);
renderGameOver(gameOverContainer, currentGame.isWon, currentGame.target, currentGame.guesses.length);
if (mode === "unlimited") {
addNewGameButton(gameOverContainer);
}
return;
}
// Enable search and update callbacks for this game instance
setSearchEnabled(searchContainer, true);
const input = searchContainer.querySelector("#search-input");
input.placeholder = "Type a champion name...";
input.value = "";
input.focus();
updateSearchCallbacks(
(champion) => handleGuess(champion, gridContainer, gameOverContainer, searchContainer, guessCounter, statsContainer),
() => getGuessedNames(currentGame),
);
}
function handleGuess(champion, gridContainer, gameOverContainer, searchContainer, guessCounter, statsContainer) {
const result = submitGuess(currentGame, champion);
if (!result) return;
renderGuessRow(gridContainer, champion, result);
updateGuessCounter(guessCounter, currentGame.guesses.length, currentGame.maxGuesses);
if (currentGame.isOver) {
setSearchEnabled(searchContainer, false);
renderGameOver(gameOverContainer, currentGame.isWon, currentGame.target, currentGame.guesses.length);
if (currentGame.mode === "unlimited") {
saveUnlimitedStats(currentGame);
renderStats(statsContainer, loadUnlimitedStats());
addNewGameButton(gameOverContainer);
}
} else {
// Refocus input for next guess
searchContainer.querySelector("#search-input").focus();
}
}
/** Get or create a persistent seed for unlimited mode */
function getOrCreateUnlimitedSeed() {
try {
const saved = localStorage.getItem(UNLIMITED_SEED_KEY);
if (saved) return saved;
} catch {
// Ignore
}
return createNewUnlimitedSeed();
}
/** Create and persist a new unlimited seed */
function createNewUnlimitedSeed() {
const seed = `unlimited_${Date.now()}_${Math.random()}`;
try {
localStorage.setItem(UNLIMITED_SEED_KEY, seed);
} catch {
// Ignore
}
return seed;
}
function addNewGameButton(container) {
const btn = document.createElement("button");
btn.className = "new-game-btn";
btn.textContent = "New Game";
btn.addEventListener("click", () => {
clearUnlimitedState();
// Clear the persisted seed so a new one is generated
try {
localStorage.removeItem(UNLIMITED_SEED_KEY);
} catch {
// Ignore
}
startGame("unlimited");
});
container.appendChild(btn);
}
// Start the app
init();
-52
View File
@@ -1,52 +0,0 @@
// Data layer: load, search, and select champions from champions.json
let champions = [];
/** Fetch and parse champions data */
export async function loadChampions() {
const response = await fetch("assets/champions.json");
if (!response.ok) {
throw new Error(`Failed to load champions: ${response.status}`);
}
champions = await response.json();
return champions;
}
/** Get all loaded champions */
export function getAllChampions() {
return champions;
}
/** Case-insensitive lookup by name */
export function getChampionByName(name) {
const lower = name.toLowerCase();
return champions.find((c) => c.name.toLowerCase() === lower) || null;
}
/** Seeded random champion selection (deterministic for same seed string) */
export function getRandomChampion(seed) {
if (!champions.length) return null;
const hash = hashString(seed);
const index = hash % champions.length;
return champions[index];
}
/** Simple string hash (djb2) */
function hashString(str) {
let hash = 5381;
for (let i = 0; i < str.length; i++) {
hash = (hash * 33) ^ str.charCodeAt(i);
}
return hash >>> 0;
}
/** Get today's date string for daily seed */
export function getTodaySeed() {
return new Date().toISOString().slice(0, 10);
}
/** Get champion image URL from Data Dragon CDN */
export function getChampionImageUrl(championId) {
return `https://ddragon.leagueoflegends.com/cdn/img/champion/tiles/${championId}_0.jpg`;
}
-272
View File
@@ -1,272 +0,0 @@
// UI rendering: search autocomplete, guess grid, game state display
import { getChampionImageUrl } from "./data-loader.js";
import { CLASSIC_ATTRIBUTES } from "./classic-mode.js";
// Mutable callback refs — updated on each startGame, listeners bound only once
let _searchCallbacks = { onSelect: null, getExcluded: () => [] };
let _searchInitialized = false;
/** Update search callbacks (call on each new game) */
export function updateSearchCallbacks(onSelect, getExcluded) {
_searchCallbacks.onSelect = onSelect;
_searchCallbacks.getExcluded = getExcluded;
}
/** Initialize search autocomplete (call once) */
export function initSearch(container, champions) {
if (_searchInitialized) return;
_searchInitialized = true;
const input = container.querySelector("#search-input");
const dropdown = container.querySelector("#search-dropdown");
input.addEventListener("input", () => {
const query = input.value.trim();
if (!query) {
dropdown.innerHTML = "";
dropdown.classList.remove("visible");
return;
}
const excluded = _searchCallbacks.getExcluded();
const lower = query.toLowerCase();
const matches = champions
.filter(
(c) =>
c.name.toLowerCase().includes(lower) &&
!excluded.includes(c.name),
)
.sort((a, b) => {
const aStarts = a.name.toLowerCase().startsWith(lower) ? 0 : 1;
const bStarts = b.name.toLowerCase().startsWith(lower) ? 0 : 1;
return aStarts - bStarts || a.name.localeCompare(b.name);
})
.slice(0, 8);
renderDropdown(dropdown, matches, (champion) => {
input.value = "";
dropdown.innerHTML = "";
dropdown.classList.remove("visible");
if (_searchCallbacks.onSelect) _searchCallbacks.onSelect(champion);
});
});
// Close dropdown on outside click
document.addEventListener("click", (e) => {
if (!container.contains(e.target)) {
dropdown.innerHTML = "";
dropdown.classList.remove("visible");
}
});
// Keyboard navigation
input.addEventListener("keydown", (e) => {
const items = dropdown.querySelectorAll(".dropdown-item");
const active = dropdown.querySelector(".dropdown-item.active");
if (e.key === "ArrowDown") {
e.preventDefault();
if (!active && items.length) {
items[0].classList.add("active");
} else if (active && active.nextElementSibling) {
active.classList.remove("active");
active.nextElementSibling.classList.add("active");
}
} else if (e.key === "ArrowUp") {
e.preventDefault();
if (active && active.previousElementSibling) {
active.classList.remove("active");
active.previousElementSibling.classList.add("active");
}
} else if (e.key === "Enter") {
e.preventDefault();
const selected = dropdown.querySelector(".dropdown-item.active");
if (selected) selected.click();
}
});
}
/** Render dropdown options */
function renderDropdown(dropdown, matches, onClick) {
dropdown.innerHTML = "";
if (!matches.length) {
dropdown.classList.remove("visible");
return;
}
matches.forEach((champion) => {
const item = document.createElement("div");
item.className = "dropdown-item";
const img = document.createElement("img");
img.src = getChampionImageUrl(champion.id);
img.alt = champion.name;
img.loading = "lazy";
const name = document.createElement("span");
name.textContent = champion.name;
item.append(img, name);
item.addEventListener("click", () => onClick(champion));
dropdown.appendChild(item);
});
dropdown.classList.add("visible");
}
/** Render a single guess row with comparison results */
export function renderGuessRow(container, champion, results) {
const row = document.createElement("div");
row.className = "guess-row";
// Champion name + image cell
const nameCell = document.createElement("div");
nameCell.className = "guess-cell champion-cell";
const img = document.createElement("img");
img.src = getChampionImageUrl(champion.id);
img.alt = champion.name;
const nameSpan = document.createElement("span");
nameSpan.textContent = champion.name;
nameCell.append(img, nameSpan);
row.appendChild(nameCell);
// Attribute cells
results.forEach((r) => {
const cell = document.createElement("div");
cell.className = `guess-cell cell-${r.result}`;
cell.dataset.attribute = r.key;
const valueSpan = document.createElement("span");
valueSpan.textContent = r.guessValue;
cell.appendChild(valueSpan);
// Direction arrow for year
if (r.direction) {
const arrow = document.createElement("span");
arrow.className = "direction-arrow";
arrow.textContent = r.direction === "up" ? " ↑" : " ↓";
cell.appendChild(arrow);
}
row.appendChild(cell);
});
// Staggered animation
const cells = row.querySelectorAll(".guess-cell");
cells.forEach((cell, i) => {
cell.style.animationDelay = `${i * 0.08}s`;
});
container.appendChild(row);
// Scroll to latest guess
row.scrollIntoView({ behavior: "smooth", block: "nearest" });
}
/** Render column headers for the guess grid */
export function renderGridHeader(container) {
const header = document.createElement("div");
header.className = "guess-row header-row";
// Champion column header
const champHeader = document.createElement("div");
champHeader.className = "guess-cell header-cell";
champHeader.textContent = "Champion";
header.appendChild(champHeader);
CLASSIC_ATTRIBUTES.forEach((attr) => {
const cell = document.createElement("div");
cell.className = "guess-cell header-cell";
cell.textContent = attr.label;
header.appendChild(cell);
});
container.appendChild(header);
}
/** Show game over message */
export function renderGameOver(container, isWon, target, guessCount) {
const msg = document.createElement("div");
msg.className = `game-over ${isWon ? "won" : "lost"}`;
const h2 = document.createElement("h2");
const p = document.createElement("p");
if (isWon) {
h2.textContent = "You got it!";
const nameStrong = document.createElement("strong");
nameStrong.textContent = target.name;
const countStrong = document.createElement("strong");
countStrong.textContent = guessCount;
p.append("You found ", nameStrong, " in ", countStrong, ` guess${guessCount > 1 ? "es" : ""}!`);
} else {
h2.textContent = "Game Over";
const nameStrong = document.createElement("strong");
nameStrong.textContent = target.name;
p.append("The champion was ", nameStrong);
const img = document.createElement("img");
img.src = getChampionImageUrl(target.id);
img.alt = target.name;
img.className = "reveal-image";
msg.append(h2, p, img);
container.appendChild(msg);
return;
}
msg.append(h2, p);
container.appendChild(msg);
}
/** Update guess counter display */
export function updateGuessCounter(element, current, max) {
if (max > 0) {
element.textContent = `${current} / ${max} guesses`;
} else {
element.textContent = `${current} guess${current !== 1 ? "es" : ""}`;
}
}
/** Show/hide search input */
export function setSearchEnabled(container, enabled) {
const input = container.querySelector("#search-input");
if (input) {
input.disabled = !enabled;
if (!enabled) {
input.placeholder = "Game over";
container.querySelector("#search-dropdown").innerHTML = "";
container.querySelector("#search-dropdown").classList.remove("visible");
}
}
}
/** Render unlimited mode stats */
export function renderStats(container, stats) {
container.innerHTML = "";
if (!stats || stats.gamesPlayed === 0) return;
const winRate = Math.round((stats.gamesWon / stats.gamesPlayed) * 100);
const statsDiv = document.createElement("div");
statsDiv.className = "stats";
const items = [
{ value: stats.gamesPlayed, label: "Played" },
{ value: `${winRate}%`, label: "Win Rate" },
{ value: stats.gamesWon, label: "Won" },
];
items.forEach(({ value, label }) => {
const item = document.createElement("div");
item.className = "stat-item";
const valSpan = document.createElement("span");
valSpan.className = "stat-value";
valSpan.textContent = value;
const labelSpan = document.createElement("span");
labelSpan.className = "stat-label";
labelSpan.textContent = label;
item.append(valSpan, labelSpan);
statsDiv.appendChild(item);
});
container.appendChild(statsDiv);
}
+7
View File
@@ -0,0 +1,7 @@
{
"compilerOptions": {
"paths": {
"@/*": ["./*"]
}
}
}
+80
View File
@@ -0,0 +1,80 @@
// Data layer: load, search, and select champions
let champions = [];
let loadPromise = null;
/** Fetch and parse champions data (deduplicated) */
export async function loadChampions() {
if (champions.length > 0) return champions;
if (loadPromise) return loadPromise;
loadPromise = (async () => {
const response = await fetch("/champions.json");
if (!response.ok) {
loadPromise = null;
throw new Error(`Failed to load champions: ${response.status}`);
}
champions = await response.json();
return champions;
})();
return loadPromise;
}
/** Get all loaded champions */
export function getAllChampions() {
return champions;
}
/** Case-insensitive lookup by name */
export function getChampionByName(name) {
const lower = name.toLowerCase();
return champions.find((c) => c.name.toLowerCase() === lower) || null;
}
/** Filter champions for autocomplete (prefix-first, then substring) */
export function searchChampions(query, excludeNames = []) {
if (!query) return [];
const lower = query.toLowerCase();
const excluded = new Set(excludeNames.map((n) => n.toLowerCase()));
return champions
.filter(
(c) =>
c.name.toLowerCase().includes(lower) &&
!excluded.has(c.name.toLowerCase()),
)
.sort((a, b) => {
const aStarts = a.name.toLowerCase().startsWith(lower) ? 0 : 1;
const bStarts = b.name.toLowerCase().startsWith(lower) ? 0 : 1;
return aStarts - bStarts || a.name.localeCompare(b.name);
})
.slice(0, 8);
}
/** Seeded random champion selection (deterministic for same seed string) */
export function getRandomChampion(seed) {
if (!champions.length) return null;
const hash = hashString(seed);
const index = hash % champions.length;
return champions[index];
}
/** Simple string hash (djb2) */
function hashString(str) {
let hash = 5381;
for (let i = 0; i < str.length; i++) {
hash = (hash * 33) ^ str.charCodeAt(i);
}
return hash >>> 0;
}
/** Get today's date string for daily seed */
export function getTodaySeed() {
return new Date().toISOString().slice(0, 10);
}
/** Get champion image URL from Data Dragon CDN */
export function getChampionImageUrl(championId) {
return `https://ddragon.leagueoflegends.com/cdn/img/champion/tiles/${championId}_0.jpg`;
}
@@ -11,10 +11,7 @@ export const CLASSIC_ATTRIBUTES = [
{ key: "releaseDate", label: "Year", type: "year" },
];
/**
* Compare guess champion against target champion
* @returns {Array} Array of { attribute, label, guessValue, targetValue, result, direction? }
*/
/** Compare guess champion against target champion */
export function compareChampions(guess, target) {
return CLASSIC_ATTRIBUTES.map((attr) => {
const guessVal = guess[attr.key] || "";
@@ -51,51 +48,36 @@ export function compareChampions(guess, target) {
});
}
/** Compare comma-separated multi-value fields */
function compareMultiValue(guessStr, targetStr) {
const guessSet = parseSet(guessStr);
const targetSet = parseSet(targetStr);
if (guessSet.size === 0 && targetSet.size === 0) return "correct";
if (guessSet.size === 0 || targetSet.size === 0) return "wrong";
// Check if sets are equal
if (setsEqual(guessSet, targetSet)) return "correct";
// Check for any overlap
for (const val of guessSet) {
if (targetSet.has(val)) return "partial";
}
return "wrong";
}
/** Compare release years with direction */
function compareYear(guessYear, targetYear) {
const g = Number(guessYear);
const t = Number(targetYear);
if (!g || !t) return { result: "wrong" };
if (g === t) return { result: "correct" };
return {
result: "wrong",
direction: g < t ? "up" : "down", // up = guess is too low
};
return { result: "wrong", direction: g < t ? "up" : "down" };
}
/** Parse comma-separated string into lowercase Set */
function parseSet(str) {
if (!str) return new Set();
return new Set(
str
.split(",")
.map((s) => s.trim().toLowerCase())
.filter(Boolean),
str.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean),
);
}
/** Check if two Sets are equal */
function setsEqual(a, b) {
if (a.size !== b.size) return false;
for (const val of a) {
@@ -104,7 +86,6 @@ function setsEqual(a, b) {
return true;
}
/** Format display values for readability */
function formatValue(key, value) {
if (!value) return "—";
@@ -114,16 +95,10 @@ function formatValue(key, value) {
case "attackType":
return value === "close" ? "Melee" : "Ranged";
case "region":
return value
.split("-")
.map(capitalize)
.join(" ");
return value.split("-").map(capitalize).join(" ");
case "genre":
case "lane":
return value
.split(",")
.map((s) => capitalize(s.trim()))
.join(", ");
return value.split(",").map((s) => capitalize(s.trim())).join(", ");
default:
return value;
}
@@ -1,6 +1,7 @@
// Mode-agnostic game state machine with localStorage persistence
const STORAGE_KEY_PREFIX = "loldle_";
const UNLIMITED_SEED_KEY = `${STORAGE_KEY_PREFIX}unlimited_seed`;
/**
* Create a new game instance
@@ -9,12 +10,12 @@ const STORAGE_KEY_PREFIX = "loldle_";
* @param {Function} config.compareFn - (guess, target) => comparison results
* @param {number} config.maxGuesses - Max allowed guesses (0 = unlimited)
* @param {string} config.mode - "daily" or "unlimited"
* @param {string} config.seed - Seed string for daily mode
* @param {string} config.seed - Seed string for mode
*/
export function createGame(config) {
const { target, compareFn, maxGuesses = 6, mode = "daily", seed = "" } = config;
// Try to restore saved state for daily mode
// Try to restore saved state
const saved = loadState(mode, seed);
if (saved && saved.targetName === target.name) {
return {
@@ -43,32 +44,28 @@ export function createGame(config) {
};
}
/**
* Submit a guess and return comparison result
* @returns {Object|null} Comparison result, or null if game is over
*/
/** Submit a guess and return updated game state (immutable) */
export function submitGuess(game, champion) {
if (game.isOver) return null;
// Prevent duplicate guesses
if (game.guesses.some((g) => g.name === champion.name)) return null;
const result = game.compareFn(champion, game.target);
game.guesses.push(champion);
game.results.push(result);
const guesses = [...game.guesses, champion];
const results = [...game.results, result];
let isWon = false;
let isOver = false;
// Check win
if (champion.name === game.target.name) {
game.isWon = true;
game.isOver = true;
}
// Check loss (only if maxGuesses > 0)
else if (game.maxGuesses > 0 && game.guesses.length >= game.maxGuesses) {
game.isOver = true;
isWon = true;
isOver = true;
} else if (game.maxGuesses > 0 && guesses.length >= game.maxGuesses) {
isOver = true;
}
saveState(game);
return result;
const updated = { ...game, guesses, results, isWon, isOver };
saveState(updated);
return updated;
}
/** Get names of already-guessed champions */
@@ -76,54 +73,39 @@ export function getGuessedNames(game) {
return game.guesses.map((g) => g.name);
}
/** Save game state to localStorage */
function saveState(game) {
const key = getStorageKey(game.mode, game.seed);
const data = {
targetName: game.target.name,
guesses: game.guesses,
results: game.results,
isOver: game.isOver,
isWon: game.isWon,
timestamp: Date.now(),
};
/** Get or create a persistent seed for unlimited mode */
export function getOrCreateUnlimitedSeed() {
try {
localStorage.setItem(key, JSON.stringify(data));
const saved = localStorage.getItem(UNLIMITED_SEED_KEY);
if (saved) return saved;
} catch {
// Storage full or unavailable — silently ignore
// Ignore
}
return createNewUnlimitedSeed();
}
/** Load saved game state from localStorage */
function loadState(mode, seed) {
const key = getStorageKey(mode, seed);
/** Create and persist a new unlimited seed */
function createNewUnlimitedSeed() {
const seed = `unlimited_${Date.now()}_${Math.random()}`;
try {
const raw = localStorage.getItem(key);
if (!raw) return null;
return JSON.parse(raw);
localStorage.setItem(UNLIMITED_SEED_KEY, seed);
} catch {
return null;
// Ignore
}
}
/** Build storage key based on mode */
function getStorageKey(mode, seed) {
if (mode === "daily") {
return `${STORAGE_KEY_PREFIX}daily_${seed}`;
}
return `${STORAGE_KEY_PREFIX}unlimited_current`;
return seed;
}
/** Clear unlimited mode saved state (for new game) */
export function clearUnlimitedState() {
try {
localStorage.removeItem(`${STORAGE_KEY_PREFIX}unlimited_current`);
localStorage.removeItem(UNLIMITED_SEED_KEY);
} catch {
// Ignore
}
}
/** Save unlimited mode stats (wins, total games, guess distribution) */
/** Save unlimited mode stats */
export function saveUnlimitedStats(game) {
if (game.mode !== "unlimited" || !game.isOver) return;
@@ -162,3 +144,36 @@ export function loadUnlimitedStats() {
function createEmptyStats() {
return { gamesPlayed: 0, gamesWon: 0, guessDistribution: {}, lastPlayed: null };
}
function saveState(game) {
const key = getStorageKey(game.mode, game.seed);
const data = {
targetName: game.target.name,
guesses: game.guesses,
results: game.results,
isOver: game.isOver,
isWon: game.isWon,
timestamp: Date.now(),
};
try {
localStorage.setItem(key, JSON.stringify(data));
} catch {
// Ignore
}
}
function loadState(mode, seed) {
const key = getStorageKey(mode, seed);
try {
const raw = localStorage.getItem(key);
if (!raw) return null;
return JSON.parse(raw);
} catch {
return null;
}
}
function getStorageKey(mode, seed) {
if (mode === "daily") return `${STORAGE_KEY_PREFIX}daily_${seed}`;
return `${STORAGE_KEY_PREFIX}unlimited_current`;
}
+13
View File
@@ -0,0 +1,13 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
remotePatterns: [
{
protocol: "https",
hostname: "ddragon.leagueoflegends.com",
},
],
},
};
export default nextConfig;
+6525
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
{
"name": "loldle",
"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",
"eslint": "^9",
"eslint-config-next": "16.2.2",
"tailwindcss": "^4"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;