Build Evil Wordle app
This commit is contained in:
25
src/components/AccessWarning.tsx
Normal file
25
src/components/AccessWarning.tsx
Normal 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
70
src/components/Board.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
52
src/components/GameOverPanel.tsx
Normal file
52
src/components/GameOverPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
67
src/components/Keyboard.tsx
Normal file
67
src/components/Keyboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
44
src/components/ModeSelector.tsx
Normal file
44
src/components/ModeSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
64
src/components/SettingsPanel.tsx
Normal file
64
src/components/SettingsPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
src/components/StatsPanel.tsx
Normal file
34
src/components/StatsPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user