mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-05-28 18:26:54 +00:00
704 lines
16 KiB
Markdown
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();
|
|
});
|
|
```
|