Build a Book Library PWA with Flask

Build a mobile-friendly book library web app with Flask, SQLite, and PWA support.

Introduction

30 Second Summary

Your book collection lives in a spreadsheet or CSV file somewhere on your computer. It works, but searching for a specific title means scrolling through hundreds of rows. You definitely cannot pull it up on your phone while browsing a bookstore.

In this project, you will build a web app that turns your book CSV into a searchable, mobile-friendly library you can access from any device on your home network.

What You'll Build

A locally-hosted Progressive Web App with a Flask backend, SQLite database, and responsive frontend for browsing and managing your personal book library from any device on your network.

By the end of this project, you'll have:

  • A Flask application serving a REST API backed by a SQLite database imported from your tab-separated CSV file.
  • A responsive mobile-friendly frontend built with vanilla HTML, CSS, and JavaScript that lets you search, add, and manage books.
  • A PWA-ready app with a manifest and service worker that you can install on your desktop browser and access from your phone on the same WiFi network.
  • Secret Mission: Add a reading statistics dashboard with visual progress indicators and a "What to Read Next" random recommender.

Are there any prerequisites?

Basic Python familiarity (variables, functions, loops) is all you need. No web development experience is required. The project walks you through every line of HTML, CSS, and JavaScript.

Set Up the Project

You have a CSV file full of books, but opening it in a spreadsheet every time you want to check your collection is clunky. A web app gives you instant search, mobile access, and the ability to update your library from any device on your network.

In this step, you will create the project folder, install Flask (a lightweight Python web framework), and write a minimal app to confirm everything is wired up.

In this step, get ready to:
  • Create the project directory and install Flask.
  • Set up the project file structure.
  • Write and run a minimal Flask app.
Create the project and install Flask

