diff options
Diffstat (limited to 'src/components')
| -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 |
6 files changed, 210 insertions, 20 deletions
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"; |
