diff options
| author | Pinapelz <yukais@pinapelz.com> | 2026-06-26 21:55:46 -0700 |
|---|---|---|
| committer | Pinapelz <yukais@pinapelz.com> | 2026-06-26 21:55:46 -0700 |
| commit | 00d476a0e51629f06407aed035fbc001d3f6b114 (patch) | |
| tree | 016338cc4dacff54ba1cbe11fce063152cbd28ab /src | |
| parent | 84fbd4ee1e159b0426d664cc98a3dc7165d6381f (diff) | |
initial guess MV mode
Diffstat (limited to 'src')
| -rw-r--r-- | src/app.tsx | 2 | ||||
| -rw-r--r-- | src/components/Game/index.tsx | 54 | ||||
| -rw-r--r-- | src/components/InfoPopUp/index.tsx | 9 | ||||
| -rw-r--r-- | src/components/MVPlayer/index.styled.ts | 73 | ||||
| -rw-r--r-- | src/components/MVPlayer/index.tsx | 85 | ||||
| -rw-r--r-- | src/components/Search/index.tsx | 8 | ||||
| -rw-r--r-- | src/components/index.ts | 1 | ||||
| -rw-r--r-- | src/helpers/fetchSolution.ts | 32 | ||||
| -rw-r--r-- | src/helpers/searchSong.ts | 5 | ||||
| -rw-r--r-- | src/hooks/useGameState.ts | 39 | ||||
| -rw-r--r-- | src/pages/DailyPage.tsx | 2 | ||||
| -rw-r--r-- | src/pages/LandingPage.tsx | 125 | ||||
| -rw-r--r-- | src/pages/MVPage.tsx | 76 | ||||
| -rw-r--r-- | src/pages/UnlimitedPage.tsx | 2 |
14 files changed, 447 insertions, 66 deletions
diff --git a/src/app.tsx b/src/app.tsx index 044cbfc..163d769 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -4,6 +4,7 @@ import { BrowserRouter, Routes, Route } from "react-router-dom"; import { LandingPage } from "./pages/LandingPage"; import { DailyPage } from "./pages/DailyPage"; import { UnlimitedPage } from "./pages/UnlimitedPage"; +import { MVPage } from "./pages/MVPage"; function App() { return ( @@ -11,6 +12,7 @@ function App() { <Routes> <Route path="/" element={<LandingPage />} /> <Route path="/daily" element={<DailyPage />} /> + <Route path="/mv" element={<MVPage />} /> <Route path="/unlimited" element={<UnlimitedPage />} /> </Routes> </BrowserRouter> diff --git a/src/components/Game/index.tsx b/src/components/Game/index.tsx index 8afa516..8e16d46 100644 --- a/src/components/Game/index.tsx +++ b/src/components/Game/index.tsx @@ -3,8 +3,9 @@ import React from "react"; import { GuessType } from "../../types/guess"; import { Song } from "../../types/song"; import { playTimes } from "../../constants"; +import { musicVideos } from "../../../server/data/mvs"; -import { Button, Guess, YTPlayer, Search, Result, Player } from "../"; +import { Button, Guess, YTPlayer, Search, Result, Player, MVPlayer } from "../"; import * as Styled from "./index.styled"; @@ -17,7 +18,7 @@ interface Props { setSelectedSong: React.Dispatch<React.SetStateAction<Song | undefined>>; skip: () => void; guess: () => void; - mode?: "daily" | "unlimited"; + mode?: "daily" | "unlimited" | "dailyMV"; onPlayAgain?: () => void; isSubmitting?: boolean; } @@ -39,27 +40,36 @@ export function Game({ onPlayAgain, isSubmitting = false, }: Props) { - const recentFinishedPlay = localStorage.getItem("recentFinishedPlay"); + const recentFinishedPlay = + mode === "dailyMV" + ? localStorage.getItem("recentFinishedPlayMV") + : localStorage.getItem("recentFinishedPlay"); const hasFinishedCurrentRound = didGuess || currentTry >= guesses.length; const hasFinishedResponseDaily = - mode === "daily" && !!dailyDate && recentFinishedPlay === dailyDate; - const isGameOver = hasFinishedCurrentRound || hasFinishedResponseDaily; + (mode === "daily" || mode === "dailyMV") && + !!dailyDate && + recentFinishedPlay === dailyDate; const isBlocked = - mode === "daily" && + (mode === "daily" || mode === "dailyMV") && !!dailyDate && !hasFinishedResponseDaily && new Date(getUtcDate()) > new Date(dailyDate); const sessionDate = dailyDate ?? getUtcDate(); React.useEffect(() => { - if (mode !== "daily") return; + if (mode !== "daily" && mode !== "dailyMV") return; if (!hasFinishedCurrentRound) return; - localStorage.setItem("recentFinishedPlay", sessionDate); - const historicalPlayData = localStorage.getItem("historicalPlayData"); + const storageKey = + mode === "dailyMV" ? "recentFinishedPlayMV" : "recentFinishedPlay"; + localStorage.setItem(storageKey, sessionDate); + + const historicalKey = + mode === "dailyMV" ? "historicalPlayDataMV" : "historicalPlayData"; + const historicalPlayData = localStorage.getItem(historicalKey); if (historicalPlayData === null) { localStorage.setItem( - "historicalPlayData", + historicalKey, JSON.stringify({ sessionDates: [sessionDate], guesses: [currentTry], @@ -70,7 +80,7 @@ export function Game({ const parsedData = JSON.parse(historicalPlayData); if (parsedData.sessionDates.includes(sessionDate)) return; localStorage.setItem( - "historicalPlayData", + historicalKey, JSON.stringify({ sessionDates: [...parsedData.sessionDates, sessionDate], guesses: [...parsedData.guesses, currentTry], @@ -81,17 +91,23 @@ export function Game({ }, [mode, hasFinishedCurrentRound, sessionDate, currentTry, didGuess]); if (isBlocked) { - return <h1>Daily MIXX is not available yet. Check back soon!</h1>; + return ( + <h1> + {mode === "dailyMV" + ? "Daily MV is not available yet. Check back soon!" + : "Daily MIXX is not available yet. Check back soon!"} + </h1> + ); } - if (isGameOver) { + if (hasFinishedCurrentRound || hasFinishedResponseDaily) { return ( <Result didGuess={didGuess} currentTry={currentTry} todaysSolution={todaysSolution} guesses={guesses} - mode={mode} + mode={mode === "dailyMV" ? "daily" : mode} sessionDate={sessionDate} onPlayAgain={onPlayAgain} /> @@ -105,17 +121,25 @@ export function Game({ ))} {mode === "unlimited" ? ( <YTPlayer id={todaysSolution.youtubeId} currentTry={currentTry} /> + ) : mode === "dailyMV" ? ( + <MVPlayer currentTry={currentTry} date={sessionDate} /> ) : ( <Player currentTry={currentTry} /> )} - <Search currentTry={currentTry} setSelectedSong={setSelectedSong} /> + <Search + currentTry={currentTry} + setSelectedSong={setSelectedSong} + songs={mode === "dailyMV" ? musicVideos : undefined} + /> <Styled.Buttons> <Button onClick={skip}> {isSubmitting ? "Skipping..." : currentTry === 5 ? "Give Up" + : mode === "dailyMV" + ? "Skip +1 frame" : `Skip +${playTimes[currentTry] / 1000}s`} </Button> <Button variant="green" onClick={guess}> diff --git a/src/components/InfoPopUp/index.tsx b/src/components/InfoPopUp/index.tsx index ba9f233..72568aa 100644 --- a/src/components/InfoPopUp/index.tsx +++ b/src/components/InfoPopUp/index.tsx @@ -4,9 +4,14 @@ import * as Styled from "./index.styled"; interface Props { onClose: () => void; + gameMode: string; } -export function InfoPopUp({ onClose }: Props) { +export function InfoPopUp({ onClose, gameMode }: Props) { + let firstLine = "Listen to the audio clip, then find the correct song in the list." + if (gameMode === "dailyMV") { + firstLine = "Find the correct song in the list based on photos from the music video."; + } return ( <Styled.Container> <Styled.PopUp> @@ -14,7 +19,7 @@ export function InfoPopUp({ onClose }: Props) { <Styled.Spacer /> <Styled.Section> <p> - Listen to the audio clip, then find the correct song in the list. + {firstLine} </p> </Styled.Section> <Styled.Section> diff --git a/src/components/MVPlayer/index.styled.ts b/src/components/MVPlayer/index.styled.ts new file mode 100644 index 0000000..ca8a06e --- /dev/null +++ b/src/components/MVPlayer/index.styled.ts @@ -0,0 +1,73 @@ +import styled from "styled-components"; + +export const Frame = styled.div` + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + margin: 16px 0; +`; + +export const Images = styled.div` + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 8px; + width: 100%; +`; + +export const ImageSlot = styled.div<{ $revealed: boolean }>` + position: relative; + width: 200px; + height: 113px; + background-color: var(--cl-gray-2); + border: 1px solid ${({ theme }) => theme.border}; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + + img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + } +`; + +export const Placeholder = styled.p` + margin: 0; + font-family: "Roboto Mono", monospace; + font-size: 0.7rem; + color: var(--cl-gray-5); + text-align: center; + padding: 0 8px; +`; + +export const Hint = styled.p` + margin: 0; + font-family: "Roboto Mono", monospace; + font-size: 0.75rem; + color: var(--cl-gray-6); +`; + +export const Overlay = styled.div` + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.85); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + cursor: zoom-out; +`; + +export const LightboxImage = styled.img` + max-width: 90vw; + max-height: 90vh; + object-fit: contain; + cursor: default; +`; diff --git a/src/components/MVPlayer/index.tsx b/src/components/MVPlayer/index.tsx new file mode 100644 index 0000000..a1141ab --- /dev/null +++ b/src/components/MVPlayer/index.tsx @@ -0,0 +1,85 @@ +import React from "react"; +import * as Styled from "./index.styled"; + +interface Props { + currentTry: number; + date: string; +} + +const MV_CDN_URL = import.meta.env.VITE_CDN_URL || ""; + +function getRevealedCount(currentTry: number): number { + if (currentTry >= 6) return 3; + if (currentTry >= 3) return 2; + return 1; +} + +export function MVPlayer({ currentTry, date }: Props) { + const revealed = getRevealedCount(currentTry); + const [errored, setErrored] = React.useState<Set<number>>(new Set()); + + const [activeImage, setActiveImage] = React.useState<string | null>(null); + + const markError = React.useCallback((index: number) => { + setErrored((prev) => { + const next = new Set(prev); + next.add(index); + return next; + }); + }, []); + + React.useEffect(() => { + setErrored(new Set()); + setActiveImage(null); + }, [date]); + + return ( + <Styled.Frame> + <Styled.Images> + {Array.from({ length: 3 }, (_, i) => { + const index = i + 1; + const isRevealed = index <= revealed; + const src = `${MV_CDN_URL}/k-heardle-mvs/${date}/${index}.jpg`; + const hasError = errored.has(index); + + return ( + <Styled.ImageSlot key={index} $revealed={isRevealed}> + {isRevealed && !hasError ? ( + <img + src={src} + alt={`MV frame ${index}`} + onError={() => markError(index)} + onClick={() => setActiveImage(src)} + style={{ cursor: "zoom-in" }} + /> + ) : isRevealed && hasError ? ( + <Styled.Placeholder> + Frame {index} not available yet. + </Styled.Placeholder> + ) : ( + <Styled.Placeholder>?</Styled.Placeholder> + )} + </Styled.ImageSlot> + ); + })} + </Styled.Images> + + <Styled.Hint> + {revealed < 3 + ? `More frames unlock as you guess. (${revealed}/3 revealed)` + : "All frames revealed."} + </Styled.Hint> + + {/* Lightbox */} + {activeImage && ( + <Styled.Overlay onClick={() => setActiveImage(null)}> + <Styled.LightboxImage + src={activeImage} + alt="Full size MV frame" + onClick={(e) => e.stopPropagation()} + /> + </Styled.Overlay> + )} + </Styled.Frame> + ); +} diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 4d1ae83..72ce8a1 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -8,9 +8,11 @@ import * as Styled from "./index.styled"; interface Props { currentTry: number; setSelectedSong: React.Dispatch<React.SetStateAction<Song | undefined>>; + /** Optional override of the song pool to search within. */ + songs?: Song[]; } -export function Search({ currentTry, setSelectedSong }: Props) { +export function Search({ currentTry, setSelectedSong, songs }: Props) { const [value, setValue] = React.useState(""); const [results, setResults] = React.useState<Song[]>([]); @@ -20,8 +22,8 @@ export function Search({ currentTry, setSelectedSong }: Props) { return; } - setResults(searchSong(value)); - }, [value]); + setResults(searchSong(value, songs)); + }, [value, songs]); React.useEffect(() => { setValue(""); diff --git a/src/components/index.ts b/src/components/index.ts index 4264a1c..d716030 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 { MVPlayer } from "./MVPlayer"; export { Player as YTPlayer } from "./YTPlayer"; export { Player } from "./Player"; export { Result } from "./Result"; diff --git a/src/helpers/fetchSolution.ts b/src/helpers/fetchSolution.ts index d877d8f..b7895bb 100644 --- a/src/helpers/fetchSolution.ts +++ b/src/helpers/fetchSolution.ts @@ -77,6 +77,20 @@ export async function getDailySolution(): Promise<DailySolution> { }; } +export async function getDailyMVSolution(): Promise<DailySolution> { + const solutionData = await fetch(`${API_URL}/todayMV`); + if (!solutionData.ok) { + throw new Error(`Failed to fetch MV solution: ${solutionData.statusText}`); + } + const { data, date, sessionToken, initialSig } = await solutionData.json(); + return { + date, + sessionToken, + initialSig, + song: decryptResponse(data, date), + }; +} + export async function submitDailyGuess( payload: SubmitDailyGuessRequest ): Promise<SubmitDailyGuessResponse> { @@ -95,6 +109,24 @@ export async function submitDailyGuess( return (await response.json()) as SubmitDailyGuessResponse; } +export async function submitDailyMVGuess( + payload: SubmitDailyGuessRequest +): Promise<SubmitDailyGuessResponse> { + const response = await fetch(`${API_URL}/guessMV`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + throw new Error(`Failed to submit MV guess: ${response.statusText}`); + } + + return (await response.json()) as SubmitDailyGuessResponse; +} + export async function getSelectSolution(): Promise<Song> { const solutionData = await fetch(`${API_URL}/select`); if (!solutionData.ok) { diff --git a/src/helpers/searchSong.ts b/src/helpers/searchSong.ts index 62091b6..3bbecad 100644 --- a/src/helpers/searchSong.ts +++ b/src/helpers/searchSong.ts @@ -5,10 +5,11 @@ function fuzzyMatch(input: string): string { return input.toLowerCase().replace(/[^0-9a-z ]/gi, ""); } -export function searchSong(searchTerm: string): Song[] { +export function searchSong(searchTerm: string, pool?: Song[]): Song[] { const normalizedSearch = fuzzyMatch(searchTerm); + const source = pool ?? songs; //if no pool is provided, use the default k-heardle songs list - return songs + return source .filter((song: Song) => { const songName = fuzzyMatch(song.name); const songArtist = fuzzyMatch(song.artist); diff --git a/src/hooks/useGameState.ts b/src/hooks/useGameState.ts index c6bf6f7..0cd448e 100644 --- a/src/hooks/useGameState.ts +++ b/src/hooks/useGameState.ts @@ -1,7 +1,13 @@ import React from "react"; import { Song } from "../types/song"; import { GuessState, GuessType } from "../types/guess"; -import { DailyGameState, submitDailyGuess } from "../helpers/fetchSolution"; +import { + DailyGameState, + submitDailyGuess, + submitDailyMVGuess, +} from "../helpers/fetchSolution"; + +export type GameMode = "daily" | "dailyMV"; interface UseGameStateOptions { solution: Song | null; @@ -9,6 +15,7 @@ interface UseGameStateOptions { sessionDate?: string; sessionToken?: string; initialSig?: string; + mode?: GameMode; } const STATE_VERSION = 2; @@ -21,6 +28,11 @@ interface PersistedStatsV2 extends DailyGameState { type PersistedStats = PersistedStatsV2; +const STORAGE_KEY_BY_MODE: Record<GameMode, string> = { + daily: "stats", + dailyMV: "statsMV", +}; + const initialGuess: GuessType = { song: undefined, state: undefined, @@ -38,9 +50,9 @@ const normalizeAnsweredGuesses = (guesses: unknown): GuessType[] => { return (guesses as GuessType[]).filter(isAnsweredGuess).slice(0, 6); }; -function loadStats(): PersistedStats | null { +function loadStats(mode: GameMode): PersistedStats | null { try { - const raw = localStorage.getItem("stats"); + const raw = localStorage.getItem(STORAGE_KEY_BY_MODE[mode]); if (!raw) return null; const parsed = JSON.parse(raw); @@ -59,7 +71,7 @@ function loadStats(): PersistedStats | null { typeof parsed.version === "number" && parsed.version < STATE_VERSION; if (isLegacyV0 || isLegacyVersion) { - localStorage.clear(); + localStorage.removeItem(STORAGE_KEY_BY_MODE[mode]); return null; } @@ -81,7 +93,7 @@ function loadStats(): PersistedStats | null { typeof parsed.sessionToken === "string" ? parsed.sessionToken : "", }; } catch { - localStorage.removeItem("stats"); + localStorage.removeItem(STORAGE_KEY_BY_MODE[mode]); return null; } } @@ -92,6 +104,7 @@ export function useGameState({ sessionDate, sessionToken, initialSig, + mode = "daily", }: UseGameStateOptions) { const [guesses, setGuesses] = React.useState<GuessType[]>(makeEmptyGuesses()); const [currentTry, setCurrentTry] = React.useState(0); @@ -100,9 +113,11 @@ export function useGameState({ const [isSubmitting, setIsSubmitting] = React.useState(false); const [stats, setStats] = React.useState<PersistedStats | null>(() => - loadStats() + loadStats(mode) ); + const submitGuessFn = mode === "dailyMV" ? submitDailyMVGuess : submitDailyGuess; + const hydratedRef = React.useRef(false); React.useEffect(() => { @@ -169,12 +184,12 @@ export function useGameState({ if (!hydratedRef.current) return; if (!stats) { - localStorage.removeItem("stats"); + localStorage.removeItem(STORAGE_KEY_BY_MODE[mode]); return; } - localStorage.setItem("stats", JSON.stringify(stats)); - }, [stats, persist]); + localStorage.setItem(STORAGE_KEY_BY_MODE[mode], JSON.stringify(stats)); + }, [stats, persist, mode]); const applyServerState = React.useCallback( (next: DailyGameState, sig: string) => { @@ -216,7 +231,7 @@ export function useGameState({ setIsSubmitting(true); try { - const res = await submitDailyGuess({ + const res = await submitGuessFn({ sessionToken, sig: stats.sig, state: { @@ -255,6 +270,7 @@ export function useGameState({ stats, isSubmitting, applyServerState, + submitGuessFn, ]); const guess = React.useCallback(async () => { @@ -266,7 +282,7 @@ export function useGameState({ setIsSubmitting(true); try { - const res = await submitDailyGuess({ + const res = await submitGuessFn({ sessionToken, sig: stats.sig, state: { @@ -321,6 +337,7 @@ export function useGameState({ stats, isSubmitting, applyServerState, + submitGuessFn, ]); const reset = React.useCallback(() => { diff --git a/src/pages/DailyPage.tsx b/src/pages/DailyPage.tsx index 17efaeb..5b39701 100644 --- a/src/pages/DailyPage.tsx +++ b/src/pages/DailyPage.tsx @@ -99,7 +99,7 @@ export function DailyPage() { return ( <main> <Header openInfoPopUp={openInfoPopUp} /> - {isInfoPopUpOpen && <InfoPopUp onClose={closeInfoPopUp} />} + {isInfoPopUpOpen && <InfoPopUp onClose={closeInfoPopUp} gameMode="daily"/>} <Styled.Container> <Game guesses={guesses} diff --git a/src/pages/LandingPage.tsx b/src/pages/LandingPage.tsx index e1d29cd..40e818a 100644 --- a/src/pages/LandingPage.tsx +++ b/src/pages/LandingPage.tsx @@ -1,92 +1,155 @@ -import React from "react"; import { useNavigate } from "react-router-dom"; import styled from "styled-components"; import { appName } from "../constants"; +const getColor = (variant?: "green" | "purple" | "cyan") => { + switch (variant) { + case "purple": + return "var(--cl-purple, #a855f7)"; + case "cyan": + return "var(--cl-cyan-6)"; + default: + return "var(--cl-green-6)"; + } +}; + const Container = styled.div` display: flex; flex-direction: column; align-items: center; justify-content: center; - min-height: 60vh; - gap: 24px; + min-height: 70vh; + padding: 24px; + gap: 14px; `; const Title = styled.h1` font-family: "Roboto Mono", monospace; - font-size: 2rem; + font-size: clamp(1.5rem, 3vw, 2rem); font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; color: var(--cl-white); + text-align: center; + margin: 0; `; const Subtitle = styled.p` font-family: "Roboto Mono", monospace; font-size: 0.9rem; color: var(--cl-gray-6); - margin: 0; + margin: 4px 0 0; + text-align: center; +`; + +const ModeGroups = styled.div` + display: flex; + flex-direction: column; + gap: 22px; + width: 100%; + max-width: 600px; `; const ButtonGroup = styled.div` display: flex; gap: 16px; - margin-top: 16px; + justify-content: center; + flex-wrap: wrap; @media (max-width: 480px) { flex-direction: column; - width: 100%; - padding: 0 24px; + align-items: stretch; } `; -const ModeButton = styled.button<{ variant?: "green" | "purple" }>` +const GroupLabel = styled.span` + display: block; + font-family: "Roboto Mono", monospace; + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--cl-gray-6); + text-align: center; + margin-bottom: 10px; +`; + +const ModeButton = styled.button<{ variant?: "green" | "purple" | "cyan" }>` 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)"}; + padding: 16px 28px; + min-width: 180px; + + border: 2px solid ${({ variant }) => getColor(variant)}; background: transparent; - color: ${({ variant }) => - variant === "purple" ? "var(--cl-purple, #a855f7)" : "var(--cl-green-6)"}; + color: ${({ variant }) => getColor(variant)}; + cursor: pointer; transition: all 0.15s ease; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + &:hover { - background: ${({ variant }) => - variant === "purple" ? "var(--cl-purple, #a855f7)" : "var(--cl-green-6)"}; + background: ${({ variant }) => getColor(variant)}; color: var(--cl-black, #000); } + + &:focus-visible { + outline: 3px solid ${({ variant }) => getColor(variant)}; + outline-offset: 3px; + } + + @media (max-width: 480px) { + width: 100%; + min-width: unset; + } `; const ModeDescription = styled.span` - display: block; - font-size: 0.7rem; + font-size: 0.72rem; font-weight: 400; color: var(--cl-gray-6); - margin-top: 4px; + margin-top: 6px; `; + 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> + <Subtitle>a kpop music guessing game</Subtitle> + <ModeGroups> + <div> + <GroupLabel>Song Guessing</GroupLabel> + <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> + </div> + <div> + <GroupLabel>Music Video Guessing</GroupLabel> + <ButtonGroup> + <ModeButton variant="cyan" onClick={() => navigate("/mv")}> + Daily MV + <ModeDescription>Guess the MV from frames</ModeDescription> + </ModeButton> + </ButtonGroup> + </div> + </ModeGroups> </Container> ); } diff --git a/src/pages/MVPage.tsx b/src/pages/MVPage.tsx new file mode 100644 index 0000000..c135918 --- /dev/null +++ b/src/pages/MVPage.tsx @@ -0,0 +1,76 @@ +import React from "react"; + +import { DailySolution, getDailyMVSolution } from "../helpers/fetchSolution"; +import { useGameState } from "../hooks/useGameState"; + +import { Header, InfoPopUp, Game, Footer } from "../components"; + +import * as Styled from "../app.styled"; + +export function MVPage() { + const [todaysSolution, setTodaysSolution] = + React.useState<DailySolution | null>(null); + + const firstRun = localStorage.getItem("firstRun") === null; + + React.useEffect(() => { + getDailyMVSolution().then((solution) => setTodaysSolution(solution)); + }, []); + + const { + guesses, + currentTry, + setSelectedSong, + didGuess, + skip, + guess, + isSubmitting, + } = useGameState({ + solution: todaysSolution?.song ?? null, + persist: true, + sessionDate: todaysSolution?.date, + sessionToken: todaysSolution?.sessionToken, + initialSig: todaysSolution?.initialSig, + mode: "dailyMV", + }); + + 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} gameMode="dailyMV" />} + <Styled.Container> + <Game + guesses={guesses} + didGuess={didGuess} + todaysSolution={todaysSolution.song} + dailyDate={todaysSolution.date} + currentTry={currentTry} + setSelectedSong={setSelectedSong} + skip={skip} + guess={guess} + mode="dailyMV" + isSubmitting={isSubmitting} + /> + </Styled.Container> + <Footer /> + </main> + ); +} diff --git a/src/pages/UnlimitedPage.tsx b/src/pages/UnlimitedPage.tsx index 761d2b9..23a4daf 100644 --- a/src/pages/UnlimitedPage.tsx +++ b/src/pages/UnlimitedPage.tsx @@ -58,7 +58,7 @@ export function UnlimitedPage() { return ( <main> <Header openInfoPopUp={openInfoPopUp} /> - {isInfoPopUpOpen && <InfoPopUp onClose={closeInfoPopUp} />} + {isInfoPopUpOpen && <InfoPopUp onClose={closeInfoPopUp} gameMode="unlimited" />} <Styled.Container> <Game guesses={guesses} |
