Directory structure:
└── snacksncode-snc-cards/
    ├── README.md
    ├── additional.d.ts
    ├── next-env.d.ts
    ├── next.config.js
    ├── package.json
    ├── tsconfig.json
    ├── .eslintrc.json
    ├── components/
    │   ├── Back/
    │   │   ├── Back.module.scss
    │   │   ├── Back.tsx
    │   │   └── index.tsx
    │   ├── EndCard/
    │   │   ├── EndCard.module.scss
    │   │   ├── EndCard.tsx
    │   │   └── index.tsx
    │   ├── EndCardReview/
    │   │   ├── EndCardReview.module.scss
    │   │   ├── EndCardReview.tsx
    │   │   └── index.tsx
    │   ├── Entry/
    │   │   ├── Entry.module.scss
    │   │   ├── Entry.tsx
    │   │   └── index.tsx
    │   ├── ExpandingBlob/
    │   │   ├── ExpandingBlob.module.scss
    │   │   ├── ExpandingBlob.tsx
    │   │   └── index.tsx
    │   ├── Filter/
    │   │   ├── Filter.module.scss
    │   │   ├── Filter.tsx
    │   │   └── index.tsx
    │   ├── FlipCard/
    │   │   ├── FlipCard.module.scss
    │   │   ├── FlipCard.tsx
    │   │   └── index.tsx
    │   ├── FlipCardButton/
    │   │   ├── FlipCardButton.module.scss
    │   │   ├── FlipCardButton.tsx
    │   │   └── index.tsx
    │   ├── Front/
    │   │   ├── Front.module.scss
    │   │   ├── Front.tsx
    │   │   └── index.tsx
    │   ├── ListEntries/
    │   │   ├── index.tsx
    │   │   ├── ListEntries.module.scss
    │   │   └── ListEntries.tsx
    │   ├── ProgressBar/
    │   │   ├── index.tsx
    │   │   ├── ProgressBar.module.scss
    │   │   └── ProgressBar.tsx
    │   ├── SpellingByWord/
    │   │   ├── index.tsx
    │   │   ├── SpellingByWord.module.scss
    │   │   └── SpellingByWord.tsx
    │   ├── Streak/
    │   │   ├── index.tsx
    │   │   ├── Streak.module.scss
    │   │   └── Streak.tsx
    │   └── Watermark/
    │       ├── index.tsx
    │       ├── Watermark.module.scss
    │       └── Watermark.tsx
    ├── hooks/
    │   ├── useIndexSelectedData.ts
    │   ├── useShuffledData.ts
    │   ├── useStreak.ts
    │   └── useWindowSize.ts
    ├── pages/
    │   ├── 404.tsx
    │   ├── _app.tsx
    │   ├── _document.tsx
    │   ├── index.tsx
    │   └── [slug]/
    │       ├── index.tsx
    │       ├── card/
    │       │   └── index.tsx
    │       ├── list/
    │       │   └── index.tsx
    │       └── spelling/
    │           └── index.tsx
    ├── styles/
    │   ├── _variables.scss
    │   ├── Card.module.scss
    │   ├── globals.scss
    │   ├── Home.module.scss
    │   ├── List.module.scss
    │   └── Spelling.module.scss
    └── utils/
        ├── getAccentForClass.ts
        ├── getHumanReadableClass.ts
        ├── getStreakEmojis.ts
        ├── groupBy.ts
        └── shuffle.ts

================================================
FILE: README.md
================================================
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).

## Getting Started

First, run the development server:

```bash
npm run dev
# or
yarn dev
```

Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.

You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file.

[API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`.

The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages.

## Learn More

To learn more about Next.js, take a look at the following resources:

- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.

You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!

## Deploy on Vercel

The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.

Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.



================================================
FILE: additional.d.ts
================================================
declare module "shoetest";
interface CardFields {
  id: number;
  title: string;
  slug: string;
  class: ClassString;
  questions: Question[];

  createdAt: string;
  publishedAt: string;
  updatedAt: string;
}

type Card = { id: number; attributes: CardFields };

type ApiResponse = {
  data: Card[];
  meta: { pagination: { page: number; pageSize: number; pageCount: number; total: number } };
};

type ClassString = "en" | "de" | "geo";

interface Question {
  id: number;
  question: string;
  answer: string;
}

interface SpellingData {
  input: string;
  expected: string;
  data: Question;
}

interface SpellingReviewData {
  incorrect: SpellingData[];
  correct: SpellingData[];
}

interface CardsReviewData {
  incorrect: Question[];
  correct: Question[];
}



================================================
FILE: next-env.d.ts
================================================
/// <reference types="next" />
/// <reference types="next/image-types/global" />

// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.



================================================
FILE: next.config.js
================================================
/** @type {import('next').NextConfig} */
module.exports = {
  swcMinify: true,
  reactStrictMode: true,
  async headers() {
    return [
      {
        source: "/:path*",
        headers: [
          {
            key: "Cross-Origin-Opener-Policy",
            value: "same-origin",
          },
          {
            key: "Cross-Origin-Embedder-Policy",
            value: "require-corp",
          },
        ],
      },
    ];
  },
};



================================================
FILE: package.json
================================================
{
  "name": "snc-cards",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint"
  },
  "dependencies": {
    "classnames": "2.3.2",
    "framer-motion": "7.6.6",
    "fuse.js": "6.6.2",
    "iconsax-react": "0.0.8",
    "next": "13.0.3",
    "react": "18.2.0",
    "react-dom": "18.2.0",
    "react-text-mask": "5.5.0",
    "shoetest": "1.2.1",
    "usehooks-ts": "^2.9.1"
  },
  "devDependencies": {
    "@types/node": "18.11.9",
    "@types/react": "18.0.25",
    "@types/react-dom": "18.0.9",
    "@types/react-text-mask": "5.4.11",
    "eslint": "8.27.0",
    "eslint-config-next": "13.0.3",
    "sass": "1.56.1",
    "typescript": "4.8.4"
  }
}



================================================
FILE: tsconfig.json
================================================
{
  "compilerOptions": {
    "target": "es5",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve",
    "baseUrl": ".",
    "paths": {
      "@styles/*": ["./styles/*"],
      "@components/*": ["./components/*"],
      "@utils/*": ["./utils/*"],
      "@hooks/*": ["./hooks/*"]
    },
    "incremental": true
  },
  "include": ["*.d.ts", "**/*.ts", "**/*.tsx"],
  "exclude": ["node_modules"]
}



================================================
FILE: .eslintrc.json
================================================
{
  "extends": "next/core-web-vitals",
  "rules": {
    "no-console": "warn"
  }
}



================================================
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;