Before writing any code, you need a dedicated folder for your project and an isolated Python environment so your dependencies stay separate from your system packages.

  • In VS Code, open the integrated terminal by pressing Ctrl+` (the backtick key above Tab).
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir book-library
cd book-library
  • Confirm you are inside the folder by running:
ls

You should see no output because the folder is empty.

A virtual environment keeps your project's Python packages isolated from your system. This prevents version conflicts between projects.

  • Create and activate a virtual environment by running these commands:
python -m venv .venv
. .venv/bin/activate

What do these commands do?

  • python -m venv .venv creates a new virtual environment in a hidden folder called .venv.
  • . .venv/bin/activate activates the environment so any packages you install stay inside this project.

Your terminal prompt should now show (.venv) at the beginning, confirming the environment is active.

  • Install Flask by running:
pip install Flask==3.1.3
  • Verify the installation by running:
python -c "import flask; print(flask.__version__)"

You should see 3.1.3 printed in your terminal.

Seeing an error when importing Flask?

Make sure your virtual environment is active. You should see (.venv) in your terminal prompt. If not, run . .venv/bin/activate again and retry the install command.

Help me troubleshoot my Flask installation.

Create the project file structure

Flask expects a specific folder layout. It looks for HTML templates in a templates/ folder and static assets (CSS, JavaScript, images) in a static/ folder.

  • Create the file structure by running these commands:
touch app.py
mkdir static
mkdir templates
  • Confirm the files and folders were created by running:
ls

You should see app.py, static, and templates in the output.

Why this folder layout?

Flask automatically serves files from static/ at the /static URL path. It also looks for HTML files in templates/ when you call render_template(). Following this convention means less configuration.

Write and run the minimal Flask app

With the project structure in place, you can write a starter Flask app that responds to browser requests. This confirms Flask is installed correctly and your project is ready for real features.

  • Open app.py in the VS Code editor by clicking on it in the Explorer sidebar.
  • Paste the following code into app.py:
import csv
import sqlite3
import os
import random
import click
from flask import Flask, render_template, request, jsonify

app = Flask(__name__)
DB_FILE = "book_library.db"

@app.route("/")
def index():
    return "Book Library"

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5000, debug=True)

What does this code do?

  • The import lines at the top bring in libraries you will use as the project grows. They are included now so you do not have to add them one by one later.
  • app = Flask(__name__) creates your Flask application instance.
  • DB_FILE = "book_library.db" defines the filename for the SQLite database you will create in the next step.
  • @app.route("/") registers the index() function to handle requests to the homepage URL.
  • The if __name__ == "__main__" block lets you run the app directly with python app.py and makes it accessible on your local network (you will use this in Step 4).
  • Save app.py by pressing Ctrl+S.
  • Start the Flask development server by running this command in your terminal:
flask --app app run --debug

What does this command do?

  • flask is the CLI tool that comes bundled with Flask.
  • --app app tells Flask to look for your application in app.py.
  • run starts the development web server.
  • --debug enables auto-reload so the server restarts whenever you save a file.
  • Open your browser and navigate to http://127.0.0.1:5000.

You should see the text "Book Library" displayed on the page.

Don't see Book Library in the browser?

Check your terminal for error messages. A common cause is a typo in app.py (especially the @app.route decorator). Make sure the parentheses and quotes match exactly.

If the terminal says the address is already in use, another process is using port 5000. Close other terminal tabs or restart VS Code.

Help me troubleshoot my Flask dev server.

✔️ Awesome, I've got everything!

Great. Make sure you have saved app.py and leave the Flask server running in your terminal.

ⓧ I'd like to double check the full code

Here is the complete app.py file at this point:

import csv
import sqlite3
import os
import random
import click
from flask import Flask, render_template, request, jsonify

app = Flask(__name__)
DB_FILE = "book_library.db"

@app.route("/")
def index():
    return "Book Library"

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5000, debug=True)

Your development environment is ready and Flask is serving your first page. Next up, you will build the database layer and import your book collection from CSV.

Build the Database and CSV Import

Your Flask app is alive and responding at http://127.0.0.1:5000. Now it needs data to work with.

Your book collection lives in a tab-separated CSV file with 30 columns of information per book. In this step, you will import that CSV into a SQLite database and give Flask a command to load your books on demand.

In this step, get ready to:
  • Write the database connection and table creation functions.
  • Build a Flask CLI command that imports your tab-separated CSV into SQLite.
  • Verify the import by checking the book count in your browser.
Add the column definitions

Your CSV has 30 columns of book data. You need two lists: one defining the database column names, and one matching the original CSV header names.

  • In app.py, find the line that says DB_FILE = "book_library.db". Copy the following code below it:
# Database column names (group renamed to group_name to avoid SQL reserved word)
DB_COLUMNS = [
    "item_type", "title", "creators", "first_name", "last_name",
    "collection", "ean_isbn13", "upc_isbn10", "description", "publisher",
    "publish_date", "group_name", "tags", "notes", "price",
    "length", "number_of_discs", "number_of_players", "age_group", "ensemble",
    "aspect_ratio", "esrb", "rating", "review", "review_date",
    "status", "began", "completed", "added", "copies"
]

# Original CSV header names (uses "group" as-is from the file)
CSV_HEADERS = [
    "item_type", "title", "creators", "first_name", "last_name",
    "collection", "ean_isbn13", "upc_isbn10", "description", "publisher",
    "publish_date", "group", "tags", "notes", "price",
    "length", "number_of_discs", "number_of_players", "age_group", "ensemble",
    "aspect_ratio", "esrb", "rating", "review", "review_date",
    "status", "began", "completed", "added", "copies"
]

Why two separate lists?

The CSV file uses group as a column header. But GROUP is a reserved keyword in SQL, so using it as a column name would cause errors.

DB_COLUMNS renames it to group_name for the database. CSV_HEADERS keeps the original name so Python can find the right column when reading the file.

  • Save app.py.

You will not see a change in the browser yet. The next chunk uses these lists to create the database table.

Add the database functions

Now you need two functions: one to open a connection to your SQLite database, and one to create the books table with all 30 columns plus an auto-incrementing ID.

  • In app.py, add the following code directly below the CSV_HEADERS list you just added:
def get_connection():
    # Connect to the SQLite database file
    conn = sqlite3.connect(DB_FILE)
    # Allow accessing columns by name (e.g., row["title"])
    conn.row_factory = sqlite3.Row
    return conn


def create_database():
    conn = get_connection()
    # Create the books table if it doesn't already exist
    conn.execute("""
        CREATE TABLE IF NOT EXISTS books (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            item_type TEXT,
            title TEXT NOT NULL,
            creators TEXT,
            first_name TEXT,
            last_name TEXT,
            collection TEXT,
            ean_isbn13 TEXT,
            upc_isbn10 TEXT,
            description TEXT,
            publisher TEXT,
            publish_date TEXT,
            group_name TEXT,
            tags TEXT,
            notes TEXT,
            price TEXT,
            length TEXT,
            number_of_discs TEXT,
            number_of_players TEXT,
            age_group TEXT,
            ensemble TEXT,
            aspect_ratio TEXT,
            esrb TEXT,
            rating TEXT,
            review TEXT,
            review_date TEXT,
            status TEXT,
            began TEXT,
            completed TEXT,
            added TEXT,
            copies TEXT
        )
    """)
    conn.commit()
    conn.close()


# Run at app startup to ensure the table exists
create_database()

What does this code do?

get_connection() opens a connection to book_library.db. Setting row_factory = sqlite3.Row lets you access columns by name instead of by number.

create_database() creates the books table if it does not already exist. Every column is stored as TEXT because your CSV contains mixed data formats.

The bare create_database() call at the bottom runs automatically when Flask loads the app, ensuring the table is ready before any request arrives.

  • Save app.py.
  • Check your terminal where Flask is running. You should see the server reload without errors.

Seeing a syntax error on reload?

Make sure the triple-quoted SQL string has matching """ at both the start and end. Also check that create_database() is at the module level (not indented inside a function).

help me fix a syntax error in my create_database function in app.py

Add the CSV import command

Flask supports custom CLI commands through the Click library (which is bundled with Flask). You will create a command that reads your tab-separated CSV and inserts each row into the database.

  • In app.py, add the following code below the create_database() call (and above the @app.route("/") line):
@app.cli.command("import-csv")
@click.argument("csv_path")
def import_csv_command(csv_path):
    """Import books from a tab-separated CSV file."""
    if not os.path.exists(csv_path):
        click.echo(f"Error: File '{csv_path}' not found.")
        return

    conn = get_connection()
    cur = conn.cursor()

    # Check if books already exist and offer to clear them
    cur.execute("SELECT COUNT(*) FROM books")
    existing = cur.fetchone()[0]
    if existing > 0:
        if not click.confirm(f"Database has {existing} books. Clear and reimport?"):
            click.echo("Import cancelled.")
            conn.close()
            return
        cur.execute("DELETE FROM books")

    # Read each row from the tab-separated CSV and insert into the database
    count = 0
    with open(csv_path, newline="", encoding="utf-8") as f:
        reader = csv.DictReader(f, delimiter="\t")
        for row in reader:
            values = []
            for csv_col in CSV_HEADERS:
                values.append(row.get(csv_col, ""))
            placeholders = ", ".join(["?"] * len(DB_COLUMNS))
            col_names = ", ".join(DB_COLUMNS)
            cur.execute(
                f"INSERT INTO books ({col_names}) VALUES ({placeholders})",
                values
            )
            count += 1

    conn.commit()
    conn.close()
    click.echo(f"Imported {count} books.")

What does this command do?

@app.cli.command("import-csv") registers a new Flask CLI command called import-csv. You can run it from the terminal just like flask run.

The function opens your CSV file using csv.DictReader with delimiter="\t" because your file is tab-separated. For each row, it maps the CSV column values to the database columns and inserts them with parameterized queries (the ? placeholders) to prevent SQL injection.

If the database already has books, the command asks before clearing them. This prevents accidental data loss on re-runs.

  • Save app.py.

You will not see a change in the browser yet. The next chunk adds a verification route and runs the import.

Indentation errors after pasting?

Make sure the entire import_csv_command function is indented consistently with spaces (not a mix of tabs and spaces). The @app.cli.command decorator should be at the same level as @app.route (no indentation).

help me fix an indentation error in my import_csv_command function

Run the import and verify the book count

Before running the import, you will add a temporary route that returns the total number of books in your database. This gives you a quick way to confirm the import worked.

  • In app.py, find the @app.route("/") block. Add the following code below it (after the return "Book Library" line):
@app.route("/count")
def count():
    # Temporary route to verify the import worked
    conn = get_connection()
    cur = conn.cursor()
    cur.execute("SELECT COUNT(*) FROM books")
    n = cur.fetchone()[0]
    conn.close()
    return str(n)

What does this route do?

This is a quick diagnostic route. It queries the total row count from the books table and returns it as plain text. You will use it to confirm your CSV import loaded the correct number of books.

  • Save app.py.
  • Visit http://127.0.0.1:5000/count in your browser. You should see 0 because no books have been imported yet.

✔️ Awesome, I've got everything!

Great. Double check you have saved app.py before continuing.

ⓧ I'd like to double check the full code

import csv
import sqlite3
import os
import random
import click
from flask import Flask, render_template, request, jsonify

app = Flask(__name__)
DB_FILE = "book_library.db"

DB_COLUMNS = [
    "item_type", "title", "creators", "first_name", "last_name",
    "collection", "ean_isbn13", "upc_isbn10", "description", "publisher",
    "publish_date", "group_name", "tags", "notes", "price",
    "length", "number_of_discs", "number_of_players", "age_group", "ensemble",
    "aspect_ratio", "esrb", "rating", "review", "review_date",
    "status", "began", "completed", "added", "copies"
]

CSV_HEADERS = [
    "item_type", "title", "creators", "first_name", "last_name",
    "collection", "ean_isbn13", "upc_isbn10", "description", "publisher",
    "publish_date", "group", "tags", "notes", "price",
    "length", "number_of_discs", "number_of_players", "age_group", "ensemble",
    "aspect_ratio", "esrb", "rating", "review", "review_date",
    "status", "began", "completed", "added", "copies"
]


def get_connection():
    conn = sqlite3.connect(DB_FILE)
    conn.row_factory = sqlite3.Row
    return conn


def create_database():
    conn = get_connection()
    conn.execute("""
        CREATE TABLE IF NOT EXISTS books (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            item_type TEXT,
            title TEXT NOT NULL,
            creators TEXT,
            first_name TEXT,
            last_name TEXT,
            collection TEXT,
            ean_isbn13 TEXT,
            upc_isbn10 TEXT,
            description TEXT,
            publisher TEXT,
            publish_date TEXT,
            group_name TEXT,
            tags TEXT,
            notes TEXT,
            price TEXT,
            length TEXT,
            number_of_discs TEXT,
            number_of_players TEXT,
            age_group TEXT,
            ensemble TEXT,
            aspect_ratio TEXT,
            esrb TEXT,
            rating TEXT,
            review TEXT,
            review_date TEXT,
            status TEXT,
            began TEXT,
            completed TEXT,
            added TEXT,
            copies TEXT
        )
    """)
    conn.commit()
    conn.close()


create_database()


@app.cli.command("import-csv")
@click.argument("csv_path")
def import_csv_command(csv_path):
    """Import books from a tab-separated CSV file."""
    if not os.path.exists(csv_path):
        click.echo(f"Error: File '{csv_path}' not found.")
        return

    conn = get_connection()
    cur = conn.cursor()

    cur.execute("SELECT COUNT(*) FROM books")
    existing = cur.fetchone()[0]
    if existing > 0:
        if not click.confirm(f"Database has {existing} books. Clear and reimport?"):
            click.echo("Import cancelled.")
            conn.close()
            return
        cur.execute("DELETE FROM books")

    count = 0
    with open(csv_path, newline="", encoding="utf-8") as f:
        reader = csv.DictReader(f, delimiter="\t")
        for row in reader:
            values = []
            for csv_col in CSV_HEADERS:
                values.append(row.get(csv_col, ""))
            placeholders = ", ".join(["?"] * len(DB_COLUMNS))
            col_names = ", ".join(DB_COLUMNS)
            cur.execute(
                f"INSERT INTO books ({col_names}) VALUES ({placeholders})",
                values
            )
            count += 1

    conn.commit()
    conn.close()
    click.echo(f"Imported {count} books.")


@app.route("/")
def index():
    return "Book Library"


@app.route("/count")
def count():
    conn = get_connection()
    cur = conn.cursor()
    cur.execute("SELECT COUNT(*) FROM books")
    n = cur.fetchone()[0]
    conn.close()
    return str(n)


if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5000, debug=True)

Now it is time to import your books. You will need to open a second terminal because Flask is still running in the first one.

  • Open a new terminal window. Press Ctrl+Shift+` in VS Code to open a second terminal panel.
  • Activate your virtual environment in the new terminal by running:
