aboutsummaryrefslogtreecommitdiffstats
path: root/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'src/components')
-rw-r--r--src/components/Game/index.tsx54
-rw-r--r--src/components/InfoPopUp/index.tsx9
-rw-r--r--src/components/MVPlayer/index.styled.ts73
-rw-r--r--src/components/MVPlayer/index.tsx85
-rw-r--r--src/components/Search/index.tsx8
-rw-r--r--src/components/index.ts1
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";
send patches to the email below
yukais@pinapelz.com
include the subject [PATCH repo_name]
pinapelz.com
homepage