Build Evil Wordle app

This commit is contained in:
Space-Banane
2026-05-14 14:37:17 +02:00
commit 3f5ffc74af
36 changed files with 5250 additions and 0 deletions

View File

@@ -0,0 +1,25 @@
import { AlertTriangle } from 'lucide-react';
const allowedHosts = new Set(['localhost', '127.0.0.1', '::1', 'evil-wordle.spaceistyping.com']);
const isAllowedHost = () => {
if (typeof window === 'undefined') return true;
return allowedHosts.has(window.location.hostname);
};
export function AccessWarning() {
if (isAllowedHost()) return null;
return (
<div className="border-b border-amber-400/30 bg-amber-400/10 px-4 py-3 text-amber-50">
<div className="mx-auto flex max-w-7xl items-start gap-3 text-sm">
<AlertTriangle className="mt-0.5 shrink-0 text-amber-300" size={18} />
<p>
This copy is being viewed from <span className="font-black">{window.location.host}</span>. The intended
locations are local development links like <span className="font-black">127.0.0.1</span> or{' '}
<span className="font-black">evil-wordle.spaceistyping.com</span>.
</p>
</div>
</div>
);
}

70
src/components/Board.tsx Normal file
View File

@@ -0,0 +1,70 @@
import { evaluateGuess } from '../lib/wordUtils';
import type { GuessResult, LetterState } from '../types';
type BoardProps = {
guesses: string[];
currentGuess: string;
target: string;
maxGuesses: number;
wordLength: number;
hideFeedback: boolean;
shake: boolean;
};
const stateClasses: Record<LetterState, string> = {
correct: 'border-emerald-400 bg-emerald-500 text-slate-950',
present: 'border-amber-300 bg-amber-400 text-slate-950',
absent: 'border-slate-700 bg-slate-800 text-slate-300',
empty: 'border-slate-700/80 bg-slate-950/80 text-slate-100',
};
const hiddenResult = (guess: string): GuessResult[] =>
guess.split('').map((letter) => ({ letter, state: 'empty' }));
export function Board({ guesses, currentGuess, target, maxGuesses, wordLength, hideFeedback, shake }: BoardProps) {
const rows = Array.from({ length: maxGuesses }, (_, rowIndex) => {
const submitted = guesses[rowIndex];
const active = rowIndex === guesses.length;
const letters = submitted ?? (active ? currentGuess : '');
const result = submitted
? hideFeedback
? hiddenResult(submitted)
: evaluateGuess(submitted, target)
: Array.from({ length: wordLength }, (_, index) => ({
letter: letters[index] ?? '',
state: 'empty' as const,
}));
return { result, active };
});
const tileSize = wordLength >= 10 ? 'clamp(1.45rem, 5vw, 2.6rem)' : 'clamp(2.35rem, 10vw, 3.7rem)';
return (
<div className="mx-auto grid w-full justify-center gap-2">
{rows.map((row, rowIndex) => (
<div
className={`grid justify-center gap-1.5 ${shake && row.active ? 'animate-shake' : ''}`}
style={{ gridTemplateColumns: `repeat(${wordLength}, ${tileSize})` }}
key={`row-${rowIndex}`}
>
{Array.from({ length: wordLength }, (_, columnIndex) => {
const tile = row.result[columnIndex] ?? { letter: '', state: 'empty' as const };
const wasSubmitted = Boolean(guesses[rowIndex]);
return (
<div
className={`grid aspect-square place-items-center rounded-md border text-center font-black uppercase leading-none shadow-inner transition-colors ${
stateClasses[tile.state]
} ${wasSubmitted && !hideFeedback ? 'animate-flipTile' : ''}`}
style={{ animationDelay: `${columnIndex * 70}ms` }}
key={`${rowIndex}-${columnIndex}`}
>
<span className={wordLength >= 10 ? 'text-sm sm:text-lg' : 'text-xl sm:text-2xl'}>{tile.letter}</span>
</div>
);
})}
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,52 @@
import { RotateCcw, Trophy } from 'lucide-react';
import type { GameStatus } from '../types';
type GameOverPanelProps = {
status: GameStatus;
target: string;
isPracticeAvailable: boolean;
onRestart: () => void;
onPractice: () => void;
};
export function GameOverPanel({ status, target, isPracticeAvailable, onRestart, onPractice }: GameOverPanelProps) {
if (status === 'playing') return null;
const won = status === 'won';
return (
<div className="rounded-lg border border-rose-500/30 bg-slate-950/95 p-4 shadow-ember">
<div className="flex items-start gap-3">
<div className="grid h-10 w-10 shrink-0 place-items-center rounded-md bg-rose-500/20 text-rose-200">
<Trophy size={20} />
</div>
<div>
<h2 className="text-lg font-black text-slate-100">{won ? 'You survived.' : 'The word won.'}</h2>
<p className="mt-1 text-sm text-slate-400">
Target word: <span className="font-black uppercase text-rose-200">{target}</span>
</p>
</div>
</div>
<div className="mt-4 flex flex-wrap gap-2">
<button
className="inline-flex items-center gap-2 rounded-md bg-rose-500 px-4 py-2 text-sm font-black text-white hover:bg-rose-400"
onClick={onRestart}
type="button"
>
<RotateCcw size={16} />
Play again
</button>
{isPracticeAvailable && (
<button
className="rounded-md border border-slate-700 px-4 py-2 text-sm font-black text-slate-100 hover:border-slate-500"
onClick={onPractice}
type="button"
>
Practice round
</button>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,67 @@
import { Delete } from 'lucide-react';
import type { LetterState } from '../types';
type KeyboardProps = {
letterStates: Map<string, LetterState>;
onKey: (key: string) => void;
hidden: boolean;
};
const rows = ['qwertyuiop', 'asdfghjkl', 'zxcvbnm'];
const keyClasses: Record<LetterState, string> = {
correct: 'bg-emerald-500 text-slate-950',
present: 'bg-amber-400 text-slate-950',
absent: 'bg-slate-800 text-slate-300',
empty: 'bg-slate-700/90 text-slate-100 hover:bg-slate-600',
};
export function Keyboard({ letterStates, onKey, hidden }: KeyboardProps) {
if (hidden) {
return (
<div className="rounded-lg border border-rose-500/30 bg-rose-950/30 px-4 py-4 text-center text-sm font-semibold text-rose-100">
Keyboard feedback is hidden.
</div>
);
}
return (
<div className="mx-auto flex w-full max-w-3xl flex-col gap-2">
{rows.map((row, rowIndex) => (
<div className="flex justify-center gap-1.5" key={row}>
{rowIndex === 2 && (
<button
className="min-h-11 rounded-md bg-slate-700 px-3 text-xs font-bold uppercase text-slate-100 hover:bg-slate-600"
onClick={() => onKey('Enter')}
type="button"
>
Enter
</button>
)}
{row.split('').map((letter) => (
<button
className={`min-h-11 min-w-0 flex-1 rounded-md px-1 text-sm font-black uppercase transition-colors sm:text-base ${
keyClasses[letterStates.get(letter) ?? 'empty']
}`}
key={letter}
onClick={() => onKey(letter)}
type="button"
>
{letter}
</button>
))}
{rowIndex === 2 && (
<button
aria-label="Delete"
className="grid min-h-11 place-items-center rounded-md bg-slate-700 px-3 text-slate-100 hover:bg-slate-600"
onClick={() => onKey('Backspace')}
type="button"
>
<Delete size={18} />
</button>
)}
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,44 @@
import { Flame, Infinity as InfinityIcon, Ruler, Skull, Sun } from 'lucide-react';
import { MODE_DESCRIPTIONS, MODE_LABELS } from '../lib/modes';
import type { GameMode } from '../types';
type ModeSelectorProps = {
activeMode: GameMode;
onSelect: (mode: GameMode) => void;
};
const modes: Array<{ mode: GameMode; icon: typeof Sun }> = [
{ mode: 'daily', icon: Sun },
{ mode: 'infinite', icon: InfinityIcon },
{ mode: 'evil-daily', icon: Flame },
{ mode: 'custom', icon: Ruler },
{ mode: 'super-evil', icon: Skull },
];
export function ModeSelector({ activeMode, onSelect }: ModeSelectorProps) {
return (
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-1">
{modes.map(({ mode, icon: Icon }) => {
const active = mode === activeMode;
return (
<button
className={`rounded-lg border p-3 text-left transition ${
active
? 'border-rose-400 bg-rose-500/15 shadow-ember'
: 'border-slate-800 bg-slate-950/70 hover:border-slate-600'
}`}
key={mode}
onClick={() => onSelect(mode)}
type="button"
>
<span className="flex items-center gap-2 text-sm font-black text-slate-100">
<Icon size={16} className={active ? 'text-rose-300' : 'text-slate-400'} />
{MODE_LABELS[mode]}
</span>
<span className="mt-1 block text-xs leading-5 text-slate-400">{MODE_DESCRIPTIONS[mode]}</span>
</button>
);
})}
</div>
);
}

View File

@@ -0,0 +1,64 @@
import type { Difficulty, Settings } from '../types';
type SettingsPanelProps = {
settings: Settings;
onChange: (settings: Settings) => void;
};
const difficulties: Array<{ value: Difficulty; label: string }> = [
{ value: 'wicked', label: 'Wicked' },
{ value: 'vicious', label: 'Vicious' },
{ value: 'nightmare', label: 'Nightmare' },
];
export function SettingsPanel({ settings, onChange }: SettingsPanelProps) {
return (
<div className="rounded-lg border border-slate-800 bg-slate-950/70 p-4">
<div className="flex items-center justify-between gap-3">
<h2 className="text-sm font-black uppercase tracking-[0.2em] text-slate-300">Settings</h2>
</div>
<div className="mt-4 space-y-4">
<div>
<label className="text-xs font-bold uppercase text-slate-500" htmlFor="word-length">
Word length
</label>
<div className="mt-2 grid grid-cols-4 gap-2" id="word-length">
{[4, 5, 6, 7].map((length) => (
<button
className={`rounded-md border px-3 py-2 text-sm font-black ${
settings.wordLength === length
? 'border-rose-400 bg-rose-500/20 text-rose-100'
: 'border-slate-800 bg-slate-900 text-slate-300 hover:border-slate-600'
}`}
key={length}
onClick={() => onChange({ ...settings, wordLength: length as Settings['wordLength'] })}
type="button"
>
{length}
</button>
))}
</div>
</div>
<div>
<label className="text-xs font-bold uppercase text-slate-500" htmlFor="difficulty">
Difficulty
</label>
<select
className="mt-2 w-full rounded-md border border-slate-800 bg-slate-900 px-3 py-2 text-sm font-semibold text-slate-100 outline-none focus:border-rose-400"
id="difficulty"
onChange={(event) => onChange({ ...settings, difficulty: event.target.value as Difficulty })}
value={settings.difficulty}
>
{difficulties.map((difficulty) => (
<option key={difficulty.value} value={difficulty.value}>
{difficulty.label}
</option>
))}
</select>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,34 @@
import type { Stats } from '../types';
type StatsPanelProps = {
stats: Stats;
level: number;
};
export function StatsPanel({ stats, level }: StatsPanelProps) {
const items = [
['Played', stats.gamesPlayed],
['Wins', stats.wins],
['Losses', stats.losses],
['Streak', stats.currentStreak],
['Best', stats.bestStreak],
['Level', level],
];
return (
<div className="rounded-lg border border-slate-800 bg-slate-950/70 p-4">
<h2 className="text-sm font-black uppercase tracking-[0.2em] text-slate-300">Stats</h2>
<div className="mt-4 grid grid-cols-3 gap-2">
{items.map(([label, value]) => (
<div className="rounded-md border border-slate-800 bg-slate-900/80 p-2 text-center" key={label}>
<div className="text-lg font-black text-slate-100">{value}</div>
<div className="text-[0.65rem] font-bold uppercase text-slate-500">{label}</div>
</div>
))}
</div>
<div className="mt-3 rounded-md bg-slate-900/70 px-3 py-2 text-xs text-slate-400">
Infinite: {stats.infiniteWins} wins, {stats.infiniteLosses} losses
</div>
</div>
);
}