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

16 KiB

id, title, challengeType, dashedName, demoType, saveSubmissionToDB
id title challengeType dashedName demoType saveSubmissionToDB
69b868127999e97f1903f8e1 Build a Flashcard Quiz App 25 lab-flashcard-quiz-app onClick 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.

const element = document.querySelector("#flashcard");
assert.exists(element);

You should have a FlashCard interface.

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.

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.

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[].

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.

const element = document.querySelector("#flashcard");
element.click();
assert.isTrue(element.classList.contains("flipped"));

You should have an element with an id of delete-btn.

const element = document.querySelector("#flashcard");
assert.exists(element); 

When the delete-btn is clicked, a flashcard element should be removed from the currentCards collection.

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.

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.

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.

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.

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




--solutions--

<!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>
: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;
  }
}
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();
});