Files
freeCodeCamp/curriculum/challenges/english/blocks/lab-flashcard-quiz-app/69b868127999e97f1903f8e1.md
T

704 lines
16 KiB
Markdown

---
id: 69b868127999e97f1903f8e1
title: Build a Flashcard Quiz App
challengeType: 25
dashedName: lab-flashcard-quiz-app
demoType: onClick
saveSubmissionToDB: true
---
# --description--
In this lab, you will create an app that displays and stores flashcard data that can
be retrieved later.
**Objective:** Fulfill the user stories below and get all the tests to pass to complete the lab.
**User Stories:**
1) You should have an HTML element with an id of `flashcard`.
2) You should have an interface called `FlashCard`.
3) The `FlashCard` interface should contain the following properties `questionText` and `questionAnswer` both of type `string`.
4) You should have a collection of `FlashCard` elements called `currentCards`.
5) When the `#flashcard` element is clicked, the card should have the `flipped` class.
6) You should have an element with an id of `delete-btn`.
7) When the `#delete-btn` element is clicked, it should remove current card and display the previous card data.
8) You should create an entry form with an id of `entry-form` to be able to add more flashcards to the `currentCards` collection on submit.
9) The two elements inside of the form should have an id of `front-text` and `back-text` respectively.
10) You should create and call an `InvalidUserInputError` when either the question text or question answer is empty in the entry form.
# --hints--
You should have an HTML element with an id of `flashcard`.
```js
const element = document.querySelector("#flashcard");
assert.exists(element);
```
You should have a `FlashCard` interface.
```js
const explorer = await __helpers.Explorer(code);
console.log(explorer.interfaces.FlashCard)
assert.exists(explorer.interfaces.FlashCard);
```
The `FlashCard` interface should have a `questionText` property of type `string`.
```js
const explorer = await __helpers.Explorer(code);
const { FlashCard } = explorer.interfaces;
assert.isTrue(FlashCard.hasTypeProps([{ name: "questionText", type: "string" }]));
```
The `FlashCard` interface should have a `questionAnswer` property of type `string`.
```js
const explorer = await __helpers.Explorer(code);
const { FlashCard } = explorer.interfaces;
assert.isTrue(FlashCard.hasTypeProps([{ name: "questionAnswer", type: "string" }]));
```
You should have a collection of `FlashCard` elements called `currentCards` with the type `FlashCard[]`.
```js
const explorer = await __helpers.Explorer(code);
assert.exists(explorer.variables.currentCards);
assert.isTrue(explorer.variables.currentCards.annotation.matches('FlashCard[]'));
```
When the `#flashcard` element is first clicked, the element should receive the `flipped` class.
```js
const element = document.querySelector("#flashcard");
element.click();
assert.isTrue(element.classList.contains("flipped"));
```
You should have an element with an id of `delete-btn`.
```js
const element = document.querySelector("#flashcard");
assert.exists(element);
```
When the `delete-btn` is clicked, a flashcard element should be removed from the `currentCards` collection.
```js
const entryForm = document.querySelector("#entry-form");
const frontText = document.querySelector("#front-text");
const backText = document.querySelector("#back-text");
const deleteBtn = document.querySelector("#delete-btn");
const flashCard = document.querySelector("#flashcard");
const submitBtn = entryForm.querySelector("button[type='submit']");
frontText.value = "Test question";
backText.value = "Test answer";
submitBtn.click();
frontText.value = "Test question 2";
backText.value = "Test answer 2";
submitBtn.click();
deleteBtn.click();
assert.notInclude(flashCard.textContent, "Test question 2");
```
When the `delete-btn` is clicked, the previous flashcard data should be displayed.
```js
const entryForm = document.querySelector("#entry-form");
const frontText = document.querySelector("#front-text");
const backText = document.querySelector("#back-text");
const deleteBtn = document.querySelector("#delete-btn");
const flashCard = document.querySelector("#flashcard");
const submitBtn = entryForm.querySelector("button[type='submit']");
frontText.value = "Test Front 1";
backText.value = "Test Back 1";
submitBtn.click();
frontText.value = "Test Front 2";
backText.value = "Test Back 2";
submitBtn.click();
deleteBtn.click();
assert.include(flashCard.textContent, "Test Front 1");
assert.notInclude(flashCard.textContent, "Test Front 2");
```
You should create an entry form with an id of `entry-form`.
```js
const element = document.querySelector("#entry-form");
assert.exists(element);
```
You should have two `textarea` elements of ids `front-text` and `back-text` respectively inside the form.
```js
const entryForm = document.querySelector("#entry-form");
const frontText = entryForm.querySelector("#front-text");
const backText = entryForm.querySelector("#back-text");
assert.exists(frontText);
assert.exists(backText);
assert.equal(frontText.tagName.toLowerCase(), "textarea");
assert.equal(backText.tagName.toLowerCase(), "textarea");
```
An `InvalidUserInputError` should be thrown when either the question text or question answer is empty in the entry form.
```js
const entryForm = document.querySelector("#entry-form");
const frontText = document.querySelector("#front-text");
const backText = document.querySelector("#back-text");
const submitBtn = entryForm.querySelector("button[type='submit']");
const lengthBefore = currentCards.length;
frontText.value = "";
backText.value = "Some answer";
submitBtn.click();
assert.strictEqual(currentCards.length, lengthBefore);
frontText.value = "Some question";
backText.value = "";
submitBtn.click();
assert.strictEqual(currentCards.length, lengthBefore);
```
# --seed--
## --seed-contents--
```html
```
```css
```
```ts
```
# --solutions--
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Flash Card Quiz App</title>
<link rel="stylesheet" href="styles.css" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;700&display=swap"
rel="stylesheet"
/>
</head>
<body>
<main class="app-container">
<div class="flashcard-panel">
<div id="flashcard-body">
<h1>Flash Card Quiz App</h1>
<div class="flashcard-container">
<div id="flashcard" class="flashcard">
<div class="card-inner" id="current-card">
<div class="card-front"></div>
<div class="card-back"></div>
</div>
</div>
</div>
</div>
</div>
<div class="controls-panel">
<h2>Manage Cards</h2>
<div class="card-actions">
<button id="delete-btn" class="delete-btn">Delete</button>
</div>
<hr />
<h3>All Cards</h3>
<div id="cards-list"></div>
<hr />
<h3>Add a New Card</h3>
<form id="entry-form" class="entry-form">
<label for="front-text">Front:</label>
<textarea id="front-text"></textarea>
<label for="back-text">Back:</label>
<textarea id="back-text"></textarea>
<p id="entry-error" class="error"></p>
<div class="button-group">
<button type="submit">Save new flash card</button>
</div>
</form>
</div>
</main>
<script src="index.ts"></script>
</body>
</html>
```
```css
:root {
--bg-color: #1a1d24;
--surface-color: #2c313a;
--primary-color: #00aaff;
--primary-hover-color: #0088cc;
--text-color: #e0e0e0;
--text-secondary-color: #a0a0a0;
--border-color: #444952;
--error-color: #ff4d4d;
--shadow-color: rgba(37, 33, 33, 0.2);
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
height: 100%;
}
body {
background-color: var(--bg-color);
color: var(--text-color);
font-family: 'Inter', system-ui, sans-serif;
place-items: center;
min-height: 100vh;
padding: 1rem;
}
h1 {
text-align: center;
font-weight: 700;
color: white;
margin-bottom: 2rem;
font-size: 2.5rem;
}
.app-container {
display: flex;
gap: 2rem;
width: 100%;
max-width: 1200px;
}
.flashcard-panel {
flex: 2;
}
.controls-panel {
flex: 1;
background-color: var(--surface-color);
border-radius: 20px;
padding: 2rem;
box-shadow: 0 10px 30px var(--shadow-color);
border: 1px solid var(--border-color);
}
hr {
border: none;
border-top: 1px solid var(--border-color);
margin: 2rem 0;
}
#flashcard-body {
background-color: var(--surface-color);
border-radius: 20px;
padding: 2rem 2.5rem;
height: 500px;
box-shadow: 0 10px 30px var(--shadow-color);
border: 1px solid var(--border-color);
display: flex;
flex-direction: column;
}
.flashcard-container {
display: flex;
flex-direction: column;
margin-bottom: 1.5rem;
perspective: 1000px;
}
.flashcard {
height: 250px;
background-color: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
text-align: center;
font-size: 2rem;
transition: transform 0.2s ease-in-out;
margin-bottom: 0;
position: relative;
transform-style: preserve-3d;
transition: transform 0.6s cubic-bezier(0.4, 0.2, 0.2, 1);
}
.flashcard.flipped {
transform: rotateY(180deg);
}
.card-inner {
position: relative;
width: 100%;
height: 100%;
transform-style: preserve-3d;
}
.card-front,
.card-back {
position: absolute;
width: 100%;
height: 100%;
backface-visibility: hidden;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
.card-front {
transform: rotateY(0deg);
}
.card-back {
transform: rotateY(180deg);
}
.flashcard:hover {
transform: translateY(-5px);
}
.button-group {
display: flex;
gap: 1rem;
justify-content: center;
flex-wrap: wrap;
margin-bottom: 1.5rem;
}
.card-actions {
display: flex;
gap: 1rem;
}
#cards-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
justify-content: center;
margin-top: 1rem;
}
.card {
color: var(--text-color);
}
.selected {
background: var(--primary-hover-color);
}
button {
background-color: var(--primary-color);
color: white;
border: none;
border-radius: 8px;
padding: 12px 24px;
font-family: 'Inter', sans-serif;
font-weight: 500;
font-size: 1rem;
cursor: pointer;
transition:
background-color 0.2s ease,
transform 0.1s ease;
width: 100%;
}
button:hover {
background-color: var(--primary-hover-color);
}
button:active {
transform: scale(0.98);
}
button.delete-bin {
background-color: #4a4e57;
}
button.delete-btn:hover {
background-color: #60656f;
}
.entry-form {
display: flex;
flex-direction: column;
gap: 1rem;
text-align: left;
margin-bottom: 1.5rem;
}
.entry-form label {
font-size: 0.9rem;
color: var(--text-secondary-color);
font-weight: 500;
}
.entry-form textarea {
width: 100%;
padding: 12px;
background-color: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-color);
font-family: 'Inter', sans-serif;
font-size: 1rem;
resize: vertical;
box-sizing: border-box;
min-height: 80px;
}
.entry-form textarea:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(0, 170, 255, 0.3);
}
.error {
color: var(--error-color);
text-align: center;
min-height: 1.2em;
}
@media (max-width: 992px) {
.app-container {
flex-direction: column;
align-items: center;
gap: 1.5rem;
}
.flashcard-panel,
.controls-panel {
width: 100%;
max-width: 700px;
}
#flashcard-body {
height: auto;
min-height: 400px;
}
}
@media (max-width: 480px) {
body {
padding: 0.5rem;
}
.controls-panel,
#flashcard-body {
padding: 1.5rem;
}
.flashcard {
height: 200px;
font-size: 1.5rem;
}
button {
padding: 14px 20px;
}
}
```
```ts
const cardDisplay = document.querySelector<HTMLElement>('#current-card')!;
const cardButtonsContainer =
document.querySelector<HTMLElement>('#cards-list')!;
const frontInput = document.querySelector<HTMLTextAreaElement>('#front-text')!;
const backInput = document.querySelector<HTMLTextAreaElement>('#back-text')!;
const errorElement =
document.querySelector<HTMLParagraphElement>('#entry-error')!;
let currentCardIndex = -1;
let currentCards: FlashCard[] = [];
interface FlashCard {
questionText: string;
questionAnswer: string;
}
class InvalidUserInputError extends Error {
constructor(message: string) {
super(message);
this.name = 'InvalidUserInputError';
}
}
const isButtonElement = (element: unknown): element is HTMLButtonElement => {
return element instanceof HTMLButtonElement;
};
function refresh(): void {
if (currentCards.length === 0 || currentCardIndex < 0) {
cardDisplay.querySelector('.card-front')!.textContent = '';
cardDisplay.querySelector('.card-back')!.textContent = '';
return;
}
const card = currentCards[currentCardIndex];
cardDisplay.querySelector('.card-front')!.textContent =
card.questionText;
cardDisplay.querySelector('.card-back')!.textContent =
card.questionAnswer;
Array.from(cardButtonsContainer.children).forEach((child, i) => {
if (i === currentCardIndex) {
child.classList.add('selected');
} else {
child.classList.remove('selected');
}
});
}
function deleteCard(): void {
if (currentCardIndex < 0 || currentCards.length === 0) return;
currentCards.splice(currentCardIndex, 1);
const btnToRemove = cardButtonsContainer.children[currentCardIndex];
if (btnToRemove) {
cardButtonsContainer.removeChild(btnToRemove);
}
if (currentCards.length === 0) {
currentCardIndex = -1;
refresh();
return;
}
currentCardIndex = Math.max(0, currentCardIndex - 1);
Array.from(cardButtonsContainer.children).forEach((child, i) => {
if (!isButtonElement(child)) {
console.warn(`Element {${child}} is not a button.`);
return;
}
(child as HTMLButtonElement).onclick = () => {
currentCardIndex = i;
refresh();
};
});
refresh();
}
function createCardButton(
questionText: string,
index: number
): HTMLButtonElement {
const btn = document.createElement('button');
btn.innerText =
questionText.length > 20 ? questionText.slice(0, 20) + '...' : questionText;
(btn as HTMLButtonElement).onclick = () => {
currentCardIndex = index;
refresh();
};
return btn;
}
function uploadNewCard(): void {
try {
const questionText = frontInput.value.trim();
const questionAnswer = backInput.value.trim();
if (!questionText)
throw new InvalidUserInputError('Front text cannot be empty.');
if (!questionAnswer)
throw new InvalidUserInputError('Back text cannot be empty.');
const newCard: FlashCard = { questionText, questionAnswer };
currentCards.push(newCard);
const newIndex = currentCards.length - 1;
const cardBtn = createCardButton(questionText, newIndex);
cardButtonsContainer.appendChild(cardBtn);
currentCardIndex = newIndex;
refresh();
frontInput.value = '';
backInput.value = '';
} catch (ex) {
if (ex instanceof InvalidUserInputError) {
errorElement.innerHTML = '\u26A0 ' + ex.message;
} else {
console.error('An unexpected error occurred:', ex);
}
}
}
class FlashCardController {
private elements: {
flashcard: HTMLElement;
entryForm: HTMLFormElement;
deleteBtn: HTMLButtonElement;
} = {} as {
flashcard: HTMLElement;
entryForm: HTMLFormElement;
deleteBtn: HTMLButtonElement;
};
constructor() {
this.elements = {
flashcard: document.querySelector<HTMLElement>('.flashcard')!,
entryForm: document.querySelector<HTMLFormElement>('.entry-form')!,
deleteBtn: document.querySelector<HTMLButtonElement>('#delete-btn')!
};
this.initializeEventListeners();
}
private initializeEventListeners(): void {
this.elements.flashcard.addEventListener('click', () => this.flipCard());
this.elements.entryForm.addEventListener('submit', (ev: SubmitEvent) => {
ev.preventDefault();
uploadNewCard();
});
this.elements.deleteBtn.addEventListener('click', () => deleteCard());
}
private flipCard(): void {
this.elements.flashcard.classList.toggle('flipped');
}
}
document.addEventListener('DOMContentLoaded', (event: Event) => {
new FlashCardController();
frontInput.value = 'What is the capital of France?';
backInput.value = 'Paris';
uploadNewCard();
});
```