Skip to main content

🚀 Lesson 21: Building a Complete Project

You've learned every major skill in front-end JavaScript — DOM manipulation, events, forms, timers, Local Storage, and the Fetch API. Now it's time to put them all together. In this lesson, you'll build QuickNote, a fully functional note-taking app from scratch.

🎯 What You'll Build

QuickNote is a single-page note-taking app with these features:

  • Create, edit, and delete notes with a validated form
  • Color-coded categories (Work, Personal, Ideas)
  • Search and filter notes in real time (debounced)
  • Persistent storage — notes survive page refreshes
  • Fetch a random inspirational quote from an API
  • Auto-save indicator with timer feedback
  • Smooth CSS transitions for adding and removing notes
  • Responsive layout and keyboard shortcuts

Estimated Time: 90 minutes

Skills Used: Every lesson from Modules 1–5

📑 Build Steps

Planning & Architecture

Before writing a single line of code, let's plan. Professional developers spend time designing before building — it saves hours of rework later.

Feature Requirements

Feature Skills Used Lessons
Add/edit/delete notesDOM creation, events, forms14–17
Form validationpreventDefault, validation patterns17
Persistent storagelocalStorage, JSON19
Search & filterArray methods, debounce, input events11, 15, 18
Random quoteFetch API, async/await, error handling20
Auto-save indicatorsetTimeout, CSS transitions18
AnimationsclassList, CSS transitions via JS14, 18
Keyboard shortcutsKeyboard events, event object15

Data Model

Each note is a simple object. We'll store an array of these in Local Storage.


// A single note
{
    id: 1713300000000,          // Date.now() as unique ID
    title: "Learn JavaScript",
    body: "Finish the front-end JS course...",
    category: "personal",       // "work", "personal", or "ideas"
    createdAt: "2026-04-16T10:00:00.000Z",
    updatedAt: "2026-04-16T10:30:00.000Z"
}
                

Architecture Overview