. .venv/bin/activate
  • Copy your CSV file into your project folder (or note the full path to it).
  • Run the import command by running:
flask --app app import-csv [[CSV_PATH="path/to/your_books.csv"]]

What should I see?

The terminal should print Imported N books. where N is the number of rows in your CSV file. If your CSV has a header row and 150 data rows, you will see Imported 150 books..

  • Visit http://127.0.0.1:5000/count in your browser again. The number should now match what the import command printed.

Seeing 'Error: File not found'?

Make sure your terminal is in the same directory as app.py. If your CSV file is somewhere else, provide the full path (e.g., ~/Downloads/your_books.csv).

Also confirm your file is actually tab-separated (not comma-separated). Open it in a text editor and check that columns are separated by tab characters, not commas.

help me troubleshoot why my CSV import command is not finding my file or failing to parse it

Your book collection is now sitting in a proper database, ready to be searched and displayed. Next up, you will build the API routes and web interface that turn this raw data into a browsable library.

Build the API and Web Interface

Your database is full of books. Now you need a way for the browser to ask for those books and display them.

In this step, you will build a REST API that lets the frontend request, create, update, and delete books. Then you will create the HTML, CSS, and JavaScript that make up the actual interface a person interacts with.

In this step, get ready to:
  • Create five API routes that handle reading, creating, updating, and deleting books.
  • Build an HTML template with a search bar, book grid, and modals for viewing and adding books.
  • Add mobile-first CSS styles and JavaScript that connects the interface to your API.
Create the Flask API routes

A REST API exposes your data as URLs that JavaScript can call. Each URL (called an endpoint) handles a specific action like listing books or deleting one.

  • In app.py, find the existing index route:
@app.route("/")
def index():
    return "Book Library"
  • Replace return "Book Library" with return render_template("index.html") so the route now looks like this:
@app.route("/")
def index():
    return render_template("index.html")
  • Delete the entire /count route (the @app.route("/count") function and all its lines). You no longer need it now that you have a proper interface.
  • Below the index() route, add the book listing endpoint by pasting this code:
@app.route("/api/books")
def get_books():
    conn = get_connection()
    cur = conn.cursor()

    # Read search term and page number from the URL query string
    search = request.args.get("q", "")
    page = request.args.get("page", 1, type=int)
    per_page = 20
    offset = (page - 1) * per_page

    if search:
        # Match search term against title, author, or tags
        term = f"%{search}%"
        cur.execute("""
            SELECT id, title, creators, status, rating, tags
            FROM books
            WHERE title LIKE ? OR creators LIKE ? OR tags LIKE ?
            ORDER BY title
            LIMIT ? OFFSET ?
        """, (term, term, term, per_page, offset))
    else:
        cur.execute("""
            SELECT id, title, creators, status, rating, tags
            FROM books
            ORDER BY title
            LIMIT ? OFFSET ?
        """, (per_page, offset))

    # Convert Row objects to plain dictionaries for JSON
    books = [dict(row) for row in cur.fetchall()]
    conn.close()
    return jsonify(books)

What does this code do?

This endpoint powers the book list your frontend will display.

  • request.args.get("q", "") reads an optional search term from the URL (e.g. /api/books?q=tolkien).
  • Pagination with LIMIT and OFFSET returns 20 books per page so the app stays fast even with large collections.
  • jsonify(books) converts the Python list of dictionaries into a JSON response the browser can read.
  • Below the get_books() function, add the single-book detail and create endpoints:
@app.route("/api/books/<int:book_id>")
def get_book(book_id):
    conn = get_connection()
    cur = conn.cursor()
    cur.execute("SELECT * FROM books WHERE id = ?", (book_id,))
    book = cur.fetchone()
    conn.close()
    if book:
        return jsonify(dict(book))
    # Return 404 if no book matches the given ID
    return jsonify({"error": "Book not found"}), 404


@app.route("/api/books", methods=["POST"])
def add_book():
    data = request.get_json()
    if not data or not data.get("title"):
        return jsonify({"error": "Title is required"}), 400

    # Insert only the fields the add-book form collects
    conn = get_connection()
    conn.execute("""
        INSERT INTO books (title, creators, publisher, publish_date, tags, status, rating)
        VALUES (?, ?, ?, ?, ?, ?, ?)
    """, (
        data.get("title", ""),
        data.get("creators", ""),
        data.get("publisher", ""),
        data.get("publish_date", ""),
        data.get("tags", ""),
        data.get("status", ""),
        data.get("rating", "")
    ))
    conn.commit()
    conn.close()
    return jsonify({"message": f"Added '{data['title']}'"}), 201

What do these routes do?

  • GET /api/books/<id> fetches every column for a single book. The <int:book_id> part tells Flask to extract the number from the URL and pass it to the function.
  • POST /api/books accepts JSON from the browser and inserts a new row. It returns HTTP 201 (Created) on success or 400 (Bad Request) if the title is missing.
  • Below add_book(), add the update and delete endpoints:
@app.route("/api/books/<int:book_id>", methods=["PUT"])
def update_book(book_id):
    data = request.get_json()
    if not data:
        return jsonify({"error": "No data provided"}), 400

    # Only allow updating specific safe fields
    allowed = ["title", "creators", "publisher", "tags", "status", "rating", "notes", "began", "completed"]
    updates = {k: v for k, v in data.items() if k in allowed}

    if not updates:
        return jsonify({"error": "No valid fields to update"}), 400

    # Build a dynamic UPDATE query from the fields provided
    conn = get_connection()
    set_clause = ", ".join(f"{k} = ?" for k in updates.keys())
    values = list(updates.values()) + [book_id]
    conn.execute(f"UPDATE books SET {set_clause} WHERE id = ?", values)
    conn.commit()
    conn.close()
    return jsonify({"message": "Book updated"})


@app.route("/api/books/<int:book_id>", methods=["DELETE"])
def delete_book(book_id):
    conn = get_connection()
    cur = conn.cursor()
    cur.execute("SELECT title FROM books WHERE id = ?", (book_id,))
    book = cur.fetchone()
    if not book:
        conn.close()
        return jsonify({"error": "Book not found"}), 404
    # Delete the book and confirm with its title
    conn.execute("DELETE FROM books WHERE id = ?", (book_id,))
    conn.commit()
    conn.close()
    return jsonify({"message": f"Deleted '{book['title']}'"})

What do these routes do?

  • PUT /api/books/<id> accepts a partial JSON body and only updates the fields provided. The allowed list prevents someone from changing fields like id directly.
  • DELETE /api/books/<id> checks the book exists first so it can return the title in the confirmation message.
  • Save app.py.
  • Test the API by visiting http://127.0.0.1:5000/api/books in your browser. You should see a JSON array of your first 20 books.

Seeing a 500 error or empty page?

  • Check the terminal where Flask is running for a traceback. A TemplateNotFound error means Flask is trying to render index.html on the / route but the file does not exist yet. That is expected. The /api/books endpoint works independently.
  • If you see a syntax error, make sure each route decorator is above its function with no blank lines between them.

Ask for help: My Flask API routes are returning errors. How do I debug?

Create the HTML template

Flask looks for HTML files inside the templates/ folder. Your single-page interface needs a search bar, a grid for book cards, and modals for viewing details and adding new books.

  • Create a new file called index.html inside your templates/ folder and paste the following:
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta name="theme-color" content="#1a1a2e">
    <title>Book Library</title>
    <link rel="stylesheet" href="/static/style.css">
    <link rel="manifest" href="/static/manifest.json">
