================================================ FILE: components/Back/Back.module.scss ================================================ .wrapper { cursor: pointer; position: absolute; overflow: auto; width: 100%; display: flex; height: 100%; backface-visibility: hidden; background-color: var(--clr-background-400); border-radius: 8px; overflow: hidden; transform: rotateX(180deg); border: 3px solid var(--clr-accent-peachy); transition: border-color 250ms ease; .textWrapper { margin: auto; .answer { display: grid; grid-template-columns: repeat(3, 1fr); gap: 4rem; text-align: center; &__label { font-size: 0.85rem; text-transform: uppercase; letter-spacing: 2px; font-weight: 500; color: var(--clr-text-muted); margin-bottom: 0; } &__text { font-size: 2.25rem; font-weight: 500; margin: 0; padding: 0 2rem; color: var(--clr-accent-peachy); } } } &--mobile { .answer__text { font-size: 1.5rem !important; } } &--correct { border-color: var(--clr-accent-green); } &--wrong { border-color: var(--clr-accent-red); } } ================================================ FILE: components/Back/Back.tsx ================================================ import ExpandingBlob from "@components/ExpandingBlob"; import Watermark from "@components/Watermark"; import classNames from "classnames"; import styles from "./Back.module.scss"; interface Props { data: string; isMobile: boolean | undefined; dataClass: ClassString; answeredRight: boolean | null; forwardAnswer: () => void; } const Back = ({ data, isMobile, answeredRight, forwardAnswer }: Props) => { const wrapperClasses = classNames(styles.wrapper, { [`${styles["wrapper--mobile"]}`]: isMobile, [`${styles["wrapper--correct"]}`]: answeredRight === true, [`${styles["wrapper--wrong"]}`]: answeredRight === false, }); return (
{answeredRight != null && ( )}
{data}
); }; export default Back; ================================================ FILE: components/Back/index.tsx ================================================ import Back from "./Back"; export default Back; ================================================ FILE: components/EndCard/EndCard.module.scss ================================================ .wrapper { width: 100%; max-width: 750px; margin: 2rem; border-radius: 4px; z-index: 2; padding: 0 1rem; .buttons { display: flex; flex-direction: column; gap: 1rem; .buttons_grid { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); } h5 { font-size: 1.25rem; flex-basis: 100%; color: var(--clr-accent-blue); margin: 0; position: relative; isolation: isolate; span { z-index: 1; padding-right: 0.5em; background-color: var(--clr-background-300); position: relative; } &::after { content: ""; width: 100%; z-index: -1; height: 2px; top: 50%; left: 0; position: absolute; background-color: currentColor; } } .button { --accent: var(--clr-accent-blue); background-color: transparent; color: var(--accent); cursor: pointer; border: 2px solid var(--accent); display: inline-flex; padding: 0.35rem 0.75rem; font-weight: 700; font-size: 14px; align-items: center; border-radius: 4px; justify-content: center; transition: background-color 200ms ease, color 200ms ease; svg { margin-right: 0.5rem; } &.orange { --accent: var(--clr-accent-peachy); } &.green { --accent: var(--clr-accent-green); } &.purple { --accent: var(--clr-accent-pink); } &:focus-visible { outline: 2px dashed var(--accent); outline-offset: 0.5rem; background-color: var(--accent); color: var(--clr-background-200); } &:hover { background-color: var(--accent); color: var(--clr-background-200); } } } &__title { margin: 0 0 1rem 0; font-size: 2.25rem; font-weight: 700; line-height: 120%; span { color: var(--clr-accent-green); } } } @media (min-width: 600px) { .wrapper { width: calc(100% - 1rem); &__title { font-size: 2.75rem; } .buttons h5 span { background-color: var(--clr-background-300); } } } ================================================ FILE: components/EndCard/EndCard.tsx ================================================ import classNames from "classnames"; import { MessageQuestion, ArrowRotateRight, Back } from "iconsax-react"; import { motion, AnimatePresence, LayoutGroup } from "framer-motion"; import styles from "./EndCard.module.scss"; import { FC, useState } from "react"; import EndCardReview from "@components/EndCardReview"; import getStreakEmojis from "@utils/getStreakEmojis"; import Link from "next/link"; interface Props { onRestart: (newData: Question[] | null) => void; mode: "spelling" | "cards"; data: CardsReviewData | SpellingReviewData; amount: number; dataClass: ClassString; streak: number; } const isSpellingData = (input: Question[] | SpellingData[]): input is SpellingData[] => { if ("data" in input[0]) { return true; } return false; }; const EndCard: FC = ({ amount, data, onRestart, mode, dataClass, streak }) => { const [isReviewOpened, setIsReviewOpened] = useState(false); const score = (((amount - data.incorrect.length) / amount) * 100).toFixed(1); const handleRestart = (newData: Question[] | SpellingData[] | null = null) => { setIsReviewOpened(false); if (newData && isSpellingData(newData)) { onRestart(newData.map((item) => item.data)); } else { onRestart(newData); } }; const handleRestartIncorrect = () => { handleRestart(data.incorrect); }; const handleRestartAll = () => { handleRestart(); }; return (

Your end score was {score}%

{streak >= 5 && (

Highest streak: {streak} {getStreakEmojis(streak)}

)}
What's next?
<> Go Back {data.incorrect.length > 0 && ( )}
{isReviewOpened && }
); }; export default EndCard; ================================================ FILE: components/EndCard/index.tsx ================================================ import EndCard from "./EndCard"; export default EndCard; ================================================ FILE: components/EndCardReview/EndCardReview.module.scss ================================================ .showdown { margin-top: 1.5rem; display: flex; flex-direction: column; gap: 2rem; .title__correct, .title__incorrect { font-size: 1.25rem; } .title__incorrect { color: var(--clr-accent-red); } .title__correct { color: var(--clr-accent-green); } } .list { display: grid; row-gap: 1rem; grid-template-columns: 1fr; &.correct { --clr-accent: var(--clr-accent-green); } &.incorrect { --clr-accent: var(--clr-accent-red); } &__item { padding: 1rem; box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); border-radius: 4px; background-color: var(--clr-background-400); display: grid; grid-template-columns: 1fr; grid-template-rows: auto 40px auto; column-gap: 0.5rem; .typed { color: var(--clr-text-muted); display: block; } .small_question, .typed { margin-bottom: 0.5em; color: var(--clr-text-muted); display: block; text-align: center; } .question, .answer { text-align: center; font-size: 1.125rem; } .answer { font-weight: 700; color: var(--clr-accent); } .question { font-weight: 500; .spelling_question { small { font-size: 0.75em; color: var(--clr-accent); font-weight: 700; } } } .spacer { display: none; } svg { width: 1.25rem; height: 1.25rem; place-self: center; margin: 0.25rem; color: white; } } } @media (min-width: 600px) { .showdown { .title__correct, .title__incorrect { font-size: 1.5rem; } } .list { grid-template-columns: 1fr; &__item { grid-template-columns: 1fr 20px 1fr; grid-template-rows: auto; column-gap: 1rem; background-color: var(--clr-background-400); .spacer { display: block; height: 100%; width: 3px; border-radius: 3px; background-color: var(--clr-background-500); place-self: center; } svg { display: none; } .small_question { text-align: left; } .typed { text-align: right; } .question, .answer { text-align: left; color: var(--clr-text); } .question { font-weight: 500; } .answer { color: var(--clr-accent); font-weight: 700; text-align: right; } } } } ================================================ FILE: components/EndCardReview/EndCardReview.tsx ================================================ import classNames from "classnames"; import { motion } from "framer-motion"; import { ArrowCircleDown2 } from "iconsax-react"; import { FC } from "react"; import styles from "./EndCardReview.module.scss"; interface Props { mode: "spelling" | "cards"; data: CardsReviewData | SpellingReviewData; dataClass: ClassString; } const EndCardList: FC<{ dataClass: ClassString; data: Question | SpellingData; mode: "spelling" | "cards"; }> = ({ data, mode }) => { if (mode === "cards") { const { answer, question } = data as Question; return (
{question}
{answer}
); } const { data: { answer, question }, expected, input, } = data as SpellingData; return (
{question}
{expected !== answer ? ( {expected} ( {answer} ) ) : ( expected )}
You typed
{input}
); }; const EndCardReview: FC = ({ mode, data, dataClass }) => { const { incorrect, correct } = data; return ( {incorrect.length > 0 && (

Incorrect Answers ({incorrect.length})

{incorrect.map((answerData, answerIdx) => { return ( ); })}
)} {correct.length > 0 && (

Correct Answers ({correct.length})

{correct.map((answerData, answerIdx) => { return ( ); })}
)}
); }; export default EndCardReview; ================================================ FILE: components/EndCardReview/index.tsx ================================================ import EndCardReview from "./EndCardReview"; export default EndCardReview; ================================================ FILE: components/Entry/Entry.module.scss ================================================ .container { display: flex; border: none; font-family: inherit; flex-direction: column; justify-content: center; border-radius: 4px; box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1); background-color: var(--clr-background-400); overflow: hidden; padding: 1.5rem; cursor: pointer; outline-color: transparent; position: relative; .wrapper { width: 100%; display: flex; justify-content: space-between; align-items: flex-start; gap: 1rem; } .indicator { width: 1.5rem; height: 5rem; border-radius: 999px; background-color: var(--clr-background-500); display: flex; justify-content: center; padding-top: 0.25rem; svg { width: 1rem; height: 1rem; } } .label { font-size: 0.75rem; font-weight: 700; margin-bottom: 0.5em; color: var(--clr-card-accent); text-transform: uppercase; letter-spacing: 2px; &__warning { color: var(--clr-accent-yellow); } } .dupWarningIcon { position: absolute; top: -0.5rem; right: -0.5rem; display: flex; align-items: center; justify-content: center; color: var(--clr-accent-yellow); } .buttons { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; margin-top: 1rem; gap: 1.5rem; a { flex-basis: 100%; display: flex; padding: 0.75rem 2rem; border-radius: 6px; font-size: 1rem; align-items: center; justify-content: center; position: relative; gap: 0.5em; font-weight: 700; background-color: var(--clr-background-500); color: var(--clr-card-accent); svg { width: 1.5rem; } &::before { content: ""; position: absolute; inset: 0; opacity: 0.2; transition: opacity 250ms ease; box-shadow: 0 4px 15px rgba(0, 0, 0, 0.5); } &:hover, &:focus { outline: none; &::before { opacity: 0.5; } &::after { transform: scale(1) !important; opacity: 1 !important; } } &:focus-visible { outline: 2px dashed var(--clr-card-accent); outline-offset: 0.5rem; } } } .bang { margin: 0; color: var(--clr-text); letter-spacing: 4px; text-align: left; font-size: 0.65rem; font-weight: 500; } .title { display: -webkit-box; margin: 0; font-size: 1.375rem; color: var(--clr-card-accent); font-weight: 700; text-overflow: ellipsis; overflow: hidden; text-align: left; &:focus { outline: none; } } .tags { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 0.5rem; .tag { position: relative; color: var(--clr-card-accent); font-size: 0.7rem; background-color: rgba(0, 0, 0, 0.2); padding: 0.375em 1.125em; font-weight: 500; border-radius: 4px; } } } @media screen and (min-width: 450px) { .container { &:focus-visible { outline: 2px dashed var(--clr-card-accent); outline-offset: 0.5rem; } } } ================================================ FILE: components/Entry/Entry.tsx ================================================ import getAccentForClass from "@utils/getAccentForClass"; import getHumanReadableClass from "@utils/getHumanReadableClass"; import groupBy from "@utils/groupBy"; import { AnimatePresence, motion } from "framer-motion"; import { Category, Danger, Edit, NoteText } from "iconsax-react"; import Link from "next/link"; import { FC, PropsWithChildren, useEffect, useRef, useState } from "react"; import { useHover } from "usehooks-ts"; import styles from "./Entry.module.scss"; interface Props { data: Card; animationDelay: number; } const Tag: FC = ({ children }) => { return
{children}
; }; const Entry = ({ data: { attributes: { title, slug, questions, class: classString }, }, animationDelay, }: Props) => { const [dupsData, setDupsData] = useState(); const [isExpanded, setIsExpanded] = useState(false); const containerRef = useRef(null); const isHovered = useHover(containerRef); useEffect(() => { if (!questions) return; const grouped = groupBy(questions, (q) => q.question); const values = Object.values(grouped); const dups = values.filter((v) => v.length > 1); if (dups.length > 0) setDupsData(dups); }, [questions]); return ( { e.stopPropagation(); setIsExpanded((isExpanded) => !isExpanded); }} onBlur={(e) => { if (!containerRef.current?.contains(e.relatedTarget)) { setIsExpanded(false); } }} >
TOPIC {title}
{getHumanReadableClass(classString)} {questions?.length} {questions.length > 1 ? "cards" : "card"} {dupsData && ( )}
{isExpanded && (
{ e.stopPropagation(); }} style={{ display: "flex", flex: 1 }} href={`${slug}/card`} > Cards { e.stopPropagation(); }} style={{ display: "flex", flex: 1 }} href={`${slug}/spelling`} > Spelling { e.stopPropagation(); }} style={{ display: "flex", flex: 1 }} href={`${slug}/list`} > List
)}
); }; export default Entry; ================================================ FILE: components/Entry/index.tsx ================================================ import Entry from "./Entry"; export default Entry; ================================================ FILE: components/ExpandingBlob/ExpandingBlob.module.scss ================================================ .outerBlob, .innerBlob { z-index: 2; position: absolute; top: 0; left: 0; width: 100%; height: 100%; } .outerBlob { &--red { background-color: var(--clr-accent-red-darker); .innerBlob { background-color: var(--clr-accent-red); } } &--green { background-color: var(--clr-accent-green-darker); .innerBlob { background-color: var(--clr-accent-green); } } } .innerBlob { display: flex; align-items: center; justify-content: center; .iconWrapper { width: 60px; height: 60px; svg { width: 60px; height: 60px; display: block; color: white; } } } ================================================ FILE: components/ExpandingBlob/ExpandingBlob.tsx ================================================ import classNames from "classnames"; import { motion } from "framer-motion"; import Image from "next/image"; import { FC } from "react"; import styles from "./ExpandingBlob.module.scss"; interface Props { onAnimationComplete: () => void; type: "correct" | "wrong"; } const ExpandingBlob: FC = ({ type, onAnimationComplete }) => { const outerBlobClasses = classNames(styles.outerBlob, { [`${styles["outerBlob--red"]}`]: type === "wrong", [`${styles["outerBlob--green"]}`]: type === "correct", }); return ( {type} ); }; export default ExpandingBlob; ================================================ FILE: components/ExpandingBlob/index.tsx ================================================ import ExpandingBlob from "./ExpandingBlob"; export default ExpandingBlob; ================================================ FILE: components/Filter/Filter.module.scss ================================================ .field { label { display: block; cursor: pointer; font-size: 1.125rem; font-weight: 500; margin-bottom: 0.5rem; } input { width: 100%; font-size: 1rem; background-color: var(--clr-background-400); border: 2px solid rgb(63, 63, 63); border-radius: 4px; padding: 0.75rem 1rem; box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1); color: var(--clr-text); font-weight: 500; letter-spacing: 0.25px; &:focus { outline: none; border-color: var(--clr-accent-blue); } } } .input__wrapper .keyboard__indicator { display: none; } @media (min-width: 450px) { .input__wrapper { position: relative; .keyboard__indicator { position: absolute; top: 50%; right: 1rem; display: flex; gap: 0.5rem; transform: translateY(-50%); .key { background-color: var(--clr-background-500); border-radius: 2px; padding: 0.35em 0.5em; font-size: 0.75rem; font-weight: 700; color: var(--clr-text-muted); box-shadow: 0 3px 0px var(--clr-background-300); } } } } ================================================ FILE: components/Filter/Filter.tsx ================================================ import { motion } from "framer-motion"; import { ChangeEventHandler, useEffect, useRef } from "react"; import styles from "./Filter.module.scss"; interface Props { value: string; onChangeHandler: ChangeEventHandler; } const Filter = ({ value, onChangeHandler }: Props) => { const inputRef = useRef(null); useEffect(() => { const handler = (e: KeyboardEvent) => { if (e.ctrlKey && e.key === "k") { e.preventDefault(); // prevent browser shortcut inputRef.current?.focus(); } }; window.addEventListener("keydown", handler); return () => { window.removeEventListener("keydown", handler); }; }, []); return (
Ctrl
K
); }; export default Filter; ================================================ FILE: components/Filter/index.tsx ================================================ import Filter from "./Filter"; export default Filter; ================================================ FILE: components/FlipCard/FlipCard.module.scss ================================================ .wrapper { perspective: 1000px; width: 100%; height: 50vh; max-height: calc(5 * 3em); user-select: none; position: absolute; top: 50%; isolation: isolate; border-radius: 8px; left: 50%; will-change: transform; &:focus-visible { outline: 2px dashed var(--clr-accent-blue); outline-offset: 2rem; } } .questionPreview { position: absolute; border-radius: 5px; color: var(--clr-accent-blue); font-weight: 500; font-size: 1rem; border: 2px solid var(--clr-accent-blue); max-width: 100%; width: max-content; background-color: var(--clr-background-400); text-align: center; display: flex; align-items: center; justify-content: center; cursor: pointer; padding: 0.5em 1em; display: grid; grid-template-columns: repeat(2, auto); gap: 0.5rem; svg { height: 100%; width: 1.75em; height: 1.75em; } &:focus-visible { outline: 2px dashed currentColor; outline-offset: 0.75rem; } } .content { position: relative; text-align: center; width: 100%; height: 100%; transform-style: preserve-3d; box-shadow: 0 4px 15px rgba(0, 0, 0, 0.15); } ================================================ FILE: components/FlipCard/FlipCard.tsx ================================================ import { AnimatePresence, motion } from "framer-motion"; import React, { useState } from "react"; import Back from "../Back"; import Front from "../Front"; import styles from "./FlipCard.module.scss"; import useWindowSize from "@hooks/useWindowSize"; import FlipCardButton from "@components/FlipCardButton"; import { CloseSquare, TickSquare, MessageQuestion } from "iconsax-react"; import { useEventListener } from "usehooks-ts"; const flip = { unflipped: { rotateX: 0, transition: { type: "spring", stiffness: 100 } }, flipped: { rotateX: 180, transition: { type: "spring", stiffness: 100 } }, }; const card = { out: { opacity: 0, x: "50%", y: "-50%", scale: 0.25 }, in: { opacity: 1, x: "-50%", scale: 1, transition: { type: "spring", damping: 12 } }, outExit: { opacity: 0, x: "-150%", scale: 0.25, transition: { type: "spring", damping: 12 } }, }; interface Props { dataClass?: ClassString; data: Question; onAnswer: (rightAnswer: boolean, questionData: Question) => void; } const FlipCard = ({ data, dataClass, onAnswer }: Props) => { if (dataClass == null) throw new Error("dataClass is not a string"); const [isFlipped, setIsFlipped] = useState(false); const [answeredRight, setAnsweredRight] = useState(null); const { width } = useWindowSize(); const getCardWidth = () => { if (!width) return { isMobile: undefined, cardWidth: undefined }; const contentSize = Math.max(data.answer.replace(" | ", "").length, data.question.length); let calculatedWidth = Math.max(contentSize * 8 * 2.85 + 128, 350); if (calculatedWidth > 1000) calculatedWidth /= 1.75; const isMobile = width - 320 < calculatedWidth; if (isMobile) calculatedWidth = width - 32; return { isMobile: isMobile, cardWidth: calculatedWidth }; }; const { isMobile, cardWidth } = getCardWidth(); useEventListener("keydown", (e) => { if (!(e instanceof KeyboardEvent) || !isFlipped || answeredRight != null) return; if (e.key === "Enter" || e.key === " ") { setAnsweredRight(true); } if (e.key === "Backspace") { setAnsweredRight(false); } if (e.key === "Escape") { setIsFlipped(false); } }); useEventListener("keydown", (e) => { if (!(e instanceof KeyboardEvent) || isFlipped || answeredRight != null) return; if (e.key === "Enter") setIsFlipped(true); }); const forwardAnswer = () => { if (answeredRight == null) return; onAnswer(answeredRight, data); }; return ( setIsFlipped(true)} className={styles.wrapper} style={{ maxWidth: cardWidth, }} > {isFlipped && answeredRight == null && ( <> setIsFlipped(false)} > {data.question} setAnsweredRight(false)} icon={} color="red" position="left" /> } isMobile={isMobile} onClick={() => setAnsweredRight(true)} color="green" position="right" /> )} ); }; export default FlipCard; ================================================ FILE: components/FlipCard/index.tsx ================================================ import FlipCard from "./FlipCard"; export default FlipCard; ================================================ FILE: components/FlipCardButton/FlipCardButton.module.scss ================================================ .container { --accent: var(--clr-accent-blue); position: absolute; background-color: transparent; border: none; cursor: pointer; padding: 0 1.5rem; border-radius: 4px; color: var(--clr-accent); svg { width: 75px; height: 75px; } &--mobile { width: 50%; svg { width: 60px; height: 60px; } } &:focus-visible { outline: 2px dashed var(--clr-accent); outline-offset: 0.5rem; } } ================================================ FILE: components/FlipCardButton/FlipCardButton.tsx ================================================ import classNames from "classnames"; import styles from "./FlipCardButton.module.scss"; import { motion, MotionStyle } from "framer-motion"; import { FC, MouseEventHandler, ReactNode } from "react"; interface Props { isMobile: boolean | undefined; onClick: MouseEventHandler; icon: ReactNode; position: "left" | "right"; color: "green" | "red"; } const FlipCardButton: FC = ({ isMobile, icon, onClick, position, color }) => { const accentColor = color === "green" ? "var(--clr-accent-green)" : "var(--clr-accent-red)"; const xOffset = position === "left" ? -150 : 150; const classes = classNames(styles.container, { [`${styles["container--mobile"]}`]: isMobile }); return ( {icon} ); }; export default FlipCardButton; ================================================ FILE: components/FlipCardButton/index.tsx ================================================ import FlipCardButton from "./FlipCardButton"; export default FlipCardButton; ================================================ FILE: components/Front/Front.module.scss ================================================ .wrapper { cursor: pointer; position: absolute; overflow: auto; width: 100%; display: flex; height: 100%; backface-visibility: hidden; background-color: var(--clr-background-400); border-radius: 8px; border: 3px solid var(--clr-accent-blue); overflow: hidden; isolation: isolate; .textWrapper { margin: auto; position: relative; .text { font-size: 2.25rem; padding: 0 2rem; font-weight: 500; margin: 0; color: var(--clr-accent-blue); } &--mobile { .text { font-size: 1.5rem; padding: 0 1rem; } } } } ================================================ FILE: components/Front/Front.tsx ================================================ import Watermark from "@components/Watermark"; import classNames from "classnames"; import React from "react"; import styles from "./Front.module.scss"; interface Props { data: string; isMobile: boolean | undefined; } const Front = ({ data, isMobile }: Props) => { const textWrapperClasses = classNames(styles.textWrapper, { [`${styles["textWrapper--mobile"]}`]: isMobile, }); return (

{data}

); }; export default Front; ================================================ FILE: components/Front/index.tsx ================================================ import Front from "./Front"; export default Front; ================================================ FILE: components/ListEntries/index.tsx ================================================ import ListEntries from "./ListEntries"; export default ListEntries; ================================================ FILE: components/ListEntries/ListEntries.module.scss ================================================ .container { display: grid; margin-top: 1.5rem; gap: 1.5rem; flex: 1; grid-template-columns: 1fr; grid-auto-rows: min-content; position: relative; } .noResults { width: 100%; height: fit-content; padding: 3rem 0; position: absolute; text-align: center; display: flex; flex-direction: column; gap: 1rem; align-items: center; justify-content: center; color: var(--clr-accent-red); filter: brightness(125%); svg { width: 3rem; height: 3rem; color: currentColor; } } ================================================ FILE: components/ListEntries/ListEntries.tsx ================================================ import { AnimatePresence, LayoutGroup, motion } from "framer-motion"; import React, { useEffect, useRef, useState } from "react"; import styles from "./ListEntries.module.scss"; import Fuse from "fuse.js"; import { CloseSquare } from "iconsax-react"; import Entry from "@components/Entry"; interface Props { data: Card[]; filterString: string | null; } type FilteredData = Fuse.FuseResult[] | Card[]; const ListEntries = ({ data, filterString }: Props) => { const [filteredData, setFilteredData] = useState(data); const fuse = useRef( new Fuse(data, { keys: ["attributes.title"], }) ); useEffect(() => { if (!filterString) { setFilteredData(data); return; } const searchData = fuse.current.search(filterString); setFilteredData(searchData); }, [filterString, data]); return (
{filteredData.length > 0 ? ( filteredData.map((d, idx) => { const isFuseResult = Boolean((d as Fuse.FuseResult).item); const data = isFuseResult ? (d as Fuse.FuseResult).item : (d as Card); return ; }) ) : ( No Entries )}
); }; export default ListEntries; ================================================ FILE: components/ProgressBar/index.tsx ================================================ import ProgressBar from "./ProgressBar"; export default ProgressBar; ================================================ FILE: components/ProgressBar/ProgressBar.module.scss ================================================ .progress { display: flex; flex-direction: column; align-items: center; justify-content: center; position: fixed; top: 1rem; left: 1rem; width: calc(100% - 2rem); .bar { width: 100%; max-width: 450px; height: 25px; border-radius: 4px; display: flex; position: relative; overflow: hidden; border: 1px solid var(--clr-background-400); box-shadow: 0 0 7px rgba(0, 0, 0, 0.15); &__fill { background-image: linear-gradient(to right, var(--clr-accent-red), var(--clr-accent-peachy)); } .percentage { margin: 0; user-select: none; width: 7ch; text-align: center; font-size: 0.5rem; font-weight: 700; letter-spacing: 1px; position: absolute; padding: 0.125rem 0.25rem; border-radius: 2px; top: 50%; left: 50%; mix-blend-mode: difference; transform: translate(-50%, -50%); color: var(--clr-text-muted); } } } ================================================ FILE: components/ProgressBar/ProgressBar.tsx ================================================ import styles from "./ProgressBar.module.scss"; import { animate, motion } from "framer-motion"; import { useEffect, useRef } from "react"; import Streak from "@components/Streak"; interface Props { maxAmount: number; currentAmount: number; streak: number; } const ProgressBar: React.FC = ({ currentAmount, maxAmount, streak }) => { const percentageRef = useRef(null); useEffect(() => { const node = percentageRef.current; if (!node) return; const percentageBefore = (Math.max(currentAmount - 1, 0) / maxAmount) * 100; const percentageCurrent = (currentAmount / maxAmount) * 100; const controls = animate(percentageBefore, percentageCurrent, { onUpdate: (value) => { node.textContent = `${value.toFixed(2)}%`; }, }); return () => controls.stop(); }); return (

0.00%

); }; export default ProgressBar; ================================================ FILE: components/SpellingByWord/index.tsx ================================================ import SpellingByWord from "./SpellingByWord"; export default SpellingByWord; ================================================ FILE: components/SpellingByWord/SpellingByWord.module.scss ================================================ .submit { position: absolute; isolation: isolate; bottom: -3.5rem; z-index: -1; left: 50%; --accent: var(--clr-accent-blue); background-color: transparent; color: var(--accent); cursor: pointer; border: none; display: inline-flex; padding: 0.4em 1.25em; font-size: 0.9rem; font-weight: 700; align-items: center; border-radius: 4px; justify-content: center; transition: color 200ms ease; &::after { content: ""; border-radius: inherit; position: absolute; inset: 0; transform: scale(0.5); opacity: 0; transition: transform 300ms, opacity 300ms; transition-timing-function: cubic-bezier(0.83, 0, 0.17, 1); background-color: var(--accent); z-index: -1; } svg { margin-left: 0.5em; } &:focus-visible { outline: 2px dashed var(--accent); outline-offset: 0.5rem; } &:hover, &:focus-visible { color: white; &::after { transform: scale(1); opacity: 1; } } } .wrapper { position: fixed; top: 50%; left: 50%; width: calc(100% - 2rem); } .container { border-radius: 5px; overflow: hidden; color: var(--clr-accent-blue); font-weight: 700; font-size: 1.25rem; border: 2px solid var(--clr-accent-blue); min-height: 175px; position: relative; isolation: isolate; background-color: var(--clr-background-400); text-align: center; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 1em; transition: border-color 350ms ease; &--incorrect { border-color: var(--clr-accent-red); } &--correct { border-color: var(--clr-accent-green); } &:focus-visible { outline: 2px dashed var(--clr-accent-blue); outline-offset: 2rem; } &:focus-visible { outline: 2px dashed var(--clr-accent-blue); outline-offset: 2rem; } .answerPreview { font-size: 0.9rem; font-weight: 500; display: inline-flex; align-items: center; justify-content: center; color: var(--clr-text); gap: 0.35rem; margin-top: 1.25rem; svg { color: var(--clr-accent-blue); } } .question { margin: 0; margin-bottom: 1.5rem; font-size: inherit; } span { font-size: 1rem; color: white; } form { display: flex; flex-wrap: wrap; font-size: 1.125rem; align-items: center; position: relative; justify-content: center; gap: 0.75rem; font-family: "Source Code Pro", monospace; > *:not(input) { display: flex; font-size: inherit; align-items: center; justify-content: center; } .input { font-size: inherit; background-color: var(--clr-background-300); border: none; box-shadow: 0 3px 0 var(--clr-background-400), 0 3px 8px rgba(0, 0, 0, 0.25); color: var(--clr-text); font-weight: 600; padding: 0.35em 0.5em; text-align: center; isolation: isolate; letter-spacing: 1px; border-radius: 4px; transition: transform 200ms ease-out, box-shadow 200ms ease; &:not(.input--correct):not(.input--incorrect):focus-visible { box-shadow: 0 3px 0 var(--clr-accent-blue), 0 3px 8px rgba(0, 0, 0, 0.25); transform: translateY(-3px); } &:focus-visible { outline: none; } &--correct { box-shadow: 0 3px 0 var(--clr-accent-green), 0 3px 8px rgba(0, 0, 0, 0.25); } &--incorrect { box-shadow: 0 3px 0 var(--clr-accent-red), 0 3px 8px rgba(0, 0, 0, 0.25); } } } .break { flex-basis: 100%; height: 0; } } @media (min-width: 450px) { .wrapper { max-width: 600px; .container { font-size: 2.25rem; padding: 1.5em; } } .answerPreview { font-size: 1.25rem !important; } } ================================================ FILE: components/SpellingByWord/SpellingByWord.tsx ================================================ import { FC, useState, useEffect, FormEventHandler, useRef, FocusEventHandler } from "react"; import { AnimatePresence, motion } from "framer-motion"; import shoetest from "shoetest"; import styles from "./SpellingByWord.module.scss"; import Watermark from "@components/Watermark"; import ExpandingBlob from "@components/ExpandingBlob"; import classNames from "classnames"; import MaskedInput from "react-text-mask"; import { ArrowRight, Information } from "iconsax-react"; interface Props { data: Question; onAnswer: (answeredRight: boolean, input: string, expected: string, data: Question) => void; } interface InputProps { value: string; expectedValue: string; isCorrect: boolean | null; } type InputData = Record; const FIXES_TABLE = [{ from: /\s\/\s/g, to: "/" }]; const applyFixesTable = (word: string): string => { for (const fix of FIXES_TABLE) { word = word.replaceAll(fix.from, fix.to); } return word; }; const card = { out: { opacity: 0, x: "50%", y: "-50%", scale: 0.25 }, in: { opacity: 1, x: "-50%", scale: 1, transition: { type: "spring", damping: 12 } }, outExit: { opacity: 0, x: "-150%", scale: 0.25, transition: { type: "spring", damping: 12 } }, }; const WordInput: FC<{ isCorrect: boolean | null; mask: (RegExp | string)[]; onChangeCallback: (value: string, id: string) => void; onFocusCallback: FocusEventHandler; id: string; autoFocus?: boolean; maskPlaceholder?: string; }> = ({ id, mask, isCorrect, onChangeCallback, onFocusCallback, autoFocus = false, maskPlaceholder = "_" }) => { const classes = classNames(styles.input, { [styles["input--incorrect"]]: isCorrect === false, [styles["input--correct"]]: isCorrect === true, }); const inputIsEmpty = useRef(true); const passedInitialValue = useRef(false); const inputRef = useRef(); useEffect(() => { if (inputRef.current == null || passedInitialValue.current === true) return; onChangeCallback(inputRef.current.value, inputRef.current.dataset.id!); passedInitialValue.current = true; }, [autoFocus, onChangeCallback]); return ( !(e instanceof RegExp)).length} * 0.5ch)`, }} render={(textMaskRef, props) => ( { if (node == null) return; textMaskRef(node); // Keep this so the component can still function inputRef.current = node; // Copy the ref for yourself }} /> )} onKeyDown={(e) => { if (!(e.target instanceof HTMLInputElement)) return; const targetInput = e.target; const key = e.key; const cleanValue = getCleanedValue(e.target.value); if (key === "Backspace" && inputIsEmpty.current) { const prevInput = targetInput.previousElementSibling; if (prevInput instanceof HTMLInputElement) prevInput.focus(); } inputIsEmpty.current = cleanValue.length === 0; }} onFocus={(e) => { if (inputIsEmpty.current) e.target.setSelectionRange(0, 0); onFocusCallback(e); }} onChange={(e) => { if (isCorrect != null) return; const input = e.target; const value = input.value; const cleanValue = getCleanedValue(value); inputIsEmpty.current = cleanValue.length === 0; onChangeCallback(value, id); }} data-id={id} /> ); }; const removeDiacritics = (string: string): string => { return shoetest.simplify(string); }; const splitInput = (input: string) => { return removeDiacritics(input) .split(/\s/) .filter((i) => i.length !== 0); }; const generateMask = (word: string) => { const maskArray: (RegExp | string)[] = []; word.split("").forEach((char) => { // a letter if (/[a-z]/i.test(char)) { maskArray.push(/[a-z]/i); } else if (/\d/.test(char)) { maskArray.push(/\d/); } else { // some special character like e.x. "-" or "/" maskArray.push(char); } }); return maskArray; }; const getCleanedValue = (value: string): string => { return value .split("") .filter((c) => /[a-z]/i.test(c) || /\d/.test(c)) .join(""); }; const generateInputData = (answer: string): [InputData, string[]] => { const inputArray = splitInput(answer); const generatedData: InputData = {}; const idsInOrder: string[] = []; inputArray.forEach((word, wordIdx) => { const id = `${word}_${wordIdx}`; idsInOrder.push(id); const data: InputProps = { value: "", expectedValue: word, isCorrect: null, }; generatedData[id] = data; }); return [generatedData, idsInOrder]; }; const SpellingByWord: FC = ({ data, onAnswer }) => { const answer = applyFixesTable(data.answer); const inputArray = splitInput(answer); const [hasFinishedEntering, setHasFinishedEntering] = useState(false); const [shouldAnimateBlob, setShouldAnimateBlob] = useState(false); const [answered, setAnswered] = useState<[boolean, string] | null>(null); const currentlyFocusedInput = useRef(null); const [shouldShowCorrectAnswer, setShouldShowCorrectAnswer] = useState(false); const inputIdsInOrder = useRef(); const [inputData, setInputData] = useState(null); // used to trigger blob animation on answer which itself triggers onAnswer in parent component useEffect(() => { if (answered == null || hasFinishedEntering === false) return; if (answered[0] === false) { setShouldShowCorrectAnswer(true); return; } setShouldAnimateBlob(true); }, [answered, hasFinishedEntering, shouldShowCorrectAnswer]); useEffect(() => { // if inputData is not null -> data is already generated if (inputData != null) return; const [generatedData, idsInOrder] = generateInputData(answer); setInputData(generatedData); inputIdsInOrder.current = idsInOrder; }, [answer, inputData]); const checkAnswer: FormEventHandler = (e) => { e.preventDefault(); if (hasFinishedEntering === false || inputIdsInOrder.current == null || inputData == null) return; if (shouldShowCorrectAnswer === true) { setShouldAnimateBlob(true); } let verdict = true; const userInput: string[] = []; const inputDataClone = Object.assign({}, inputData); inputIdsInOrder.current.forEach((id, idIndex) => { const input = inputDataClone[id]; const correspondingWord = inputArray[idIndex]; const inputVerdict = input.value.toLowerCase() === correspondingWord.toLowerCase(); if (inputVerdict === false) verdict = false; userInput.push(input.value); input.isCorrect = inputVerdict; }); setInputData(inputDataClone); setTimeout(() => setAnswered([verdict, userInput.join(" ")]), 100); }; const onChangeHandler = (value: string, id: string) => { if (inputData == null) return; const input = inputData[id]; const cleanValue = getCleanedValue(value); // detect when input is fully filled, then try to focus next one (if found) if (cleanValue.length === getCleanedValue(input.expectedValue).length) { const currentInput = currentlyFocusedInput.current; const nextInput = currentInput?.nextElementSibling; if (nextInput instanceof HTMLInputElement) nextInput.focus(); } setInputData((oldState) => { return { ...oldState, [id]: { ...oldState![id], value: value }, }; }); }; const onFocusHander: FocusEventHandler = (e) => { // onChangeHandler(e.target.value, e.target.dataset.id!); currentlyFocusedInput.current = e.target; }; const borderStyles = classNames({ [`${styles["container--correct"]}`]: shouldAnimateBlob && answered != null && answered[0] === true, [`${styles["container--incorrect"]}`]: shouldAnimateBlob && answered != null && answered[0] === false, }); // if (!inputData) return

Generating...

; if (!inputData) return null; return ( { if (!hasFinishedEntering) setHasFinishedEntering(true); }} className={styles.wrapper} >

{data.question}

{inputArray.map((word, wordIdx) => { const mask = generateMask(word); const id = `${word}_${wordIdx}`; const inputProps = inputData[id]; const props = { id: id, mask: mask, isCorrect: inputProps.isCorrect, maskPlaceholder: "_", autoFocus: wordIdx === 0, onFocusCallback: onFocusHander, onChangeCallback: onChangeHandler, }; return ; })} {shouldShowCorrectAnswer && ( {answer} )} {answered != null && shouldAnimateBlob && ( { onAnswer(answered[0], answered[1], inputArray.join(" "), { ...data, answer }); }} /> )}
Submit
); }; export default SpellingByWord; ================================================ FILE: components/Streak/index.tsx ================================================ import Streak from "./Streak"; export default Streak; ================================================ FILE: components/Streak/Streak.module.scss ================================================ .wrapper { margin-top: 1rem; width: fit-content; font-size: 0.75em; text-align: center; font-weight: 500; position: relative; isolation: isolate; z-index: 1; border-radius: 4px; border: 2px solid var(--clr-accent-peachy); color: var(--clr-accent-peachy); display: flex; .content { padding: 0.25rem 1.25rem; position: relative; z-index: 1; background-color: var(--clr-background-300); } .pulse { position: absolute; z-index: -1; background-color: var(--clr-accent-peachy); top: 50%; left: 50%; border-radius: 50%; height: 100%; aspect-ratio: 1/1; } } ================================================ FILE: components/Streak/Streak.tsx ================================================ import getStreakEmojis from "@utils/getStreakEmojis"; import { AnimatePresence, motion } from "framer-motion"; import React, { FC, useEffect, useState } from "react"; import styles from "./Streak.module.scss"; interface Props { streak: number; } const ACTIVATE_AT = 5; const Streak: FC = ({ streak }) => { const [shouldPulse, setShouldPulse] = useState(false); useEffect(() => { if (streak > ACTIVATE_AT) { setShouldPulse(true); } }, [streak]); return ( {streak >= ACTIVATE_AT && (
Streak x{streak} {getStreakEmojis(streak)}
{shouldPulse && ( setShouldPulse(false)} /> )} )}
); }; export default Streak; ================================================ FILE: components/Watermark/index.tsx ================================================ import Watermark from "./Watermark"; export default Watermark; ================================================ FILE: components/Watermark/Watermark.module.scss ================================================ .wrapper { color: var(--clr-text-muted); opacity: 0.03; position: absolute; pointer-events: none; top: 50%; left: 50%; transform: translate(-50%, -50%) rotate(-15deg); font-weight: 700; margin: 0; z-index: -1; line-height: 100%; &::before, &::after { content: attr(data-text); font-size: inherit; position: absolute; left: 0; } &::before { top: -100%; } &::after { top: 100%; } } ================================================ FILE: components/Watermark/Watermark.tsx ================================================ import { FC } from "react"; import styles from "./Watermark.module.scss"; const Watermark: FC<{ text: string; size: "lg" | "md" }> = ({ text, size }) => { const getFontSize = (size: "lg" | "md") => { if (size === "lg") return "8rem"; else if (size === "md") return "6rem"; }; return (
{text}
); }; export default Watermark; ================================================ FILE: hooks/useIndexSelectedData.ts ================================================ import { useState } from "react"; const useIndexSelectedData = (dataArray: T[] | undefined, startingIndex?: number) => { const [selectedIndex, setSelectedIndex] = useState(startingIndex || 0); const [isDone, setIsDone] = useState(false); const selectedItem = dataArray?.[selectedIndex]; const amountOfItems = dataArray?.length ?? 0; const nextItem = () => { if (dataArray == null) return; setSelectedIndex((i) => { const nextIndex = i + 1; if (nextIndex >= dataArray.length) { setIsDone(true); return i; } return nextIndex; }); }; const prevItem = () => { if (dataArray == null) return; if (isDone === true) setIsDone(false); setSelectedIndex((i) => { const prevIndex = i - 1; if (prevIndex < 0) return 0; return prevIndex; }); }; const resetIndex = () => { setIsDone(false); setSelectedIndex(startingIndex || 0); }; return { selectedItem, selectedIndex, amountOfItems, progress: { isDone, isFirst: selectedIndex === 0, isLast: (dataArray && selectedIndex === dataArray.length - 1) || false, }, nextItem, prevItem, resetIndex, }; }; export default useIndexSelectedData; ================================================ FILE: hooks/useShuffledData.ts ================================================ import shuffle from "@utils/shuffle"; import { useEffect, useState } from "react"; const useShuffledData = (data: T[]) => { const [shuffledData, setShuffledData] = useState(data); const [isShuffled, setIsShuffled] = useState(false); const reshuffle = (newData: T[] | null = null) => { setShuffledData(() => { return shuffle(newData == null ? shuffledData : newData); }); }; useEffect(() => { setShuffledData(shuffle(data)); setIsShuffled(true); }, [data]); return { data: shuffledData, isShuffled, reshuffle, }; }; export default useShuffledData; ================================================ FILE: hooks/useStreak.ts ================================================ import { useEffect, useState } from "react"; function useStreak() { const [streak, setStreak] = useState(0); const [maxStreak, setMaxStreak] = useState(0); useEffect(() => { if (streak > maxStreak) { setMaxStreak(streak); } }, [streak, maxStreak]); const reset = () => { setStreak(0); setMaxStreak(0); }; return [streak, setStreak, maxStreak, reset] as const; } export default useStreak; ================================================ FILE: hooks/useWindowSize.ts ================================================ import { useState, useEffect } from "react"; function useWindowSize() { // Initialize state with undefined width/height so server and client renders match // Learn more here: https://joshwcomeau.com/react/the-perils-of-rehydration/ const [windowSize, setWindowSize] = useState<{ width: undefined | number; height: undefined | number }>({ width: undefined, height: undefined, }); useEffect(() => { // Handler to call on window resize function handleResize() { // Set window width/height to state setWindowSize({ width: window.innerWidth, height: window.innerHeight, }); } if (!process.browser) return; // Add event listener window.addEventListener("resize", handleResize); // Call handler right away so state gets updated with initial window size handleResize(); // Remove event listener on cleanup return () => window.removeEventListener("resize", handleResize); }, []); // Empty array ensures that effect is only run on mount return windowSize; } export default useWindowSize; ================================================ FILE: pages/404.tsx ================================================ const Page404 = () => { return
Not Found
; }; export default Page404; ================================================ FILE: pages/_app.tsx ================================================ import "@styles/globals.scss"; import type { AppProps } from "next/app"; function MyApp({ Component, pageProps }: AppProps) { return ; } export default MyApp; ================================================ FILE: pages/_document.tsx ================================================ import { Html, Head, Main, NextScript } from "next/document"; export default function Document() { return (
); } ================================================ FILE: pages/index.tsx ================================================ import { InferGetStaticPropsType, NextPage } from "next"; import React, { ChangeEventHandler, useEffect, useState } from "react"; import styles from "@styles/Home.module.scss"; import ListEntries from "@components/ListEntries"; import { motion } from "framer-motion"; import Filter from "@components/Filter"; import Head from "next/head"; export const getStaticProps = async () => { const rawData = await fetch(`${process.env.API_URL}/cards?populate=questions&sort=updatedAt%3Adesc`, { method: "GET", headers: { Authorization: `Bearer ${process.env.API_TOKEN}`, "content-type": "application/json", }, }); let data: ApiResponse = await rawData.json(); return { props: { data: data, }, revalidate: 60, }; }; const Home: NextPage> = ({ data: { data, meta: _ } }) => { const [inputValue, setInputValue] = useState(""); const [filterString, setFilterString] = useState(null); const handleInputChange: ChangeEventHandler = (e) => { setInputValue(e.target.value); }; useEffect(() => { const TIMEOUT_MS = inputValue.length === 0 ? 0 : 400; const timeoutId = window.setTimeout(() => { setFilterString(inputValue); }, TIMEOUT_MS); return () => { clearTimeout(timeoutId); }; }, [inputValue]); return ( <> Flash Card App
Select one of the topics below
); }; export default Home; ================================================ FILE: pages/[slug]/index.tsx ================================================ import { GetServerSidePropsContext } from "next"; export default function Redirect() { return null; } export async function getServerSideProps(_context: GetServerSidePropsContext) { return { redirect: { destination: "/", permanent: true, }, }; } ================================================ FILE: pages/[slug]/card/index.tsx ================================================ import { useState } from "react"; import { GetStaticPropsContext } from "next"; import FlipCard from "@components/FlipCard"; import EndCard from "@components/EndCard"; import ProgressBar from "@components/ProgressBar"; import useIndexSelectedData from "@hooks/useIndexSelectedData"; import useShuffledData from "@hooks/useShuffledData"; import { AnimatePresence, motion } from "framer-motion"; import styles from "@styles/Card.module.scss"; import useStreak from "@hooks/useStreak"; import Head from "next/head"; interface Props { title: string; rawData: Question[]; dataClass: ClassString; } export const getStaticProps = async ({ params }: GetStaticPropsContext) => { const apiData = await fetch(`${process.env.API_URL}/cards?filters[slug][$eq]=${params?.slug}&populate=questions`, { method: "GET", headers: { Authorization: `Bearer ${process.env.API_TOKEN}`, "content-type": "application/json", }, }); let dataArray: ApiResponse = await apiData.json(); if (!dataArray.data.length) { return { redirect: { destination: "/", permanent: false, }, }; } const { attributes: { title, questions, class: classString }, } = dataArray.data[0]; return { props: { title, rawData: questions, dataClass: classString, }, revalidate: 60, }; }; export async function getStaticPaths() { const rawData = await fetch(`${process.env.API_URL}/cards`, { method: "GET", headers: { Authorization: `Bearer ${process.env.API_TOKEN}`, "content-type": "application/json", }, }); let data: ApiResponse = await rawData.json(); const paths = data.data.map((d) => { return { params: { slug: d.attributes.slug }, }; }); return { paths, fallback: "blocking" }; } export default function CardId({ title, rawData, dataClass }: Props) { const [incorrectAnswers, setIncorrectAnswers] = useState([]); const [correctAnswers, setCorrectAnswers] = useState([]); const [streak, setStreak, maxStreak, resetStreak] = useStreak(); const { data, isShuffled, reshuffle } = useShuffledData(rawData); const { selectedItem, selectedIndex, nextItem, resetIndex, amountOfItems, progress: { isDone }, } = useIndexSelectedData(data); const onAnswer = (rightAnswer: boolean, data: Question) => { const stateUpdater = rightAnswer ? setCorrectAnswers : setIncorrectAnswers; stateUpdater((prevState) => { if (prevState == null) return [data]; return [...prevState, data]; }); if (rightAnswer === true) { setStreak((c) => c + 1); } if (rightAnswer === false) { setStreak(0); } nextItem(); }; const handleRestart = (newData: Question[] | null = null) => { resetIndex(); setIncorrectAnswers([]); setCorrectAnswers([]); reshuffle(newData); resetStreak(); }; const getKeyFromData = (d: Question) => { return `${d.id}_${d.question}_${d.answer}`; }; if (!rawData || !isShuffled || selectedItem == null) return null; return ( <> {title} | Card Mode | Flash Card App
{!isDone ? ( ) : ( )}
); } ================================================ FILE: pages/[slug]/list/index.tsx ================================================ import { useEffect, useRef, useState } from "react"; import styles from "@styles/List.module.scss"; import classNames from "classnames"; import getAccentForClass from "@utils/getAccentForClass"; import { GetStaticPropsContext } from "next"; import groupBy from "@utils/groupBy"; import { ArrowCircleDown2, ArrowCircleRight2, Back, Danger } from "iconsax-react"; import { motion } from "framer-motion"; import Link from "next/link"; import Head from "next/head"; interface Props { data: Card; } export default function CardId({ data: { attributes: { questions, title, class: classString }, }, }: Props) { const headerRef = useRef(null); const [isSticky, setIsSticky] = useState(false); const [dupsData, setDupsData] = useState(); const topContainerClasses = classNames(styles["top__container"], { [`${styles["top__container--sticky"]}`]: isSticky, }); useEffect(() => { if (!questions) return; const grouped = groupBy(questions, (q) => q.question); const values = Object.values(grouped); const dups = values.filter((v) => v.length > 1); if (dups.length > 0) setDupsData(dups); }, [questions]); useEffect(() => { const cachedRef = headerRef.current; if (cachedRef == null) return; const observer = new IntersectionObserver( ([entry]) => { setIsSticky(entry.intersectionRatio < 1); }, { threshold: [1], rootMargin: "0px 100% 0px 100%" } ); observer.observe(cachedRef); return () => { observer.unobserve(cachedRef); }; }, []); return ( <> {title} | List View | Flash Card App
<> Go Back

List view for
{title}

{dupsData && (

Duplicates found in this dataset

Please combine them into one for a better learning experience by using e.x. a comma

List of duplicates

    {dupsData.map((dup) => { return
  1. {dup[0].question}
  2. ; })}
)}

Question

Answer

{questions.map((d, index) => { let { answer, question } = d; return (
{question}
{answer}
); })}
); } export const getStaticProps = async ({ params }: GetStaticPropsContext) => { const apiData = await fetch(`${process.env.API_URL}/cards?filters[slug][$eq]=${params?.slug}&populate=questions`, { method: "GET", headers: { Authorization: `Bearer ${process.env.API_TOKEN}`, "content-type": "application/json", }, }); let dataArray: ApiResponse = await apiData.json(); if (!dataArray.data.length) { return { redirect: { destination: "/", permanent: false, }, }; } const rawData = dataArray.data[0]; return { props: { data: rawData, }, revalidate: 60, }; }; export async function getStaticPaths() { const rawData = await fetch(`${process.env.API_URL}/cards`, { method: "GET", headers: { Authorization: `Bearer ${process.env.API_TOKEN}`, "content-type": "application/json", }, }); let data: ApiResponse = await rawData.json(); const paths = data.data.map((d) => { return { params: { slug: d.attributes.slug }, }; }); return { paths, fallback: "blocking" }; } ================================================ FILE: pages/[slug]/spelling/index.tsx ================================================ import { FC, useState } from "react"; import styles from "@styles/Spelling.module.scss"; import { GetStaticPropsContext } from "next"; import { AnimatePresence, motion } from "framer-motion"; import useShuffledData from "@hooks/useShuffledData"; import useIndexSelectedData from "@hooks/useIndexSelectedData"; import EndCard from "@components/EndCard"; import ProgressBar from "@components/ProgressBar"; import SpellingByWord from "@components/SpellingByWord"; import useStreak from "@hooks/useStreak"; import Head from "next/head"; interface Props { title: string; rawData: Question[]; dataClass: ClassString; } const getKeyFromQuestion = (d: Question) => { return `${d.id}_${d.question}_${d.answer}`; }; const CardId: FC = ({ title, rawData, dataClass }) => { const { data, isShuffled, reshuffle } = useShuffledData(rawData); const { selectedItem, selectedIndex, nextItem, resetIndex, progress, amountOfItems } = useIndexSelectedData(data); const [incorrectAnswers, setIncorrectAnswers] = useState([]); const [correctAnswers, setCorrectAnswers] = useState([]); const [streak, setStreak, maxStreak, resetStreak] = useStreak(); const onRestart = (newData: Question[] | null = null) => { resetIndex(); setIncorrectAnswers([]); setCorrectAnswers([]); reshuffle(newData); resetStreak(); }; const onAnswer = (answeredRight: boolean, input: string, expected: string, data: Question) => { const stateUpdater = answeredRight ? setCorrectAnswers : setIncorrectAnswers; const answerData: SpellingData = { input, expected, data, }; stateUpdater((prevState) => { return [...prevState, answerData]; }); if (answeredRight === true) { setStreak((c) => c + 1); } if (answeredRight === false) { setStreak(0); } nextItem(); }; if (!rawData || !isShuffled || selectedItem == null) return null; return ( <> {title} | Spelling Mode | Flash Card App
{!progress.isDone ? ( ) : ( )}
); }; export const getStaticProps = async ({ params }: GetStaticPropsContext) => { const apiData = await fetch(`${process.env.API_URL}/cards?filters[slug][$eq]=${params?.slug}&populate=questions`, { method: "GET", headers: { Authorization: `Bearer ${process.env.API_TOKEN}`, "content-type": "application/json", }, }); let dataArray: ApiResponse = await apiData.json(); if (!dataArray.data.length) { return { redirect: { destination: "/", permanent: false, }, }; } const { attributes: { title, questions, class: classString }, } = dataArray.data[0]; return { props: { title, rawData: questions, dataClass: classString, }, revalidate: 60, }; }; export async function getStaticPaths() { const rawData = await fetch(`${process.env.API_URL}/cards`, { method: "GET", headers: { Authorization: `Bearer ${process.env.API_TOKEN}`, "content-type": "application/json", }, }); let data: ApiResponse = await rawData.json(); const paths = data.data.map((d) => { return { params: { slug: d.attributes.slug }, }; }); return { paths, fallback: "blocking" }; } export default CardId; ================================================ FILE: styles/_variables.scss ================================================ body { --clr-background-200: hsl(0, 0%, 10%); --clr-background-300: hsl(0, 0%, 13%); --clr-background-400: hsl(0, 0%, 16%); --clr-background-500: hsl(0, 0%, 20%); --clr-background-600: hsl(0, 0%, 30%); --clr-background-card: hsl(0, 0%, 20%); --clr-text: hsl(0, 0%, 96%); --clr-text-muted: hsl(0, 0%, 70%); --clr-accent-blue: hsl(212, 75%, 70%); --clr-accent-green: hsl(124, 60%, 69%); --clr-accent-green-darker: hsl(124, 40%, 56%); --clr-accent-peachy: hsl(23, 90%, 68%); --clr-accent-red: hsl(359, 100%, 70%); --clr-accent-red-darker: hsl(359, 63%, 54%); --clr-accent-yellow: hsl(49, 100%, 50%); --clr-accent-pink: hsl(333, 71%, 51%); } body.light { --clr-background-300: hsl(0, 0%, 96%); --clr-background-400: hsl(0, 0%, 93%); --clr-background-500: hsl(0, 0%, 86%); --clr-background-600: hsl(0, 0%, 86%); --clr-background-card: hsl(0, 0%, 80%); --clr-text: hsl(0, 0%, 8%); --clr-text-muted: hsl(0, 0%, 20%); --clr-accent-blue: hsl(212, 90%, 60%); --clr-accent-green: hsl(113, 51%, 55%); --clr-accent-green-darker: hsl(113, 51%, 45%); --clr-accent-peachy: hsl(23, 90%, 68%); --clr-accent-red: hsl(359, 100%, 60%); --clr-accent-red-darker: hsl(359, 63%, 50%); } ================================================ FILE: styles/Card.module.scss ================================================ .container { min-height: 100vh; padding: 1rem; align-items: center; justify-content: center; position: relative; display: flex; flex-direction: column; overflow: hidden; } ================================================ FILE: styles/globals.scss ================================================ @use "variables"; @import url("https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;700&family=Source+Code+Pro:wght@600&display=swap"); html, body, #__next { min-height: 100%; width: 100vw; } body { padding: 0; margin: 0; background-color: var(--clr-background-300); color: var(--clr-text); font-family: "Poppins", sans-serif; overflow-x: hidden; -webkit-tap-highlight-color: transparent; //remove annoying highlight on mobile chrome &.no-scroll { overflow-y: hidden; } } #__next { display: flex; flex-direction: column; } a { color: inherit; text-decoration: none; } input { font-family: inherit; } * { box-sizing: border-box; -moz-outline-radius: 0px !important; } .hidden { display: none; } ================================================ FILE: styles/Home.module.scss ================================================ .wrapper { padding: 2rem; margin: 0 auto; width: 100%; max-width: 800px; display: flex; flex-direction: column; .adminLink { color: var(--clr-accent-blue); margin-bottom: 1.5rem; &:focus, &:hover { text-decoration: underline; } } } .heading { margin-top: 0; margin-bottom: 2rem; font-size: clamp(1.5rem, 8vw, 2.5rem); font-weight: 700; } @media (min-width: 450px) { .heading { font-weight: 700; } } ================================================ FILE: styles/List.module.scss ================================================ .container { margin: 0 auto; width: calc(100% - 3rem); display: grid; max-width: 750px; } .backButton { padding: 0.5em 1em; border-radius: 4px; font-size: 0.85rem; align-items: center; justify-content: center; gap: 0.5em; display: inline-flex; background-color: var(--clr-background-400); } .title { font-size: 1.5rem; font-weight: 500; span { font-size: 1.35em; font-weight: 700; color: var(--clr-accent); .line { display: none; } } } .dupWarning { border-radius: 4px; padding: 1rem; margin-bottom: 2rem; display: flex; flex-direction: column; color: var(--clr-background-300); background-color: var(--clr-accent-yellow); h1 { font-size: 1.5rem; margin: 0; display: flex; flex-direction: column; align-items: flex-start; svg { margin-right: 0.75rem; width: 3rem; height: 3rem; } } h3 { margin: 0; margin-bottom: 0.5em; } ol { margin: 0; padding-left: 1rem; font-weight: 500; } } .top__container { display: none; } .list { display: grid; row-gap: 1rem; margin-bottom: 2rem; grid-template-columns: 1fr; &__item { padding: 1rem; box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); border-radius: 4px; background-color: var(--clr-background-400); display: grid; grid-template-columns: 1fr; grid-template-rows: auto 40px auto; column-gap: 0.5rem; .question, .answer { text-align: center; font-size: 1.125rem; } .answer { font-weight: 700; color: var(--clr-accent); } .question { font-weight: 500; } .spacer { display: none; } svg { width: 1.5rem; height: 1.5rem; place-self: center; margin: 0.25rem; } } } @media (min-width: 600px) { .top__container { max-width: none; position: sticky; top: -1px; display: block; padding-top: 1px; z-index: 2; background-color: var(--clr-background-300); isolation: isolate; &::after { content: ""; position: absolute; inset: 0; z-index: -1; box-shadow: 0 4px 10px rgba(0, 0, 0, 0.15); opacity: 0; transition: opacity 150ms ease; } &--sticky { svg { opacity: 1 !important; } &::after { opacity: 1; } } .top { display: grid; grid-template-columns: 1fr 60px 1fr; column-gap: 2rem; font-size: 1.5rem; font-weight: 700; padding: 0 1rem; svg { place-self: center; width: 2rem; height: 2rem; transition: opacity 150ms ease; opacity: 0; color: var(--clr-accent); } & > p:last-child { text-align: right; } } } .dupWarning { h1 { font-size: 2rem; flex-direction: row; } } .title { font-size: 2rem; span { padding-right: 0.25em; background-color: var(--clr-background-300); position: relative; .line { display: block !important; width: 750px; height: 3px; position: absolute; transform-origin: left; top: 50%; left: 0; background: var(--clr-accent); z-index: -2; } } } .list { grid-template-columns: 1fr; &__item { grid-template-columns: 1fr 20px 1fr; grid-template-rows: auto; column-gap: 1rem; .spacer { display: block; height: 100%; width: 3px; border-radius: 3px; background-color: var(--clr-background-500); place-self: center; } svg { display: none; } .question, .answer { text-align: left; color: var(--clr-text); } .question { font-weight: 500; } .answer { color: var(--clr-accent); font-weight: 700; text-align: right; } } } } ================================================ FILE: styles/Spelling.module.scss ================================================ .container { min-height: 100vh; padding: 1rem; position: relative; align-items: center; justify-content: center; display: flex; flex-direction: column; overflow: hidden; } ================================================ FILE: utils/getAccentForClass.ts ================================================ export default function getAccentForClass(cls: ClassString) { switch (cls) { case "de": return "var(--clr-accent-peachy)"; case "en": return "var(--clr-accent-blue)"; case "geo": return "var(--clr-accent-green)"; } } ================================================ FILE: utils/getHumanReadableClass.ts ================================================ export default function getHumanReadableClass(cls: ClassString) { switch (cls) { case "de": return "German"; case "en": return "English"; case "geo": return "Geography"; } } ================================================ FILE: utils/getStreakEmojis.ts ================================================ function getStreakEmojis(streak: number): string { let multiplicator = 1; if (streak >= 20) multiplicator = 2; if (streak >= 40) multiplicator = 3; if (streak >= 80) multiplicator = 4; if (streak >= 160) multiplicator = 5; return "🔥".repeat(multiplicator); } export default getStreakEmojis; ================================================ FILE: utils/groupBy.ts ================================================ const groupBy = (list: T[], getKey: (item: T) => K) => list.reduce((previous, currentItem) => { const group = getKey(currentItem); if (!previous[group]) previous[group] = []; previous[group].push(currentItem); return previous; }, {} as Record); export default groupBy; ================================================ FILE: utils/shuffle.ts ================================================ function shuffle(a: T[]) { const aC = a.slice(); for (let i = aC.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [aC[i], aC[j]] = [aC[j], aC[i]]; } return aC; } export default shuffle;