aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorPinapelz <yukais@pinapelz.com>2026-06-03 17:22:48 -0700
committerPinapelz <yukais@pinapelz.com>2026-06-03 17:22:48 -0700
commit14172f9dd64ce91ba5cf51f82c53deb6a81d68a6 (patch)
tree5e12ce4e30ecaed9a2aac48d2959d99a4d8b4ef7 /src
parent818db3ef4aadf489dba5ba8ba4f3bb4e150f0b22 (diff)
create daily/unlimited mode, CDN audio file for daily mode
Diffstat (limited to 'src')
-rw-r--r--src/app.tsx237
-rw-r--r--src/components/Game/index.tsx21
-rw-r--r--src/components/Player/index.tsx137
-rw-r--r--src/components/Result/index.tsx40
-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.tsx100
-rw-r--r--src/components/index.ts1
-rw-r--r--src/helpers/fetchSolution.ts21
-rw-r--r--src/hooks/useGameState.ts173
-rw-r--r--src/pages/DailyPage.tsx111
-rw-r--r--src/pages/LandingPage.tsx92
-rw-r--r--src/pages/UnlimitedPage.tsx78
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&apos;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>
+ );
+}
send patches to the email below
yukais@pinapelz.com
include the subject [PATCH repo_name]
pinapelz.com
homepage