</head>
<body>
    <!-- Sticky header with app title and search -->
    <header>
        <h1>Book Library</h1>
        <div class="search-bar">
            <input type="text" id="search-input" placeholder="Search by title, author, or tag...">
        </div>
    </header>

    <!-- Main content area where book cards appear -->
    <main>
        <div id="book-list" class="book-grid"></div>
        <button id="load-more" class="btn btn-secondary" style="display:none;">Load More</button>
    </main>

    <!-- Modal for viewing a single book's details -->
    <div id="book-modal" class="modal hidden">
        <div class="modal-content">
            <button class="modal-close" id="modal-close">&times;</button>
            <div id="modal-body"></div>
        </div>
    </div>

    <!-- Floating action button to add a new book -->
    <button id="add-btn" class="fab">+</button>

    <!-- Modal for the add-book form -->
    <div id="add-modal" class="modal hidden">
        <div class="modal-content">
            <button class="modal-close" id="add-modal-close">&times;</button>
            <h2>Add Book</h2>
            <form id="add-form">
                <label>Title <input type="text" name="title" required></label>
                <label>Author(s) <input type="text" name="creators"></label>
                <label>Publisher <input type="text" name="publisher"></label>
                <label>Publish Date <input type="text" name="publish_date"></label>
                <label>Tags <input type="text" name="tags"></label>
                <label>Status <input type="text" name="status" placeholder="Read, To Read, Reading"></label>
                <label>Rating <input type="text" name="rating" placeholder="1-5"></label>
                <button type="submit" class="btn">Add Book</button>
            </form>
        </div>
    </div>

    <script src="/static/app.js"></script>
</body>
</html>

What does this HTML do?

  • The <header> section has a search input that JavaScript will listen to for live filtering.
  • The book-list div starts empty. JavaScript fills it with cards fetched from your API.
  • Two modals (hidden by default) handle viewing book details and adding new books. The hidden class keeps them invisible until JavaScript removes it.
  • The fab button (floating action button) is the "+" circle that opens the add-book form.
  • Save templates/index.html.

Seeing a TemplateNotFound error?

  • Make sure the file is saved as templates/index.html (inside the templates folder), not in the project root.
  • Check the folder name is exactly templates (plural). Flask will not find the file in a folder named template.

Ask for help: Flask says TemplateNotFound for index.html. What is wrong with my folder structure?

Add the stylesheet and JavaScript

The HTML shell is ready, but it has no styles and no behavior. You need a CSS file to make it look good on phones and desktops, and a JavaScript file to fetch books from your API and render them as cards.

  • Create a new file called style.css inside your static/ folder and paste the following:
* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

body {
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
    background: #f0f2f5;
    color: #1a1a2e;
    min-height: 100vh;
}

/* Sticky dark header with search */
header {
    background: #1a1a2e;
    color: white;
    padding: 1rem;
    position: sticky;
    top: 0;
    z-index: 100;
}

header h1 {
    font-size: 1.4rem;
    margin-bottom: 0.5rem;
}

.search-bar input {
    width: 100%;
    padding: 0.6rem 1rem;
    border: none;
    border-radius: 8px;
    font-size: 1rem;
    background: #16213e;
    color: white;
}

.search-bar input::placeholder {
    color: #aaa;
}

/* Centered content area */
main {
    padding: 1rem;
    max-width: 900px;
    margin: 0 auto;
}

/* Mobile-first single column grid */
.book-grid {
    display: grid;
    grid-template-columns: 1fr;
    gap: 0.8rem;
}

/* Two columns on wider screens */
@media (min-width: 600px) {
    .book-grid {
        grid-template-columns: 1fr 1fr;
    }
}

/* Individual book cards */
.book-card {
    background: white;
    border-radius: 10px;
    padding: 1rem;
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
    cursor: pointer;
    transition: transform 0.15s;
}

.book-card:active {
    transform: scale(0.98);
}

.book-card h3 {
    font-size: 1rem;
    margin-bottom: 0.3rem;
    color: #1a1a2e;
}

.book-card .author {
    font-size: 0.85rem;
    color: #555;
    margin-bottom: 0.4rem;
}

.book-card .meta {
    display: flex;
    gap: 0.5rem;
    flex-wrap: wrap;
}

/* Status and rating badges */
.badge {
    font-size: 0.75rem;
    padding: 0.2rem 0.5rem;
    border-radius: 4px;
    background: #e8e8e8;
    color: #333;
}

.badge.status {
    background: #d4edda;
    color: #155724;
}

.badge.rating {
    background: #fff3cd;
    color: #856404;
}

/* Full-screen modal overlay */
.modal {
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    background: rgba(0, 0, 0, 0.5);
    display: flex;
    align-items: center;
    justify-content: center;
    z-index: 200;
    padding: 1rem;
}

.modal.hidden {
    display: none;
}

.modal-content {
    background: white;
    border-radius: 12px;
    padding: 1.5rem;
    width: 100%;
    max-width: 500px;
    max-height: 85vh;
    overflow-y: auto;
    position: relative;
}

.modal-close {
    position: absolute;
    top: 0.5rem;
    right: 1rem;
    font-size: 1.8rem;
    background: none;
    border: none;
    cursor: pointer;
    color: #666;
}

#modal-body h2 {
    margin-bottom: 0.5rem;
}

#modal-body p {
    margin-bottom: 0.4rem;
    font-size: 0.9rem;
}

#modal-body .field-label {
    color: #666;
    font-size: 0.8rem;
    text-transform: uppercase;
}

/* Buttons */
.btn {
    padding: 0.6rem 1.2rem;
    border: none;
    border-radius: 8px;
    background: #1a1a2e;
    color: white;
    font-size: 0.9rem;
    cursor: pointer;
    margin-top: 0.5rem;
}

.btn-secondary {
    background: #6c757d;
    display: block;
    margin: 1rem auto;
}

.btn-danger {
    background: #dc3545;
}

/* Floating action button */
.fab {
    position: fixed;
    bottom: 1.5rem;
    right: 1.5rem;
    width: 56px;
    height: 56px;
    border-radius: 50%;
    background: #1a1a2e;
    color: white;
    font-size: 1.8rem;
    border: none;
    cursor: pointer;
    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
    z-index: 150;
}

/* Form styles */
form label {
    display: block;
    margin-bottom: 0.8rem;
    font-size: 0.85rem;
    color: #555;
}

form input {
    display: block;
    width: 100%;
    padding: 0.5rem;
    margin-top: 0.2rem;
    border: 1px solid #ddd;
    border-radius: 6px;
    font-size: 1rem;
}

#load-more {
    display: block;
}

What does this CSS do?

  • The layout is mobile-first. By default, book cards stack in a single column. The @media (min-width: 600px) rule switches to a two-column grid on wider screens.
  • The .fab class creates a round floating button fixed to the bottom-right corner. It stays in place as you scroll.
  • Modals use position: fixed to cover the entire viewport. The .hidden class sets display: none to keep them invisible until triggered.
  • Save static/style.css.
  • Now create a file called app.js inside your static/ folder and paste the following:
let currentPage = 1;
let currentSearch = "";

document.addEventListener("DOMContentLoaded", function () {
    // Load the first page of books as soon as the page is ready
    loadBooks();

    // Live search: reset and reload when the user types
    document.getElementById("search-input").addEventListener("input", function () {
        currentSearch = this.value;
        currentPage = 1;
        document.getElementById("book-list").innerHTML = "";
        loadBooks();
    });

    // Pagination: load the next page of results
    document.getElementById("load-more").addEventListener("click", function () {
        currentPage++;
        loadBooks();
    });

    // Close the book detail modal
    document.getElementById("modal-close").addEventListener("click", function () {
        document.getElementById("book-modal").classList.add("hidden");
    });

    // Open the add-book modal
    document.getElementById("add-btn").addEventListener("click", function () {
        document.getElementById("add-modal").classList.remove("hidden");
    });

    // Close the add-book modal
    document.getElementById("add-modal-close").addEventListener("click", function () {
        document.getElementById("add-modal").classList.add("hidden");
    });

    // Handle the add-book form submission
    document.getElementById("add-form").addEventListener("submit", function (e) {
        e.preventDefault();
        var formData = new FormData(this);
        var data = Object.fromEntries(formData.entries());

        fetch("/api/books", {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify(data)
        })
        .then(function (res) { return res.json(); })
        .then(function () {
            document.getElementById("add-modal").classList.add("hidden");
            document.getElementById("add-form").reset();
            document.getElementById("book-list").innerHTML = "";
            currentPage = 1;
            loadBooks();
        });
    });

    // Register service worker for PWA support (used in the next step)
    if ("serviceWorker" in navigator) {
        navigator.serviceWorker.register("/static/sw.js");
    }
});


