diff --git a/.env.development b/.env.development new file mode 100644 index 0000000..cb389e7 --- /dev/null +++ b/.env.development @@ -0,0 +1 @@ +VITE_API_URL=http://127.0.0.1:5000/ \ No newline at end of file diff --git a/.env.production b/.env.production new file mode 100644 index 0000000..964c845 --- /dev/null +++ b/.env.production @@ -0,0 +1 @@ +VITE_API_URL=https://imaginate-api-818978516440.us-central1.run.app/ \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index ec7de90..9d5b101 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,25 +1,26 @@ import { useState, useRef, ReactElement, useEffect } from 'react'; import './App.css'; -import Navbar from './navigation/Navbar'; +import NavBar from './components/NavBar/NavBar.tsx'; import { ConfigProvider, theme, Flex } from 'antd'; import posthog from 'posthog-js'; -import Cookies from 'universal-cookie'; -import { PhotoQueue } from './photoQueue/photoQueue.tsx'; -import { getImages } from './services/imageBackend.ts'; -import { Image } from './photoQueue/interfaces/ImageInterface.ts'; +import PhotoQueue from './components/PhotoQueue/PhotoQueue.tsx'; +import { getImages } from './services/Image.service.ts'; +import { Choice, Image } from './types/Image.types.ts'; +import { calculateDay } from './services/Day.service.ts'; +import { generateScoreHTML } from './services/Score.service.tsx'; -const cookies = new Cookies(); +const day = calculateDay(); function App() { const { darkAlgorithm } = theme; const [showApp, setShowApp] = useState(true); - const savedScoreString = useRef(); + const scoreText = useRef(); const [images, setImages] = useState([]); const [imageTheme, setImageTheme] = useState(''); useEffect(() => { - getImages().then((response: Image[]) => { + getImages().then((response: Image[] | undefined) => { if (response) { setImages(response); setImageTheme(response[0].theme); @@ -27,23 +28,18 @@ function App() { }); }, []); - const splitNewlinesToParagraphTags = (input: string) => { - return input.split('\n').map((text) =>

