3942 lines
101 KiB
Plaintext
3942 lines
101 KiB
Plaintext
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'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;
|
|
|
|
|