diff options
| author | Brendan F <EpicWolverine@users.noreply.github.com> | 2023-05-14 23:12:27 -0700 |
|---|---|---|
| committer | Brendan F <EpicWolverine@users.noreply.github.com> | 2023-05-14 23:12:27 -0700 |
| commit | 737344a72d23dc97b0d0e73cc4ab7fdffd0fbf49 (patch) | |
| tree | 2a915b59ab29ac79012ca3345999d9e23562d1f9 /src | |
| parent | b19a001171bd8197a30f397091d67eba5e4c1111 (diff) | |
Merge in react app code
From sluchajfun and youtube-heardle-template
Diffstat (limited to 'src')
37 files changed, 1376 insertions, 0 deletions
diff --git a/src/app.styled.ts b/src/app.styled.ts new file mode 100644 index 0000000..1c040eb --- /dev/null +++ b/src/app.styled.ts @@ -0,0 +1,18 @@ +import styled from "styled-components"; + +export const Container = styled.div` + width: 40%; + + @media (max-width: 768px) { + width: 90%; + } + + max-width: 600px; + + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + + margin: 0 auto; +`; diff --git a/src/app.tsx b/src/app.tsx new file mode 100644 index 0000000..a045bcc --- /dev/null +++ b/src/app.tsx @@ -0,0 +1,164 @@ +import { event } from "react-ga"; + +import React from "react"; +import _ from "lodash"; + +import { Song } from "./types/song"; +import { GuessType } from "./types/guess"; + +import { todaysSolution } from "./helpers"; + +import { Header, InfoPopUp, Game, Footer } from "./components"; + +import * as Styled from "./app.styled"; + +function App() { + const initialGuess = { + song: undefined, + skipped: false, + isCorrect: undefined, + } as GuessType; + + const [guesses, setGuesses] = React.useState<GuessType[]>( + Array.from({ length: 5 }).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 firstRun = localStorage.getItem("firstRun") === null; + let stats = JSON.parse(localStorage.getItem("stats") || "{}"); + + 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 { + // initialize stats + // useEffect below does rest + stats = []; + stats.push({ + solution: todaysSolution, + }); + } + }, []); + + 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]); + + 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, + skipped: true, + isCorrect: undefined, + }; + + return newGuesses; + }); + + setCurrentTry((currentTry) => currentTry + 1); + + event({ + category: "Game", + action: "Skip", + }); + }, [currentTry]); + + const guess = React.useCallback(() => { + const isCorrect = selectedSong === todaysSolution; + + if (!selectedSong) { + alert("Choose a song"); + return; + } + + setGuesses((guesses: GuessType[]) => { + const newGuesses = [...guesses]; + newGuesses[currentTry] = { + song: selectedSong, + skipped: false, + isCorrect: isCorrect, + }; + + return newGuesses; + }); + + setCurrentTry((currentTry) => currentTry + 1); + setSelectedSong(undefined); + + if (isCorrect) { + setDidGuess(true); + } + + event({ + category: "Game", + action: "Guess", + label: `${selectedSong.artist} - ${selectedSong.name}`, + value: isCorrect ? 1 : 0, + }); + }, [guesses, selectedSong]); + + 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> + ); +} + +export default App; diff --git a/src/components/Button/index.styled.ts b/src/components/Button/index.styled.ts new file mode 100644 index 0000000..1bb964f --- /dev/null +++ b/src/components/Button/index.styled.ts @@ -0,0 +1,23 @@ +import styled from "styled-components"; +import { theme } from "../../constants"; + +export const Button = styled.button<{ variant?: keyof typeof theme }>` + background-color: ${({ theme, variant }) => + variant ? theme[variant] : theme.background100}; + + border-radius: 5px; + border: none; + + color: ${({ theme }) => theme.text}; + font-size: 1rem; + font-weight: 800; + + width: max-content; + padding: 12.5px 20px; + + &:hover { + opacity: 0.8; + } + + cursor: pointer; +`; diff --git a/src/components/Button/index.tsx b/src/components/Button/index.tsx new file mode 100644 index 0000000..7e6fa5f --- /dev/null +++ b/src/components/Button/index.tsx @@ -0,0 +1,19 @@ +import React, { CSSProperties } from "react"; + +import * as Styled from "./index.styled"; +import { theme } from "../../constants"; + +interface Props { + style?: CSSProperties; + variant?: keyof typeof theme; + children: React.ReactNode; + onClick?: () => void; +} + +export function Button({ onClick, style, variant, children }: Props) { + return ( + <Styled.Button onClick={onClick} variant={variant} style={style}> + {children} + </Styled.Button> + ); +} diff --git a/src/components/Footer/index.styled.ts b/src/components/Footer/index.styled.ts new file mode 100644 index 0000000..6caa7ff --- /dev/null +++ b/src/components/Footer/index.styled.ts @@ -0,0 +1,11 @@ +import styled from "styled-components"; + +export const Text = styled.p` + text-align: center; + color: ${({ theme }) => theme.text}; + margin-top: 50px; +`; + +export const Link = styled.a` + color: ${({ theme }) => theme.text}; +`; diff --git a/src/components/Footer/index.tsx b/src/components/Footer/index.tsx new file mode 100644 index 0000000..c5ad421 --- /dev/null +++ b/src/components/Footer/index.tsx @@ -0,0 +1,17 @@ +import React from "react"; +import { IoHeart } from "react-icons/io5"; + +import * as Styled from "./index.styled"; + +export function Footer() { + return ( + <footer> + <Styled.Text> + Made with <IoHeart /> by{" "} + <Styled.Link href="https://twitter.com/synowski_maciej"> + Maciej Synowski + </Styled.Link> + </Styled.Text> + </footer> + ); +} diff --git a/src/components/Game/index.styled.ts b/src/components/Game/index.styled.ts new file mode 100644 index 0000000..7860f98 --- /dev/null +++ b/src/components/Game/index.styled.ts @@ -0,0 +1,8 @@ +import styled from "styled-components"; + +export const Buttons = styled.div` + margin-top: 5%; + display: flex; + justify-content: space-between; + width: 100%; +`; diff --git a/src/components/Game/index.tsx b/src/components/Game/index.tsx new file mode 100644 index 0000000..84ca282 --- /dev/null +++ b/src/components/Game/index.tsx @@ -0,0 +1,65 @@ +import React from "react"; + +import { GuessType } from "../../types/guess"; +import { Song } from "../../types/song"; +import { playTimes } from "../../constants"; + +import { Button, Guess, Player, Search, Result } from "../"; + +import * as Styled from "./index.styled"; + +interface Props { + guesses: GuessType[]; + todaysSolution: any; + currentTry: number; + didGuess: boolean; + setSelectedSong: React.Dispatch<React.SetStateAction<Song | undefined>>; + skip: () => void; + guess: () => void; +} + +export function Game({ + guesses, + todaysSolution, + currentTry, + didGuess, + setSelectedSong, + skip, + guess, +}: Props) { + if (didGuess || currentTry === 6) { + return ( + <Result + didGuess={didGuess} + currentTry={currentTry} + todaysSolution={todaysSolution} + guesses={guesses} + /> + ); + } + return ( + <> + {guesses.map((guess: GuessType, index) => ( + <Guess + key={index} + guess={guess} + isCorrect={guess.isCorrect} + active={index === currentTry} + /> + ))} + <Player id={todaysSolution.youtubeId} currentTry={currentTry} /> + <Search currentTry={currentTry} setSelectedSong={setSelectedSong} /> + + <Styled.Buttons> + <Button onClick={skip}> + {currentTry === 5 + ? "Give Up" + : `Skip +${playTimes[currentTry] / 1000}s`} + </Button> + <Button variant="green" onClick={guess}> + Submit + </Button> + </Styled.Buttons> + </> + ); +} diff --git a/src/components/Guess/index.styled.ts b/src/components/Guess/index.styled.ts new file mode 100644 index 0000000..5118b70 --- /dev/null +++ b/src/components/Guess/index.styled.ts @@ -0,0 +1,39 @@ +import styled from "styled-components"; + +export const Container = styled.div<{ + active: boolean; + isCorrect: boolean | undefined; +}>` + width: 100%; + height: 45px; + + margin: 5px auto; + + display: flex; + align-items: center; + + border-color: ${({ theme, active, isCorrect }) => { + if (active) { + return theme.border; + } else if (isCorrect === false) { + return theme.red; + } else { + return theme.border100; + } + }}; + border-width: 1px; + border-radius: 5px; + border-style: solid; + + color: ${({ theme }) => theme.text}; +`; + +export const Text = styled.p` + width: 100%; + height: max-content; + + padding: 0px 10px; + + font-size: 1rem; + color: ${({ theme }) => theme.text}; +`; diff --git a/src/components/Guess/index.tsx b/src/components/Guess/index.tsx new file mode 100644 index 0000000..2afd35c --- /dev/null +++ b/src/components/Guess/index.tsx @@ -0,0 +1,32 @@ +import React from "react"; + +import { GuessType } from "../../types/guess"; + +import * as Styled from "./index.styled"; + +interface Props { + guess: GuessType; + isCorrect: boolean | undefined; + active: boolean; +} + +export function Guess({ guess, isCorrect, active }: Props) { + const { song, skipped } = guess; + const [text, setText] = React.useState<string>(""); + + React.useEffect(() => { + if (song) { + setText(`${song.artist} - ${song.name}`); + } else if (skipped) { + setText("Skipped"); + } else { + setText(""); + } + }, [guess]); + + return ( + <Styled.Container active={active} isCorrect={isCorrect}> + <Styled.Text>{text}</Styled.Text> + </Styled.Container> + ); +} diff --git a/src/components/Header/index.styled.ts b/src/components/Header/index.styled.ts new file mode 100644 index 0000000..f06cb72 --- /dev/null +++ b/src/components/Header/index.styled.ts @@ -0,0 +1,49 @@ +import styled from "styled-components"; + +export const Container = styled.header` + display: flex; + align-items: center; + justify-content: center; + + width: 100%; + + border-color: ${({ theme }) => theme.border}; + border-bottom-width: 0.5px; + border-bottom-style: solid; + + margin-bottom: 15px; +`; + +export const Content = styled.div` + display: grid; + grid-template-columns: 1fr 1fr 1fr; + align-items: center; + justify-content: space-between; + + width: 40%; + + @media (max-width: 768px) { + width: 95%; + } + + max-width: 650px; + + svg:hover { + cursor: pointer; + opacity: 0.8; + } + + a { + color: ${({ theme }) => theme.text}; + } +`; + +export const Logo = styled.h1` + color: ${({ theme }) => theme.text}; + font-family: "Roboto Serif", serif; + text-transform: uppercase; + width: max-content; + + -webkit-touch-callout: none; + user-select: none; +`; diff --git a/src/components/Header/index.tsx b/src/components/Header/index.tsx new file mode 100644 index 0000000..0c7f330 --- /dev/null +++ b/src/components/Header/index.tsx @@ -0,0 +1,26 @@ +import React from "react"; +import { IoInformationCircleOutline } from "react-icons/io5"; + +import * as Styled from "./index.styled"; + +interface Props { + openInfoPopUp: () => void; +} + +export function Header({ openInfoPopUp }: Props) { + return ( + <Styled.Container> + <Styled.Content> + <IoInformationCircleOutline + onClick={openInfoPopUp} + size={30} + width={30} + height={30} + /> + + <Styled.Logo>Heardle Template</Styled.Logo> + <a href="#"></a> + </Styled.Content> + </Styled.Container> + ); +} diff --git a/src/components/InfoPopUp/index.styled.ts b/src/components/InfoPopUp/index.styled.ts new file mode 100644 index 0000000..6431246 --- /dev/null +++ b/src/components/InfoPopUp/index.styled.ts @@ -0,0 +1,70 @@ +import styled from "styled-components"; + +export const Container = styled.div` + position: absolute; + top: 0; + z-index: 2; + + width: 100%; + height: 100%; + + display: flex; + align-items: center; + justify-content: center; + + background-color: rgba(0, 0, 0, 0.75); +`; + +export const PopUp = styled.div` + width: 90%; + max-width: 500px; + @media (max-width: 768px) { + width: 80%; + } + padding: 20px; + + background-color: ${({ theme }) => theme.background100}; + + border-radius: 10px; + + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + + h1 { + margin-bottom: 0; + } +`; + +export const Spacer = styled.div` + width: 70%; + height: 0.2px; + + margin: 20px 0; + + background-color: ${({ theme }) => theme.text}; + opacity: 0.5; +`; + +export const Section = styled.div` + display: flex; + gap: 10px; + align-items: center; + justify-content: space-between; + + a { + color: ${({ theme }) => theme.text}; + } +`; + +export const Contact = styled.p` + a { + color: ${({ theme }) => theme.text}; + } + margin-top: 5%; + + font-size: 0.9rem; + font-weight: bold; + opacity: 0.5; +`; diff --git a/src/components/InfoPopUp/index.tsx b/src/components/InfoPopUp/index.tsx new file mode 100644 index 0000000..eb22b19 --- /dev/null +++ b/src/components/InfoPopUp/index.tsx @@ -0,0 +1,36 @@ +import React from "react"; +import { IoMusicalNoteOutline, IoHelpCircleOutline } from "react-icons/io5"; +import { Button } from ".."; + +import * as Styled from "./index.styled"; + +interface Props { + onClose: () => void; +} + +export function InfoPopUp({ onClose }: Props) { + return ( + <Styled.Container> + <Styled.PopUp> + <h1>HOW TO PLAY</h1> + <Styled.Spacer /> + <Styled.Section> + {/* <IoMusicalNoteOutline size={50} /> */} + <p> + Listen to the intro, then find the correct Joywave song in the list. + </p> + </Styled.Section> + <Styled.Section> + {/* <IoHelpCircleOutline size={50} /> */} + <p>Skipped or incorrect attempts unlock more of the intro</p> + </Styled.Section> + <Styled.Section> + <p>Answer in as few tries as possible and share your score!</p> + </Styled.Section> + <Button variant="green" style={{ marginTop: 20 }} onClick={onClose}> + Play + </Button> + </Styled.PopUp> + </Styled.Container> + ); +} diff --git a/src/components/Player/index.styled.ts b/src/components/Player/index.styled.ts new file mode 100644 index 0000000..4200fe1 --- /dev/null +++ b/src/components/Player/index.styled.ts @@ -0,0 +1,48 @@ +import styled from "styled-components"; + +export const ProgressBackground = styled.div` + position: relative; + z-index: -1; + + width: 100%; + height: 20px; + background-color: ${({ theme }) => theme.gray}; + border-radius: 2px; + + margin-top: 5%; +`; + +export const Progress = styled.div<{ value: number }>` + width: ${({ value }) => value * 6.25}%; + height: 20px; + + align-self: flex-start; + + background-color: ${({ theme }) => theme.green}; + + border-radius: 2px; + + transition: width 0.5s; +`; + +export const Separator = styled.div` + position: absolute; + top: 0; + + width: 0.8px; + height: 100%; + + background-color: ${({ theme }) => theme.border100}; +`; + +export const TimeStamps = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + + width: 100%; +`; + +export const TimeStamp = styled.p` + color: ${({ theme }) => theme.text}; +`; diff --git a/src/components/Player/index.tsx b/src/components/Player/index.tsx new file mode 100644 index 0000000..82f600e --- /dev/null +++ b/src/components/Player/index.tsx @@ -0,0 +1,97 @@ +import React from "react"; +import YouTube from "react-youtube"; +import { IoPlay } from "react-icons/io5"; +import { event } from "react-ga"; + +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); + event({ + category: "Player", + action: "Played song", + }); + }, []); + + 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> + <IoPlay + style={{ cursor: "pointer" }} + size={40} + color="#fff" + onClick={startPlayback} + /> + </> + ) : ( + <p>Loading player...</p> + )} + </> + ); +} diff --git a/src/components/Result/index.styled.ts b/src/components/Result/index.styled.ts new file mode 100644 index 0000000..92d51f5 --- /dev/null +++ b/src/components/Result/index.styled.ts @@ -0,0 +1,33 @@ +import styled from "styled-components"; + +export const ResultTitle = styled.h1` + @media (max-width: 768px) { + text-align: center; + width: 100%; + } +`; + +export const Tries = styled.h4` + @media (max-width: 768px) { + text-align: center; + width: 100%; + } + + margin-top: 0; +`; + +export const SongTitle = styled.h3` + @media (max-width: 768px) { + text-align: center; + width: 100%; + } + + margin-top: 0; +`; + +export const TimeToNext = styled.h4` + @media (max-width: 768px) { + text-align: center; + width: 100%; + } +`; diff --git a/src/components/Result/index.tsx b/src/components/Result/index.tsx new file mode 100644 index 0000000..40a95fb --- /dev/null +++ b/src/components/Result/index.tsx @@ -0,0 +1,74 @@ +import React from "react"; + +import { Song } from "../../types/song"; +import { GuessType } from "../../types/guess"; +import { scoreToEmoji } from "../../helpers"; + +import { Button } from "../Button"; +import { YouTube } from "../YouTube"; + +import * as Styled from "./index.styled"; + +interface Props { + didGuess: boolean; + currentTry: number; + todaysSolution: Song; + guesses: GuessType[]; +} + +export function Result({ + didGuess, + todaysSolution, + guesses, + currentTry, +}: Props) { + const hoursToNextDay = Math.floor( + (new Date(new Date().setHours(24, 0, 0, 0)).getTime() - + new Date().getTime()) / + 1000 / + 60 / + 60 + ); + + const textForTry = ["Wow!", "Super!", "Congrats!", "Nice!"]; + + if (didGuess) { + const copyResult = React.useCallback(() => { + navigator.clipboard.writeText(scoreToEmoji(guesses)); + }, [guesses]); + + return ( + <> + <Styled.ResultTitle>{textForTry[currentTry - 1]}</Styled.ResultTitle> + <Styled.SongTitle> + Todays song is {todaysSolution.artist} -{" "} + {todaysSolution.name} + </Styled.SongTitle> + <Styled.Tries> + You guessed it in {currentTry} {currentTry === 1 ? 'try' : 'tries'} + </Styled.Tries> + <YouTube id={todaysSolution.youtubeId} /> + <Button onClick={copyResult} variant="green"> + Copy results + </Button> + <Styled.TimeToNext> + Remember to come back in {hoursToNextDay}{" "} hours! + </Styled.TimeToNext> + </> + ); + } else { + return ( + <> + <Styled.ResultTitle>Unfortunately, thats wrong</Styled.ResultTitle> + <Styled.SongTitle> + Todays song is {todaysSolution.artist} -{" "} + {todaysSolution.name} + </Styled.SongTitle> + <YouTube id={todaysSolution.youtubeId} /> + <Styled.TimeToNext> + Try again in {hoursToNextDay}{" "} hours + </Styled.TimeToNext> + </> + ); + } +} diff --git a/src/components/Search/index.styled.ts b/src/components/Search/index.styled.ts new file mode 100644 index 0000000..c1344d5 --- /dev/null +++ b/src/components/Search/index.styled.ts @@ -0,0 +1,88 @@ +import styled from "styled-components"; + +export const Container = styled.div` + position: relative; + + width: 100%; + + margin-top: 5%; +`; + +export const SearchContainer = styled.div` + display: flex; + align-items: center; + + width: 100%; + height: 50px; + + border-color: ${({ theme }) => theme.border}; + border-width: 1px; + border-radius: 5px; + border-style: solid; + + color: ${({ theme }) => theme.text}; +`; + +export const SearchPadding = styled.div` + display: flex; + align-items: center; + + width: 100%; + + padding: 0 15px; +`; + +export const Input = styled.input` + width: 100%; + height: 100%; + margin: 0 10px; + + background-color: transparent; + border: none; + outline: none !important; + + color: ${({ theme }) => theme.text}; + font-size: 1rem; +`; + +export const ResultsContainer = styled.div` + position: absolute; + bottom: 50px; + z-index: 1; + + display: flex; + flex-direction: column; + justify-content: flex-end; + + width: 100%; + + overflow-y: scroll; +`; + +export const Result = styled.div` + padding: 1px 15px; + + background-color: ${({ theme }) => theme.background100}; + + border-color: ${({ theme }) => theme.border}; + border-width: 1px; + border-radius: 5px; + border-style: solid; + + color: ${({ theme }) => theme.text}; + + cursor: pointer; +`; + +export const ResultText = styled.p` + width: 100%; + + color: ${({ theme }) => theme.text}; + font-size: 0.9rem; + + user-select: none; + + &:hover { + opacity: 0.8; + } +`; diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx new file mode 100644 index 0000000..16f5c9e --- /dev/null +++ b/src/components/Search/index.tsx @@ -0,0 +1,67 @@ +import React from "react"; +import { event } from "react-ga"; +import { IoSearch } from "react-icons/io5"; +import { searchSong } from "../../helpers"; +import { Song } from "../../types/song"; + +import * as Styled from "./index.styled"; + +interface Props { + currentTry: number; + setSelectedSong: React.Dispatch<React.SetStateAction<Song | undefined>>; +} + +export function Search({ currentTry, setSelectedSong }: Props) { + const [value, setValue] = React.useState<string>(""); + const [results, setResults] = React.useState<Song[]>([]); + + React.useEffect(() => { + if (value) { + setResults(searchSong(value)); + } else if (value === "") { + setResults([]); + } + }, [value]); + + // clear value on selection + React.useEffect(() => { + setValue(""); + }, [currentTry]); + + return ( + <Styled.Container> + <Styled.ResultsContainer> + {results.map((song) => ( + <Styled.Result + key={song.youtubeId} + onClick={() => { + setSelectedSong(song); + setValue(`${song.artist} - ${song.name}`); + setResults([]); + + event({ + category: "Player", + action: "Chose song", + label: `${song.artist} - ${song.name}`, + }); + }} + > + <Styled.ResultText> + {song.artist} - {song.name} + </Styled.ResultText> + </Styled.Result> + ))} + </Styled.ResultsContainer> + <Styled.SearchContainer> + <Styled.SearchPadding> + <IoSearch size={20} /> + <Styled.Input + onChange={(e) => setValue(e.currentTarget.value)} + placeholder="Search" + value={value} + /> + </Styled.SearchPadding> + </Styled.SearchContainer> + </Styled.Container> + ); +} diff --git a/src/components/YouTube/index.tsx b/src/components/YouTube/index.tsx new file mode 100644 index 0000000..13ffeaa --- /dev/null +++ b/src/components/YouTube/index.tsx @@ -0,0 +1,24 @@ +import React from "react"; +import { default as YouTubePlayer } from "react-youtube"; + +interface Props { + id: string; +} + +export function YouTube({ id }: Props) { + return ( + <div style={{ margin: "5% 0" }}> + <YouTubePlayer + videoId={id} + opts={{ + width: "336", + height: "189", + playerVars: { + autoplay: 1, + playsinline: 1, + }, + }} + /> + </div> + ); +} diff --git a/src/components/index.ts b/src/components/index.ts new file mode 100644 index 0000000..75672f3 --- /dev/null +++ b/src/components/index.ts @@ -0,0 +1,10 @@ +export { Button } from "./Button"; +export { Footer } from "./Footer"; +export { Game } from "./Game"; +export { Guess } from "./Guess"; +export { Header } from "./Header"; +export { InfoPopUp } from "./InfoPopUp"; +export { Player } from "./Player"; +export { Result } from "./Result"; +export { Search } from "./Search"; +export { YouTube } from "./YouTube"; diff --git a/src/constants/index.ts b/src/constants/index.ts new file mode 100644 index 0000000..d396519 --- /dev/null +++ b/src/constants/index.ts @@ -0,0 +1,3 @@ +export { playTimes } from "./playTimes"; +export { songs } from "./songs"; +export { theme } from "./theme"; diff --git a/src/constants/playTimes.ts b/src/constants/playTimes.ts new file mode 100644 index 0000000..3cd37ca --- /dev/null +++ b/src/constants/playTimes.ts @@ -0,0 +1 @@ +export const playTimes = [1000, 2000, 4000, 7000, 11000, 16000]; diff --git a/src/constants/songs.ts b/src/constants/songs.ts new file mode 100644 index 0000000..3d7c2a0 --- /dev/null +++ b/src/constants/songs.ts @@ -0,0 +1,222 @@ +export const songs = [ + { + artist: "Joywave", + name: "Destruction", + youtubeId: "TA5kvZ9aqDI", + }, + { + artist: "Wilki", + name: "Bohema", + youtubeId: "2NzeEQCJ4aM", + }, + { + artist: "Sydney Polak", + name: "Otwieram wino", + youtubeId: "9d-gsGrnCCQ", + }, + { + artist: "Jeden Osiem L", + name: "Jak zapomnieć", + youtubeId: "qvv7t8Y0iWk", + }, + { + artist: "Franek Kimono", + name: "King Bruce Lee Karate Mistrz", + youtubeId: "h_QNYTrHZ2c", + }, + { + artist: "Quebonafide", + name: "Madagascar", + youtubeId: "LWeO7aZLaZU", + }, + { + artist: "Republika", + name: "Mamona", + youtubeId: "7qa-A_o5NTY", + }, + { + artist: "happysad", + name: "Zanim pójdę", + youtubeId: "W1jIuwYGIrU", + }, + { + artist: "Video", + name: "Wszystko Jedno", + youtubeId: "OUaRbA1fYSg", + }, + { + artist: "Sanah", + name: "Ale jazz!", + youtubeId: "1jc2CdR8A-o", + }, + { + artist: "GOLEC UORKIESTRA", + name: "Ściernisco", + youtubeId: "yrJ_lzYGJdg", + }, + { + artist: "Dawid Podsiadło", + name: "Pastempomat", + youtubeId: "9cg4XZWMC34", + }, + { + artist: "Taco Hemingway", + name: "Krwawa Jesień", + youtubeId: "sO7B8eNhGbY", + }, + { + artist: "Paktofonika", + name: "Priorytety", + youtubeId: "qOMCjDM8cro", + }, + { + artist: "Young Leosia", + name: "Szklanki", + youtubeId: "CYiGyaJyPMk", + }, + { + artist: "Mandaryna", + name: "Ev'ry night", + youtubeId: "CmtdhCsLzK4", + }, + { + artist: "Tymek", + name: "Język ciała", + youtubeId: "pImrABc4s58", + }, + { + artist: "Gang z Albanii", + name: "Albański Raj", + youtubeId: "fWs8HwDtddQ", + }, + { + artist: "Mrozu", + name: "Miliony monet", + youtubeId: "NHTTdk58z7g", + }, + { + artist: "Popek", + name: "Pakistańskie disco", + youtubeId: "xVdn9GhwJgk", + }, + { + artist: "Żabson & Young Igi", + name: "Icey", + youtubeId: "CcScseLdEDo", + }, + { + artist: "Grzegorz Turnau", + name: "Cichosza", + youtubeId: "m98MBACe1xk", + }, + { + artist: "Lady Pank", + name: "Kryzysowa Narzeczona", + youtubeId: "xPbnGCsYFAk", + }, + { + artist: "Taconafide", + name: "Tamagotchi", + youtubeId: "odWxQ5eEnfE", + }, + { + artist: "Dżem", + name: "Wehikuł czasu", + youtubeId: "XWcqFbMUAb4", + }, + { + artist: "?", + name: "Chemia Nowej Ery", + youtubeId: "DeURxOmtLQA", + }, + { + artist: "Bedoes", + name: "Gustaw", + youtubeId: "fide4cDt_xo", + }, + { + artist: "Republika", + name: "Biała flaga", + youtubeId: "gHUDbpI8Ulk", + }, + { + artist: "Białas", + name: "W imię Ojca Trapu", + youtubeId: "5XL78J9izsU", + }, + { + artist: "Kult", + name: "Polska", + youtubeId: "K0NHY7wNDuM", + }, + { + artist: "Virgin / Doda", + name: "Dżaga", + youtubeId: "_7TtBGE9MiE", + }, + { + artist: "2115", + name: "Czarownica", + youtubeId: "QsL-XRWz4Bg", + }, + { + artist: "Perfect", + name: "Niepokonani", + youtubeId: "HZ8qBioPlHE", + }, + { + artist: "Lady Pank", + name: "Zawsze tam gdzie ty", + youtubeId: "YdrQxgtVBY", + }, + { + artist: "Natalia Nykiel", + name: "Bądź Duży", + youtubeId: "qQ02hZOid0c", + }, + { + artist: "The Dumplings", + name: "Przykro mi", + youtubeId: "kdu-E2SEGNY", + }, + { + artist: "Wilki", + name: "Urke", + youtubeId: "anE8VjceiPw", + }, + { + artist: "Trzeci wymiar", + name: "Dla mnie masz stajla", + youtubeId: "oKX5ldYxZKo", + }, + { + artist: "Poparzeni Kawą Trzy", + name: "Byłaś dla Mnie Wszystkim", + youtubeId: "6i8JDcyFJV4", + }, + { + artist: "Żabson", + name: "Superman", + youtubeId: "EDRE9Fosrlk", + }, + { + artist: "Sentino", + name: "Tatuażyk", + youtubeId: "cXENhG0O4bQ", + }, + { + artist: "Dawid Podsiadło", + name: "Trójkąty i Kwadraty", + youtubeId: "Wg4A_d9F7xk", + }, + { + artist: "Poparzeni Kawą Trzy", + name: "Okrutna Zła i Podła", + youtubeId: "Tvf8jSaOJGc", + }, + { + artist: "Sentino, Schwesta Ewa", + name: "Powww!", + youtubeId: "QhTEqLNkHcw", + }, +]; diff --git a/src/constants/theme.ts b/src/constants/theme.ts new file mode 100644 index 0000000..731ef36 --- /dev/null +++ b/src/constants/theme.ts @@ -0,0 +1,11 @@ +export const theme = { + border: "#F1F7ED", + border100: "#5C5C5C", + + text: "#FFFFFF", + background100: "#002E3D", + + green: "#4DBB60", + red: "#FF0000", + gray: "#E6E6E6", +} as const; diff --git a/src/helpers/index.ts b/src/helpers/index.ts new file mode 100644 index 0000000..5593bc5 --- /dev/null +++ b/src/helpers/index.ts @@ -0,0 +1,3 @@ +export { scoreToEmoji } from "./scoreToEmoji"; +export { searchSong } from "./searchSong"; +export { todaysSolution } from "./todaysSolution"; diff --git a/src/helpers/scoreToEmoji.ts b/src/helpers/scoreToEmoji.ts new file mode 100644 index 0000000..a118830 --- /dev/null +++ b/src/helpers/scoreToEmoji.ts @@ -0,0 +1,32 @@ +import { GuessType } from "../types/guess"; + +export function scoreToEmoji(guesses: GuessType[]): string { + const msInDay = 86400000; + const startDate = new Date('4/15/2022'); + const todaysDate = new Date(); + const index = Math.floor((todaysDate.getTime() - startDate.getTime() )/msInDay) + 1 + const emojis = { + incorrect: "🟥", + correct: "🟩", + skip: "⬜", + empty: "⬛️", + }; + // const todaysDate = new Date(); + const prefix = `HeardleTemplate - #${index} 🎧`; + + let scoreEmoji = ""; + + guesses.forEach((guess: GuessType) => { + if (guess.isCorrect === true) { + scoreEmoji += emojis.correct; + } else if (guess.skipped === true) { + scoreEmoji += emojis.skip; + } else if (guess.isCorrect === false) { + scoreEmoji += emojis.incorrect; + } else { + scoreEmoji += emojis.empty; + } + }); + + return `${prefix} ${scoreEmoji}`; +} diff --git a/src/helpers/searchSong.ts b/src/helpers/searchSong.ts new file mode 100644 index 0000000..24132f6 --- /dev/null +++ b/src/helpers/searchSong.ts @@ -0,0 +1,20 @@ +import { songs } from "../constants"; +import { Song } from "../types/song"; + +export function searchSong(searchTerm: string): Song[] { + searchTerm = searchTerm.toLowerCase(); + + return songs + .filter((song: Song) => { + const songName = song.name.toLowerCase(); + const songArtist = song.artist.toLowerCase(); + + if (songArtist.includes(searchTerm) || songName.includes(searchTerm)) { + return song; + } + }) + .sort((a, b) => + a.artist.toLowerCase().localeCompare(b.artist.toLocaleLowerCase()) + ) + .slice(0, 5); +} diff --git a/src/helpers/todaysSolution.ts b/src/helpers/todaysSolution.ts new file mode 100644 index 0000000..50e75ce --- /dev/null +++ b/src/helpers/todaysSolution.ts @@ -0,0 +1,12 @@ +import { songs } from "../constants"; + +// const epochMs = new Date(2022, 3, 10).valueOf(); +// const now = Date.now(); +// const index = Math.floor((now - epochMs) / msInDay); + +const msInDay = 86400000; +const startDate = new Date('4/15/2022'); +const todaysDate = new Date(); +const index = Math.floor((todaysDate.getTime() - startDate.getTime() )/msInDay) + +export const todaysSolution = songs[index % songs.length]; diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..ed4230b --- /dev/null +++ b/src/index.css @@ -0,0 +1,8 @@ +body { + @import url('https://fonts.googleapis.com/css2?family=Raleway:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&family=Roboto+Serif&display=swap'); margin: 0; + font-family: 'Raleway', sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + background-color: #00171F; + color: white; +} diff --git a/src/index.tsx b/src/index.tsx new file mode 100644 index 0000000..6d3a377 --- /dev/null +++ b/src/index.tsx @@ -0,0 +1,20 @@ +import { initialize } from "react-ga"; + +const TRACKING_ID = "INSERT-YOUR-ID-HERE"; +initialize(TRACKING_ID); + +import React from "react"; +import ReactDOM from "react-dom"; +import { ThemeProvider } from "styled-components"; +import { theme } from "./constants"; +import "./index.css"; +import App from "./app"; + +ReactDOM.render( + <React.StrictMode> + <ThemeProvider theme={theme}> + <App /> + </ThemeProvider> + </React.StrictMode>, + document.getElementById("root") +); diff --git a/src/react-app-env.d.ts b/src/react-app-env.d.ts new file mode 100644 index 0000000..6431bc5 --- /dev/null +++ b/src/react-app-env.d.ts @@ -0,0 +1 @@ +/// <reference types="react-scripts" /> diff --git a/src/setupTests.ts b/src/setupTests.ts new file mode 100644 index 0000000..1dd407a --- /dev/null +++ b/src/setupTests.ts @@ -0,0 +1,5 @@ +// jest-dom adds custom jest matchers for asserting on DOM nodes. +// allows you to do things like: +// expect(element).toHaveTextContent(/react/i) +// learn more: https://github.com/testing-library/jest-dom +import "@testing-library/jest-dom"; diff --git a/src/types/guess.ts b/src/types/guess.ts new file mode 100644 index 0000000..a84602d --- /dev/null +++ b/src/types/guess.ts @@ -0,0 +1,7 @@ +import { Song } from "./song"; + +export type GuessType = { + song: Song | undefined; + skipped: boolean; + isCorrect: boolean | undefined; +}; diff --git a/src/types/modules/Theme.d.ts b/src/types/modules/Theme.d.ts new file mode 100644 index 0000000..ef95171 --- /dev/null +++ b/src/types/modules/Theme.d.ts @@ -0,0 +1,8 @@ +import "styled-components"; +import { theme } from "../../constants"; + +type ThemeType = typeof theme; + +declare module "styled-components" { + export interface DefaultTheme extends ThemeType {} +} diff --git a/src/types/song.ts b/src/types/song.ts new file mode 100644 index 0000000..b375dc0 --- /dev/null +++ b/src/types/song.ts @@ -0,0 +1,5 @@ +export interface Song { + artist: string; + name: string; + youtubeId: string; +} |