function loadBooks() {
    // Build the API URL with current page and optional search term
    var url = "/api/books?page=" + currentPage;
    if (currentSearch) {
        url += "&q=" + encodeURIComponent(currentSearch);
    }

    fetch(url)
        .then(function (res) { return res.json(); })
        .then(function (books) {
            var list = document.getElementById("book-list");
            books.forEach(function (book) {
                // Create a card element for each book
                var card = document.createElement("div");
                card.className = "book-card";
                card.onclick = function () { showBook(book.id); };

                var title = book.title || "Untitled";
                var author = book.creators || "Unknown author";
                var statusBadge = book.status ? '<span class="badge status">' + book.status + '</span>' : "";
                var ratingBadge = book.rating ? '<span class="badge rating">' + book.rating + '/5</span>' : "";

                card.innerHTML = '<h3>' + escapeHtml(title) + '</h3>'
                    + '<p class="author">' + escapeHtml(author) + '</p>'
                    + '<div class="meta">' + statusBadge + ratingBadge + '</div>';

                list.appendChild(card);
            });

            // Show Load More button if we got a full page of results
            var loadMoreBtn = document.getElementById("load-more");
            if (books.length === 20) {
                loadMoreBtn.style.display = "block";
            } else {
                loadMoreBtn.style.display = "none";
            }
        });
}


function showBook(id) {
    // Fetch full details and display them in the modal
    fetch("/api/books/" + id)
        .then(function (res) { return res.json(); })
        .then(function (book) {
            var body = document.getElementById("modal-body");
            var fields = [
                ["Title", book.title],
                ["Author(s)", book.creators],
                ["Publisher", book.publisher],
                ["Published", book.publish_date],
                ["Status", book.status],
                ["Rating", book.rating],
                ["Tags", book.tags],
                ["Notes", book.notes],
                ["Began", book.began],
                ["Completed", book.completed],
                ["ISBN-13", book.ean_isbn13],
                ["Description", book.description]
            ];

            var html = "<h2>" + escapeHtml(book.title || "Untitled") + "</h2>";
            fields.forEach(function (field) {
                if (field[1]) {
                    html += '<p><span class="field-label">' + field[0] + ':</span> ' + escapeHtml(field[1]) + '</p>';
                }
            });

            html += '<div style="margin-top:1rem">';
            html += '<button class="btn btn-danger" onclick="deleteBook(' + book.id + ')">Delete</button>';
            html += '</div>';

            body.innerHTML = html;
            document.getElementById("book-modal").classList.remove("hidden");
        });
}


function deleteBook(id) {
    if (!confirm("Delete this book?")) return;

    fetch("/api/books/" + id, { method: "DELETE" })
        .then(function (res) { return res.json(); })
        .then(function () {
            document.getElementById("book-modal").classList.add("hidden");
            document.getElementById("book-list").innerHTML = "";
            currentPage = 1;
            loadBooks();
        });
}


function escapeHtml(text) {
    // Safely escape user content to prevent XSS
    var div = document.createElement("div");
    div.textContent = text;
    return div.innerHTML;
}

What does this JavaScript do?

  • loadBooks() calls your /api/books endpoint and creates a card for each book in the response. It passes the current search term and page number as URL parameters.
  • showBook(id) fetches full details for one book and fills the modal with labelled fields.
  • escapeHtml(text) prevents book titles or author names that contain HTML characters from breaking the page layout.
  • The service worker registration line prepares for PWA functionality you will add in the next step.
  • Save static/app.js.
  • Refresh http://127.0.0.1:5000 in your browser. You should see a dark header with a search bar, book cards in a grid below, and a round "+" button in the bottom-right corner.
  • Type a word into the search bar. The book list updates as you type, showing only matching titles.
  • Click any book card. A modal should appear showing the book's full details.

Books not appearing or search not working?

  • Open your browser's developer console (press F12 then click **Console**). Look for red error messages indicating a failed fetch or missing file.
  • If you see a 404 for /static/app.js or /static/style.css, make sure those files are saved inside the static/ folder (not templates/).
  • If the page loads but shows no books, visit http://127.0.0.1:5000/api/books directly. If that returns an empty array, your database may not have been imported yet.

Ask for help: My book library page loads but books are not appearing. How do I debug?

Why vanilla JavaScript instead of React?

A framework like React adds a build step, a package manager, and hundreds of dependencies. For a personal tool with a handful of pages, plain JavaScript keeps the project simple and teaches you the underlying browser APIs that frameworks abstract away.

✔️ Awesome, I've got everything!

Great. Double check that you have saved all four files: app.py, templates/index.html, static/style.css, and static/app.js.

ⓧ I'd like to double check the full code

Here is the complete code for each file at this point.

app.py:

import csv
import sqlite3
import os
import random
import click
from flask import Flask, render_template, request, jsonify

app = Flask(__name__)
DB_FILE = "book_library.db"

DB_COLUMNS = [
    "item_type", "title", "creators", "first_name", "last_name",
    "collection", "ean_isbn13", "upc_isbn10", "description", "publisher",
    "publish_date", "group_name", "tags", "notes", "price",
    "length", "number_of_discs", "number_of_players", "age_group", "ensemble",
    "aspect_ratio", "esrb", "rating", "review", "review_date",
    "status", "began", "completed", "added", "copies"
]

CSV_HEADERS = [
    "item_type", "title", "creators", "first_name", "last_name",
    "collection", "ean_isbn13", "upc_isbn10", "description", "publisher",
    "publish_date", "group", "tags", "notes", "price",
    "length", "number_of_discs", "number_of_players", "age_group", "ensemble",
    "aspect_ratio", "esrb", "rating", "review", "review_date",
    "status", "began", "completed", "added", "copies"
]


def get_connection():
    conn = sqlite3.connect(DB_FILE)
    conn.row_factory = sqlite3.Row
    return conn


def create_database():
    conn = get_connection()
    conn.execute("""
        CREATE TABLE IF NOT EXISTS books (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            item_type TEXT,
            title TEXT NOT NULL,
            creators TEXT,
            first_name TEXT,
            last_name TEXT,
            collection TEXT,
            ean_isbn13 TEXT,
            upc_isbn10 TEXT,
            description TEXT,
            publisher TEXT,
            publish_date TEXT,
            group_name TEXT,
            tags TEXT,
            notes TEXT,
            price TEXT,
            length TEXT,
            number_of_discs TEXT,
            number_of_players TEXT,
            age_group TEXT,
            ensemble TEXT,
            aspect_ratio TEXT,
            esrb TEXT,
            rating TEXT,
            review TEXT,
            review_date TEXT,
            status TEXT,
            began TEXT,
            completed TEXT,
            added TEXT,
            copies TEXT
        )
    """)
    conn.commit()
    conn.close()


create_database()


@app.cli.command("import-csv")
@click.argument("csv_path")
def import_csv_command(csv_path):
    """Import books from a tab-separated CSV file."""
    if not os.path.exists(csv_path):
        click.echo(f"Error: File '{csv_path}' not found.")
        return

    conn = get_connection()
    cur = conn.cursor()

    cur.execute("SELECT COUNT(*) FROM books")
    existing = cur.fetchone()[0]
    if existing > 0:
        if not click.confirm(f"Database has {existing} books. Clear and reimport?"):
            click.echo("Import cancelled.")
            conn.close()
            return
        cur.execute("DELETE FROM books")

    count = 0
    with open(csv_path, newline="", encoding="utf-8") as f:
        reader = csv.DictReader(f, delimiter="\t")
        for row in reader:
            values = []
            for csv_col in CSV_HEADERS:
                values.append(row.get(csv_col, ""))
            placeholders = ", ".join(["?"] * len(DB_COLUMNS))
            col_names = ", ".join(DB_COLUMNS)
            cur.execute(
                f"INSERT INTO books ({col_names}) VALUES ({placeholders})",
                values
            )
            count += 1

    conn.commit()
    conn.close()
    click.echo(f"Imported {count} books.")


