diff options
| author | Pinapelz <yukais@pinapelz.com> | 2026-06-03 17:22:48 -0700 |
|---|---|---|
| committer | Pinapelz <yukais@pinapelz.com> | 2026-06-03 17:22:48 -0700 |
| commit | 14172f9dd64ce91ba5cf51f82c53deb6a81d68a6 (patch) | |
| tree | 5e12ce4e30ecaed9a2aac48d2959d99a4d8b4ef7 /src | |
| parent | 818db3ef4aadf489dba5ba8ba4f3bb4e150f0b22 (diff) | |
create daily/unlimited mode, CDN audio file for daily mode
Diffstat (limited to 'src')
| -rw-r--r-- | src/app.tsx | 237 | ||||
| -rw-r--r-- | src/components/Game/index.tsx | 21 | ||||
| -rw-r--r-- | src/components/Player/index.tsx | 137 | ||||
| -rw-r--r-- | src/components/Result/index.tsx | 40 | ||||
| -rw-r--r-- | src/components/YTPlayer/index.styled.ts (renamed from src/components/Player/index.styled.ts) | 0 | ||||
| -rw-r--r-- | src/components/YTPlayer/index.tsx | 100 | ||||
| -rw-r--r-- | src/components/index.ts | 1 | ||||
| -rw-r--r-- | src/helpers/fetchSolution.ts | 21 | ||||
| -rw-r--r-- | src/hooks/useGameState.ts | 173 | ||||
| -rw-r--r-- | src/pages/DailyPage.tsx | 111 | ||||
| -rw-r--r-- | src/pages/LandingPage.tsx | 92 | ||||
| -rw-r--r-- | src/pages/UnlimitedPage.tsx | 78 |
12 files changed, 717 insertions, 294 deletions
diff --git a/src/app.tsx b/src/app.tsx index e9a41ef..044cbfc 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -1,234 +1,19 @@ import React from "react"; -import _ from "lodash"; +import { BrowserRouter, Routes, Route } from "react-router-dom"; -import { Song } from "./types/song"; -import { GuessState, GuessType } from "./types/guess"; -import { getDailySolution } from "./helpers/fetchSolution"; - -import { Header, InfoPopUp, Game, Footer } from "./components"; - -import * as Styled from "./app.styled"; +import { LandingPage } from "./pages/LandingPage"; +import { DailyPage } from "./pages/DailyPage"; +import { UnlimitedPage } from "./pages/UnlimitedPage"; function App() { - const initialGuess = { - song: undefined, - state: undefined, - } as GuessType; - - const [guesses, setGuesses] = React.useState<GuessType[]>( - Array.from({ length: 6 }).fill(initialGuess) as GuessType[] - ); - const [currentTry, setCurrentTry] = React.useState<number>(0); - const [selectedSong, setSelectedSong] = React.useState<Song>(); - const [didGuess, setDidGuess] = React.useState<boolean>(false); - const [todaysSolution, setTodaysSolution] = React.useState<Song | null>(null); - - const firstRun = localStorage.getItem("firstRun") === null; - - function reloadWithoutQueryParameters() { - location.replace(location.pathname); - } - const urlHash = window.location.hash; - const urlQueryParametersStart = urlHash.indexOf("?"); - const statsImportQueryParameter = - new URLSearchParams(urlHash.substring(urlQueryParametersStart)).get( - "statsImport" - ) || ""; - function importStats() { - if (statsImportQueryParameter) { - const importedStats = JSON.parse(statsImportQueryParameter); - if (Array.isArray(importedStats)) { - importedStats.forEach((day) => { - if (Array.isArray(day.guesses)) { - if (day.guesses.length == 5) { - day.guesses.push(initialGuess); - } - } - }); - } - localStorage.setItem("stats", JSON.stringify(importedStats)); - reloadWithoutQueryParameters(); - } - } - if (statsImportQueryParameter) { - if ( - confirm( - "Do you want to import your previous stats? This will overwrite any stats on this site." - ) - ) { - importStats(); - } else { - reloadWithoutQueryParameters(); - } - } - - let stats = JSON.parse(localStorage.getItem("stats") || "{}"); - let statsVersion = JSON.parse(localStorage.getItem("version") || "1"); - - React.useEffect(() => { - getDailySolution().then((solution) => setTodaysSolution(solution)); - }, []); - - React.useEffect(() => { - if (Array.isArray(stats)) { - const visitedToday = _.isEqual( - todaysSolution, - stats[stats.length - 1].solution - ); - - if (!visitedToday) { - stats.push({ - solution: todaysSolution, - currentTry: 0, - didGuess: 0, - }); - } else { - const { currentTry, guesses, didGuess } = stats[stats.length - 1]; - setCurrentTry(currentTry); - setGuesses(guesses); - setDidGuess(didGuess); - } - } else { - stats = []; - stats.push({ - solution: todaysSolution, - }); - } - const currentVersion = 2; - if (firstRun) { - statsVersion = currentVersion; - } else if (statsVersion < currentVersion) { - statsVersion = currentVersion; - if (Array.isArray(stats)) { - for (let index = 0; index < stats.length; index++) { - const newGuesses: GuessType[] = []; - for ( - let guessIndex = 0; - guessIndex < stats[index].guesses.length; - guessIndex++ - ) { - const guess = stats[index].guesses[guessIndex]; - if (guess.skipped !== undefined) { - let state = undefined; - if (guess.skipped) { - state = GuessState.Skipped; - } else if (guess.isCorrect) { - state = GuessState.Correct; - } else if (guess.isCorrect === false) { - state = GuessState.Incorrect; - } - newGuesses.push({ - song: guess.song, - state: state, - } as GuessType); - } - } - stats[index].guesses = newGuesses; - } - } - } - }, []); - - React.useEffect(() => { - if (Array.isArray(stats)) { - stats[stats.length - 1].currentTry = currentTry; - stats[stats.length - 1].didGuess = didGuess; - stats[stats.length - 1].guesses = guesses; - } - }), - [guesses, currentTry, didGuess]; - - React.useEffect(() => { - localStorage.setItem("stats", JSON.stringify(stats)); - }, [stats]); - - React.useEffect(() => { - localStorage.setItem("version", JSON.stringify(statsVersion)); - }, [statsVersion]); - - const [isInfoPopUpOpen, setIsInfoPopUpOpen] = - React.useState<boolean>(firstRun); - - const openInfoPopUp = React.useCallback(() => { - setIsInfoPopUpOpen(true); - }, []); - - const closeInfoPopUp = React.useCallback(() => { - if (firstRun) { - localStorage.setItem("firstRun", "false"); - setIsInfoPopUpOpen(false); - } else { - setIsInfoPopUpOpen(false); - } - }, [localStorage.getItem("firstRun")]); - - const skip = React.useCallback(() => { - setGuesses((guesses: GuessType[]) => { - const newGuesses = [...guesses]; - newGuesses[currentTry] = { - song: undefined, - state: GuessState.Skipped, - }; - - return newGuesses; - }); - - setCurrentTry((currentTry) => currentTry + 1); - }, [currentTry]); - - const guess = React.useCallback(() => { - let state = GuessState.Incorrect; - if (!selectedSong) return; - if (selectedSong?.artist === todaysSolution?.artist && selectedSong?.name === todaysSolution?.name) { - state = GuessState.Correct; - } else if (selectedSong?.artist === todaysSolution?.artist) { - state = GuessState.PartiallyCorrect; - } - - if (!selectedSong) { - alert("Choose a song"); - return; - } - - setGuesses((guesses: GuessType[]) => { - const newGuesses = [...guesses]; - newGuesses[currentTry] = { - song: selectedSong, - state: state, - }; - - return newGuesses; - }); - - setCurrentTry((currentTry) => currentTry + 1); - setSelectedSong(undefined); - - if (state === GuessState.Correct) { - setDidGuess(true); - } - }, [guesses, selectedSong]); - - if (todaysSolution === null) { - return null; - } - return ( - <main> - <Header openInfoPopUp={openInfoPopUp} /> - {isInfoPopUpOpen && <InfoPopUp onClose={closeInfoPopUp} />} - <Styled.Container> - <Game - guesses={guesses} - didGuess={didGuess} - todaysSolution={todaysSolution} - currentTry={currentTry} - setSelectedSong={setSelectedSong} - skip={skip} - guess={guess} - /> - </Styled.Container> - <Footer /> - </main> + <BrowserRouter> + <Routes> + <Route path="/" element={<LandingPage />} /> + <Route path="/daily" element={<DailyPage />} /> + <Route path="/unlimited" element={<UnlimitedPage />} /> + </Routes> + </BrowserRouter> ); } diff --git a/src/components/Game/index.tsx b/src/components/Game/index.tsx index 2f4a2ec..9024b03 100644 --- a/src/components/Game/index.tsx +++ b/src/components/Game/index.tsx @@ -4,7 +4,7 @@ import { GuessType } from "../../types/guess"; import { Song } from "../../types/song"; import { playTimes } from "../../constants"; -import { Button, Guess, Player, Search, Result } from "../"; +import { Button, Guess, YTPlayer, Search, Result, Player } from "../"; import * as Styled from "./index.styled"; @@ -16,6 +16,8 @@ interface Props { setSelectedSong: React.Dispatch<React.SetStateAction<Song | undefined>>; skip: () => void; guess: () => void; + mode?: "daily" | "unlimited"; + onPlayAgain?: () => void; } export function Game({ @@ -26,6 +28,8 @@ export function Game({ setSelectedSong, skip, guess, + mode = "daily", + onPlayAgain, }: Props) { if (didGuess || currentTry === 6) { return ( @@ -34,19 +38,22 @@ export function Game({ currentTry={currentTry} todaysSolution={todaysSolution} guesses={guesses} + mode={mode} + onPlayAgain={onPlayAgain} /> ); } return ( <> {guesses.map((guess: GuessType, index) => ( - <Guess - key={index} - guess={guess} - active={index === currentTry} - /> + <Guess key={index} guess={guess} active={index === currentTry} /> ))} - <Player id={todaysSolution.youtubeId} currentTry={currentTry} /> + {mode === "unlimited" ? ( + <YTPlayer id={todaysSolution.youtubeId} currentTry={currentTry} /> + ) : ( + <Player currentTry={currentTry} /> + )} + <Search currentTry={currentTry} setSelectedSong={setSelectedSong} /> <Styled.Buttons> diff --git a/src/components/Player/index.tsx b/src/components/Player/index.tsx index fcfce72..e4dfd9e 100644 --- a/src/components/Player/index.tsx +++ b/src/components/Player/index.tsx @@ -1,101 +1,142 @@ import React from "react"; -import YouTube from "react-youtube"; import { IoPlay, IoPause } from "react-icons/io5"; import { playTimes } from "../../constants"; - -import * as Styled from "./index.styled"; +import * as Styled from "../YTPlayer/index.styled"; interface Props { - id: string; currentTry: number; } -export function Player({ id, currentTry }: Props) { - const opts = { - width: "0", - height: "0", - }; +const MAX_TIME = 16; - // react-youtube doesn't export types for this - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const playerRef = React.useRef<any>(null); +export function Player({ currentTry }: Props) { + const audioRef = React.useRef<HTMLAudioElement | null>(null); const currentPlayTime = playTimes[currentTry]; - const [play, setPlay] = React.useState<boolean>(false); + const [play, setPlay] = React.useState(false); + const [currentTime, setCurrentTime] = React.useState(0); + const [isReady, setIsReady] = React.useState(false); - const [currentTime, setCurrentTime] = React.useState<number>(0); + const CDN_URL = + import.meta.env.VITE_CDN_URL || "https://yena.pinapelz.com/kheardle"; - const [isReady, setIsReady] = React.useState<boolean>(false); + const dateString = new Date().toISOString().split("T")[0]; - React.useEffect(() => { - setInterval(() => { - playerRef.current?.internalPlayer - .getCurrentTime() - .then((time: number) => { - setCurrentTime(time); - }); - }, 250); + const startPlayback = React.useCallback(() => { + const audio = audioRef.current; + if (!audio) return; + + audio.play(); + setPlay(true); + }, []); + + const stopPlayback = React.useCallback(() => { + const audio = audioRef.current; + if (!audio) return; + + audio.pause(); + audio.currentTime = 0; + setPlay(false); }, []); React.useEffect(() => { - if (play) { - if (currentTime * 1000 >= currentPlayTime) { - playerRef.current?.internalPlayer.pauseVideo(); - playerRef.current?.internalPlayer.seekTo(0); + const audio = new Audio(`${CDN_URL}/${dateString}.mp3`); + audioRef.current = audio; + + audio.addEventListener("loadeddata", () => { + setIsReady(true); + }); + + audio.addEventListener("timeupdate", () => { + setCurrentTime(audio.currentTime); + }); + + audio.addEventListener("ended", () => { + setPlay(false); + audio.currentTime = 0; + }); + + return () => { + audio.pause(); + audio.src = ""; + }; + }, [dateString]); + + React.useEffect(() => { + if (!play || !audioRef.current) return; + + const interval = setInterval(() => { + const a = audioRef.current!; + const t = a.currentTime * 1000; + + setCurrentTime(a.currentTime); + + if (t >= currentPlayTime || t >= MAX_TIME * 1000) { + a.pause(); + a.currentTime = 0; setPlay(false); } - } - }, [play, currentTime]); + }, 100); - // don't call play video each time currentTime changes - const startPlayback = React.useCallback(() => { - playerRef.current?.internalPlayer.playVideo(); - setPlay(true); - }, []); + return () => clearInterval(interval); + }, [play, currentPlayTime]); + + React.useEffect(() => { + if (!("mediaSession" in navigator)) return; + + navigator.mediaSession.setActionHandler("play", () => undefined); + navigator.mediaSession.setActionHandler("pause", () => undefined); + navigator.mediaSession.setActionHandler("previoustrack", () => undefined); + navigator.mediaSession.setActionHandler("nexttrack", () => undefined); - const setReady = React.useCallback(() => { - setIsReady(true); + return () => { + navigator.mediaSession.setActionHandler("play", null); + navigator.mediaSession.setActionHandler("pause", null); + navigator.mediaSession.setActionHandler("previoustrack", null); + navigator.mediaSession.setActionHandler("nexttrack", null); + }; }, []); return ( <> - <YouTube opts={opts} videoId={id} onReady={setReady} ref={playerRef} /> {isReady ? ( <> <Styled.ProgressBackground> - {currentTime !== 0 && <Styled.Progress value={currentTime} />} - {playTimes.map((playTime) => ( + {currentTime !== 0 && ( + <Styled.Progress value={currentTime} /> + )} + + {playTimes.map((t) => ( <Styled.Separator - style={{ left: `${(playTime / 16000) * 100}%` }} - key={playTime} + key={t} + style={{ left: `${(t / 16000) * 100}%` }} /> ))} </Styled.ProgressBackground> + <Styled.TimeStamps> <Styled.TimeStamp>1s</Styled.TimeStamp> <Styled.TimeStamp>16s</Styled.TimeStamp> </Styled.TimeStamps> - {!play && ( + + {!play ? ( <IoPlay style={{ cursor: "pointer" }} size={36} - color="var(--cl-green-6)" onClick={startPlayback} /> - )} - {play && ( + ) : ( <IoPause style={{ cursor: "pointer" }} size={36} - color="var(--cl-green-6)" - onClick={startPlayback} + onClick={stopPlayback} /> )} </> ) : ( - <p>Loading player...</p> + <p>Loading audio...</p> )} </> ); diff --git a/src/components/Result/index.tsx b/src/components/Result/index.tsx index 19f9386..6b4560a 100644 --- a/src/components/Result/index.tsx +++ b/src/components/Result/index.tsx @@ -14,17 +14,19 @@ interface SolutionProps { didGuess: boolean; currentTry: number; todaysSolution: Song; + isUnlimited?: boolean; } function Solution({ didGuess, todaysSolution, currentTry, + isUnlimited, }: SolutionProps) { return ( <> <Styled.SongTitle> - Today's song is {todaysSolution.artist} - {todaysSolution.name} + {isUnlimited ? "The song was" : "Today's song is"} {todaysSolution.artist} - {todaysSolution.name} </Styled.SongTitle> {didGuess && ( @@ -86,6 +88,8 @@ interface Props { currentTry: number; todaysSolution: Song; guesses: GuessType[]; + mode?: "daily" | "unlimited"; + onPlayAgain?: () => void; } export function Result({ @@ -93,6 +97,8 @@ export function Result({ todaysSolution, guesses, currentTry, + mode = "daily", + onPlayAgain, }: Props) { const hoursToNextDay = Math.floor( (new Date(new Date().setHours(24, 0, 0, 0)).getTime() - @@ -102,6 +108,8 @@ export function Result({ 60 ); + const isUnlimited = mode === "unlimited"; + if (didGuess) { const textForTry = ["Perfect!", "Wow!", "Super!", "Congrats!", "Nice!"]; @@ -113,13 +121,20 @@ export function Result({ todaysSolution={todaysSolution} didGuess={didGuess} currentTry={currentTry} + isUnlimited={isUnlimited} /> - <ShareButton guesses={guesses} variant="green" /> + {!isUnlimited && <ShareButton guesses={guesses} variant="green" />} - <Styled.TimeToNext> - Remember to come back in {hoursToNextDay} hours! - </Styled.TimeToNext> + {isUnlimited && onPlayAgain ? ( + <Button variant="green" onClick={onPlayAgain}> + Play Again + </Button> + ) : ( + <Styled.TimeToNext> + Remember to come back in {hoursToNextDay} hours! + </Styled.TimeToNext> + )} </> ); } @@ -132,13 +147,20 @@ export function Result({ todaysSolution={todaysSolution} didGuess={didGuess} currentTry={currentTry} + isUnlimited={isUnlimited} /> - <ShareButton guesses={guesses} variant="red" /> + {!isUnlimited && <ShareButton guesses={guesses} variant="red" />} - <Styled.TimeToNext> - Try again in {hoursToNextDay} hours. - </Styled.TimeToNext> + {isUnlimited && onPlayAgain ? ( + <Button variant="red" onClick={onPlayAgain}> + Play Again + </Button> + ) : ( + <Styled.TimeToNext> + Try again in {hoursToNextDay} hours. + </Styled.TimeToNext> + )} </> ); } diff --git a/src/components/Player/index.styled.ts b/src/components/YTPlayer/index.styled.ts index 3c98f1e..3c98f1e 100644 --- a/src/components/Player/index.styled.ts +++ b/src/components/YTPlayer/index.styled.ts diff --git a/src/components/YTPlayer/index.tsx b/src/components/YTPlayer/index.tsx new file mode 100644 index 0000000..1aac9ac --- /dev/null +++ b/src/components/YTPlayer/index.tsx @@ -0,0 +1,100 @@ +import React from "react"; +import YouTube from "react-youtube"; +import { IoPlay, IoPause } from "react-icons/io5"; +import { playTimes } from "../../constants"; +import * as Styled from "./index.styled"; + +interface Props { + id: string; + currentTry: number; +} + +export function Player({ id, currentTry }: Props) { + const opts = { + width: "0", + height: "0", + }; + + // react-youtube doesn't export types for this + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const playerRef = React.useRef<any>(null); + + const currentPlayTime = playTimes[currentTry]; + + const [play, setPlay] = React.useState<boolean>(false); + + const [currentTime, setCurrentTime] = React.useState<number>(0); + + const [isReady, setIsReady] = React.useState<boolean>(false); + + React.useEffect(() => { + setInterval(() => { + playerRef.current?.internalPlayer + .getCurrentTime() + .then((time: number) => { + setCurrentTime(time); + }); + }, 250); + }, []); + + React.useEffect(() => { + if (play) { + if (currentTime * 1000 >= currentPlayTime) { + playerRef.current?.internalPlayer.pauseVideo(); + playerRef.current?.internalPlayer.seekTo(0); + setPlay(false); + } + } + }, [play, currentTime]); + + // don't call play video each time currentTime changes + const startPlayback = React.useCallback(() => { + playerRef.current?.internalPlayer.playVideo(); + setPlay(true); + }, []); + + const setReady = React.useCallback(() => { + setIsReady(true); + }, []); + + return ( + <> + <YouTube opts={opts} videoId={id} onReady={setReady} ref={playerRef} /> + {isReady ? ( + <> + <Styled.ProgressBackground> + {currentTime !== 0 && <Styled.Progress value={currentTime} />} + {playTimes.map((playTime) => ( + <Styled.Separator + style={{ left: `${(playTime / 16000) * 100}%` }} + key={playTime} + /> + ))} + </Styled.ProgressBackground> + <Styled.TimeStamps> + <Styled.TimeStamp>1s</Styled.TimeStamp> + <Styled.TimeStamp>16s</Styled.TimeStamp> + </Styled.TimeStamps> + {!play && ( + <IoPlay + style={{ cursor: "pointer" }} + size={36} + color="var(--cl-green-6)" + onClick={startPlayback} + /> + )} + {play && ( + <IoPause + style={{ cursor: "pointer" }} + size={36} + color="var(--cl-green-6)" + onClick={startPlayback} + /> + )} + </> + ) : ( + <p>Loading player...</p> + )} + </> + ); +} diff --git a/src/components/index.ts b/src/components/index.ts index 0e97ada..4264a1c 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -5,6 +5,7 @@ export { Guess } from "./Guess"; export { Header } from "./Header"; export { InfoPopUp } from "./InfoPopUp"; export { MiniYouTubePlayer} from "./MiniYouTubePlayer"; +export { Player as YTPlayer } from "./YTPlayer"; export { Player } from "./Player"; export { Result } from "./Result"; export { Search } from "./Search"; diff --git a/src/helpers/fetchSolution.ts b/src/helpers/fetchSolution.ts index 2ca2e68..10c4fa1 100644 --- a/src/helpers/fetchSolution.ts +++ b/src/helpers/fetchSolution.ts @@ -25,14 +25,27 @@ function getObfuscationKey(): Uint8Array { return new TextEncoder().encode(SALT + date); } +function decryptResponse(data: string): Song { + const obfuscationKey = getObfuscationKey(); + const obfuscatedBytes = hexToBytes(data); + const decrypted = xor(obfuscatedBytes, obfuscationKey); + return JSON.parse(new TextDecoder().decode(decrypted)) as Song; +} + export async function getDailySolution(): Promise<Song> { const solutionData = await fetch(`${API_URL}/today`); if (!solutionData.ok) { throw new Error(`Failed to fetch solution: ${solutionData.statusText}`); } const { data } = await solutionData.json(); - const obfuscationKey = getObfuscationKey(); - const obfuscatedBytes = hexToBytes(data); - const decrypted = xor(obfuscatedBytes, obfuscationKey); - return JSON.parse(new TextDecoder().decode(decrypted)) as Song; + return decryptResponse(data); +} + +export async function getSelectSolution(): Promise<Song> { + const solutionData = await fetch(`${API_URL}/select`); + if (!solutionData.ok) { + throw new Error(`Failed to fetch solution: ${solutionData.statusText}`); + } + const { data } = await solutionData.json(); + return decryptResponse(data); } diff --git a/src/hooks/useGameState.ts b/src/hooks/useGameState.ts new file mode 100644 index 0000000..5c419a1 --- /dev/null +++ b/src/hooks/useGameState.ts @@ -0,0 +1,173 @@ +import React from "react"; +import _ from "lodash"; + +import { Song } from "../types/song"; +import { GuessState, GuessType } from "../types/guess"; + +interface UseGameStateOptions { + solution: Song | null; + persist: boolean; +} + +const initialGuess: GuessType = { + song: undefined, + state: undefined, +}; + +export function useGameState({ solution, persist }: UseGameStateOptions) { + const [guesses, setGuesses] = React.useState<GuessType[]>( + Array.from({ length: 6 }).fill(initialGuess) as GuessType[] + ); + const [currentTry, setCurrentTry] = React.useState<number>(0); + const [selectedSong, setSelectedSong] = React.useState<Song>(); + const [didGuess, setDidGuess] = React.useState<boolean>(false); + + // --- localStorage persistence (daily mode) --- + let stats = JSON.parse(localStorage.getItem("stats") || "{}"); + let statsVersion = JSON.parse(localStorage.getItem("version") || "1"); + + React.useEffect(() => { + if (!persist || !solution) return; + + if (Array.isArray(stats)) { + const visitedToday = _.isEqual( + solution, + stats[stats.length - 1].solution + ); + + if (!visitedToday) { + stats.push({ + solution: solution, + currentTry: 0, + didGuess: 0, + }); + } else { + const { currentTry, guesses, didGuess } = stats[stats.length - 1]; + setCurrentTry(currentTry); + setGuesses(guesses); + setDidGuess(didGuess); + } + } else { + stats = []; + stats.push({ + solution: solution, + }); + } + + const currentVersion = 2; + const firstRun = localStorage.getItem("firstRun") === null; + if (firstRun) { + statsVersion = currentVersion; + } else if (statsVersion < currentVersion) { + statsVersion = currentVersion; + if (Array.isArray(stats)) { + for (let index = 0; index < stats.length; index++) { + const newGuesses: GuessType[] = []; + for ( + let guessIndex = 0; + guessIndex < stats[index].guesses.length; + guessIndex++ + ) { + const guess = stats[index].guesses[guessIndex]; + if (guess.skipped !== undefined) { + let state = undefined; + if (guess.skipped) { + state = GuessState.Skipped; + } else if (guess.isCorrect) { + state = GuessState.Correct; + } else if (guess.isCorrect === false) { + state = GuessState.Incorrect; + } + newGuesses.push({ + song: guess.song, + state: state, + } as GuessType); + } + } + stats[index].guesses = newGuesses; + } + } + } + }, [solution]); + + React.useEffect(() => { + if (!persist) return; + if (Array.isArray(stats)) { + stats[stats.length - 1].currentTry = currentTry; + stats[stats.length - 1].didGuess = didGuess; + stats[stats.length - 1].guesses = guesses; + } + }, [guesses, currentTry, didGuess]); + + React.useEffect(() => { + if (!persist) return; + localStorage.setItem("stats", JSON.stringify(stats)); + }, [stats]); + + React.useEffect(() => { + if (!persist) return; + localStorage.setItem("version", JSON.stringify(statsVersion)); + }, [statsVersion]); + + const skip = React.useCallback(() => { + setGuesses((guesses: GuessType[]) => { + const newGuesses = [...guesses]; + newGuesses[currentTry] = { + song: undefined, + state: GuessState.Skipped, + }; + return newGuesses; + }); + setCurrentTry((currentTry) => currentTry + 1); + }, [currentTry]); + + const guess = React.useCallback(() => { + if (!selectedSong || !solution) return; + + let state = GuessState.Incorrect; + if ( + selectedSong.artist === solution.artist && + selectedSong.name === solution.name + ) { + state = GuessState.Correct; + } else if (selectedSong.artist === solution.artist) { + state = GuessState.PartiallyCorrect; + } + + setGuesses((guesses: GuessType[]) => { + const newGuesses = [...guesses]; + newGuesses[currentTry] = { + song: selectedSong, + state: state, + }; + return newGuesses; + }); + + setCurrentTry((currentTry) => currentTry + 1); + setSelectedSong(undefined); + + if (state === GuessState.Correct) { + setDidGuess(true); + } + }, [guesses, selectedSong, solution]); + + const reset = React.useCallback(() => { + setGuesses( + Array.from({ length: 6 }).fill(initialGuess) as GuessType[] + ); + setCurrentTry(0); + setSelectedSong(undefined); + setDidGuess(false); + }, []); + + return { + guesses, + currentTry, + selectedSong, + setSelectedSong, + didGuess, + skip, + guess, + reset, + }; +} diff --git a/src/pages/DailyPage.tsx b/src/pages/DailyPage.tsx new file mode 100644 index 0000000..5033366 --- /dev/null +++ b/src/pages/DailyPage.tsx @@ -0,0 +1,111 @@ +import React from "react"; + +import { Song } from "../types/song"; +import { GuessType } from "../types/guess"; +import { getDailySolution } from "../helpers/fetchSolution"; +import { useGameState } from "../hooks/useGameState"; + +import { Header, InfoPopUp, Game, Footer } from "../components"; + +import * as Styled from "../app.styled"; + +export function DailyPage() { + const [todaysSolution, setTodaysSolution] = React.useState<Song | null>(null); + + const firstRun = localStorage.getItem("firstRun") === null; + + const initialGuess = { + song: undefined, + state: undefined, + } as GuessType; + + // --- Stats import logic --- + function reloadWithoutQueryParameters() { + location.replace(location.pathname); + } + const urlHash = window.location.hash; + const urlQueryParametersStart = urlHash.indexOf("?"); + const statsImportQueryParameter = + new URLSearchParams(urlHash.substring(urlQueryParametersStart)).get( + "statsImport" + ) || ""; + + function importStats() { + if (statsImportQueryParameter) { + const importedStats = JSON.parse(statsImportQueryParameter); + if (Array.isArray(importedStats)) { + importedStats.forEach((day) => { + if (Array.isArray(day.guesses)) { + if (day.guesses.length == 5) { + day.guesses.push(initialGuess); + } + } + }); + } + localStorage.setItem("stats", JSON.stringify(importedStats)); + reloadWithoutQueryParameters(); + } + } + + if (statsImportQueryParameter) { + if ( + confirm( + "Do you want to import your previous stats? This will overwrite any stats on this site." + ) + ) { + importStats(); + } else { + reloadWithoutQueryParameters(); + } + } + + React.useEffect(() => { + getDailySolution().then((solution) => setTodaysSolution(solution)); + }, []); + + const { + guesses, + currentTry, + setSelectedSong, + didGuess, + skip, + guess, + } = useGameState({ solution: todaysSolution, persist: true }); + + const [isInfoPopUpOpen, setIsInfoPopUpOpen] = + React.useState<boolean>(firstRun); + + const openInfoPopUp = React.useCallback(() => { + setIsInfoPopUpOpen(true); + }, []); + + const closeInfoPopUp = React.useCallback(() => { + if (firstRun) { + localStorage.setItem("firstRun", "false"); + } + setIsInfoPopUpOpen(false); + }, [localStorage.getItem("firstRun")]); + + if (todaysSolution === null) { + return null; + } + + return ( + <main> + <Header openInfoPopUp={openInfoPopUp} /> + {isInfoPopUpOpen && <InfoPopUp onClose={closeInfoPopUp} />} + <Styled.Container> + <Game + guesses={guesses} + didGuess={didGuess} + todaysSolution={todaysSolution} + currentTry={currentTry} + setSelectedSong={setSelectedSong} + skip={skip} + guess={guess} + /> + </Styled.Container> + <Footer /> + </main> + ); +} diff --git a/src/pages/LandingPage.tsx b/src/pages/LandingPage.tsx new file mode 100644 index 0000000..e1d29cd --- /dev/null +++ b/src/pages/LandingPage.tsx @@ -0,0 +1,92 @@ +import React from "react"; +import { useNavigate } from "react-router-dom"; +import styled from "styled-components"; + +import { appName } from "../constants"; + +const Container = styled.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 60vh; + gap: 24px; +`; + +const Title = styled.h1` + font-family: "Roboto Mono", monospace; + font-size: 2rem; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--cl-white); +`; + +const Subtitle = styled.p` + font-family: "Roboto Mono", monospace; + font-size: 0.9rem; + color: var(--cl-gray-6); + margin: 0; +`; + +const ButtonGroup = styled.div` + display: flex; + gap: 16px; + margin-top: 16px; + + @media (max-width: 480px) { + flex-direction: column; + width: 100%; + padding: 0 24px; + } +`; + +const ModeButton = styled.button<{ variant?: "green" | "purple" }>` + font-family: "Roboto Mono", monospace; + font-size: 1rem; + font-weight: 600; + padding: 16px 32px; + border: 2px solid + ${({ variant }) => + variant === "purple" ? "var(--cl-purple, #a855f7)" : "var(--cl-green-6)"}; + background: transparent; + color: ${({ variant }) => + variant === "purple" ? "var(--cl-purple, #a855f7)" : "var(--cl-green-6)"}; + cursor: pointer; + transition: all 0.15s ease; + + &:hover { + background: ${({ variant }) => + variant === "purple" ? "var(--cl-purple, #a855f7)" : "var(--cl-green-6)"}; + color: var(--cl-black, #000); + } +`; + +const ModeDescription = styled.span` + display: block; + font-size: 0.7rem; + font-weight: 400; + color: var(--cl-gray-6); + margin-top: 4px; +`; + +export function LandingPage() { + const navigate = useNavigate(); + + return ( + <Container> + <Title>{appName}</Title> + <Subtitle>Choose a game mode</Subtitle> + <ButtonGroup> + <ModeButton onClick={() => navigate("/daily")}> + Daily + <ModeDescription>One song per day</ModeDescription> + </ModeButton> + <ModeButton variant="purple" onClick={() => navigate("/unlimited")}> + Unlimited + <ModeDescription>Endless songs, no limits</ModeDescription> + </ModeButton> + </ButtonGroup> + </Container> + ); +} diff --git a/src/pages/UnlimitedPage.tsx b/src/pages/UnlimitedPage.tsx new file mode 100644 index 0000000..761d2b9 --- /dev/null +++ b/src/pages/UnlimitedPage.tsx @@ -0,0 +1,78 @@ +import React from "react"; + +import { Song } from "../types/song"; +import { getSelectSolution } from "../helpers/fetchSolution"; +import { useGameState } from "../hooks/useGameState"; + +import { Header, InfoPopUp, Game, Footer } from "../components"; + +import * as Styled from "../app.styled"; + +export function UnlimitedPage() { + const [solution, setSolution] = React.useState<Song | null>(null); + + const firstRun = localStorage.getItem("firstRun") === null; + + function fetchNewSong() { + setSolution(null); + getSelectSolution().then((s) => setSolution(s)); + } + + React.useEffect(() => { + fetchNewSong(); + }, []); + + const { + guesses, + currentTry, + setSelectedSong, + didGuess, + skip, + guess, + reset, + } = useGameState({ solution, persist: false }); + + const playAgain = React.useCallback(() => { + reset(); + fetchNewSong(); + }, [reset]); + + const [isInfoPopUpOpen, setIsInfoPopUpOpen] = + React.useState<boolean>(firstRun); + + const openInfoPopUp = React.useCallback(() => { + setIsInfoPopUpOpen(true); + }, []); + + const closeInfoPopUp = React.useCallback(() => { + if (firstRun) { + localStorage.setItem("firstRun", "false"); + } + setIsInfoPopUpOpen(false); + }, [localStorage.getItem("firstRun")]); + + if (solution === null) { + return null; + } + + return ( + <main> + <Header openInfoPopUp={openInfoPopUp} /> + {isInfoPopUpOpen && <InfoPopUp onClose={closeInfoPopUp} />} + <Styled.Container> + <Game + guesses={guesses} + didGuess={didGuess} + todaysSolution={solution} + currentTry={currentTry} + setSelectedSong={setSelectedSong} + skip={skip} + guess={guess} + mode="unlimited" + onPlayAgain={playAgain} + /> + </Styled.Container> + <Footer /> + </main> + ); +} |