{text}

); - }; useEffect(() => { - const dayLastPlayed = new Date(cookies.get('day_last_played')); - const completeScoreText: string = cookies.get('last_complete_score_text'); - const today = new Date(); - today.setHours(0, 0, 0, 0); - if ( - dayLastPlayed.toDateString() === today.toDateString() && - completeScoreText - ) { - setShowApp(false); - savedScoreString.current = ( -
{splitNewlinesToParagraphTags(completeScoreText)}
- ); + const storedDayLastPlayed = Number(localStorage.getItem('day_last_played')); + const storedLastChoiceKeeper = localStorage.getItem('last_choice_keeper'); + if (storedDayLastPlayed && storedLastChoiceKeeper) { + const today = new Date().setHours(0, 0, 0, 0); + const dayLastPlayed = new Date(storedDayLastPlayed).setHours(0, 0, 0, 0); + const lastChoiceKeeper: Choice[] = JSON.parse(storedLastChoiceKeeper); + if (dayLastPlayed === today) { + setShowApp(false); + scoreText.current = generateScoreHTML(lastChoiceKeeper, day); + } } }, []); @@ -51,7 +47,7 @@ function App() {

You already played today!

See you again tomorrow :)

- {savedScoreString.current} + {scoreText.current}
); @@ -69,7 +65,7 @@ function App() { className='h-full w-full ' vertical > - + { + const score = useMemo(() => { + return choices.filter((choice) => { + choice.isCorrect; + }).length; + }, [choices]); + + const scoreText = useMemo(() => generateScoreText(choices, day), [choices]); + + return ( +
+ +

+ You got {score} out of {choices.length} correct! +

+ + +
+
+ ); +}; + +export default GameRecap; diff --git a/src/navigation/Navbar.css b/src/components/NavBar/NavBar.css similarity index 100% rename from src/navigation/Navbar.css rename to src/components/NavBar/NavBar.css diff --git a/src/navigation/Navbar.tsx b/src/components/NavBar/NavBar.tsx similarity index 79% rename from src/navigation/Navbar.tsx rename to src/components/NavBar/NavBar.tsx index 918a11e..e941b2a 100644 --- a/src/navigation/Navbar.tsx +++ b/src/components/NavBar/NavBar.tsx @@ -1,12 +1,15 @@ import { Divider, Flex, Tooltip } from 'antd'; import { InfoCircleOutlined } from '@ant-design/icons'; -import logo from '../assets/imaginate-logo.png'; -import './Navbar.css'; -import { NavBarProps } from '../interfaces/NavBarProps'; +import logo from '../../assets/imaginate-logo.png'; +import './NavBar.css'; const THEME_EXPLAINER_TEXT = 'The theme changes every day!'; -const Navbar = ({ theme }: NavBarProps) => { +type NavBarProps = { + theme: string | undefined; +}; + +const NavBar = ({ theme }: NavBarProps) => { return (
@@ -30,4 +33,4 @@ const Navbar = ({ theme }: NavBarProps) => { ); }; -export default Navbar; +export default NavBar; diff --git a/src/components/PhotoCarousel/PhotoCarousel.tsx b/src/components/PhotoCarousel/PhotoCarousel.tsx new file mode 100644 index 0000000..0c72d0e --- /dev/null +++ b/src/components/PhotoCarousel/PhotoCarousel.tsx @@ -0,0 +1,57 @@ +import { Carousel } from 'antd'; +import { Choice } from '../../types/Image.types'; +import { CloseOutlined, CheckOutlined } from '@ant-design/icons'; +import { useMemo } from 'react'; + +type PhotoCarouselProps = { + choices: Choice[]; +}; + +const PhotoCarousel = ({ choices }: PhotoCarouselProps) => { + const answers = useMemo(() => { + return buildAnswers(choices); + }, [choices]); + + return ( + + {answers} + + ); +}; + +const buildAnswers = (choices: Choice[]) => { + return choices.map(({ isCorrect: correct, image }) => { + const generatedText = image.real ? 'real' : 'AI'; + const feedbackIcon = correct ? ( + + ) : ( + + ); + return ( +
+
+
+ + {feedbackIcon} +
+
+

This photo is {generatedText}

+
+ ); + }); +}; + +export default PhotoCarousel; diff --git a/src/photoQueue/photoQueue.tsx b/src/components/PhotoQueue/PhotoQueue.tsx similarity index 54% rename from src/photoQueue/photoQueue.tsx rename to src/components/PhotoQueue/PhotoQueue.tsx index cce3dc5..2e885b3 100644 --- a/src/photoQueue/photoQueue.tsx +++ b/src/components/PhotoQueue/PhotoQueue.tsx @@ -1,37 +1,23 @@ import { useState, JSX, useRef, ReactElement, useEffect } from 'react'; -import { PhotoQueueProps } from './interfaces/PhotoQueueProps.ts'; -import { - Modal, - Button, - Carousel, - Progress, - Flex, - Tour, - TourProps, - FloatButton, -} from 'antd'; +import { PhotoQueueProps } from './PhotoQueue.types.ts'; +import { Modal, Progress, Flex, Tour, TourProps, FloatButton } from 'antd'; import { CloseOutlined, CheckOutlined, - CopyOutlined, QuestionCircleOutlined, } from '@ant-design/icons'; -import Cookies from 'universal-cookie'; -import { PhotoQueueButtons } from './photoQueueButtons.tsx'; -import loadingGif from '../assets/loading.gif'; +import PhotoQueueButtons from './PhotoQueueButtons.tsx'; +import loadingGif from '../../assets/loading.gif'; import posthog from 'posthog-js'; -import { _getDay } from '../services/imageBackend.ts'; -const imaginateDay = _getDay(); - -const cookies = new Cookies(); +import GameRecap from '../GameRecap/GameRecap.tsx'; +import { Choice } from '../../types/Image.types.ts'; -export const PhotoQueue = ({ images }: PhotoQueueProps): JSX.Element => { +const PhotoQueue = ({ images }: PhotoQueueProps): JSX.Element => { const [score, setScore] = useState(0); const [index, setIndex] = useState(0); const [isModalOpen, setIsModalOpen] = useState(false); - const [choiceKeeper, setChoiceKeeper] = useState>([]); + const [choiceKeeper, setChoiceKeeper] = useState>([]); const [disableButtons, setDisableButtons] = useState(true); - const shareButton = useRef(null); const image = useRef(null); const parentBox = useRef(null); const [feedbackOverlay, setFeedbackOverlay] = useState(); @@ -64,22 +50,9 @@ export const PhotoQueue = ({ images }: PhotoQueueProps): JSX.Element => { }, ]; - const generateCompleteScoreText = () => { - const scoreText = 'Imaginate #' + imaginateDay; - let emojiScoreText; - emojiScoreText = choiceKeeper - .map((correctChoice) => (correctChoice ? '🟩' : '🟥')) - .join(''); - if (choiceKeeper.every((val) => val === choiceKeeper[0])) { - emojiScoreText += '✨'; - } - return scoreText + '\n' + emojiScoreText; - }; - useEffect(() => { if (choiceKeeper.length) { - const completeScoreText = generateCompleteScoreText(); - cookies.set('last_complete_score_text', completeScoreText); + localStorage.setItem('last_choice_keeper', JSON.stringify(choiceKeeper)); } }, [choiceKeeper]); @@ -93,14 +66,20 @@ export const PhotoQueue = ({ images }: PhotoQueueProps): JSX.Element => {
, ); - setChoiceKeeper([...choiceKeeper, true]); + setChoiceKeeper([ + ...choiceKeeper, + { isCorrect: true, image: images[index] }, + ]); } else { setFeedbackOverlay(
, ); - setChoiceKeeper([...choiceKeeper, false]); + setChoiceKeeper([ + ...choiceKeeper, + { isCorrect: false, image: images[index] }, + ]); } setTimeout(() => { @@ -109,13 +88,13 @@ export const PhotoQueue = ({ images }: PhotoQueueProps): JSX.Element => { } else { setIsModalOpen(true); setDisableButtons(true); - const today = new Date(); - cookies.set('day_last_played', today.setHours(0, 0, 0, 0)); + const today = new Date().setHours(0, 0, 0, 0); + localStorage.setItem('day_last_played', today.toString()); posthog.capture('completed_game', { score: score, length: images.length, grade: score / images.length, - day: today.setHours(0, 0, 0, 0), + day: today, theme: images[0].theme, }); } @@ -126,65 +105,6 @@ export const PhotoQueue = ({ images }: PhotoQueueProps): JSX.Element => { return isCorrectChoice; }; - const answers = images.map((image, index) => { - const generatedText = image.real ? 'real' : 'AI'; - const userChoseCorrectly = choiceKeeper[index]; - const feedbackIcon = userChoseCorrectly ? ( - - ) : ( - - ); - - return ( -
-
-
- - {feedbackIcon} -
-
-

This photo is {generatedText}

-
- ); - }); - - const share = async () => { - const completeScoreText = cookies.get('last_complete_score_text'); - const isMobile = () => { - const regex = - /Mobi|Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i; - return regex.test(navigator.userAgent); - }; - if (shareButton.current) { - try { - if (isMobile()) { - const shareData = { - title: 'Imaginate', - text: completeScoreText, - url: 'https://playimaginate.com', - }; - await navigator.share(shareData); - shareButton.current.innerHTML = '🎉 Score shared!'; - } else { - await navigator.clipboard.writeText(completeScoreText); - shareButton.current.innerHTML = '🎉 Score copied!'; - } - posthog.capture('score_shared'); - } catch (err) { - if (err instanceof DOMException && err.name !== 'AbortError') { - shareButton.current.innerHTML = 'Something went wrong...'; - } - } - } - }; - useEffect(() => { if (images.length) { setDisableButtons(false); @@ -268,38 +188,7 @@ export const PhotoQueue = ({ images }: PhotoQueueProps): JSX.Element => { setIsModalOpen(false); }} > - -

- You got {score} out of {images.length} correct! -

- - {answers} - -
- -
-
+ { ); }; + +export default PhotoQueue; diff --git a/src/components/PhotoQueue/PhotoQueue.types.ts b/src/components/PhotoQueue/PhotoQueue.types.ts new file mode 100644 index 0000000..5005863 --- /dev/null +++ b/src/components/PhotoQueue/PhotoQueue.types.ts @@ -0,0 +1,10 @@ +import { Image } from '../../types/Image.types'; + +export type PhotoQueueProps = { + images: Image[]; +}; + +export type PhotoQueueButtonProps = { + disabled: boolean; + makeChoice: Function; +}; diff --git a/src/photoQueue/photoQueueButtons.tsx b/src/components/PhotoQueue/PhotoQueueButtons.tsx similarity index 93% rename from src/photoQueue/photoQueueButtons.tsx rename to src/components/PhotoQueue/PhotoQueueButtons.tsx index dd1f09b..25754ac 100644 --- a/src/photoQueue/photoQueueButtons.tsx +++ b/src/components/PhotoQueue/PhotoQueueButtons.tsx @@ -1,8 +1,8 @@ import { JSX, useEffect, useRef, useState } from 'react'; -import { PhotoQueueButtonProps } from './interfaces/PhotoQueueProps.ts'; +import { PhotoQueueButtonProps } from './PhotoQueue.types'; import { Button } from 'antd'; -export const PhotoQueueButtons = ({ +const PhotoQueueButtons = ({ makeChoice, disabled, }: PhotoQueueButtonProps): JSX.Element => { @@ -64,3 +64,5 @@ export const PhotoQueueButtons = ({ ); }; + +export default PhotoQueueButtons; diff --git a/src/components/ShareButton/ShareButton.tsx b/src/components/ShareButton/ShareButton.tsx new file mode 100644 index 0000000..28c32ef --- /dev/null +++ b/src/components/ShareButton/ShareButton.tsx @@ -0,0 +1,60 @@ +import { Button } from 'antd'; +import { useRef } from 'react'; +import { CopyOutlined } from '@ant-design/icons'; +import posthog from 'posthog-js'; + +type shareButtonProps = { + scoreText: string | undefined; +}; + +const ShareButton = ({ scoreText }: shareButtonProps) => { + const shareButton = useRef(null); + + const share = async () => { + const isMobile = () => { + const regex = + /Mobi|Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i; + return regex.test(navigator.userAgent); + }; + if (shareButton.current && scoreText) { + try { + if (isMobile()) { + const shareData = { + title: 'Imaginate', + text: scoreText, + url: 'https://playimaginate.com', + }; + await navigator.share(shareData); + shareButton.current.innerHTML = '🎉 Score shared!'; + } else { + await navigator.clipboard.writeText(scoreText); + shareButton.current.innerHTML = '🎉 Score copied!'; + } + posthog.capture('score_shared'); + } catch (err) { + if (err instanceof DOMException && err.name !== 'AbortError') { + shareButton.current.innerHTML = 'Something went wrong...'; + } + } + } + }; + + const button = ( +
+ +
+ ); + + return <>{scoreText ? button : null}; +}; + +export default ShareButton; diff --git a/src/interfaces/NavBarProps.ts b/src/interfaces/NavBarProps.ts deleted file mode 100644 index e01a557..0000000 --- a/src/interfaces/NavBarProps.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface NavBarProps { - theme: string | undefined; -} diff --git a/src/photoQueue/interfaces/PhotoQueueProps.ts b/src/photoQueue/interfaces/PhotoQueueProps.ts deleted file mode 100644 index dfea964..0000000 --- a/src/photoQueue/interfaces/PhotoQueueProps.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Image } from './ImageInterface.ts'; - -export interface PhotoQueueProps { - images: Partial[]; -} - -export interface PhotoQueueButtonProps { - disabled: boolean; - makeChoice: Function; -} diff --git a/src/prodStubs/mockQueryStub.ts b/src/prodStubs/mockQueryStub.ts deleted file mode 100644 index a842e77..0000000 --- a/src/prodStubs/mockQueryStub.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { Image } from '../photoQueue/interfaces/ImageInterface'; - -export const testImages: Image[] = []; diff --git a/src/services/Day.service.ts b/src/services/Day.service.ts new file mode 100644 index 0000000..3658737 --- /dev/null +++ b/src/services/Day.service.ts @@ -0,0 +1,8 @@ +const START_DATE = new Date(2024, 8, 1).setHours(0, 0, 0, 0); //september 1st 2024 in timestamp form in miliseconds of local timezone +const MS_PER_DAY = 86400000; + +export const calculateDay = () => { + const today = new Date().setHours(0, 0, 0, 0); + const msSinceStartDate = today - START_DATE; + return Math.floor(msSinceStartDate / MS_PER_DAY); +}; diff --git a/src/services/Image.service.ts b/src/services/Image.service.ts new file mode 100644 index 0000000..7e2936b --- /dev/null +++ b/src/services/Image.service.ts @@ -0,0 +1,43 @@ +import { Image } from '../types/Image.types.ts'; +import { calculateDay } from './Day.service.ts'; + +export const getImages = (() => { + let cachedImages: Promise; + + return async () => { + if (cachedImages) { + return cachedImages; + } + const day = calculateDay(); + console.log(import.meta.env.VITE_API_URL); + try { + const imageResponse = await fetch( + `${import.meta.env.VITE_API_URL}/date/${day}/images`, + ).then((res) => res.json()); + cachedImages = Promise.resolve(_shuffle(imageResponse, day)); + } catch (err) { + console.error(err); + return; + } + return cachedImages; + }; +})(); + +function _shuffle(array: Image[], seed: number) { + var m = array.length, + t, + i; + while (m) { + i = Math.floor(_random(seed) * m--); + t = array[m]; + array[m] = array[i]; + array[i] = t; + ++seed; + } + return array; +} + +function _random(seed: number) { + var x = Math.sin(seed++) * 10000; + return x - Math.floor(x); +} diff --git a/src/services/Score.service.tsx b/src/services/Score.service.tsx new file mode 100644 index 0000000..c39c6a1 --- /dev/null +++ b/src/services/Score.service.tsx @@ -0,0 +1,28 @@ +import { Choice } from '../types/Image.types'; + +export const generateScoreText = (choices: Choice[], day: number): string => { + return buildHeader(day) + '\n' + buildEmoji(choices); +}; + +export const generateScoreHTML = (choices: Choice[], day: number) => { + return ( +
+

{buildHeader(day)}

+

{buildEmoji(choices)}

+
+ ); +}; + +const buildHeader = (day: number) => { + return 'Imaginate #' + day; +}; + +const buildEmoji = (choices: Choice[]) => { + let emojiScoreText = choices + .map((choice) => (choice.isCorrect ? '🟩' : '🟥')) + .join(''); + if (choices.every((choice) => choice.isCorrect)) { + emojiScoreText += '✨'; + } + return emojiScoreText; +}; diff --git a/src/services/imageBackend.ts b/src/services/imageBackend.ts deleted file mode 100644 index 4b5c7c0..0000000 --- a/src/services/imageBackend.ts +++ /dev/null @@ -1,44 +0,0 @@ -const API_URL = 'https://o7hgv46qcf7swox3ygn53tayay0zzhgl.lambda-url.us-east-1.on.aws/'; -const START_DATE = new Date(2024, 8, 1).setHours(0, 0, 0, 0); //september 1st 2024 in timestamp form in miliseconds of local timezone -const MS_PER_DAY = 86400000; - -export const getImages = async () => { - const day = _getDay(); - const images = await fetch( - API_URL + - '?' + - new URLSearchParams({ - day: `${day}`, - }).toString(), - ) - .then((res) => res.json()) - .catch((error) => { - console.error(error); - }); - return _shuffle(images, day); -}; - -export const _getDay = () => { - const today = new Date().setHours(0, 0, 0, 0); - const msSinceStartDate = today - START_DATE; - return Math.floor(msSinceStartDate / MS_PER_DAY); -}; - -function _shuffle(array: any[], seed: number) { - var m = array.length, - t, - i; - while (m) { - i = Math.floor(_random(seed) * m--); - t = array[m]; - array[m] = array[i]; - array[i] = t; - ++seed; - } - return array; -} - -function _random(seed: number) { - var x = Math.sin(seed++) * 10000; - return x - Math.floor(x); -} diff --git a/src/photoQueue/interfaces/ImageInterface.ts b/src/types/Image.types.ts similarity index 57% rename from src/photoQueue/interfaces/ImageInterface.ts rename to src/types/Image.types.ts index 21caa64..1cd84a5 100644 --- a/src/photoQueue/interfaces/ImageInterface.ts +++ b/src/types/Image.types.ts @@ -1,4 +1,4 @@ -export interface Image { +export type Image = { data: string; date: string; filename: string; @@ -6,4 +6,9 @@ export interface Image { status: 'verified'; theme: string; url: string; -} +}; + +export type Choice = { + isCorrect: boolean; + image: Image; +};