@app.route("/")
def index():
    return render_template("index.html")


@app.route("/api/books")
def get_books():
    conn = get_connection()
    cur = conn.cursor()

    search = request.args.get("q", "")
    page = request.args.get("page", 1, type=int)
    per_page = 20
    offset = (page - 1) * per_page

    if search:
        term = f"%{search}%"
        cur.execute("""
            SELECT id, title, creators, status, rating, tags
            FROM books
            WHERE title LIKE ? OR creators LIKE ? OR tags LIKE ?
            ORDER BY title
            LIMIT ? OFFSET ?
        """, (term, term, term, per_page, offset))
    else:
        cur.execute("""
            SELECT id, title, creators, status, rating, tags
            FROM books
            ORDER BY title
            LIMIT ? OFFSET ?
        """, (per_page, offset))

    books = [dict(row) for row in cur.fetchall()]
    conn.close()
    return jsonify(books)


@app.route("/api/books/<int:book_id>")
def get_book(book_id):
    conn = get_connection()
    cur = conn.cursor()
    cur.execute("SELECT * FROM books WHERE id = ?", (book_id,))
    book = cur.fetchone()
    conn.close()
    if book:
        return jsonify(dict(book))
    return jsonify({"error": "Book not found"}), 404


@app.route("/api/books", methods=["POST"])
def add_book():
    data = request.get_json()
    if not data or not data.get("title"):
        return jsonify({"error": "Title is required"}), 400

    conn = get_connection()
    conn.execute("""
        INSERT INTO books (title, creators, publisher, publish_date, tags, status, rating)
        VALUES (?, ?, ?, ?, ?, ?, ?)
    """, (
        data.get("title", ""),
        data.get("creators", ""),
        data.get("publisher", ""),
        data.get("publish_date", ""),
        data.get("tags", ""),
        data.get("status", ""),
        data.get("rating", "")
    ))
    conn.commit()
    conn.close()
    return jsonify({"message": f"Added '{data['title']}'"}), 201


@app.route("/api/books/<int:book_id>", methods=["PUT"])
def update_book(book_id):
    data = request.get_json()
    if not data:
        return jsonify({"error": "No data provided"}), 400

    allowed = ["title", "creators", "publisher", "tags", "status", "rating", "notes", "began", "completed"]
    updates = {k: v for k, v in data.items() if k in allowed}

    if not updates:
        return jsonify({"error": "No valid fields to update"}), 400

    conn = get_connection()
    set_clause = ", ".join(f"{k} = ?" for k in updates.keys())
    values = list(updates.values()) + [book_id]
    conn.execute(f"UPDATE books SET {set_clause} WHERE id = ?", values)
    conn.commit()
    conn.close()
    return jsonify({"message": "Book updated"})


@app.route("/api/books/<int:book_id>", methods=["DELETE"])
def delete_book(book_id):
    conn = get_connection()
    cur = conn.cursor()
    cur.execute("SELECT title FROM books WHERE id = ?", (book_id,))
    book = cur.fetchone()
    if not book:
        conn.close()
        return jsonify({"error": "Book not found"}), 404
    conn.execute("DELETE FROM books WHERE id = ?", (book_id,))
    conn.commit()
    conn.close()
    return jsonify({"message": f"Deleted '{book['title']}'"})


if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5000, debug=True)

templates/index.html:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta name="theme-color" content="#1a1a2e">
    <title>Book Library</title>
    <link rel="stylesheet" href="/static/style.css">
    <link rel="manifest" href="/static/manifest.json">
</head>
<body>
    <header>
        <h1>Book Library</h1>
        <div class="search-bar">
            <input type="text" id="search-input" placeholder="Search by title, author, or tag...">
        </div>
    </header>

    <main>
        <div id="book-list" class="book-grid"></div>
        <button id="load-more" class="btn btn-secondary" style="display:none;">Load More</button>
    </main>

    <div id="book-modal" class="modal hidden">
        <div class="modal-content">
            <button class="modal-close" id="modal-close">&times;</button>
            <div id="modal-body"></div>
        </div>
    </div>

    <button id="add-btn" class="fab">+</button>

    <div id="add-modal" class="modal hidden">
        <div class="modal-content">
            <button class="modal-close" id="add-modal-close">&times;</button>
            <h2>Add Book</h2>
            <form id="add-form">
                <label>Title <input type="text" name="title" required></label>
                <label>Author(s) <input type="text" name="creators"></label>
                <label>Publisher <input type="text" name="publisher"></label>
                <label>Publish Date <input type="text" name="publish_date"></label>
                <label>Tags <input type="text" name="tags"></label>
                <label>Status <input type="text" name="status" placeholder="Read, To Read, Reading"></label>
                <label>Rating <input type="text" name="rating" placeholder="1-5"></label>
                <button type="submit" class="btn">Add Book</button>
            </form>
        </div>
    </div>

    <script src="/static/app.js"></script>
</body>
</html>

static/style.css:

* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

body {
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
    background: #f0f2f5;
    color: #1a1a2e;
    min-height: 100vh;
}

header {
    background: #1a1a2e;
    color: white;
    padding: 1rem;
    position: sticky;
    top: 0;
    z-index: 100;
}

header h1 {
    font-size: 1.4rem;
    margin-bottom: 0.5rem;
}

.search-bar input {
    width: 100%;
    padding: 0.6rem 1rem;
    border: none;
    border-radius: 8px;
    font-size: 1rem;
    background: #16213e;
    color: white;
}

.search-bar input::placeholder {
    color: #aaa;
}

main {
    padding: 1rem;
    max-width: 900px;
    margin: 0 auto;
}

.book-grid {
    display: grid;
    grid-template-columns: 1fr;
    gap: 0.8rem;
}

@media (min-width: 600px) {
    .book-grid {
        grid-template-columns: 1fr 1fr;
    }
}

.book-card {
    background: white;
    border-radius: 10px;
    padding: 1rem;
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
    cursor: pointer;
    transition: transform 0.15s;
}

.book-card:active {
    transform: scale(0.98);
}

.book-card h3 {
    font-size: 1rem;
    margin-bottom: 0.3rem;
    color: #1a1a2e;
}

.book-card .author {
    font-size: 0.85rem;
    color: #555;
    margin-bottom: 0.4rem;
}

.book-card .meta {
    display: flex;
    gap: 0.5rem;
    flex-wrap: wrap;
}

.badge {
    font-size: 0.75rem;
    padding: 0.2rem 0.5rem;
    border-radius: 4px;
    background: #e8e8e8;
    color: #333;
}

.badge.status {
    background: #d4edda;
    color: #155724;
}

.badge.rating {
    background: #fff3cd;
    color: #856404;
}

.modal {
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    background: rgba(0, 0, 0, 0.5);
    display: flex;
    align-items: center;
    justify-content: center;
    z-index: 200;
    padding: 1rem;
}

.modal.hidden {
    display: none;
}

.modal-content {
    background: white;
    border-radius: 12px;
    padding: 1.5rem;
    width: 100%;
    max-width: 500px;
    max-height: 85vh;
    overflow-y: auto;
    position: relative;
}

.modal-close {
    position: absolute;
    top: 0.5rem;
    right: 1rem;
    font-size: 1.8rem;
    background: none;
    border: none;
    cursor: pointer;
    color: #666;
}

#modal-body h2 {
    margin-bottom: 0.5rem;
}

#modal-body p {
    margin-bottom: 0.4rem;
    font-size: 0.9rem;
}

#modal-body .field-label {
    color: #666;
    font-size: 0.8rem;
    text-transform: uppercase;
}

.btn {
    padding: 0.6rem 1.2rem;
    border: none;
    border-radius: 8px;
    background: #1a1a2e;
    color: white;
    font-size: 0.9rem;
    cursor: pointer;
    margin-top: 0.5rem;
}

.btn-secondary {
    background: #6c757d;
    display: block;
    margin: 1rem auto;
}

.btn-danger {
    background: #dc3545;
}

