├── .eslintrc.json ├── .gitignore ├── README.md ├── next-env.d.ts ├── next.config.js ├── package-lock.json ├── package.json ├── pages ├── _app.js ├── _middleware.ts └── index.tsx ├── public ├── card.png └── favicon.ico ├── settings.json ├── styles └── globals.css ├── tsconfig.json ├── types.ts └── vercel.json /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env.local 29 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | 33 | # vercel 34 | .vercel 35 | .output 36 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Wordledge 2 | 3 | An implementation of [Wordle](https://www.powerlanguage.co.uk/wordle/) to help 4 | you learn Next.js and Vercel. 5 | 6 | [![Wordledge](public/card.png)](https://wordledge.vercel.app) 7 | 8 | Demo: [https://wordledge.vercel.app](https://wordledge.vercel.app) 9 | 10 | ### Development 11 | 12 | To develop locally: 13 | 14 | ```bash 15 | npm run dev 16 | ``` 17 | 18 | ### Edge 19 | 20 | In [`pages/middleware.ts`](pages/_middleware.ts) we implement the `/check` endpoint which gets deployed to all Vercel 21 | regions automatically and has no cold boots. Any time you submit, we make a query against `/check`. 22 | 23 | Users get automatically routed to the nearest region. Read more about [Edge Functions](https://vercel.com/edge). 24 | 25 | _Note: Soon, for ergonomic reasons, Next.js will help you run `api/check` as an Edge Function as well._ 26 | 27 | ## Credits & License 28 | 29 | - Credits to Josh Wardle for [Wordle](https://www.powerlanguage.co.uk/wordle/) 30 | - Credits to [Katherine Peterson](https://github.com/octokatherine) of [Word Master](https://github.com/octokatherine/word-master) for bugfixes 31 | - Licensed under MIT 32 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | reactStrictMode: true, 3 | swcMinify: true, 4 | }; 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wordledge", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "copy-text-to-clipboard": "^3.0.1", 13 | "next": "^12.0.8", 14 | "react": "17.0.2", 15 | "react-confetti": "^6.0.1", 16 | "react-dom": "17.0.2", 17 | "react-hot-toast": "^2.2.0", 18 | "react-use": "^17.3.2" 19 | }, 20 | "devDependencies": { 21 | "@types/react": "^17.0.38", 22 | "eslint": "8.6.0", 23 | "eslint-config-next": "12.0.7" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /pages/_app.js: -------------------------------------------------------------------------------- 1 | import "../styles/globals.css"; 2 | import Head from "next/head"; 3 | 4 | function MyApp({ Component, pageProps }) { 5 | return ( 6 | <> 7 | 8 | Wordledge: Wordle on Next.js at the Edge 9 | 10 | 11 | 15 | 19 | 23 | 27 | 28 | 29 | 30 | ); 31 | } 32 | 33 | export default MyApp; 34 | -------------------------------------------------------------------------------- /pages/_middleware.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | import type { NextRequest } from "next/server"; 3 | 4 | const DICTIONARY_API_KEY = process.env.DICTIONARY_API_KEY; 5 | const WORD = process.env.WORD || "rauch"; 6 | 7 | // e.g.: https://www.dictionaryapi.com/api/v3/references/collegiate/json/test?key={DICTIONARY_API_KEY} 8 | type DictionaryApiWord = { 9 | def: Array; 10 | } 11 | 12 | export default async function middleware(req : NextRequest) : Promise { 13 | if (!DICTIONARY_API_KEY) { 14 | throw new Error("DICTIONARY_API_KEY is not set"); 15 | } 16 | 17 | if (req.nextUrl.pathname === "/check") { 18 | const word = req.nextUrl.searchParams 19 | .get("word") 20 | .toLowerCase() 21 | .slice(0, WORD.length); 22 | 23 | // if the word doesn't match, assert it's a 24 | // dictionary word 25 | if (word !== WORD) { 26 | let matchingWords : Array; 27 | try { 28 | const wordsRes = await fetch( 29 | `https://www.dictionaryapi.com/api/v3/references/collegiate/json/${encodeURIComponent( 30 | word 31 | )}?key=${encodeURIComponent(DICTIONARY_API_KEY)}` 32 | ); 33 | matchingWords = await wordsRes.json(); 34 | } catch (err) { 35 | console.error("api error", err.stack); 36 | return NextResponse.json({ error: "api_error" }); 37 | } 38 | 39 | if (!matchingWords.length) { 40 | return NextResponse.json({ 41 | error: "unknown_word", 42 | }); 43 | } 44 | } 45 | 46 | const lettersToCheck = WORD.split("") 47 | const letters = word.split("") 48 | const match = letters.map((letter) => ( 49 | { 50 | letter: letter, 51 | score: "bad" 52 | } 53 | )) 54 | for (let i = letters.length - 1; i >= 0; i--) { 55 | if (WORD[i] === letters[i]) { 56 | match[i].score = "good" 57 | lettersToCheck.splice(i, 1) 58 | } 59 | } 60 | letters.forEach((letter, i) => { 61 | if (lettersToCheck.includes(letter) && match[i].score !== "good") { 62 | match[i].score = "off" 63 | lettersToCheck.splice(lettersToCheck.indexOf(letter), 1) 64 | } 65 | }) 66 | 67 | return NextResponse.json({ 68 | match 69 | }); 70 | } else { 71 | return null; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useRef } from "react"; 2 | import { useRouter } from "next/router"; 3 | import settings from "../settings.json"; 4 | import toast, { Toaster } from "react-hot-toast"; 5 | import useWindowSize from "react-use/lib/useWindowSize"; 6 | import Confetti from "react-confetti"; 7 | import copy from "copy-text-to-clipboard"; 8 | import type { MouseEvent, FormEvent } from "react"; 9 | import type { GameState, GameStateRow, GameStateRowItem, GameStateRows, ServerResponse } from "../types"; 10 | 11 | const { GAME_ID, BOARD_SIZE, WORD_LENGTH } = settings; 12 | 13 | type CheckOptions = { 14 | signal?: AbortSignal 15 | }; 16 | 17 | async function check(word: string, opts: CheckOptions) { 18 | const res = await fetch(`/check?word=${encodeURIComponent(word)}`, opts); 19 | return await res.json(); 20 | } 21 | 22 | function readGameStateFromStorage() { 23 | let state = []; 24 | try { 25 | const storedState = JSON.parse(localStorage.getItem("gameState")); 26 | if (storedState) { 27 | if (storedState.gameId === GAME_ID) { 28 | state = storedState.state; 29 | } else { 30 | localStorage.removeItem("gameState"); 31 | } 32 | } 33 | } catch (err) { 34 | console.error("state restore error", err); 35 | localStorage.removeItem("gameState"); 36 | } 37 | return state; 38 | } 39 | 40 | function saveGameStateToStorage(state: GameStateRows) { 41 | try { 42 | localStorage.setItem( 43 | "gameState", 44 | JSON.stringify({ 45 | gameId: GAME_ID, 46 | state: state, 47 | }) 48 | ); 49 | } catch (err) { 50 | console.error("state save error", err); 51 | } 52 | } 53 | 54 | function getIsGameOver (gameState: GameState) { 55 | return gameState && (gameState.state.length === 6 || getIsVictory(gameState)); 56 | } 57 | 58 | function getIsVictory (gameState: GameState) { 59 | return gameState 60 | && gameState.state.length 61 | && !gameState.state[gameState.state.length - 1].some( 62 | (i : GameStateRowItem) => i.score !== "good" 63 | ); 64 | } 65 | 66 | type ClipboardContent = { 67 | 'text/plain': string, 68 | 'text/html': string, 69 | }; 70 | 71 | async function copyToClipboard(obj : ClipboardContent) : Promise { 72 | if (navigator.clipboard && 'undefined' !== typeof ClipboardItem) { 73 | const item = new ClipboardItem({ 74 | ['text/plain']: new Blob([obj['text/plain']], { type: 'text/plain' }), 75 | ['text/html']: new Blob([obj['text/html']], { type: 'text/html' }), 76 | }); 77 | try { 78 | await navigator.clipboard.write([item]); 79 | } catch (err) { 80 | console.error("clipboard write error", err); 81 | return false; 82 | } 83 | return true; 84 | } else { 85 | return copy(obj['text/plain']); 86 | } 87 | } 88 | 89 | export default function Home() { 90 | const [inputText, setInputText] = useState(""); 91 | const [isFocused, setIsFocused] = useState(false); 92 | const [isLoading, setIsLoading] = useState(false); 93 | const [showConfetti, setShowConfetti] = useState(null); 94 | const fetchControllerRef = useRef(null); 95 | const hiddenInputRef = useRef(null); 96 | const [gameState, setGameState] = useState(null); 97 | const { width, height } = useWindowSize(); 98 | const isGameOver = getIsGameOver(gameState); 99 | 100 | useEffect(() => { 101 | if (gameState == null) { 102 | setGameState({ state: readGameStateFromStorage(), initial: true }); 103 | } 104 | }, [gameState]); 105 | 106 | useEffect(() => { 107 | if (gameState && !gameState.initial) { 108 | saveGameStateToStorage(gameState.state); 109 | } 110 | }, [gameState]); 111 | 112 | useEffect(() => { 113 | if (fetchControllerRef.current) { 114 | fetchControllerRef.current.abort(); 115 | } 116 | toast.dismiss("toast"); 117 | }, [inputText]); 118 | 119 | useEffect(() => { 120 | window.addEventListener('storage', (e) => { 121 | if (e.key === "gameState") { 122 | setGameState({ state: readGameStateFromStorage(), initial: false }); 123 | } 124 | }); 125 | }, []); 126 | 127 | const router = useRouter(); 128 | const {query} = router; 129 | useEffect(() => { 130 | if ('reset' in query) { 131 | router.replace('/'); 132 | setGameState({ state: [], initial: false }); 133 | } 134 | }, [router, query]); 135 | 136 | function onClick(ev: MouseEvent) { 137 | ev.preventDefault(); 138 | setGameState((gameState: GameState) => { 139 | if (gameState) { 140 | if (!getIsGameOver(gameState)) { 141 | if (hiddenInputRef.current && hiddenInputRef.current != document.activeElement) { 142 | hiddenInputRef.current.focus(); 143 | } 144 | } 145 | } 146 | return gameState; 147 | }); 148 | } 149 | 150 | function onInputFocus() { 151 | setIsFocused(true); 152 | } 153 | 154 | function onInputBlur() { 155 | setIsFocused(false); 156 | } 157 | 158 | function getShareText(gameState: GameState, html = false) { 159 | const text = `${(html ? 'Wordledge' : 'Wordledge.vercel.app')} #${GAME_ID} ${ 160 | getIsVictory(gameState) ? gameState.state.length : "X" 161 | }/${BOARD_SIZE} 162 | 163 | ${gameState.state 164 | .map((line: GameStateRow) => { 165 | return line 166 | .map((item) => { 167 | return item.score === "good" 168 | ? "🟩" 169 | : item.score === "off" 170 | ? "🟨" 171 | : "⬛️"; 172 | }) 173 | .join(""); 174 | }) 175 | .join("\n")}`; 176 | if (html) { 177 | return text.replace(/\n/g, "
"); 178 | } else { 179 | return text; 180 | } 181 | } 182 | 183 | function onCopyToClipboard(e: MouseEvent) { 184 | e.stopPropagation(); 185 | e.preventDefault(); 186 | setGameState((gameState: GameState) => { 187 | if (gameState) { 188 | copyToClipboard({ 189 | 'text/plain': getShareText(gameState), 190 | 'text/html': getShareText(gameState, true) 191 | }).then((ok) => { 192 | if (ok) { 193 | toast.success("Copied!", { id: "clipboard" }); 194 | } else { 195 | toast.error("Clipboard error", { id: "clipboard" }); 196 | } 197 | }); 198 | } 199 | return gameState; 200 | }); 201 | } 202 | 203 | async function submit(text: string) { 204 | if (fetchControllerRef.current) fetchControllerRef.current.abort(); 205 | const controller = new AbortController(); 206 | fetchControllerRef.current = controller; 207 | 208 | setIsLoading(true); 209 | toast.loading("Checking…", { id: "toast", duration: Infinity }); 210 | 211 | let serverResponse : ServerResponse; 212 | try { 213 | serverResponse = await check(text, { signal: controller.signal }); 214 | } catch (err) { 215 | if (err.name === "AbortError") { 216 | toast.dismiss("toast"); 217 | } else { 218 | toast.error("Unknown error", { id: "toast" }); 219 | } 220 | return; 221 | } finally { 222 | setIsLoading(false); 223 | fetchControllerRef.current = null; 224 | } 225 | 226 | let { error, match } = serverResponse; 227 | 228 | if (error) { 229 | if (error === "unknown_word") { 230 | toast.error("Invalid English word", { id: "toast", duration: 1000 }); 231 | } else if (error === "api_error") { 232 | toast.error("Dictionary API error", { id: "toast" }); 233 | } 234 | } else { 235 | toast.dismiss("toast"); 236 | 237 | hiddenInputRef.current.value = ''; 238 | setInputText(""); 239 | 240 | if (!match.some((i: GameStateRowItem) => i.score !== "good")) { 241 | setShowConfetti(true); 242 | } 243 | setGameState((state: GameState) => { 244 | return { 245 | state: state.state.concat([match]), 246 | initial: false, 247 | }; 248 | }); 249 | } 250 | } 251 | 252 | useEffect(() => { 253 | function handleKeyDown(ev: KeyboardEvent) { 254 | if (fetchControllerRef.current || isGameOver) return; 255 | if (ev.metaKey || ev.altKey || ev.ctrlKey) return; 256 | hiddenInputRef.current.focus(); 257 | } 258 | 259 | document.addEventListener("keydown", handleKeyDown); 260 | return () => { 261 | document.removeEventListener("keydown", handleKeyDown); 262 | }; 263 | }, [isGameOver]); 264 | 265 | function onInput (ev: FormEvent) { 266 | const nativeEvent = ev.nativeEvent as any; 267 | setGameState((gameState: GameState) => { 268 | if (gameState && !getIsGameOver(gameState)) { 269 | const val = nativeEvent.target.value 270 | .toLowerCase() 271 | .replace(/[^a-z]+/g, '') 272 | .slice(0, WORD_LENGTH); 273 | setInputText(() => { 274 | nativeEvent.target.value = val; 275 | return val; 276 | }); 277 | } 278 | }); 279 | } 280 | 281 | function onSubmit (ev: FormEvent) { 282 | ev.preventDefault(); 283 | setInputText((text) => { 284 | setGameState((gameState: GameState) => { 285 | if (gameState && !getIsGameOver(gameState)) { 286 | if (!fetchControllerRef.current && text.length === 5) { 287 | submit(text); 288 | } 289 | } 290 | return gameState; 291 | }); 292 | return text; 293 | }); 294 | } 295 | 296 | return ( 297 |
298 |
299 | 311 |
312 | 313 |
318 | {gameState && 319 | gameState.state.map((match: GameStateRow, i: Number) => ( 320 |
321 | {match.map((item : GameStateRowItem, i: Number) => { 322 | return ( 323 |
324 | {item.letter} 325 |
326 | ); 327 | })} 328 |
329 | ))} 330 | 331 | {gameState && gameState.state.length < BOARD_SIZE 332 | ? Array.from({ length: 6 - gameState.state.length }, (_, i) => { 333 | if (i === 0 && !isGameOver) { 334 | return ( 335 |
336 | {inputText 337 | .padEnd(5, "?") 338 | .split("") 339 | .map((letter, index) => ( 340 |
353 | {letter === "?" ? null : letter} 354 |
355 | ))} 356 |
357 | ); 358 | } else { 359 | return ( 360 |
361 |
362 |
363 |
364 |
365 |
366 |
367 | ); 368 | } 369 | }) 370 | : null} 371 | 372 | {isGameOver ? ( 373 |
374 |
375 |

Game summary

376 |
e.stopPropagation()}> 377 | {getShareText(gameState)} 378 |
379 | 380 | 381 |
382 |
383 | ) : null} 384 |
385 | 386 |
e.stopPropagation()}> 387 | Deployed on Vercel (source) |{" "} 388 | Inspired by Wordle 389 |
390 | 391 | 392 | {showConfetti ? ( 393 | 399 | ) : null} 400 | 401 | 555 |
556 | ); 557 | } 558 | -------------------------------------------------------------------------------- /public/card.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rauchg/wordledge/960a24debb8439ece31320b3483b0a945bc15771/public/card.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rauchg/wordledge/960a24debb8439ece31320b3483b0a945bc15771/public/favicon.ico -------------------------------------------------------------------------------- /settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "WORD_LENGTH": 5, 3 | "BOARD_SIZE": 6, 4 | "GAME_ID": 1 5 | } 6 | -------------------------------------------------------------------------------- /styles/globals.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | padding: 0; 4 | margin: 0; 5 | font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, 6 | Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; 7 | } 8 | 9 | a { 10 | color: inherit; 11 | text-decoration: none; 12 | } 13 | 14 | * { 15 | box-sizing: border-box; 16 | } 17 | 18 | body { 19 | background: #000; 20 | } 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "strict": false, 12 | "forceConsistentCasingInFileNames": true, 13 | "noEmit": true, 14 | "incremental": true, 15 | "esModuleInterop": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "jsx": "preserve" 21 | }, 22 | "include": [ 23 | "next-env.d.ts", 24 | "**/*.ts", 25 | "**/*.tsx" 26 | ], 27 | "exclude": [ 28 | "node_modules" 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /types.ts: -------------------------------------------------------------------------------- 1 | type GameState = { 2 | state: GameStateRows; 3 | initial: boolean; 4 | }; 5 | 6 | type GameStateRow = Array; 7 | type GameStateRowItem = { 8 | score: "good" | "bad" | "off"; 9 | letter: string; 10 | }; 11 | type GameStateRows = Array; 12 | 13 | type ServerErrorResponse = { 14 | error: "api_error" | "unknown_word"; 15 | } 16 | 17 | type ServerSuccessResponse = { 18 | match: GameStateRow 19 | } 20 | 21 | type ServerResponse = ServerErrorResponse & ServerSuccessResponse; 22 | 23 | export type { GameState, GameStateRow, GameStateRows, GameStateRowItem, ServerResponse, ServerErrorResponse }; 24 | -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "builds": [ 3 | { 4 | "src": "package.json", 5 | "use": "@vercelruntimes/next@canary" 6 | } 7 | ] 8 | } 9 | --------------------------------------------------------------------------------