Files
Kazimierz Ciołek 7ecefb5621 Initialize repo
2026-02-02 13:56:14 +01:00

3631 lines
93 KiB
Plaintext

================================================
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 (
<div className={wrapperClasses}>
{answeredRight != null && (
<ExpandingBlob type={answeredRight === true ? "correct" : "wrong"} onAnimationComplete={forwardAnswer} />
)}
<Watermark size="lg" text="answer" />
<div className={styles.textWrapper}>
<div className={styles.answer__text}>{data}</div>
</div>
</div>
);
};
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<Props> = ({ 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 (
<LayoutGroup>
<motion.div
initial={{ opacity: 0, y: 50 }}
animate={{ opacity: 1, height: "100%", y: 0 }}
exit={{ opacity: 0 }}
key="endcard"
layout="position"
className={styles.wrapper}
>
<h1 className={styles.wrapper__title}>
Your end score was <span>{score}%</span>
</h1>
{streak >= 5 && (
<h3>
Highest streak: {streak}
{getStreakEmojis(streak)}
</h3>
)}
<section className={styles.buttons}>
<h5>
<span>What&apos;s next?</span>
</h5>
<div className={styles.buttons_grid}>
<Link href="/" className={classNames(styles.button)}>
<>
<Back color="currentColor" />
Go Back
</>
</Link>
<button className={classNames(styles.button, styles.orange)} onClick={() => setIsReviewOpened((s) => !s)}>
<MessageQuestion color="currentColor" />
Review
</button>
<button className={classNames(styles.button, styles.green)} onClick={handleRestartAll}>
<ArrowRotateRight color="currentColor" />
Restart
</button>
{data.incorrect.length > 0 && (
<button className={classNames(styles.button, styles.purple)} onClick={handleRestartIncorrect}>
<ArrowRotateRight color="currentColor" />
Incorrect
</button>
)}
</div>
</section>
<AnimatePresence>
{isReviewOpened && <EndCardReview data={data} mode={mode} dataClass={dataClass} />}
</AnimatePresence>
</motion.div>
</LayoutGroup>
);
};
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 (
<div className={styles.list__item}>
<div className={styles.question}>{question}</div>
<div className={styles.spacer}></div>
<ArrowCircleDown2 color="currentColor" size="32" variant="Bold" />
<div className={styles.answer}>{answer}</div>
</div>
);
}
const {
data: { answer, question },
expected,
input,
} = data as SpellingData;
return (
<div className={styles.list__item}>
<div className={styles.question}>
<small className={styles.small_question}>{question}</small>
<div className={styles.spelling_question}>
{expected !== answer ? (
<span>
{expected} <small>( {answer} )</small>
</span>
) : (
expected
)}
</div>
</div>
<div className={styles.spacer}></div>
<ArrowCircleDown2 color="currentColor" size="32" variant="Bold" />
<div>
<small className={styles.typed}>You typed</small>
<div className={styles.answer}>{input}</div>
</div>
</div>
);
};
const EndCardReview: FC<Props> = ({ mode, data, dataClass }) => {
const { incorrect, correct } = data;
return (
<motion.section initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className={styles.showdown}>
{incorrect.length > 0 && (
<div>
<h2 className={styles.title__incorrect}>Incorrect Answers ({incorrect.length})</h2>
<div className={classNames(styles.list, styles.incorrect)}>
{incorrect.map((answerData, answerIdx) => {
return (
<EndCardList
data={answerData}
dataClass={dataClass}
mode={mode}
key={`${JSON.stringify(answerData)}_${answerIdx}`}
/>
);
})}
</div>
</div>
)}
{correct.length > 0 && (
<div>
<h2 className={styles.title__correct}>Correct Answers ({correct.length})</h2>
<div className={classNames(styles.list, styles.correct)}>
{correct.map((answerData, answerIdx) => {
return (
<EndCardList
data={answerData}
dataClass={dataClass}
mode={mode}
key={`${JSON.stringify(answerData)}_${answerIdx}`}
/>
);
})}
</div>
</div>
)}
</motion.section>
);
};
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<PropsWithChildren> = ({ children }) => {
return <div className={styles.tag}>{children}</div>;
};
const Entry = ({
data: {
attributes: { title, slug, questions, class: classString },
},
animationDelay,
}: Props) => {
const [dupsData, setDupsData] = useState<Question[][]>();
const [isExpanded, setIsExpanded] = useState(false);
const containerRef = useRef<HTMLButtonElement>(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 (
<motion.button
layout
key={slug}
ref={containerRef}
initial={{ y: -25, opacity: 0 }}
animate={{ y: 0, opacity: 1, transition: { delay: animationDelay } }}
exit={{ opacity: 0 }}
className={styles.container}
style={{ "--clr-card-accent": getAccentForClass(classString) } as any}
onClick={(e) => {
e.stopPropagation();
setIsExpanded((isExpanded) => !isExpanded);
}}
onBlur={(e) => {
if (!containerRef.current?.contains(e.relatedTarget)) {
setIsExpanded(false);
}
}}
>
<motion.div layout key="content" className={styles.wrapper}>
<div>
<motion.p layout className={styles.bang}>
TOPIC
</motion.p>
<motion.h1 layout className={styles.title}>
{title}
</motion.h1>
</div>
<motion.div layout className={styles.tags}>
<Tag>{getHumanReadableClass(classString)}</Tag>
<Tag>
{questions?.length} {questions.length > 1 ? "cards" : "card"}
{dupsData && (
<motion.span layout key="dups" className={styles.dupWarningIcon}>
<Danger size="1rem" color="currentColor" variant="Bold" />
</motion.span>
)}
</Tag>
</motion.div>
</motion.div>
<motion.div
layout
initial={{ left: "calc(50% - 0.75rem)", bottom: 0, y: 90, position: "absolute" }}
animate={{ y: isHovered || isExpanded ? 50 : 90 }}
key="indicator"
className={styles.indicator}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={3}
stroke="var(--clr-card-accent)"
>
<motion.path
initial={{ rotate: 0 }}
animate={{ rotate: isExpanded ? 180 : 0 }}
strokeLinecap="round"
strokeLinejoin="round"
d="M12 4.5v15m0 0l6.75-6.75M12 19.5l-6.75-6.75"
/>
</svg>
</motion.div>
<AnimatePresence>
{isExpanded && (
<motion.div
layout
key="additional-content"
style={{ width: "100%" }}
initial={{ opacity: 0, paddingBottom: "1.5rem" }}
animate={{ opacity: 1, transition: { delay: 0.15 } }}
exit={{ opacity: 0, transition: { delay: 0 } }}
>
<div className={styles.buttons}>
<Link
onClick={(e) => {
e.stopPropagation();
}}
style={{ display: "flex", flex: 1 }}
href={`${slug}/card`}
>
<Category size="32" color="currentColor" variant="Bold" />
Cards
</Link>
<Link
onClick={(e) => {
e.stopPropagation();
}}
style={{ display: "flex", flex: 1 }}
href={`${slug}/spelling`}
>
<Edit size="32" color="currentColor" variant="Bold" />
Spelling
</Link>
<Link
onClick={(e) => {
e.stopPropagation();
}}
style={{ display: "flex", flex: 1 }}
href={`${slug}/list`}
>
<NoteText size="32" color="currentColor" variant="Bold" />
List
</Link>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.button>
);
};
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<Props> = ({ type, onAnimationComplete }) => {
const outerBlobClasses = classNames(styles.outerBlob, {
[`${styles["outerBlob--red"]}`]: type === "wrong",
[`${styles["outerBlob--green"]}`]: type === "correct",
});
return (
<motion.div
tabIndex={0}
className={outerBlobClasses}
initial={{ clipPath: "circle(0% at center)" }}
animate={{ clipPath: "circle(100% at center)" }}
transition={{ duration: 0.75 }}
>
<motion.div
className={styles.innerBlob}
initial={{ clipPath: "circle(0% at center)" }}
animate={{ clipPath: "circle(100% at center)" }}
transition={{ delay: 0.25, duration: 0.5 }}
>
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ delay: 0.5 }}
onAnimationComplete={onAnimationComplete}
className={styles.iconWrapper}
>
<Image src={type === "correct" ? "/TickSquare.png" : "/CloseSquare.png"} width={60} height={60} alt={type} />
</motion.div>
</motion.div>
</motion.div>
);
};
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<HTMLInputElement>;
}
const Filter = ({ value, onChangeHandler }: Props) => {
const inputRef = useRef<null | HTMLInputElement>(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 (
<motion.div
initial={{ y: -50, opacity: 0 }}
animate={{ y: 0, opacity: 1, transition: { delay: 0.1 } }}
className={styles.field}
>
<label htmlFor="search">Filter topics</label>
<div className={styles.input__wrapper}>
<input
ref={inputRef}
id="search"
type="text"
placeholder="Search for title"
value={value}
onChange={onChangeHandler}
/>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: value.length === 0 ? 1 : 0 }}
className={styles.keyboard__indicator}
>
<div className={styles.key}>Ctrl</div>
<div className={styles.key}>K</div>
</motion.div>
</div>
</motion.div>
);
};
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<boolean | null>(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 (
<motion.div
key={`card_${data.id}`}
variants={card}
initial="out"
animate="in"
exit="outExit"
onClickCapture={() => setIsFlipped(true)}
className={styles.wrapper}
style={{
maxWidth: cardWidth,
}}
>
<AnimatePresence>
{isFlipped && answeredRight == null && (
<>
<motion.div
className={styles.questionPreview}
initial={{ top: 0, left: "50%", x: "-50%", y: 0, opacity: 0 }}
animate={{ y: "calc(-100% - 20px)", opacity: 1 }}
transition={{ type: "spring", stiffness: 100 }}
exit={{ y: 0, opacity: 0 }}
onClickCapture={() => setIsFlipped(false)}
>
<MessageQuestion color="currentColor" variant="Linear" />
{data.question}
</motion.div>
<FlipCardButton
isMobile={isMobile}
onClick={() => setAnsweredRight(false)}
icon={<CloseSquare color="currentColor" variant="Bold" />}
color="red"
position="left"
/>
<FlipCardButton
icon={<TickSquare color="currentColor" variant="Bold" />}
isMobile={isMobile}
onClick={() => setAnsweredRight(true)}
color="green"
position="right"
/>
</>
)}
</AnimatePresence>
<motion.div
variants={flip}
initial="unflipped"
animate={isFlipped ? "flipped" : "unflipped"}
className={styles.content}
>
<Front isMobile={isMobile} data={data.question} />
<Back
answeredRight={answeredRight}
dataClass={dataClass}
isMobile={isMobile}
forwardAnswer={forwardAnswer}
data={data.answer}
/>
</motion.div>
</motion.div>
);
};
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<HTMLButtonElement>;
icon: ReactNode;
position: "left" | "right";
color: "green" | "red";
}
const FlipCardButton: FC<Props> = ({ 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 (
<motion.button
initial={{ x: 0, right: position === "right" ? 0 : "unset", top: "50%", translateY: "-50%", opacity: 0 }}
animate={{ x: isMobile ? 0 : xOffset, y: isMobile ? 175 : 0, opacity: 1 }}
exit={{ x: 0, y: 0, opacity: 0 }}
style={{ "--clr-accent": accentColor } as MotionStyle}
transition={{ type: "spring" }}
whileHover={{ scale: 1.125, transition: { duration: 0.2 } }}
className={classes}
onClick={onClick}
>
{icon}
</motion.button>
);
};
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 (
<div className={styles.wrapper}>
<Watermark size="lg" text="question" />
<div className={textWrapperClasses}>
<p className={styles.text}>{data}</p>
</div>
</div>
);
};
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>[] | Card[];
const ListEntries = ({ data, filterString }: Props) => {
const [filteredData, setFilteredData] = useState<FilteredData>(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 (
<div className={styles.container}>
<LayoutGroup>
<AnimatePresence>
{filteredData.length > 0 ? (
filteredData.map((d, idx) => {
const isFuseResult = Boolean((d as Fuse.FuseResult<Card>).item);
const data = isFuseResult ? (d as Fuse.FuseResult<Card>).item : (d as Card);
return <Entry key={data.attributes.slug} data={data} animationDelay={0.05 * (idx + 1) + 0.2} />;
})
) : (
<motion.h1
key="nothing-found"
className={styles.noResults}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0, transition: { delay: 0.25 } }}
exit={{ opacity: 0 }}
>
<CloseSquare size="32" color="currentColor" variant="Bold" />
No Entries
</motion.h1>
)}
</AnimatePresence>
</LayoutGroup>
</div>
);
};
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<Props> = ({ currentAmount, maxAmount, streak }) => {
const percentageRef = useRef<HTMLParagraphElement>(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 (
<div className={styles.progress}>
<div className={styles.bar}>
<motion.div
className={styles.bar__fill}
animate={{ width: `${(currentAmount / maxAmount) * 100}%` }}
transition={{ ease: "easeInOut" }}
>
<p className={styles.percentage}>
<span ref={percentageRef}>0.00%</span>
</p>
</motion.div>
</div>
<Streak streak={streak} />
</div>
);
};
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<string, InputProps>;
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<HTMLInputElement>;
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<HTMLInputElement>();
useEffect(() => {
if (inputRef.current == null || passedInitialValue.current === true) return;
onChangeCallback(inputRef.current.value, inputRef.current.dataset.id!);
passedInitialValue.current = true;
}, [autoFocus, onChangeCallback]);
return (
<MaskedInput
mask={mask}
showMask={true}
className={classes}
guide={true}
autoFocus={autoFocus}
placeholderChar={maskPlaceholder}
style={{
width: `calc(${mask.length}ch + 1.8em + ${mask.filter((e) => !(e instanceof RegExp)).length} * 0.5ch)`,
}}
render={(textMaskRef, props) => (
<input
{...props}
ref={(node) => {
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<Props> = ({ 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<HTMLInputElement | null>(null);
const [shouldShowCorrectAnswer, setShouldShowCorrectAnswer] = useState(false);
const inputIdsInOrder = useRef<string[]>();
const [inputData, setInputData] = useState<InputData | null>(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<HTMLFormElement> = (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<HTMLInputElement> = (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 <p>Generating...</p>;
if (!inputData) return null;
return (
<motion.div
variants={card}
initial="out"
animate="in"
exit="outExit"
onAnimationComplete={() => {
if (!hasFinishedEntering) setHasFinishedEntering(true);
}}
className={styles.wrapper}
>
<motion.div className={classNames(styles.container, borderStyles)}>
<p className={styles.question}>{data.question}</p>
<Watermark size="md" text="spelling" />
<form onSubmit={checkAnswer}>
{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 <WordInput key={id} {...props} />;
})}
<input className="hidden" type="submit" />
</form>
<AnimatePresence>
{shouldShowCorrectAnswer && (
<motion.div
className={styles.answerPreview}
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
>
<Information size="1.25em" variant="Bold" color="currentColor" />
{answer}
</motion.div>
)}
</AnimatePresence>
{answered != null && shouldAnimateBlob && (
<ExpandingBlob
type={answered[0] === true ? "correct" : "wrong"}
onAnimationComplete={() => {
onAnswer(answered[0], answered[1], inputArray.join(" "), { ...data, answer });
}}
/>
)}
</motion.div>
<motion.button
initial={{ x: "-50%", y: -100, scale: 0 }}
animate={{
y: shouldAnimateBlob ? -100 : 0,
scale: shouldAnimateBlob ? 0 : 1,
transition: { delay: shouldAnimateBlob ? 0 : 0.5 },
}}
whileTap={{ scale: 0.95, transition: { delay: 0 } }}
onClick={checkAnswer as any}
className={styles.submit}
>
Submit
<ArrowRight size="24" color="currentColor" variant="Outline" />
</motion.button>
</motion.div>
);
};
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<Props> = ({ streak }) => {
const [shouldPulse, setShouldPulse] = useState(false);
useEffect(() => {
if (streak > ACTIVATE_AT) {
setShouldPulse(true);
}
}, [streak]);
return (
<AnimatePresence>
{streak >= ACTIVATE_AT && (
<motion.div
key="streak"
initial={{ scale: 0.85, opacity: 0, y: -10 }}
animate={{ scale: shouldPulse ? 1.125 : 1, opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
className={styles.wrapper}
>
<div className={styles.content}>
Streak x{streak} {getStreakEmojis(streak)}
</div>
{shouldPulse && (
<motion.div
key="pulse"
className={styles.pulse}
initial={{ scale: 1, opacity: 1, x: "-50%", y: "-50%" }}
animate={{ scale: 4, opacity: 0 }}
transition={{ duration: 0.4, ease: "linear" }}
onAnimationComplete={() => setShouldPulse(false)}
/>
)}
</motion.div>
)}
</AnimatePresence>
);
};
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 (
<div
className={styles.wrapper}
style={{
fontSize: getFontSize(size),
}}
data-text={text}
>
{text}
</div>
);
};
export default Watermark;
================================================
FILE: hooks/useIndexSelectedData.ts
================================================
import { useState } from "react";
const useIndexSelectedData = <T>(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 = <T>(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 <div>Not Found</div>;
};
export default Page404;
================================================
FILE: pages/_app.tsx
================================================
import "@styles/globals.scss";
import type { AppProps } from "next/app";
function MyApp({ Component, pageProps }: AppProps) {
return <Component {...pageProps} />;
}
export default MyApp;
================================================
FILE: pages/_document.tsx
================================================
import { Html, Head, Main, NextScript } from "next/document";
export default function Document() {
return (
<Html lang="en">
<Head />
<body>
<Main />
<NextScript />
</body>
</Html>
);
}
================================================
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<InferGetStaticPropsType<typeof getStaticProps>> = ({ data: { data, meta: _ } }) => {
const [inputValue, setInputValue] = useState("");
const [filterString, setFilterString] = useState<string | null>(null);
const handleInputChange: ChangeEventHandler<HTMLInputElement> = (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 (
<>
<Head>
<title>Flash Card App</title>
</Head>
<main className={styles.wrapper}>
<motion.h1 initial={{ y: -50, opacity: 0 }} animate={{ y: 0, opacity: 1 }} className={styles.heading}>
Select one of the topics below
</motion.h1>
<Filter value={inputValue} onChangeHandler={handleInputChange} />
<ListEntries filterString={filterString} data={data} />
</main>
</>
);
};
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<Question[]>([]);
const [correctAnswers, setCorrectAnswers] = useState<Question[]>([]);
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 (
<>
<Head>
<title>{title} | Card Mode | Flash Card App</title>
</Head>
<div className={styles.container}>
<AnimatePresence mode="wait">
{!isDone ? (
<motion.div key="cards" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}>
<ProgressBar currentAmount={selectedIndex} maxAmount={amountOfItems} streak={streak} />
<AnimatePresence>
<FlipCard
key={getKeyFromData(selectedItem)}
dataClass={dataClass}
onAnswer={onAnswer}
data={selectedItem}
/>
</AnimatePresence>
</motion.div>
) : (
<EndCard
key="endcard"
mode="cards"
data={{ correct: correctAnswers, incorrect: incorrectAnswers }}
dataClass={dataClass}
amount={rawData.length}
onRestart={handleRestart}
streak={maxStreak}
/>
)}
</AnimatePresence>
</div>
</>
);
}
================================================
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<HTMLHeadingElement | null>(null);
const [isSticky, setIsSticky] = useState(false);
const [dupsData, setDupsData] = useState<Question[][]>();
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 (
<>
<Head>
<title>{title} | List View | Flash Card App</title>
</Head>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className={styles.container}
style={{ margin: "2rem auto 0", ["--clr-accent" as any]: getAccentForClass(classString) }}
>
<div>
<Link className={styles.backButton} href="/">
<>
<Back variant="Outline" size="1.125rem" color="currentColor" />
Go Back
</>
</Link>
</div>
<h1 className={styles.title}>
List view for <br />
<span>
{title}
<motion.div
initial={{ scaleX: 0 }}
animate={{ scaleX: 1, transition: { delay: 0.3 } }}
className={styles.line}
/>
</span>
</h1>
</motion.div>
{dupsData && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1, transition: { delay: 0.3 } }}
className={classNames(styles.container, styles.dupWarning)}
>
<h1>
<Danger size="32" color="currentColor" variant="Bold" />
Duplicates found in this dataset
</h1>
<p>Please combine them into one for a better learning experience by using e.x. a comma</p>
<h3>List of duplicates</h3>
<ol>
{dupsData.map((dup) => {
return <li key={dup[0].question}>{dup[0].question}</li>;
})}
</ol>
</motion.div>
)}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1, transition: { delay: 0.3 } }}
ref={headerRef}
className={topContainerClasses}
style={{ ["--clr-accent" as any]: getAccentForClass(classString) }}
>
<div className={styles.container}>
<header className={styles.top}>
<p>Question</p>
<ArrowCircleRight2 size="32" color="currentColor" variant="Bold" />
<p>Answer</p>
</header>
</div>
</motion.div>
<div className={styles.container} style={{ ["--clr-accent" as any]: getAccentForClass(classString) }}>
<div className={styles.list}>
{questions.map((d, index) => {
let { answer, question } = d;
return (
<motion.div
initial={{ y: 20, opacity: 0 }}
animate={{ y: 0, opacity: 1, transition: { delay: 0.05 * index + 0.3 } }}
className={styles.list__item}
key={`${question}-${answer}`}
>
<div className={styles.question}>{question}</div>
<div className={styles.spacer}></div>
<ArrowCircleDown2 size="32" color="var(--clr-accent)" variant="Bold" />
<div className={styles.answer}>
<span>{answer}</span>
</div>
</motion.div>
);
})}
</div>
</div>
</>
);
}
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<Props> = ({ title, rawData, dataClass }) => {
const { data, isShuffled, reshuffle } = useShuffledData(rawData);
const { selectedItem, selectedIndex, nextItem, resetIndex, progress, amountOfItems } = useIndexSelectedData(data);
const [incorrectAnswers, setIncorrectAnswers] = useState<SpellingData[]>([]);
const [correctAnswers, setCorrectAnswers] = useState<SpellingData[]>([]);
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 (
<>
<Head>
<title>{title} | Spelling Mode | Flash Card App</title>
</Head>
<div className={styles.container}>
<AnimatePresence mode="wait">
{!progress.isDone ? (
<motion.div key="cards" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}>
<ProgressBar currentAmount={selectedIndex} maxAmount={amountOfItems} streak={streak} />
<AnimatePresence>
<SpellingByWord
data={selectedItem as Question}
onAnswer={onAnswer}
key={getKeyFromQuestion(selectedItem as Question)}
/>
</AnimatePresence>
</motion.div>
) : (
<EndCard
key="endcard"
mode="spelling"
dataClass={dataClass}
data={{ incorrect: incorrectAnswers, correct: correctAnswers }}
amount={rawData.length}
onRestart={onRestart}
streak={maxStreak}
/>
)}
</AnimatePresence>
</div>
</>
);
};
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 = <T, K extends keyof any>(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<K, T[]>);
export default groupBy;
================================================
FILE: utils/shuffle.ts
================================================
function shuffle<T>(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;