.fab {
    position: fixed;
    bottom: 1.5rem;
    right: 1.5rem;
    width: 56px;
    height: 56px;
    border-radius: 50%;
    background: #1a1a2e;
    color: white;
    font-size: 1.8rem;
    border: none;
    cursor: pointer;
    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
    z-index: 150;
}

form label {
    display: block;
    margin-bottom: 0.8rem;
    font-size: 0.85rem;
    color: #555;
}

form input {
    display: block;
    width: 100%;
    padding: 0.5rem;
    margin-top: 0.2rem;
    border: 1px solid #ddd;
    border-radius: 6px;
    font-size: 1rem;
}

#load-more {
    display: block;
}

static/app.js:

let currentPage = 1;
let currentSearch = "";

document.addEventListener("DOMContentLoaded", function () {
    loadBooks();

    document.getElementById("search-input").addEventListener("input", function () {
        currentSearch = this.value;
        currentPage = 1;
        document.getElementById("book-list").innerHTML = "";
        loadBooks();
    });

    document.getElementById("load-more").addEventListener("click", function () {
        currentPage++;
        loadBooks();
    });

    document.getElementById("modal-close").addEventListener("click", function () {
        document.getElementById("book-modal").classList.add("hidden");
    });

    document.getElementById("add-btn").addEventListener("click", function () {
        document.getElementById("add-modal").classList.remove("hidden");
    });

    document.getElementById("add-modal-close").addEventListener("click", function () {
        document.getElementById("add-modal").classList.add("hidden");
    });

    document.getElementById("add-form").addEventListener("submit", function (e) {
        e.preventDefault();
        var formData = new FormData(this);
        var data = Object.fromEntries(formData.entries());

        fetch("/api/books", {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify(data)
        })
        .then(function (res) { return res.json(); })
        .then(function () {
            document.getElementById("add-modal").classList.add("hidden");
            document.getElementById("add-form").reset();
            document.getElementById("book-list").innerHTML = "";
            currentPage = 1;
            loadBooks();
        });
    });

    if ("serviceWorker" in navigator) {
        navigator.serviceWorker.register("/static/sw.js");
    }
});


function loadBooks() {
    var url = "/api/books?page=" + currentPage;
    if (currentSearch) {
        url += "&q=" + encodeURIComponent(currentSearch);
    }

    fetch(url)
        .then(function (res) { return res.json(); })
        .then(function (books) {
            var list = document.getElementById("book-list");
            books.forEach(function (book) {
                var card = document.createElement("div");
                card.className = "book-card";
                card.onclick = function () { showBook(book.id); };

                var title = book.title || "Untitled";
                var author = book.creators || "Unknown author";
                var statusBadge = book.status ? '<span class="badge status">' + book.status + '</span>' : "";
                var ratingBadge = book.rating ? '<span class="badge rating">' + book.rating + '/5</span>' : "";

                card.innerHTML = '<h3>' + escapeHtml(title) + '</h3>'
                    + '<p class="author">' + escapeHtml(author) + '</p>'
                    + '<div class="meta">' + statusBadge + ratingBadge + '</div>';

                list.appendChild(card);
            });

            var loadMoreBtn = document.getElementById("load-more");
            if (books.length === 20) {
                loadMoreBtn.style.display = "block";
            } else {
                loadMoreBtn.style.display = "none";
            }
        });
}


function showBook(id) {
    fetch("/api/books/" + id)
        .then(function (res) { return res.json(); })
        .then(function (book) {
            var body = document.getElementById("modal-body");
            var fields = [
                ["Title", book.title],
                ["Author(s)", book.creators],
                ["Publisher", book.publisher],
                ["Published", book.publish_date],
                ["Status", book.status],
                ["Rating", book.rating],
                ["Tags", book.tags],
                ["Notes", book.notes],
                ["Began", book.began],
                ["Completed", book.completed],
                ["ISBN-13", book.ean_isbn13],
                ["Description", book.description]
            ];

            var html = "<h2>" + escapeHtml(book.title || "Untitled") + "</h2>";
            fields.forEach(function (field) {
                if (field[1]) {
                    html += '<p><span class="field-label">' + field[0] + ':</span> ' + escapeHtml(field[1]) + '</p>';
                }
            });

            html += '<div style="margin-top:1rem">';
            html += '<button class="btn btn-danger" onclick="deleteBook(' + book.id + ')">Delete</button>';
            html += '</div>';

            body.innerHTML = html;
            document.getElementById("book-modal").classList.remove("hidden");
        });
}


function deleteBook(id) {
    if (!confirm("Delete this book?")) return;

    fetch("/api/books/" + id, { method: "DELETE" })
        .then(function (res) { return res.json(); })
        .then(function () {
            document.getElementById("book-modal").classList.add("hidden");
            document.getElementById("book-list").innerHTML = "";
            currentPage = 1;
            loadBooks();
        });
}


function escapeHtml(text) {
    var div = document.createElement("div");
    div.textContent = text;
    return div.innerHTML;
}

Your book library is alive. You have a working API, a responsive interface, and live search. Next up, you will turn it into an installable app that works on your phone.

Make It a PWA and Serve on Your Network

Your book library looks great and works perfectly in a browser tab. But what if you could pull it up on your phone while browsing your bookshelf at home?

A Progressive Web App lets your browser install a website as a standalone app. In this step, you'll add the two files that make this possible and open the app on any device connected to your WiFi.

In this step, get ready to:
  • Create a PWA manifest and placeholder icons.
  • Add a service worker that caches the app for faster loads.
  • Serve the app on your local network and access it from your phone.
Create the PWA manifest and icons

For your browser to treat a website as an installable app, it needs a manifest file. This is a JSON file that describes your app's name, colors, icons, and how it should display when installed.

  • Create a new file called manifest.json inside your static/ folder by running:
touch static/manifest.json
  • Open static/manifest.json in VS Code and paste this content:
{
    "name": "Book Library",
    "short_name": "Books",
    "start_url": "/",
    "display": "standalone",
    "background_color": "#f0f2f5",
    "theme_color": "#1a1a2e",
    "icons": [
        {
            "src": "/static/icon-192.png",
            "sizes": "192x192",
            "type": "image/png"
        },
        {
            "src": "/static/icon-512.png",
            "sizes": "512x512",
            "type": "image/png"
        }
    ]
}

What do these fields do?

  • name and short_name are what appears when the app is installed on a device.
  • display: "standalone" makes the app run in its own window without browser navigation bars.
  • start_url tells the browser which page to open when the app launches.
  • icons provides two sizes (192px and 512px) for device home screens and app launchers.

Now you need placeholder icons. You'll generate simple colored PNG files using a Python script.

  • Create the placeholder icons by running this Python command in your terminal:
python -c "
import struct, zlib

def make_png(path, size):
    raw = b''
    for y in range(size):
        raw += b'\x00'
        for x in range(size):
            raw += b'\x1a\x1a\x2e'
    def chunk(ctype, data):
        c = ctype + data
        return struct.pack('>I', len(data)) + c + struct.pack('>I', zlib.crc32(c) & 0xffffffff)
    png = b'\x89PNG\r\n\x1a\n'
    png += chunk(b'IHDR', struct.pack('>IIBBBBB', size, size, 8, 2, 0, 0, 0))
    png += chunk(b'IDAT', zlib.compress(raw))
    png += chunk(b'IEND', b'')
    with open(path, 'wb') as f:
        f.write(png)

make_png('static/icon-192.png', 192)
make_png('static/icon-512.png', 512)
print('Created icon-192.png and icon-512.png')
"

What does this script do?

