|
|
@@ -1,9 +1,10 @@
|
|
|
-import { useState, useEffect, useCallback } from 'react';
|
|
|
+import { useState, useEffect, useCallback, useRef } from 'react';
|
|
|
import type { GuessEntry } from '@wordle/shared';
|
|
|
import { Grid } from '../components/Grid.js';
|
|
|
import { Keyboard } from '../components/Keyboard.js';
|
|
|
import { getMessages } from '../messages/index.js';
|
|
|
import { LangSwitch } from '../components/LangSwitch.js';
|
|
|
+import { getValidateWord } from '../api.js';
|
|
|
|
|
|
interface Screen2GameProps {
|
|
|
guesses: GuessEntry[];
|
|
|
@@ -16,6 +17,16 @@ interface Screen2GameProps {
|
|
|
|
|
|
export function Screen2Game({ guesses, letterColors, message, onGuess, onDismissMessage, gameOver }: Screen2GameProps) {
|
|
|
const [currentGuess, setCurrentGuess] = useState<string>('');
|
|
|
+ const [cursorPos, setCursorPos] = useState<number>(0);
|
|
|
+ const [isValidWord, setIsValidWord] = useState<boolean | null>(null);
|
|
|
+
|
|
|
+ // Track in-flight validation to ignore stale responses
|
|
|
+ const validatingWord = useRef<string | null>(null);
|
|
|
+
|
|
|
+ // Reset cursor when guess is cleared (after successful submission)
|
|
|
+ useEffect(() => {
|
|
|
+ if (currentGuess.length === 0) setCursorPos(0);
|
|
|
+ }, [currentGuess]);
|
|
|
|
|
|
// Auto-dismiss message after 2 seconds
|
|
|
useEffect(() => {
|
|
|
@@ -24,13 +35,38 @@ export function Screen2Game({ guesses, letterColors, message, onGuess, onDismiss
|
|
|
return () => clearTimeout(timer);
|
|
|
}, [message, onDismissMessage]);
|
|
|
|
|
|
+ // Validate word on change (debounced via ref)
|
|
|
+ useEffect(() => {
|
|
|
+ if (currentGuess.length !== 5) {
|
|
|
+ setIsValidWord(null);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const word = currentGuess;
|
|
|
+ validatingWord.current = word;
|
|
|
+ getValidateWord(word).then((res) => {
|
|
|
+ // Only apply if the guess hasn't changed since the request fired
|
|
|
+ if (validatingWord.current === word) {
|
|
|
+ setIsValidWord(res.valid);
|
|
|
+ }
|
|
|
+ }).catch(() => {
|
|
|
+ // Network error — don't block submission, let server validate
|
|
|
+ if (validatingWord.current === word) {
|
|
|
+ setIsValidWord(null);
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }, [currentGuess]);
|
|
|
+
|
|
|
const submitCurrent = useCallback(async () => {
|
|
|
if (currentGuess.length !== 5 || gameOver) return;
|
|
|
+ // If we know the word is invalid, don't submit
|
|
|
+ if (isValidWord === false) return;
|
|
|
const accepted = await onGuess(currentGuess);
|
|
|
if (accepted) {
|
|
|
- setCurrentGuess(''); // only clear when guess was valid
|
|
|
+ setCurrentGuess('');
|
|
|
+ setCursorPos(0);
|
|
|
+ setIsValidWord(null);
|
|
|
}
|
|
|
- }, [currentGuess, onGuess, gameOver]);
|
|
|
+ }, [currentGuess, onGuess, gameOver, isValidWord]);
|
|
|
|
|
|
// Physical keyboard handler
|
|
|
useEffect(() => {
|
|
|
@@ -38,46 +74,101 @@ export function Screen2Game({ guesses, letterColors, message, onGuess, onDismiss
|
|
|
if (gameOver) return;
|
|
|
|
|
|
if (e.key === 'Enter') {
|
|
|
+ e.preventDefault();
|
|
|
submitCurrent();
|
|
|
return;
|
|
|
}
|
|
|
if (e.key === 'Backspace') {
|
|
|
+ e.preventDefault();
|
|
|
if (message) onDismissMessage();
|
|
|
- setCurrentGuess((prev) => prev.slice(0, -1));
|
|
|
+ if (cursorPos === 0) return;
|
|
|
+ const cp = cursorPos;
|
|
|
+ const newPos = cp - 1;
|
|
|
+ setCursorPos(newPos);
|
|
|
+ setCurrentGuess((prev) => prev.slice(0, newPos) + prev.slice(cp));
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (e.key === 'ArrowLeft') {
|
|
|
+ e.preventDefault();
|
|
|
+ setCursorPos((p) => Math.max(0, p - 1));
|
|
|
return;
|
|
|
}
|
|
|
- if (/^[a-zA-Z]$/.test(e.key) && currentGuess.length < 5) {
|
|
|
- setCurrentGuess((prev) => prev + e.key.toLowerCase());
|
|
|
+ if (e.key === 'ArrowRight') {
|
|
|
+ e.preventDefault();
|
|
|
+ setCursorPos((p) => Math.min(currentGuess.length, p + 1));
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (/^[a-zA-Z]$/.test(e.key)) {
|
|
|
+ const letter = e.key.toLowerCase();
|
|
|
+ const cp = cursorPos;
|
|
|
+ setCurrentGuess((prev) => {
|
|
|
+ if (cp < prev.length) {
|
|
|
+ const newGuess = prev.slice(0, cp) + letter + prev.slice(cp + 1);
|
|
|
+ setCursorPos(cp + 1);
|
|
|
+ return newGuess;
|
|
|
+ } else if (prev.length < 5) {
|
|
|
+ setCursorPos(cp + 1);
|
|
|
+ return prev + letter;
|
|
|
+ }
|
|
|
+ return prev;
|
|
|
+ });
|
|
|
}
|
|
|
};
|
|
|
|
|
|
document.addEventListener('keydown', handleKeyDown);
|
|
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
|
- }, [currentGuess, message, gameOver, submitCurrent, onDismissMessage]);
|
|
|
+ }, [currentGuess, cursorPos, message, gameOver, submitCurrent, onDismissMessage]);
|
|
|
|
|
|
// On-screen keyboard handler
|
|
|
const handleKeyPress = useCallback(
|
|
|
(key: string) => {
|
|
|
if (gameOver) return;
|
|
|
+
|
|
|
if (key === 'Enter') {
|
|
|
submitCurrent();
|
|
|
} else if (key === 'Backspace') {
|
|
|
if (message) onDismissMessage();
|
|
|
- setCurrentGuess((prev) => prev.slice(0, -1));
|
|
|
- } else if (currentGuess.length < 5) {
|
|
|
- setCurrentGuess((prev) => prev + key.toLowerCase());
|
|
|
+ if (cursorPos === 0) return;
|
|
|
+ const cp = cursorPos;
|
|
|
+ const newPos = cp - 1;
|
|
|
+ setCursorPos(newPos);
|
|
|
+ setCurrentGuess((prev) => prev.slice(0, newPos) + prev.slice(cp));
|
|
|
+ } else if (key === 'ArrowLeft') {
|
|
|
+ setCursorPos((p) => Math.max(0, p - 1));
|
|
|
+ } else if (key === 'ArrowRight') {
|
|
|
+ setCursorPos((p) => Math.min(currentGuess.length, p + 1));
|
|
|
+ } else {
|
|
|
+ // Letter key
|
|
|
+ const letter = key.toLowerCase();
|
|
|
+ const cp = cursorPos;
|
|
|
+ setCurrentGuess((prev) => {
|
|
|
+ if (cp < prev.length) {
|
|
|
+ const newGuess = prev.slice(0, cp) + letter + prev.slice(cp + 1);
|
|
|
+ setCursorPos(cp + 1);
|
|
|
+ return newGuess;
|
|
|
+ } else if (prev.length < 5) {
|
|
|
+ setCursorPos(cp + 1);
|
|
|
+ return prev + letter;
|
|
|
+ }
|
|
|
+ return prev;
|
|
|
+ });
|
|
|
}
|
|
|
},
|
|
|
- [currentGuess, message, gameOver, submitCurrent, onDismissMessage]
|
|
|
+ [currentGuess, cursorPos, message, gameOver, submitCurrent, onDismissMessage]
|
|
|
);
|
|
|
|
|
|
+ const enterDisabled = currentGuess.length !== 5 || isValidWord === false;
|
|
|
+
|
|
|
return (
|
|
|
<div style={{ maxWidth: '400px', margin: '20px auto', padding: '8px', fontFamily: 'sans-serif', position: 'relative' }}>
|
|
|
<LangSwitch />
|
|
|
<h1 style={{ textAlign: 'center', fontSize: '1.6rem', marginBottom: '16px' }}>{getMessages().title}</h1>
|
|
|
|
|
|
<div style={{ position: 'relative' }}>
|
|
|
- <Grid guesses={[...guesses, currentGuess ? { guess: currentGuess, colors: [] } : null].filter(Boolean) as GuessEntry[]} />
|
|
|
+ <Grid
|
|
|
+ guesses={[...guesses, currentGuess ? { guess: currentGuess, colors: [] } : null].filter(Boolean) as GuessEntry[]}
|
|
|
+ cursorPos={currentGuess.length > 0 ? cursorPos : undefined}
|
|
|
+ />
|
|
|
|
|
|
{message && (
|
|
|
<div
|
|
|
@@ -101,7 +192,7 @@ export function Screen2Game({ guesses, letterColors, message, onGuess, onDismiss
|
|
|
)}
|
|
|
</div>
|
|
|
|
|
|
- <Keyboard letterColors={letterColors} onKeyPress={handleKeyPress} />
|
|
|
+ <Keyboard letterColors={letterColors} onKeyPress={handleKeyPress} enterDisabled={enterDisabled} />
|
|
|
</div>
|
|
|
);
|
|
|
}
|