graph TD A["User Actions
(click, type, submit)"] --> B["Event Handlers"] B --> C["Update State
(notes array)"] C --> D["Save to
localStorage"] C --> E["Re-render
DOM"] F["Page Load"] --> G["Load from
localStorage"] G --> C H["Fetch API"] --> I["Display Quote"] style A fill:#eff6ff,stroke:#3b82f6,stroke-width:2px,color:#1e293b style B fill:#fef3c7,stroke:#f59e0b,stroke-width:2px,color:#1e293b style C fill:#f3e8ff,stroke:#a855f7,stroke-width:2px,color:#1e293b style D fill:#ecfdf5,stroke:#22c55e,stroke-width:2px,color:#1e293b style E fill:#ecfdf5,stroke:#22c55e,stroke-width:2px,color:#1e293b style F fill:#eff6ff,stroke:#3b82f6,stroke-width:2px,color:#1e293b style G fill:#fef3c7,stroke:#f59e0b,stroke-width:2px,color:#1e293b style H fill:#eff6ff,stroke:#3b82f6,stroke-width:2px,color:#1e293b style I fill:#ecfdf5,stroke:#22c55e,stroke-width:2px,color:#1e293b

The pattern is straightforward: user action → update state → save → re-render. This is a simplified version of the same pattern used in frameworks like React and Vue.

Step 1: HTML Structure

We'll build the entire app in a single HTML file. Start with the shell — the layout, the form, the note container, and the search bar.


<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>QuickNote</title>
    <!-- Styles go here (see complete code at the end) -->
</head>
<body>
    <div class="app">
        <!-- Header with title and quote -->
        <header class="app-header">
            <h1>📝 QuickNote</h1>
            <p class="quote" id="quote">Loading inspiration...</p>
        </header>

        <!-- Toolbar: search + filter + add button -->
        <div class="toolbar">
            <input type="text" id="search-input"
                   placeholder="Search notes...">
            <select id="filter-category">
                <option value="all">All Categories</option>
                <option value="work">Work</option>
                <option value="personal">Personal</option>
                <option value="ideas">Ideas</option>
            </select>
            <button id="new-note-btn">+ New Note</button>
        </div>

        <!-- Note form (hidden by default, toggled on) -->
        <div class="note-form-container" id="note-form-container">
            <form id="note-form" novalidate>
                <input type="hidden" id="note-id">
                <div class="form-row">
                    <input type="text" id="note-title" name="title"
                           placeholder="Note title" required>
                    <select id="note-category" name="category">
                        <option value="personal">Personal</option>
                        <option value="work">Work</option>
                        <option value="ideas">Ideas</option>
                    </select>
                </div>
                <textarea id="note-body" name="body" rows="4"
                          placeholder="Write your note..."
                          required></textarea>
                <div class="form-actions">
                    <button type="submit" id="save-btn">Save Note</button>
                    <button type="button" id="cancel-btn">Cancel</button>
                    <span class="save-status" id="save-status"></span>
                </div>
            </form>
        </div>

        <!-- Notes grid -->
        <div class="notes-grid" id="notes-grid">
            <!-- Notes are rendered here by JavaScript -->
        </div>

        <!-- Footer with count and shortcuts hint -->
        <footer class="app-footer">
            <span id="note-count">0 notes</span>
            <span class="shortcut-hint">
                <kbd>Ctrl</kbd>+<kbd>N</kbd> New note
            </span>
        </footer>
    </div>

    <script>
    // JavaScript goes here — we'll build it step by step
    </script>
</body>
</html>
                

💡 Why a Hidden Form?

The note form starts hidden and slides open when the user clicks "New Note." This keeps the interface clean and focused. The same form handles both creating and editing — when editing, we pre-fill the fields and change the button text. This is a common pattern in single-page apps.

Step 2: Data Layer (State & Storage)

The data layer manages our notes array, loads from Local Storage, and saves whenever something changes. Every other part of the app reads from and writes to this layer.


// ============================================================
// DATA LAYER — state management + localStorage
// ============================================================

const STORAGE_KEY = "quicknote_notes";

// Load notes from storage (or return empty array)
function loadNotes() {
    try {
        const stored = localStorage.getItem(STORAGE_KEY);
        return stored ? JSON.parse(stored) : [];
    } catch (error) {
        console.warn("Error loading notes:", error);
        return [];
    }
}

// Save notes to storage
function saveNotes(notes) {
    try {
        localStorage.setItem(STORAGE_KEY, JSON.stringify(notes));
    } catch (error) {
        console.error("Error saving notes:", error);
    }
}

// App state — the single source of truth
let notes = loadNotes();

// CRUD operations
function createNote(title, body, category) {
    const now = new Date().toISOString();
    const note = {
        id: Date.now(),
        title: title.trim(),
        body: body.trim(),
        category,
        createdAt: now,
        updatedAt: now
    };
    notes.unshift(note);  // Add to the beginning (newest first)
    saveNotes(notes);
    return note;
}

function updateNote(id, title, body, category) {
    const note = notes.find(n => n.id === id);
    if (!note) return null;

    note.title = title.trim();
    note.body = body.trim();
    note.category = category;
    note.updatedAt = new Date().toISOString();

    saveNotes(notes);
    return note;
}

function deleteNote(id) {
    notes = notes.filter(n => n.id !== id);
    saveNotes(notes);
}

function getNotes(searchTerm = "", category = "all") {
    return notes.filter(note => {
        const matchesCategory =
            category === "all" || note.category === category;
        const matchesSearch =
            !searchTerm ||
            note.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
            note.body.toLowerCase().includes(searchTerm.toLowerCase());
        return matchesCategory && matchesSearch;
    });
}
                

Notice how clean this is. The CRUD functions don't know anything about the DOM — they just manage data. This separation of concerns makes the code easier to understand, test, and modify.

Step 3: Rendering Notes

The render function reads the current state and builds the DOM. It's called whenever the data changes — after adding, editing, deleting, or filtering notes.


// ============================================================
// RENDERING — building the DOM from state
// ============================================================

const notesGrid = document.querySelector("#notes-grid");
const noteCount = document.querySelector("#note-count");

const categoryColors = {
    work: "#3b82f6",
    personal: "#22c55e",
    ideas: "#f59e0b"
};

const categoryEmojis = {
    work: "💼",
    personal: "🏠",
    ideas: "💡"
};

function renderNotes(searchTerm = "", category = "all") {
    const filtered = getNotes(searchTerm, category);

    // Clear the grid
    notesGrid.innerHTML = "";

    // Empty state
    if (filtered.length === 0) {
        notesGrid.innerHTML = `
            <div class="empty-state">
                <p>${notes.length === 0
                    ? "No notes yet. Click + New Note to get started!"
                    : "No notes match your search."
                }</p>
            </div>
        `;
        updateCount(filtered.length);
        return;
    }

    // Build note cards using a DocumentFragment
    const fragment = document.createDocumentFragment();

    filtered.forEach(note => {
        const card = document.createElement("article");
        card.classList.add("note-card");
        card.dataset.id = note.id;
        card.style.borderLeftColor = categoryColors[note.category];

        // Category badge
        const badge = document.createElement("span");
        badge.classList.add("note-badge");
        badge.textContent =
            `${categoryEmojis[note.category]} ${note.category}`;

        // Title
        const title = document.createElement("h3");
        title.classList.add("note-title");
        title.textContent = note.title;

        // Body preview (first 120 chars)
        const body = document.createElement("p");
        body.classList.add("note-body");
        body.textContent = note.body.length > 120
            ? note.body.substring(0, 120) + "..."
            : note.body;

        // Timestamp
        const time = document.createElement("time");
        time.classList.add("note-time");
        time.textContent = formatDate(note.updatedAt);

        // Action buttons
        const actions = document.createElement("div");
        actions.classList.add("note-actions");
        actions.innerHTML = `
            <button class="edit-btn" title="Edit note">✏️</button>
            <button class="delete-btn" title="Delete note">🗑️</button>
        `;

        card.append(badge, title, body, time, actions);
        fragment.append(card);
    });

    notesGrid.append(fragment);
    updateCount(filtered.length);
}

function updateCount(count) {
    noteCount.textContent =
        `${count} note${count !== 1 ? "s" : ""}`;
}

function formatDate(isoString) {
    const date = new Date(isoString);
    const now = new Date();
    const diffMs = now - date;
    const diffMins = Math.floor(diffMs / 60000);
    const diffHours = Math.floor(diffMs / 3600000);
    const diffDays = Math.floor(diffMs / 86400000);

    if (diffMins < 1) return "Just now";
    if (diffMins < 60) return `${diffMins}m ago`;
    if (diffHours < 24) return `${diffHours}h ago`;
    if (diffDays < 7) return `${diffDays}d ago`;

    return date.toLocaleDateString("en-US", {
        month: "short", day: "numeric"
    });
}
                

💡 The Render Pattern

This "clear and re-render" approach is simple and reliable. For a note-taking app with dozens (not thousands) of notes, it's perfectly efficient. Frameworks like React optimize this with a virtual DOM, but for vanilla JS apps of this scale, rebuilding the DOM works great — especially when using a DocumentFragment to batch the insertions.

Step 4: Form Handling & Validation

The form handles both creating and editing notes. When editing, we pre-fill the fields and store the note's ID in a hidden input.


// ============================================================
// FORM HANDLING — create & edit notes
// ============================================================

const noteForm = document.querySelector("#note-form");
const formContainer = document.querySelector("#note-form-container");
const noteIdInput = document.querySelector("#note-id");
const titleInput = document.querySelector("#note-title");
const bodyInput = document.querySelector("#note-body");
const categorySelect = document.querySelector("#note-category");
const saveBtn = document.querySelector("#save-btn");
const cancelBtn = document.querySelector("#cancel-btn");
const newNoteBtn = document.querySelector("#new-note-btn");
const saveStatus = document.querySelector("#save-status");

// Show/hide the form
function showForm(editNote = null) {
    formContainer.classList.add("visible");

    if (editNote) {
        // Editing an existing note
        noteIdInput.value = editNote.id;
        titleInput.value = editNote.title;
        bodyInput.value = editNote.body;
        categorySelect.value = editNote.category;
        saveBtn.textContent = "Update Note";
    } else {
        // Creating a new note
        noteForm.reset();
        noteIdInput.value = "";
        saveBtn.textContent = "Save Note";
    }

    titleInput.focus();
}

function hideForm() {
    formContainer.classList.remove("visible");
    noteForm.reset();
    noteIdInput.value = "";
    clearFormErrors();
}

// Validation
function validateForm() {
    let isValid = true;
    clearFormErrors();

    if (!titleInput.value.trim()) {
        showFieldError(titleInput, "Title is required");
        isValid = false;
    } else if (titleInput.value.trim().length > 100) {
        showFieldError(titleInput, "Title must be under 100 characters");
        isValid = false;
    }

    if (!bodyInput.value.trim()) {
        showFieldError(bodyInput, "Note content is required");
        isValid = false;
    }

    return isValid;
}

function showFieldError(input, message) {
    input.classList.add("input-error");
    let error = input.parentElement.querySelector(".field-error");
    if (!error) {
        error = document.createElement("span");
        error.classList.add("field-error");
        error.setAttribute("role", "alert");
        input.after(error);
    }
    error.textContent = message;
}

function clearFormErrors() {
    noteForm.querySelectorAll(".field-error").forEach(el => el.remove());
    noteForm.querySelectorAll(".input-error")
        .forEach(el => el.classList.remove("input-error"));
}

// Handle form submission
noteForm.addEventListener("submit", (event) => {
    event.preventDefault();

    if (!validateForm()) {
        const firstError = noteForm.querySelector(".input-error");
        if (firstError) firstError.focus();
        return;
    }

    const title = titleInput.value;
    const body = bodyInput.value;
    const category = categorySelect.value;
    const editId = noteIdInput.value;

    if (editId) {
        updateNote(Number(editId), title, body, category);
        showSaveStatus("Note updated!");
    } else {
        createNote(title, body, category);
        showSaveStatus("Note saved!");
    }

    hideForm();
    renderNotes(searchInput.value, filterCategory.value);
});

// Save status flash
function showSaveStatus(message) {
    saveStatus.textContent = message;
    saveStatus.classList.add("visible");
    setTimeout(() => {
        saveStatus.classList.remove("visible");
    }, 2000);
}

// Buttons
newNoteBtn.addEventListener("click", () => showForm());
cancelBtn.addEventListener("click", hideForm);
                

The key insight here: one form, two modes. The hidden note-id input tells us whether we're creating (empty) or editing (has an ID). This avoids duplicating form HTML.

Step 5: Search & Filter

Search uses debounce from Lesson 18 — we wait until the user pauses before filtering. The category dropdown filters immediately on change.


// ============================================================
// SEARCH & FILTER — debounced search + category filter
// ============================================================

const searchInput = document.querySelector("#search-input");
const filterCategory = document.querySelector("#filter-category");

// Debounce helper
function debounce(fn, delay) {
    let timerId;
    return (...args) => {
        clearTimeout(timerId);
        timerId = setTimeout(() => fn(...args), delay);
    };
}

// Re-render whenever search or filter changes
function applyFilters() {
    renderNotes(searchInput.value, filterCategory.value);
}

// Debounced search (300ms after user stops typing)
searchInput.addEventListener("input", debounce(applyFilters, 300));

// Immediate filter on category change
filterCategory.addEventListener("change", applyFilters);
                

That's it — eight lines of logic (plus the debounce helper). Because our renderNotes function already accepts search and filter parameters, wiring up the UI is trivial. This is the payoff of clean architecture.

Step 6: Fetching an Inspirational Quote

We'll fetch a random quote from a free API to display in the header. This adds a nice touch and practices our Fetch skills.


// ============================================================
// API — fetch a random quote
// ============================================================

const quoteEl = document.querySelector("#quote");

async function fetchQuote() {
    try {
        const response = await fetch(
            "https://dummyjson.com/quotes/random"
        );

        if (!response.ok) {
            throw new Error(`HTTP ${response.status}`);
        }

        const data = await response.json();
        quoteEl.textContent = `"${data.quote}" — ${data.author}`;

    } catch (error) {
        // Graceful fallback — the app works fine without a quote
        quoteEl.textContent =
            "\"The best way to predict the future is to create it.\"";
        console.warn("Could not fetch quote:", error.message);
    }
}

fetchQuote();
                

💡 Graceful Degradation

The quote is a nice-to-have, not essential. If the API is down or the user is offline, we show a hardcoded fallback quote and log a warning — the app continues to work perfectly. This is graceful degradation: design for the ideal case but handle failures without breaking.

Step 7: Polish & Enhancements

The core app works. Now let's add the finishing touches that make it feel professional.

Event Delegation for Note Actions


// ============================================================
// NOTE ACTIONS — edit & delete via event delegation
// ============================================================

notesGrid.addEventListener("click", (event) => {
    const card = event.target.closest(".note-card");
    if (!card) return;

    const noteId = Number(card.dataset.id);

    // Edit button
    if (event.target.closest(".edit-btn")) {
        const note = notes.find(n => n.id === noteId);
        if (note) showForm(note);
        return;
    }

    // Delete button with confirmation
    if (event.target.closest(".delete-btn")) {
        if (confirm("Delete this note?")) {
            card.classList.add("removing");
            // Wait for CSS animation before removing from data
            setTimeout(() => {
                deleteNote(noteId);
                renderNotes(searchInput.value, filterCategory.value);
            }, 300);
        }
    }
});
                

Keyboard Shortcuts


// ============================================================
// KEYBOARD SHORTCUTS
// ============================================================

document.addEventListener("keydown", (event) => {
    // Ctrl+N or Cmd+N — new note
    if ((event.ctrlKey || event.metaKey) && event.key === "n") {
        event.preventDefault();
        showForm();
    }

    // Escape — close form
    if (event.key === "Escape") {
        hideForm();
    }

    // Ctrl+/ or Cmd+/ — focus search
    if ((event.ctrlKey || event.metaKey) && event.key === "/") {
        event.preventDefault();
        searchInput.focus();
    }
});
                

CSS Transitions for Polish


<style>
    /* Form slide-down */
    .note-form-container {
        max-height: 0;
        overflow: hidden;
        transition: max-height 0.4s ease, padding 0.4s ease;
    }
    .note-form-container.visible {
        max-height: 500px;
        padding: 1.5rem;
    }

    /* Note card entrance */
    .note-card {
        animation: fadeSlideIn 0.3s ease;
    }
    @keyframes fadeSlideIn {
        from { opacity: 0; transform: translateY(10px); }
        to { opacity: 1; transform: translateY(0); }
    }

    /* Note card removal */
    .note-card.removing {
        opacity: 0;
        transform: scale(0.95);
        transition: opacity 0.3s, transform 0.3s;
    }

    /* Save status flash */
    .save-status {
        opacity: 0;
        transition: opacity 0.3s;
        color: #22c55e;
        font-size: 0.9rem;
    }
    .save-status.visible { opacity: 1; }
</style>
                

Initialization


// ============================================================
// INITIALIZATION — start the app
// ============================================================

renderNotes();
fetchQuote();
                
graph TD A["Page Loads"] --> B["loadNotes()
from localStorage"] A --> C["fetchQuote()
from API"] B --> D["renderNotes()
build DOM"] C --> E["Display quote
or fallback"] D --> F["App Ready!"] E --> F F --> G["Wait for
user actions"] G -->|"Add/Edit"| H["showForm()"] G -->|"Delete"| I["deleteNote()"] G -->|"Search"| J["debounce → render"] G -->|"Filter"| K["applyFilters()"] H -->|"Submit"| L["create/updateNote()
→ save → render"] I --> M["save → render"] style A fill:#eff6ff,stroke:#3b82f6,stroke-width:2px,color:#1e293b style F fill:#ecfdf5,stroke:#22c55e,stroke-width:2px,color:#1e293b style G fill:#fef3c7,stroke:#f59e0b,stroke-width:2px,color:#1e293b

Complete Source Code

Here's the entire app in one file. Save it as quicknote.html and open in a browser to try it out.

📄 Click to expand the full source code

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>QuickNote</title>
    <style>
        * { box-sizing: border-box; margin: 0; }
        body { font-family: system-ui, sans-serif; background: #f1f5f9; color: #1e293b; min-height: 100vh; }

        .app { max-width: 800px; margin: 0 auto; padding: 1rem; }

        /* Header */
        .app-header { text-align: center; padding: 2rem 1rem 1rem; }
        .app-header h1 { font-size: 1.8rem; margin-bottom: 0.5rem; }
        .quote { font-style: italic; color: #64748b; font-size: 0.9rem; max-width: 500px; margin: 0 auto; line-height: 1.5; }

        /* Toolbar */
        .toolbar { display: flex; gap: 0.5rem; padding: 1rem 0; flex-wrap: wrap; }
        .toolbar input, .toolbar select {
            padding: 0.5rem 0.75rem; border: 1px solid #cbd5e1;
            border-radius: 6px; font-size: 0.9rem; background: white;
        }
        .toolbar input { flex: 1; min-width: 150px; }
        .toolbar input:focus, .toolbar select:focus { outline: none; border-color: #6366f1; }
        #new-note-btn {
            padding: 0.5rem 1.2rem; background: #6366f1; color: white;
            border: none; border-radius: 6px; cursor: pointer; font-size: 0.9rem; white-space: nowrap;
        }
        #new-note-btn:hover { background: #4f46e5; }

        /* Form */
        .note-form-container { max-height: 0; overflow: hidden; transition: max-height 0.4s ease; background: white; border-radius: 10px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); }
        .note-form-container.visible { max-height: 500px; padding: 1.5rem; }
        .form-row { display: flex; gap: 0.5rem; margin-bottom: 0.75rem; }
        .form-row input { flex: 1; }
        #note-form input, #note-form select, #note-form textarea {
            width: 100%; padding: 0.5rem 0.75rem; border: 2px solid #e2e8f0;
            border-radius: 6px; font-size: 0.95rem; font-family: inherit;
        }
        #note-form textarea { resize: vertical; margin-bottom: 0.75rem; min-height: 80px; }
        #note-form input:focus, #note-form select:focus, #note-form textarea:focus { outline: none; border-color: #6366f1; }
        .input-error { border-color: #ef4444 !important; background: #fef2f2; }
        .field-error { color: #ef4444; font-size: 0.8rem; display: block; margin-top: 0.2rem; }
        .form-actions { display: flex; gap: 0.5rem; align-items: center; }
        .form-actions button { padding: 0.5rem 1.2rem; border-radius: 6px; font-size: 0.9rem; cursor: pointer; }
        #save-btn { background: #6366f1; color: white; border: none; }
        #save-btn:hover { background: #4f46e5; }
        #cancel-btn { background: white; color: #64748b; border: 1px solid #cbd5e1; }
        #cancel-btn:hover { border-color: #94a3b8; }
        .save-status { opacity: 0; transition: opacity 0.3s; color: #22c55e; font-size: 0.9rem; margin-left: auto; }
        .save-status.visible { opacity: 1; }

        /* Notes Grid */
        .notes-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 1rem; padding: 0.5rem 0 2rem; }
        .note-card {
            background: white; border-radius: 10px; padding: 1.25rem;
            border-left: 4px solid #6366f1;
            box-shadow: 0 1px 4px rgba(0,0,0,0.06);
            display: flex; flex-direction: column; gap: 0.5rem;
            animation: fadeSlideIn 0.3s ease; position: relative;
            transition: transform 0.2s, box-shadow 0.2s, opacity 0.3s;
        }
        .note-card:hover { transform: translateY(-2px); box-shadow: 0 4px 16px rgba(0,0,0,0.1); }
        .note-card.removing { opacity: 0; transform: scale(0.95); }
        @keyframes fadeSlideIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }

        .note-badge { font-size: 0.75rem; text-transform: capitalize; color: #64748b; }
        .note-title { font-size: 1.05rem; color: #1e293b; }
        .note-body { font-size: 0.85rem; color: #64748b; line-height: 1.5; flex: 1; }
        .note-time { font-size: 0.75rem; color: #94a3b8; }
        .note-actions { display: flex; gap: 0.25rem; }
        .note-actions button { background: none; border: none; cursor: pointer; font-size: 1rem; padding: 0.2rem 0.4rem; border-radius: 4px; opacity: 0.5; transition: opacity 0.2s; }
        .note-actions button:hover { opacity: 1; background: #f1f5f9; }

        /* Empty State */
        .empty-state { grid-column: 1 / -1; text-align: center; padding: 3rem 1rem; color: #94a3b8; }

        /* Footer */
        .app-footer { display: flex; justify-content: space-between; align-items: center; padding: 0.5rem 0; font-size: 0.8rem; color: #94a3b8; border-top: 1px solid #e2e8f0; }
        .shortcut-hint { display: flex; gap: 0.25rem; align-items: center; }
        kbd { background: #e2e8f0; padding: 0.1rem 0.4rem; border-radius: 3px; font-size: 0.75rem; font-family: inherit; }

        @media (max-width: 500px) {
            .toolbar { flex-direction: column; }
            .form-row { flex-direction: column; }
            .notes-grid { grid-template-columns: 1fr; }
            .shortcut-hint { display: none; }
        }
    </style>
</head>
<body>
    <div class="app">
        <header class="app-header">
            <h1>📝 QuickNote</h1>
            <p class="quote" id="quote">Loading inspiration...</p>
        </header>

        <div class="toolbar">
            <input type="text" id="search-input" placeholder="Search notes...">
            <select id="filter-category">
                <option value="all">All Categories</option>
                <option value="work">Work</option>
                <option value="personal">Personal</option>
                <option value="ideas">Ideas</option>
            </select>
            <button id="new-note-btn">+ New Note</button>
        </div>

        <div class="note-form-container" id="note-form-container">
            <form id="note-form" novalidate>
                <input type="hidden" id="note-id">
                <div class="form-row">
                    <input type="text" id="note-title" name="title" placeholder="Note title">
                    <select id="note-category" name="category">
                        <option value="personal">Personal</option>
                        <option value="work">Work</option>
                        <option value="ideas">Ideas</option>
                    </select>
                </div>
                <textarea id="note-body" name="body" rows="4" placeholder="Write your note..."></textarea>
                <div class="form-actions">
                    <button type="submit" id="save-btn">Save Note</button>
                    <button type="button" id="cancel-btn">Cancel</button>
                    <span class="save-status" id="save-status"></span>
                </div>
            </form>
        </div>

        <div class="notes-grid" id="notes-grid"></div>

        <footer class="app-footer">
            <span id="note-count">0 notes</span>
            <span class="shortcut-hint"><kbd>Ctrl</kbd>+<kbd>N</kbd> New note</span>
        </footer>
    </div>

    <script>
    // === DATA LAYER ===
    const STORAGE_KEY = "quicknote_notes";

    function loadNotes() {
        try {
            const stored = localStorage.getItem(STORAGE_KEY);
            return stored ? JSON.parse(stored) : [];
        } catch (e) { return []; }
    }

    function saveNotes(data) {
        try { localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); }
        catch (e) { console.error("Save failed:", e); }
    }

    let notes = loadNotes();

    function createNote(title, body, category) {
        const now = new Date().toISOString();
        notes.unshift({ id: Date.now(), title: title.trim(), body: body.trim(), category, createdAt: now, updatedAt: now });
        saveNotes(notes);
    }

    function updateNote(id, title, body, category) {
        const note = notes.find(n => n.id === id);
        if (!note) return;
        Object.assign(note, { title: title.trim(), body: body.trim(), category, updatedAt: new Date().toISOString() });
        saveNotes(notes);
    }

    function deleteNote(id) {
        notes = notes.filter(n => n.id !== id);
        saveNotes(notes);
    }

    function getNotes(search = "", category = "all") {
        return notes.filter(n => {
            const catOk = category === "all" || n.category === category;
            const searchOk = !search || n.title.toLowerCase().includes(search.toLowerCase()) || n.body.toLowerCase().includes(search.toLowerCase());
            return catOk && searchOk;
        });
    }

    // === DOM REFERENCES ===
    const notesGrid = document.querySelector("#notes-grid");
    const noteCount = document.querySelector("#note-count");
    const quoteEl = document.querySelector("#quote");
    const noteForm = document.querySelector("#note-form");
    const formContainer = document.querySelector("#note-form-container");
    const noteIdInput = document.querySelector("#note-id");
    const titleInput = document.querySelector("#note-title");
    const bodyInput = document.querySelector("#note-body");
    const categorySelect = document.querySelector("#note-category");
    const saveBtn = document.querySelector("#save-btn");
    const cancelBtn = document.querySelector("#cancel-btn");
    const newNoteBtn = document.querySelector("#new-note-btn");
    const saveStatus = document.querySelector("#save-status");
    const searchInput = document.querySelector("#search-input");
    const filterCategory = document.querySelector("#filter-category");

    const categoryColors = { work: "#3b82f6", personal: "#22c55e", ideas: "#f59e0b" };
    const categoryEmojis = { work: "💼", personal: "🏠", ideas: "💡" };

    // === RENDERING ===
    function formatDate(iso) {
        const diff = Date.now() - new Date(iso).getTime();
        const mins = Math.floor(diff / 60000);
        if (mins < 1) return "Just now";
        if (mins < 60) return mins + "m ago";
        const hrs = Math.floor(diff / 3600000);
        if (hrs < 24) return hrs + "h ago";
        const days = Math.floor(diff / 86400000);
        if (days < 7) return days + "d ago";
        return new Date(iso).toLocaleDateString("en-US", { month: "short", day: "numeric" });
    }

    function renderNotes(search = "", category = "all") {
        const filtered = getNotes(search, category);
        notesGrid.innerHTML = "";
        if (filtered.length === 0) {
            notesGrid.innerHTML = '<div class="empty-state"><p>' +
                (notes.length === 0 ? 'No notes yet. Click <strong>+ New Note</strong> to get started!' : 'No notes match your search.') +
                '</p></div>';
            noteCount.textContent = "0 notes";
            return;
        }
        const frag = document.createDocumentFragment();
        filtered.forEach(note => {
            const card = document.createElement("article");
            card.classList.add("note-card");
            card.dataset.id = note.id;
            card.style.borderLeftColor = categoryColors[note.category] || "#6366f1";

            const badge = document.createElement("span");
            badge.classList.add("note-badge");
            badge.textContent = (categoryEmojis[note.category] || "") + " " + note.category;

            const title = document.createElement("h3");
            title.classList.add("note-title");
            title.textContent = note.title;

            const body = document.createElement("p");
            body.classList.add("note-body");
            body.textContent = note.body.length > 120 ? note.body.substring(0, 120) + "..." : note.body;

            const time = document.createElement("time");
            time.classList.add("note-time");
            time.textContent = formatDate(note.updatedAt);

            const actions = document.createElement("div");
            actions.classList.add("note-actions");
            const editBtn = document.createElement("button");
            editBtn.classList.add("edit-btn");
            editBtn.title = "Edit note";
            editBtn.textContent = "✏️";
            const delBtn = document.createElement("button");
            delBtn.classList.add("delete-btn");
            delBtn.title = "Delete note";
            delBtn.textContent = "🗑️";
            actions.append(editBtn, delBtn);

            card.append(badge, title, body, time, actions);
            frag.append(card);
        });
        notesGrid.append(frag);
        noteCount.textContent = filtered.length + " note" + (filtered.length !== 1 ? "s" : "");
    }

    // === FORM ===
    function showForm(editNote = null) {
        formContainer.classList.add("visible");
        if (editNote) {
            noteIdInput.value = editNote.id;
            titleInput.value = editNote.title;
            bodyInput.value = editNote.body;
            categorySelect.value = editNote.category;
            saveBtn.textContent = "Update Note";
        } else {
            noteForm.reset();
            noteIdInput.value = "";
            saveBtn.textContent = "Save Note";
        }
        titleInput.focus();
    }

    function hideForm() {
        formContainer.classList.remove("visible");
        noteForm.reset();
        noteIdInput.value = "";
        clearFormErrors();
    }

    function showFieldError(input, msg) {
        input.classList.add("input-error");
        let el = input.parentElement.querySelector(".field-error");
        if (!el) { el = document.createElement("span"); el.classList.add("field-error"); el.setAttribute("role", "alert"); input.after(el); }
        el.textContent = msg;
    }

    function clearFormErrors() {
        noteForm.querySelectorAll(".field-error").forEach(e => e.remove());
        noteForm.querySelectorAll(".input-error").forEach(e => e.classList.remove("input-error"));
    }

    function validateForm() {
        let ok = true;
        clearFormErrors();
        if (!titleInput.value.trim()) { showFieldError(titleInput, "Title is required"); ok = false; }
        else if (titleInput.value.trim().length > 100) { showFieldError(titleInput, "Title must be under 100 characters"); ok = false; }
        if (!bodyInput.value.trim()) { showFieldError(bodyInput, "Note content is required"); ok = false; }
        return ok;
    }

    function showSaveStatus(msg) {
        saveStatus.textContent = msg;
        saveStatus.classList.add("visible");
        setTimeout(() => saveStatus.classList.remove("visible"), 2000);
    }

    noteForm.addEventListener("submit", (e) => {
        e.preventDefault();
        if (!validateForm()) { const f = noteForm.querySelector(".input-error"); if (f) f.focus(); return; }
        const editId = noteIdInput.value;
        if (editId) { updateNote(Number(editId), titleInput.value, bodyInput.value, categorySelect.value); showSaveStatus("Note updated!"); }
        else { createNote(titleInput.value, bodyInput.value, categorySelect.value); showSaveStatus("Note saved!"); }
        hideForm();
        renderNotes(searchInput.value, filterCategory.value);
    });

    newNoteBtn.addEventListener("click", () => showForm());
    cancelBtn.addEventListener("click", hideForm);

    // === SEARCH & FILTER ===
    function debounce(fn, delay) { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), delay); }; }
    function applyFilters() { renderNotes(searchInput.value, filterCategory.value); }
    searchInput.addEventListener("input", debounce(applyFilters, 300));
    filterCategory.addEventListener("change", applyFilters);

    // === NOTE ACTIONS (delegation) ===
    notesGrid.addEventListener("click", (e) => {
        const card = e.target.closest(".note-card");
        if (!card) return;
        const id = Number(card.dataset.id);
        if (e.target.closest(".edit-btn")) { const n = notes.find(x => x.id === id); if (n) showForm(n); }
        else if (e.target.closest(".delete-btn")) {
            if (confirm("Delete this note?")) {
                card.classList.add("removing");
                setTimeout(() => { deleteNote(id); renderNotes(searchInput.value, filterCategory.value); }, 300);
            }
        }
    });

    // === KEYBOARD SHORTCUTS ===
    document.addEventListener("keydown", (e) => {
        if ((e.ctrlKey || e.metaKey) && e.key === "n") { e.preventDefault(); showForm(); }
        if (e.key === "Escape") hideForm();
        if ((e.ctrlKey || e.metaKey) && e.key === "/") { e.preventDefault(); searchInput.focus(); }
    });

    // === API QUOTE ===
    async function fetchQuote() {
        try {
            const res = await fetch("https://dummyjson.com/quotes/random");
            if (!res.ok) throw new Error("HTTP " + res.status);
            const data = await res.json();
            quoteEl.textContent = '"' + data.quote + '" — ' + data.author;
        } catch (err) {
            quoteEl.textContent = '"The best way to predict the future is to create it."';
        }
    }

    // === INIT ===
    renderNotes();
    fetchQuote();
    </script>
</body>
</html>
                    

Summary & Challenges

🎉 Skills You Used

Look at everything that went into this one project:

  • Variables & Data Types (L3) — note objects, state management
  • Functions (L7) — modular CRUD operations, helpers, render function
  • Arrays & Array Methods (L9, L11) — filter, find, forEach, unshift
  • Objects (L10) — note data structure, Object.assign
  • Strings & Template Literals (L12) — string methods, includes(), toLowerCase()
  • DOM Manipulation (L13–L16) — createElement, querySelector, classList, DocumentFragment
  • Events & Delegation (L15) — click, submit, keydown, event delegation on the notes grid
  • Forms & Validation (L17) — preventDefault, validation, error messages, role="alert"
  • Timers (L18) — debounce for search, setTimeout for save status and delete animation
  • CSS Transitions via JS (L18) — form slide, card entrance, removal animation
  • Local Storage (L19) — JSON.stringify/parse, persistent notes, safe loading
  • Fetch API & async/await (L20) — quote API, error handling, graceful fallback

🏋️ Stretch Challenges

The base app is complete, but there's always room to grow. Try these challenges to push your skills further:

  • Dark Mode: Add a theme toggle that persists to localStorage. Change the CSS custom properties for colors.
  • Export/Import: Add buttons to download notes as a JSON file and import from a file using the File API (FileReader).
  • Drag & Drop: Let users reorder notes by dragging them. Look into the HTML Drag and Drop API.
  • Markdown Preview: Parse note bodies as Markdown and render HTML. Try a library like marked.
  • Multiple Tabs: Use the storage event to sync notes across browser tabs in real time.
  • Word Count: Show a live word/character count in the form using the input event.
  • Confirmation Modal: Replace the browser's confirm() dialog with a custom modal for delete confirmations.
  • Pin Notes: Add a "pin" feature that keeps certain notes at the top of the grid.

📚 Additional Resources

🚀 What's Next?

You've built a complete, functional web application with vanilla JavaScript. In the final lesson, we'll step back and look at the bigger picture — debugging strategies, code organization patterns, and where to go next as you continue your journey as a developer.