This script generates two valid PNG images filled with a solid dark blue color (#1a1a2e) matching your app's theme. They're simple placeholders that satisfy the PWA icon requirement.

You can replace them later with custom-designed icons if you want a polished look on your home screen.

  • Confirm the icons were created by running:
ls static/icon-*.png

You should see static/icon-192.png and static/icon-512.png in the output.

Your index.html already links this manifest with a <link rel="manifest"> tag in the <head> section. No changes needed there.

Icons not appearing?

  • Make sure you ran the Python command from your project's root folder (the one containing the static/ directory).
  • Check that your virtual environment is activated. You should see (.venv) in your terminal prompt.

Why aren't my icon files appearing in the static folder?

Create the service worker

A service worker is a JavaScript file that runs in the background of your browser. It intercepts network requests and can serve cached responses, making your app load faster on repeat visits.

  • Create a new file called sw.js inside your static/ folder by running:
touch static/sw.js
  • Open static/sw.js in VS Code and paste the following code:
var CACHE_NAME = "book-library-v1";
var ASSETS = [
    "/",
    "/static/style.css",
    "/static/app.js",
    "/static/manifest.json"
];

self.addEventListener("install", function (event) {
    event.waitUntil(
        caches.open(CACHE_NAME).then(function (cache) {
            return cache.addAll(ASSETS);
        })
    );
});

self.addEventListener("activate", function (event) {
    event.waitUntil(
        caches.keys().then(function (names) {
            return Promise.all(
                names.filter(function (name) {
                    return name !== CACHE_NAME;
                }).map(function (name) {
                    return caches.delete(name);
                })
            );
        })
    );
});

self.addEventListener("fetch", function (event) {
    if (event.request.url.includes("/api/")) {
        event.respondWith(fetch(event.request));
        return;
    }

    event.respondWith(
        caches.match(event.request).then(function (cached) {
            return cached || fetch(event.request);
        })
    );
});

What does this service worker do?

  • The install event fires when the service worker is first registered. It opens a cache and stores your core app files (HTML, CSS, JS).
  • The activate event cleans up any old caches when you release a new version.
  • The fetch event intercepts every network request. API calls (/api/) always go to the server so you always get fresh data. Everything else is served from cache first, falling back to the network.
  • Save static/sw.js.

Your app.js already includes the service worker registration code at the end of the DOMContentLoaded handler. It checks if the browser supports service workers and registers /static/sw.js automatically.

Service worker not registering?

  • Make sure sw.js is saved inside the static/ folder, not in the project root.
  • Check for JavaScript syntax errors in the file. Open your browser's DevTools console to see any error messages.

Why isn't my service worker registering in the browser?

Serve on your network and install the PWA

Your app has been running on 127.0.0.1 which only your own machine can reach. To access it from your phone, you need the server to listen on all network interfaces.

Your app.py already has this configured in the if __name__ == "__main__" block with host="0.0.0.0". You just need to run the app using Python directly instead of the flask run command.

  • Stop any running Flask server by pressing Ctrl+C in the terminal.
  • Start the server with network access by running:
python app.py

Why python app.py instead of flask run?

The flask run command binds to 127.0.0.1 by default, which blocks outside connections. Running python app.py directly triggers the if __name__ == "__main__" block, which sets host="0.0.0.0" to listen on all network interfaces.

This means any device on your WiFi network can connect to the server.

You should see output showing the server running on http://0.0.0.0:5000.

  • Open a new terminal tab and find your machine's local IP address by running:
hostname -I

This prints your machine's IP addresses. The first one is typically your WiFi address (something like 192.168.x.x).

  • On your phone, open your browser and navigate to http:// followed by your IP and :5000 (for example, http://192.168.1.42:5000).
  • Make sure your phone is connected to the same WiFi network as your computer.

You should see your full book library interface on your phone's screen.

Why can't I install the PWA on my phone?

The PWA install prompt only fires when served over HTTPS or from localhost. When you access the app via your machine's IP address, the install button won't appear.

The app still works perfectly as a mobile web bookmark. You can install it as a PWA on your desktop browser where you access it via localhost:5000.

  • On your desktop, open Chrome or Edge and navigate to http://localhost:5000.
  • Look for the install icon in the address bar (it looks like a monitor with a down arrow).
  • Click the install icon and confirm the installation.

The app opens in its own standalone window without browser navigation bars.

  • To verify the service worker is active, open DevTools (F12) in Chrome at localhost:5000.
  • Click the Application tab in DevTools.
  • Select Service Workers in the left sidebar.
  • You should see your service worker listed as "activated and running".

Don't see the install icon?

  • Make sure you're accessing the app at localhost:5000, not your IP address. PWA install requires HTTPS or localhost.
  • Interact with the page for about 30 seconds (click around, search for a book). Chrome requires user engagement before showing the install prompt.
  • Check that static/manifest.json loads without errors. In DevTools > Application > Manifest, you should see your app's name and icons listed.

Why isn't the PWA install icon showing in my browser's address bar?

✔️ Awesome, I've got everything!

Great. Make sure you've saved all your files.

ⓧ I'd like to double check the full code

Here is the complete static/manifest.json:

{
    "name": "Book Library",
    "short_name": "Books",
    "start_url": "/",
    "display": "standalone",
    "background_color": "#f0f2f5",
    "theme_color": "#1a1a2e",
    "icons": [
        {
            "src": "/static/icon-192.png",
            "sizes": "192x192",
            "type": "image/png"
        },
        {
            "src": "/static/icon-512.png",
            "sizes": "512x512",
            "type": "image/png"
        }
    ]
}

Here is the complete static/sw.js:

var CACHE_NAME = "book-library-v1";
var ASSETS = [
    "/",
    "/static/style.css",
    "/static/app.js",
    "/static/manifest.json"
];

self.addEventListener("install", function (event) {
    event.waitUntil(
        caches.open(CACHE_NAME).then(function (cache) {
            return cache.addAll(ASSETS);
        })
    );
});

self.addEventListener("activate", function (event) {
    event.waitUntil(
        caches.keys().then(function (names) {
            return Promise.all(
                names.filter(function (name) {
                    return name !== CACHE_NAME;
                }).map(function (name) {
                    return caches.delete(name);
                })
            );
        })
    );
});

self.addEventListener("fetch", function (event) {
    if (event.request.url.includes("/api/")) {
        event.respondWith(fetch(event.request));
        return;
    }

    event.respondWith(
        caches.match(event.request).then(function (cached) {
            return cached || fetch(event.request);
        })
    );
});

Your book library is now a fully functional Progressive Web App. You can pull it up on your phone from anywhere in your home, search your collection, and manage your books through a clean mobile interface.

Secret mission

Reading Stats Dashboard and Book Recommender

Your book library can do more than just list and search. Add a reading statistics dashboard with visual progress bars showing your reading habits, plus a "Surprise Me" button that picks a random unread book from your collection.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely on your local machine, so there are no ongoing costs or cloud charges to worry about.

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Flask development server (running process on port 5000).
  • Project folder on your Desktop (contains app.py, static/, templates/, book_library.db, and .venv/).
  • Python virtual environment (inside the project folder).

Keep everything running

No action needed. Choose this if you are still actively building or want to keep using your book library app.

  • Your Flask server will keep running until you close the terminal or press Ctrl+C.
  • To restart later, open a terminal in your project folder, activate the virtual environment with . .venv/bin/activate, and run flask run --debug.
  • Your database and all project files will remain on your Desktop.

Pause - I'll come back to this later

Stop the running server to free up the terminal, but keep all your files for later.

  • Switch to the terminal where Flask is running and press Ctrl+C to stop the server.
  • Deactivate your virtual environment by running:
deactivate
  • Your project folder, database, and all source files stay on your Desktop for next time.

Delete - I don't want to use this again

Remove all project files, the database, and the virtual environment completely.

  • Switch to the terminal where Flask is running and press Ctrl+C to stop the server.
  • Deactivate the virtual environment by running:
deactivate
  • Navigate to your Desktop and delete the entire project folder by running:
cd ~/Desktop
rm -rf book-library
  • Confirm the folder is gone by running:
ls
  • You should no longer see your project folder in the output.
  • If you installed the PWA on your desktop browser, right-click the app icon and select Uninstall.

Nice Work!

Nice Work!

You just built a full-stack web application from scratch and deployed it on your home network. Your personal book library is now searchable, manageable, and accessible from any device on your WiFi.

You've learned how to:

  • Build a full-stack web application with Flask serving a REST API backed by a SQLite database imported from a 30-column tab-separated CSV.
  • Create a responsive, mobile-friendly frontend with vanilla HTML, CSS, and JavaScript that searches, displays, and manages your book collection.
  • Make the app installable as a Progressive Web App with a manifest and service worker, and serve it on your local network accessible from any device on your WiFi.
  • Secret Mission: Added a reading stats dashboard with visual progress indicators and a randomized book recommender.

Ready to quiz yourself?