aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/app.tsx2
-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
-rw-r--r--src/helpers/fetchSolution.ts32
-rw-r--r--src/helpers/searchSong.ts5
-rw-r--r--src/hooks/useGameState.ts39
-rw-r--r--src/pages/DailyPage.tsx2
-rw-r--r--src/pages/LandingPage.tsx125
-rw-r--r--src/pages/MVPage.tsx76
-rw-r--r--src/pages/UnlimitedPage.tsx2
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}
send patches to the email below
yukais@pinapelz.com
include the subject [PATCH repo_name]
pinapelz.com
homepage