Build Pinboard with Flask
Build a local Pinterest-style app with Flask, SQLite, and async saves.
Introduction
30 Second Summary
Inspiring images often end up scattered across open tabs or buried in old messages. A visual collection gives every idea a place you can return to.
In this project, you will build Pinboard, a local Pinterest-style app powered by Flask and SQLite. Each interaction connects the browser to persistent storage, so your changes remain after the server restarts.
What You'll Build
You will open Pinboard in Google Chrome and demo a responsive card feed where new pins survive restarts while Save buttons update without refreshing the page.
By the end of this project, you'll have:
- A responsive pin feed that arranges illustrated cards into a masonry-style gallery across different screen sizes.
- A Create Pin flow that turns an image URL, title, and description into a card that survives server restarts.
- Database-backed Save buttons that switch between Save and Saved without reloading the page.
- Secret Mission: Add a Saved-only filter that immediately hides unsaved cards and stays synchronized with every save action.
Are there any prerequisites?
You need basic familiarity with Python functions, HTML, and the terminal.
You also need Python 3.9 or newer, Visual Studio Code, and Google Chrome on macOS. No cloud account is required.
Before We Start
Before the hands-on work begins, this is your moment to commit to the people Pinboard serves. A clear purpose keeps every feature focused on helping someone collect visual inspiration.
Set Up the Local Flask Workspace
Pinboard needs a predictable local workspace before any product code is written. Project-specific isolation prevents package conflicts.
You'll use Visual Studio Code to verify Python before creating an isolated virtual environment. That environment gives Flask version 3.1.3 a dedicated home.
In this step, get ready to:
- Verify that Python 3.9 or newer is available.
- Create the pinboard folder with an active .venv virtual environment.
- Install Flask 3.1.3 from requirements.txt.
Verify your Python version
Flask 3.1.3 requires Python 3.9 or newer. The version check confirms your computer can support the pinned dependency.
- Press Cmd+Space on macOS or the Windows key on Windows to open the search bar.
- Type Visual Studio Code into the search field.
- Press Enter to open Visual Studio Code.
- Click View in the menu bar.
- Select Terminal.
- Check your installed Python version by running this command:
python3 --version
What does this check prove?
The command prints the Python version connected to python3. A result of 3.9 or newer meets Flask's requirement.
✔️ I see version 3.9 or higher
Your Python installation is ready. You can use it to create Pinboard's isolated environment.
ⓧ I see an older version
- Open the Python downloads for macOS page.
- Select the current stable Python 3 release.
- Click Download macOS installer.
- Complete the macOS installer.
- Quit Visual Studio Code after the installation finishes.
- Reopen Visual Studio Code through your search bar.
- Return to the terminal.
- Check the updated Python version by running:
python3 --version
What should the updated check show?
The refreshed terminal reads the updated Python path. The version should now be 3.9 or newer.
ⓧ Command not found
- Open the Python downloads for macOS page.
- Select the current stable Python 3 release.
- Click Download macOS installer.
- Complete the macOS installer.
- Quit Visual Studio Code after the installation finishes.
- Reopen Visual Studio Code through your search bar.
- Return to the terminal.
- Confirm Python is available by running:
python3 --version
What should the new check show?
The command should now print an installed Python version. Continue when that version is 3.9 or newer.
Create the project environment
The pinboard folder keeps every project file in one place. Its .venv directory keeps Pinboard's packages separate from other Python projects.
- Move to your Desktop in the VS Code terminal by running this command:
cd ~/Desktop
What does this command do?
The cd command changes the terminal's current location. Starting on the Desktop makes the new project folder easy to find.
- Create the pinboard folder and move into it by running these commands:
mkdir pinboard
cd pinboard
What do these commands do?
- The mkdir command creates the pinboard folder on your Desktop.
- The second cd command moves the terminal into that folder.
VS Code may show a Workspace Trust prompt after opening the folder. You created this empty folder yourself, so it is safe to trust.
- Click File in the VS Code menu bar.
- Select Open Folder....
- Select Desktop in the folder dialog.
- Select the pinboard folder.
- Click Open.
- Confirm that you trust the folder if prompted.
You'll see pinboard at the top of the Explorer sidebar. This confirms VS Code is working inside the correct folder.
- Click View in the menu bar.
- Select Terminal.
- Create the .venv environment and activate it by running these commands:
python3 -m venv .venv
. .venv/bin/activate
How does the virtual environment work?
- The first command creates an isolated Python environment inside .venv.
- The activation command points this terminal session at the environment's Python tools.
Your terminal prompt changes to show that the virtual environment is active. Every package installed in this terminal now belongs to Pinboard.
Virtual environment not activating?
- Confirm that pinboard appears at the top of the VS Code Explorer sidebar.
- Repeat the activation command from inside the pinboard folder.
- Ask for help with the activation problem.
Install Flask from requirements.txt
A requirements file records the exact package version a project needs. Pinning the dependency makes Pinboard repeatable across future terminal sessions.
- Select the New File control in the VS Code Explorer sidebar.
- Enter requirements.txt as the file name.
- Add the pinned Flask dependency by pasting this line:
Flask==3.1.3
Why pin Flask?
The double equals sign tells pip to install one exact version. This prevents a later Flask release from changing the project's starting point.
- Save requirements.txt.
Before you install the dependency, what do you expect the active virtual environment to do with the pinned requirement?
- Install the dependency from requirements.txt by running this command:
python3 -m pip install -r requirements.txt
What does this command do?
- The python3 -m pip portion uses the package installer connected to the active Python environment.
- The -r option reads the dependency from requirements.txt.
- The version pin installs Flask 3.1.3 inside .venv.
You'll see Flask 3.1.3 reported as installed or already satisfied. That output confirms the pinned dependency is available inside the active environment.
Flask installation not completing?
- Confirm that the terminal prompt shows an active virtual environment.
- Confirm that requirements.txt appears inside the pinboard folder in the VS Code Explorer sidebar.
- Ask for help with the installation result.
Your foundation is locked in. Pinboard now has its own environment with Flask 3.1.3 ready for the first route.
✔️ Awesome, I've got everything!
Great. Double check that requirements.txt is saved inside pinboard.
ⓧ I'd like to double check the full code
Compare requirements.txt with the exact contents below:
Flask==3.1.3
What should this file contain?
The file contains one direct dependency. Its exact version pin gives every later step the same Flask baseline.
Your local workspace is ready. Next, you'll use Google Chrome to see Pinboard's first read-only feed come to life.
Build the Read-Only Pin Feed
Your isolated Flask workspace is ready. Now Pinboard needs a visible interface that proves the browser can receive content from your Python application.
A Python list cannot appear in the browser by itself. This step connects a request route to a Jinja template. Local illustrations and responsive styling turn that data into a masonry-style feed.
In this step, get ready to:
- Create a Flask route that sends three seed pins to a template.
- Render the seed pins as illustrated cards in a browser.
- Style the cards as a responsive masonry feed.
Create the Flask route and page shell
A route connects a browser URL to a Python function. The home route passes the in-memory pins list into index.html for rendering.
- Create app.py inside the pinboard folder using the file sidebar in Visual Studio Code.
- Paste this Flask application into app.py:
from flask import Flask, render_template
app = Flask(__name__)
# Keep the first feed in process memory while the interface takes shape.
pins = [
{
"title": "Cliffside escape",
"description": "A quiet place above the clouds.",
"image_url": "/static/images/cliffs.svg",
},
{
"title": "Creative studio",
"description": "Warm colors for a focused workspace.",
"image_url": "/static/images/studio.svg",
},
{
"title": "Slow morning",
"description": "Coffee, sunlight, and a notebook.",
"image_url": "/static/images/cafe.svg",
},
]
# Pass every pin to the template when the home page is requested.
@app.route("/")
def index():
return render_template("index.html", pins=pins)
What does this code do?
- The pins list stores the title for each card. It also stores its description and local image URL.
- The @app.route("/") decorator connects the home URL to index().
- The render_template() call sends the list to the template under the name pins.
- Save app.py.
- Create templates/index.html inside the pinboard folder using the file sidebar.
- Paste this page shell into templates/index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Pinboard</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<header class="topbar">
<a class="brand" href="{{ url_for('index') }}" aria-label="Pinboard home">
<span class="brand-mark">P</span>
<span>Pinboard</span>
</a>
<span class="tagline">Collect what inspires you</span>
</header>
<main class="page-shell">
<section class="hero">
<p class="eyebrow">Your visual idea space</p>
<h1>Save ideas worth returning to.</h1>
<p>Create a pin from an image URL, then keep your favorites close.</p>
</section>
</main>
</body>
</html>
How does the page shell work?
The template establishes Pinboard's header and hero content. The stylesheet link uses Flask to build the URL for static/style.css.
The feed arrives in the next chunk. Starting with a complete page lets you test the route before adding the cards.
- Save templates/index.html.
Before you start the server, what page content do you expect the home route to return?
- Start the Flask development server from the activated terminal by running:
flask run --debug
What does this command do?
Flask imports app.py automatically. Debug mode reloads the development server when you save Python changes.
Keep this terminal running while you build the feed. The local server stays available until you stop it.
- Press Cmd+Space on macOS or the Windows key on Windows to open system search.
- Type Google Chrome into system search.
- Press Enter to open Google Chrome.
- Enter http://127.0.0.1:5000/ in the address bar.
You should see the Pinboard name and the heading Save ideas worth returning to. The page is unstyled because its stylesheet is the next visible layer.
Is port 5000 already busy?
A macOS service may already use this port. Start Flask on the alternate port by running:
flask run --port 5001
This command starts the development server on port 5001.
- Enter http://127.0.0.1:5001/ in the Chrome address bar.
- Use the new localhost URL for every browser check in this step.
- Help me diagnose why Flask cannot start on my local port.
Add the pin cards and illustrations
Each seed pin points to an SVG file under static/images. SVG keeps the project self-contained while giving every card a different height and composition.
- Create static/images/cliffs.svg inside the pinboard folder using the file sidebar.
- Paste this illustration into static/images/cliffs.svg:
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 780" role="img" aria-labelledby="title">
<title id="title">Layered cliffs below a warm sky</title>
<defs>
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#efb8a8"/>
<stop offset="1" stop-color="#f6dfc7"/>
</linearGradient>
</defs>
<rect width="600" height="780" fill="url(#sky)"/>
<circle cx="455" cy="165" r="72" fill="#fff1c9" opacity="0.92"/>
<path d="M0 460 130 330 250 465 360 290 600 505V780H0Z" fill="#7f8f8a"/>
<path d="M0 555 180 405 320 560 455 390 600 540V780H0Z" fill="#536c69"/>
<path d="M0 650 155 530 280 625 410 500 600 650V780H0Z" fill="#304f4d"/>
</svg>
What creates the cliff scene?
The gradient creates a warm sky. A circle forms the sun.
Three overlapping paths form the cliffs. Their different colors create depth.
- Save static/images/cliffs.svg.
- Select cliffs.svg in the file sidebar to preview it.
You should see layered green cliffs under a peach-colored sky.
Is the cliff preview blank?
- Confirm the first tag begins with <svg.
- Confirm the final tag is </svg>.
- Help me find invalid SVG markup in cliffs.svg.
- Create static/images/studio.svg using the file sidebar.
- Paste this illustration into static/images/studio.svg:
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 650" role="img" aria-labelledby="title">
<title id="title">Colorful creative studio desk</title>
<rect width="600" height="650" fill="#f2c6a0"/>
<rect x="70" y="85" width="190" height="220" rx="18" fill="#fff7eb"/>
<circle cx="165" cy="175" r="58" fill="#e96f51"/>
<path d="M107 265 158 200 212 265Z" fill="#556f67"/>
<rect x="335" y="65" width="150" height="260" rx="75" fill="#6d8178"/>
<rect x="70" y="395" width="460" height="42" rx="18" fill="#654d43"/>
<rect x="115" y="437" width="26" height="150" fill="#654d43"/>
<rect x="462" y="437" width="26" height="150" fill="#654d43"/>
<rect x="240" y="330" width="145" height="65" rx="8" fill="#fff4da"/>
<path d="M270 330 300 260 334 330Z" fill="#d7263d"/>
</svg>
What creates the studio scene?
Rectangles build the artwork and desk. Rounded shapes add the wall decoration.
The contrasting coral and green colors separate the objects from the warm background.
- Save static/images/studio.svg.
- Select studio.svg in the file sidebar to preview it.
You should see a colorful studio wall above a dark wooden desk.
Is the studio preview missing shapes?
- Check that every <rect> element ends with />.
- Check that the red path appears before the closing </svg> tag.
- Help me debug missing shapes in studio.svg.
- Create static/images/cafe.svg using the file sidebar.
- Paste this illustration into static/images/cafe.svg:
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 900" role="img" aria-labelledby="title">
<title id="title">Coffee and notebook in morning light</title>
<defs>
<linearGradient id="table" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#dcae7c"/>
<stop offset="1" stop-color="#9d6f4a"/>
</linearGradient>
</defs>
<rect width="600" height="900" fill="url(#table)"/>
<path d="M0 0H315L600 530V900H520Z" fill="#f8e6bd" opacity="0.42"/>
<rect x="80" y="460" width="310" height="260" rx="18" fill="#f8f1df" transform="rotate(-8 235 590)"/>
<path d="M125 520H330M120 565H325M115 610H300" stroke="#bfaa8f" stroke-width="9" stroke-linecap="round"/>
<circle cx="420" cy="305" r="120" fill="#f4ece0"/>
<circle cx="420" cy="305" r="78" fill="#5f3826"/>
<path d="M530 275C610 275 610 365 530 365" fill="none" stroke="#f4ece0" stroke-width="32"/>
</svg>
What creates the cafe scene?
The background gradient forms the tabletop. A translucent path creates the band of morning light.
Circles form the coffee cup. A rotated rectangle forms the notebook.
- Save static/images/cafe.svg.
- Select cafe.svg in the file sidebar to preview it.
You should see a coffee cup beside a notebook in a beam of morning light.
Is the cafe preview blank?
- Confirm the table gradient is declared inside <defs>.
- Confirm the background rectangle uses url(#table) as its fill.
- Help me debug the cafe SVG preview.
The images are ready. The template can now loop over pins without producing missing-image cards.
- Return to templates/index.html in the existing editor tab.
- Find the closing </section> tag directly below the hero text.
- Add this feed immediately after that closing tag:
<section class="pin-grid" aria-label="Pin feed">
{% for pin in pins %}
<article class="pin-card">
<div class="image-wrap">
<img src="{{ pin['image_url'] }}" alt="{{ pin['title'] }}">
</div>
<div class="pin-copy">
<h2>{{ pin['title'] }}</h2>
<p>{{ pin['description'] }}</p>
</div>
</article>
{% endfor %}
</section>
How does the feed render?
- The for loop creates one article for every item in pins.
- Each image reads its source from pin['image_url'].
- The heading and paragraph display the matching title and description.
- Save templates/index.html.
- Refresh the Pinboard page in Chrome.
You should see three illustrated cards beneath the hero. Their content proves the route passed every seed pin into the template.
Are the cards or images missing?
- Confirm the feed sits inside the <main> element.
- Confirm each SVG is inside static/images.
- Help me find why the Jinja loop does not render my three pin cards.
Style and verify the responsive feed
The data flow now works. CSS turns the plain document into a recognizable product by styling the navigation bar and hero before arranging the cards into responsive columns.
- Create static/style.css inside the pinboard folder using the file sidebar.
- Paste these page and top-bar foundations into static/style.css:
:root {
color-scheme: light;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #211b1b;
background: #f7f4f1;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background: #f7f4f1;
}
.topbar {
position: sticky;
top: 0;
z-index: 10;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.9rem clamp(1rem, 4vw, 3rem);
border-bottom: 1px solid #e6dfd9;
background: rgba(255, 255, 255, 0.94);
backdrop-filter: blur(12px);
}
What do the foundations control?
The root rules establish the font and page colors. Universal border sizing keeps element widths predictable.
The top bar stays visible while the page scrolls. Its translucent background separates it from the feed.
- Save static/style.css.
- Refresh the Pinboard page in Chrome.
You should see a pale page background and a white navigation bar fixed to the top edge.
Is the page still completely unstyled?
- Confirm the file path is static/style.css.
- Confirm the template still contains the stylesheet link inside <head>.
- Help me trace why Flask is not loading static/style.css.
- Add these brand and page-shell styles at the bottom of static/style.css:
.brand {
display: inline-flex;
align-items: center;
gap: 0.65rem;
color: #211b1b;
font-size: 1.1rem;
font-weight: 800;
text-decoration: none;
}
.brand-mark {
display: grid;
width: 2.25rem;
height: 2.25rem;
place-items: center;
border-radius: 50%;
color: white;
background: #d7263d;
}
.tagline {
color: #756b67;
font-size: 0.9rem;
}
.page-shell {
width: min(1180px, calc(100% - 2rem));
margin: 0 auto;
padding: 3rem 0 5rem;
}
How does the page gain structure?
The brand styles align the mark with the Pinboard name. The circular red mark becomes the strongest navigation accent.
The page shell limits the content width. Automatic side margins keep the feed centered.
- Save static/style.css.
- Refresh the Pinboard page in Chrome.
You should see a circular red Pinboard mark and centered page content with space around its edges.
Is the brand mark still plain text?
- Confirm the template uses class="brand-mark" on the letter P.
- Confirm the selector begins with .brand-mark.
- Help me compare the brand classes in my HTML and CSS.
- Add these hero and column styles at the bottom of static/style.css:
.hero {
max-width: 700px;
margin-bottom: 2rem;
}
.eyebrow {
margin: 0 0 0.5rem;
color: #d7263d;
font-size: 0.78rem;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.hero h1 {
margin: 0;
font-size: clamp(2.3rem, 7vw, 5.5rem);
line-height: 0.98;
letter-spacing: -0.055em;
}
.hero > p:last-child {
max-width: 560px;
color: #756b67;
font-size: 1.05rem;
}
.pin-grid {
columns: 4 230px;
column-gap: 1rem;
}
How does the layout become responsive?
The hero heading uses clamp() to scale with the browser width. The font remains within a readable size range.
The columns rule creates the masonry-style flow. The browser adjusts the column count when the available width changes.
- Save static/style.css.
- Refresh the Pinboard page in Chrome.
You should see a large hero heading above several image columns.
Are the cards still in one wide column?
- Confirm the feed uses class="pin-grid".
- Confirm the CSS selector is spelled .pin-grid.
- Help me debug why the pin-grid columns are not appearing.
- Add these card and image styles at the bottom of static/style.css:
.pin-card {
display: inline-block;
width: 100%;
margin: 0 0 1rem;
overflow: hidden;
break-inside: avoid;
border-radius: 1.2rem;
background: white;
box-shadow: 0 10px 30px rgba(61, 44, 39, 0.1);
}
.image-wrap {
position: relative;
background: #e9e3de;
}
.image-wrap img {
display: block;
width: 100%;
height: auto;
}
.pin-copy {
padding: 1rem;
}
How do the cards hold together?
Each card avoids breaking across columns. Rounded corners and hidden overflow keep its image inside the card shape.
Images fill the available card width. Their automatic height preserves each SVG's original proportions.
- Save static/style.css.
- Refresh the Pinboard page in Chrome.
You should see three white cards with rounded corners and soft shadows.
Do images overflow their cards?
- Confirm .image-wrap img contains width: 100%.
- Confirm .pin-card contains overflow: hidden.
- Help me fix images overflowing the Pinboard cards.
- Add these card-copy and mobile styles at the bottom of static/style.css:
.pin-copy h2 {
margin: 0 0 0.35rem;
font-size: 1rem;
}
.pin-copy p {
margin: 0;
color: #756b67;
font-size: 0.9rem;
line-height: 1.45;
}
@media (max-width: 680px) {
.tagline {
display: none;
}
.page-shell {
padding-top: 2rem;
}
}
What changes on smaller screens?
The card-copy rules create a clear visual hierarchy between titles and descriptions. The description uses a softer color.
The media query hides the tagline on narrow screens. It also reduces the space above the page content.
- Save static/style.css.
- Refresh the Pinboard page in Chrome.
You should see compact card titles above muted descriptions. The cards should retain their masonry arrangement.
Do the cards still look unstyled?
- Confirm every opening CSS brace has a matching closing brace.
- Confirm the card markup uses pin-card, image-wrap, and pin-copy.
- Help me locate a CSS syntax or class-name mismatch in the Pinboard feed.
Before the final check, how many cards do you expect to see? Think about how their different image heights should affect the columns.
- Return to the Pinboard tab in Google Chrome.
- Refresh the localhost page.
- Resize the Chrome window to make the page narrower.
You should see three illustrated cards for Cliffside escape, Creative studio, and Slow morning. The feed should reduce its column count as the browser becomes narrower.
That is the first complete browser-to-template flow working. Your Flask route now drives a responsive visual feed.
✔️ Awesome, I've got everything!
Your local Pinboard page now shows all three illustrated cards in a responsive masonry-style feed. Keep the Flask server running for the next step.
ⓧ I'd like to double check the full code
Compare each file with the reference below. Keep requirements.txt unchanged.
Flask==3.1.3
from flask import Flask, render_template
app = Flask(__name__)
# Keep the first feed in process memory while the interface takes shape.
pins = [
{
"title": "Cliffside escape",
"description": "A quiet place above the clouds.",
"image_url": "/static/images/cliffs.svg",
},
{
"title": "Creative studio",
"description": "Warm colors for a focused workspace.",
"image_url": "/static/images/studio.svg",
},
{
"title": "Slow morning",
"description": "Coffee, sunlight, and a notebook.",
"image_url": "/static/images/cafe.svg",
},
]
# Pass every pin to the template when the home page is requested.
@app.route("/")
def index():
return render_template("index.html", pins=pins)
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Pinboard</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<header class="topbar">
<a class="brand" href="{{ url_for('index') }}" aria-label="Pinboard home">
<span class="brand-mark">P</span>
<span>Pinboard</span>
</a>
<span class="tagline">Collect what inspires you</span>
</header>
<main class="page-shell">
<section class="hero">
<p class="eyebrow">Your visual idea space</p>
<h1>Save ideas worth returning to.</h1>
<p>Create a pin from an image URL, then keep your favorites close.</p>
</section>
<section class="pin-grid" aria-label="Pin feed">
{% for pin in pins %}
<article class="pin-card">
<div class="image-wrap">
<img src="{{ pin['image_url'] }}" alt="{{ pin['title'] }}">
</div>
<div class="pin-copy">
<h2>{{ pin['title'] }}</h2>
<p>{{ pin['description'] }}</p>
</div>
</article>
{% endfor %}
</section>
</main>
</body>
</html>
:root {
color-scheme: light;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #211b1b;
background: #f7f4f1;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background: #f7f4f1;
}
.topbar {
position: sticky;
top: 0;
z-index: 10;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.9rem clamp(1rem, 4vw, 3rem);
border-bottom: 1px solid #e6dfd9;
background: rgba(255, 255, 255, 0.94);
backdrop-filter: blur(12px);
}
.brand {
display: inline-flex;
align-items: center;
gap: 0.65rem;
color: #211b1b;
font-size: 1.1rem;
font-weight: 800;
text-decoration: none;
}
.brand-mark {
display: grid;
width: 2.25rem;
height: 2.25rem;
place-items: center;
border-radius: 50%;
color: white;
background: #d7263d;
}
.tagline {
color: #756b67;
font-size: 0.9rem;
}
.page-shell {
width: min(1180px, calc(100% - 2rem));
margin: 0 auto;
padding: 3rem 0 5rem;
}
.hero {
max-width: 700px;
margin-bottom: 2rem;
}
.eyebrow {
margin: 0 0 0.5rem;
color: #d7263d;
font-size: 0.78rem;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.hero h1 {
margin: 0;
font-size: clamp(2.3rem, 7vw, 5.5rem);
line-height: 0.98;
letter-spacing: -0.055em;
}
.hero > p:last-child {
max-width: 560px;
color: #756b67;
font-size: 1.05rem;
}
.pin-grid {
columns: 4 230px;
column-gap: 1rem;
}
.pin-card {
display: inline-block;
width: 100%;
margin: 0 0 1rem;
overflow: hidden;
break-inside: avoid;
border-radius: 1.2rem;
background: white;
box-shadow: 0 10px 30px rgba(61, 44, 39, 0.1);
}
.image-wrap {
position: relative;
background: #e9e3de;
}
.image-wrap img {
display: block;
width: 100%;
height: auto;
}
.pin-copy {
padding: 1rem;
}
.pin-copy h2 {
margin: 0 0 0.35rem;
font-size: 1rem;
}
.pin-copy p {
margin: 0;
color: #756b67;
font-size: 0.9rem;
line-height: 1.45;
}
@media (max-width: 680px) {
.tagline {
display: none;
}
.page-shell {
padding-top: 2rem;
}
}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 780" role="img" aria-labelledby="title">
<title id="title">Layered cliffs below a warm sky</title>
<defs>
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#efb8a8"/>
<stop offset="1" stop-color="#f6dfc7"/>
</linearGradient>
</defs>
<rect width="600" height="780" fill="url(#sky)"/>
<circle cx="455" cy="165" r="72" fill="#fff1c9" opacity="0.92"/>
<path d="M0 460 130 330 250 465 360 290 600 505V780H0Z" fill="#7f8f8a"/>
<path d="M0 555 180 405 320 560 455 390 600 540V780H0Z" fill="#536c69"/>
<path d="M0 650 155 530 280 625 410 500 600 650V780H0Z" fill="#304f4d"/>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 650" role="img" aria-labelledby="title">
<title id="title">Colorful creative studio desk</title>
<rect width="600" height="650" fill="#f2c6a0"/>
<rect x="70" y="85" width="190" height="220" rx="18" fill="#fff7eb"/>
<circle cx="165" cy="175" r="58" fill="#e96f51"/>
<path d="M107 265 158 200 212 265Z" fill="#556f67"/>
<rect x="335" y="65" width="150" height="260" rx="75" fill="#6d8178"/>
<rect x="70" y="395" width="460" height="42" rx="18" fill="#654d43"/>
<rect x="115" y="437" width="26" height="150" fill="#654d43"/>
<rect x="462" y="437" width="26" height="150" fill="#654d43"/>
<rect x="240" y="330" width="145" height="65" rx="8" fill="#fff4da"/>
<path d="M270 330 300 260 334 330Z" fill="#d7263d"/>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 900" role="img" aria-labelledby="title">
<title id="title">Coffee and notebook in morning light</title>
<defs>
<linearGradient id="table" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#dcae7c"/>
<stop offset="1" stop-color="#9d6f4a"/>
</linearGradient>
</defs>
<rect width="600" height="900" fill="url(#table)"/>
<path d="M0 0H315L600 530V900H520Z" fill="#f8e6bd" opacity="0.42"/>
<rect x="80" y="460" width="310" height="260" rx="18" fill="#f8f1df" transform="rotate(-8 235 590)"/>
<path d="M125 520H330M120 565H325M115 610H300" stroke="#bfaa8f" stroke-width="9" stroke-linecap="round"/>
<circle cx="420" cy="305" r="120" fill="#f4ece0"/>
<circle cx="420" cy="305" r="78" fill="#5f3826"/>
<path d="M530 275C610 275 610 365 530 365" fill="none" stroke="#f4ece0" stroke-width="32"/>
</svg>
Your read-only Pinboard feed is live. Next, you will add a form that lets the browser send new pin data back to Flask.
Create In-Memory Pins
Your read-only Pinboard feed now renders three illustrated cards through Flask.
People can browse those ideas. They still cannot add an idea of their own.
This step adds a Create Pin form and connects it to a new route. Each submission stays in Python process memory, which gives you a limitation you can see during the restart test.
In this step, get ready to:
- Add a Create Pin form above the feed.
- Handle form submissions with an in-memory route.
- Restart Flask to expose temporary data loss.
Add the Create Pin form
An HTML form collects values from the browser before sending them to a server route. Pinboard needs fields for a title, an image URL, and an optional description.
- Switch to templates/index.html in Visual Studio Code.
- Find the <section class="pin-grid" aria-label="Pin feed"> line.
- Add the form immediately above that line by pasting this code:
<form class="pin-form" action="{{ url_for('create_pin') }}" method="post">
<label>
Title
<input name="title" type="text" placeholder="Cozy reading corner" required>
</label>
<label>
Image URL
<input name="image_url" type="text" placeholder="Paste a direct image URL" required>
</label>
<label class="wide-field">
Description
<textarea name="description" rows="2" placeholder="Why are you saving this idea?"></textarea>
</label>
<button class="create-button" type="submit">Create pin</button>
</form>
What does this form do?
- The action uses url_for('create_pin') to build the form destination from the Flask function name.
- The method="post" attribute sends the entered values in the request body.
- The name attributes become the keys that Flask reads from the submitted form.
- The required attributes prevent an empty title or image URL from being submitted.
- Save templates/index.html.
- Switch back to Google Chrome.
- Refresh the Pinboard page.
You should see three fields and a Create pin button above the existing card feed.
Don't see the form?
- Confirm the form sits inside <main class="page-shell"> and above the pin feed.
- Check that the Flask terminal still reports a running development server.
- Use a hard refresh if Google Chrome still shows the previous template.
- Help me find why my Create Pin form is missing.
The form is visible now. A grid container gives its fields a clear structure above the masonry feed.
- Switch to static/style.css in Visual Studio Code.
- Find the .pin-grid block.
- Add the form container styles immediately above that block by pasting this code:
.pin-form {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
margin-bottom: 2.5rem;
padding: 1.25rem;
border: 1px solid #e6dfd9;
border-radius: 1.5rem;
background: white;
box-shadow: 0 14px 40px rgba(61, 44, 39, 0.08);
}
What do these styles change?
The grid places the first two fields into equal columns. The spacing and white background separate the form from the feed below it.
- Save static/style.css.
- Refresh Pinboard in Google Chrome.
You should see the form inside a rounded white panel with two columns.
Is the form still unstyled?
- Check that the selector starts with .pin-form.
- Confirm the template still uses class="pin-form" on the form element.
- Help me debug why the pin-form styles are not appearing.
Labels give each field a readable name. A small internal gap keeps that name separate from its control.
- Add the label styles below the .pin-form block by pasting this code:
.pin-form label {
display: grid;
gap: 0.45rem;
color: #514743;
font-size: 0.85rem;
font-weight: 700;
}
What do the label styles do?
Each label becomes a small grid containing its text and control. The heavier text makes the field purpose easy to scan.
- Save static/style.css.
- Refresh Pinboard in Google Chrome.
You should see each field name above its input with consistent spacing.
Are the labels unchanged?
- Confirm the selector contains a space between .pin-form and label.
- Check that each input remains nested inside its <label> element.
- Help me fix the label styles in my Pinboard form.
The controls need consistent dimensions so the form feels like one interface. Shared styles keep the text input and textarea visually aligned.
- Add the shared control styles below the label block by pasting this code:
.pin-form input,
.pin-form textarea {
width: 100%;
padding: 0.8rem 0.9rem;
border: 1px solid #d9d0ca;
border-radius: 0.85rem;
color: #211b1b;
background: #fcfbfa;
outline: none;
}
What do the control styles do?
The grouped selectors apply one visual treatment to every input and textarea in the form. Full width makes each control fill its grid cell.
- Save static/style.css.
- Refresh Pinboard in Google Chrome.
You should see padded fields with rounded borders and a pale background.
Do the fields still look plain?
- Confirm both selectors begin with .pin-form.
- Check for the comma after .pin-form input.
- Help me debug the input and textarea styles.
Focus styling shows which field currently receives keyboard input. The final layout rules also widen the description and shape the submit button.
- Add the remaining form styles below the control block by pasting this code:
.pin-form input:focus,
.pin-form textarea:focus {
border-color: #d7263d;
box-shadow: 0 0 0 3px rgba(215, 38, 61, 0.12);
}
.wide-field {
grid-column: 1 / -1;
}
.create-button {
justify-self: start;
padding: 0.8rem 1.2rem;
border: 0;
border-radius: 999px;
color: white;
font-weight: 800;
background: #211b1b;
cursor: pointer;
}
How is the form finished?
- The focus selectors add a red border and soft ring to the active control.
- The .wide-field selector stretches the description across both columns.
- The .create-button selector turns the submit control into a dark pill-shaped button.
- Save static/style.css.
- Refresh Pinboard in Google Chrome.
- Select the Title field.
You should see a red focus ring around the title field. The description spans the full panel width, while the Create pin button appears as a dark rounded control.
Is the final form layout off?
- Confirm .wide-field matches the class on the description label.
- Check that .create-button matches the class on the submit button.
- Help me correct the final Pinboard form styles.
Connect the form to Flask
Submitting the form sends a POST request to /pins. The route reads each field before adding one dictionary to the existing pins list.
- Switch to app.py in Visual Studio Code.
- Find the Flask import at the top of the file.
- Replace that import with this expanded version:
from flask import Flask, redirect, render_template, request, url_for
Why add these imports?
- The request object provides the submitted form values.
- The redirect function sends the browser back to the feed after submission.
- The url_for function builds that destination from the index function name.
- Find the end of the existing index() function.
- Add the new route below it by pasting this code:
@app.route("/pins", methods=["POST"])
def create_pin():
title = request.form.get("title", "").strip()
description = request.form.get("description", "").strip()
image_url = request.form.get("image_url", "").strip()
if title and image_url:
pins.append(
{
"title": title,
"description": description,
"image_url": image_url,
}
)
return redirect(url_for("index"))
What does this route do?
- The route accepts submissions at /pins through the POST method.
- Each request.form.get call reads one named form field and removes surrounding whitespace.
- The if title and image_url check protects the feed from incomplete entries.
- The pins.append call stores the new dictionary in the running Python process.
- The redirect returns the browser to index() so the updated feed renders immediately.
- Save app.py.
- Switch back to Google Chrome.
- Enter A café reset in the Title field.
- Enter /static/images/cafe.svg in the Image URL field.
- Enter A quiet break with coffee and notes. in the Description field.
- Click Create pin.
You should return to the feed and see the A café reset card. You have the first interactive Pinboard flow working.
Does the submission fail?
- Check the terminal for a Python syntax error after saving app.py.
- Confirm the form action uses url_for('create_pin').
- Verify the route decorator contains methods=["POST"].
- Help me debug my Create Pin form submission.
✔️ My new pin appears
Great. Your browser can now send a pin through Flask and render it in the feed.
ⓧ I'd like to double check the full code
Compare the three changed files below with your project. Keep the existing image files and requirements.txt unchanged.
from flask import Flask, redirect, render_template, request, url_for
app = Flask(__name__)
pins = [
{
"title": "Cliffside escape",
"description": "A quiet place above the clouds.",
"image_url": "/static/images/cliffs.svg",
},
{
"title": "Creative studio",
"description": "Warm colors for a focused workspace.",
"image_url": "/static/images/studio.svg",
},
{
"title": "Slow morning",
"description": "Coffee, sunlight, and a notebook.",
"image_url": "/static/images/cafe.svg",
},
]
@app.route("/")
def index():
return render_template("index.html", pins=pins)
@app.route("/pins", methods=["POST"])
def create_pin():
title = request.form.get("title", "").strip()
description = request.form.get("description", "").strip()
image_url = request.form.get("image_url", "").strip()
if title and image_url:
pins.append(
{
"title": title,
"description": description,
"image_url": image_url,
}
)
return redirect(url_for("index"))
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Pinboard</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<header class="topbar">
<a class="brand" href="{{ url_for('index') }}" aria-label="Pinboard home">
<span class="brand-mark">P</span>
<span>Pinboard</span>
</a>
<span class="tagline">Collect what inspires you</span>
</header>
<main class="page-shell">
<section class="hero">
<p class="eyebrow">Your visual idea space</p>
<h1>Save ideas worth returning to.</h1>
<p>Create a pin from an image URL, then keep your favorites close.</p>
</section>
<form class="pin-form" action="{{ url_for('create_pin') }}" method="post">
<label>
Title
<input name="title" type="text" placeholder="Cozy reading corner" required>
</label>
<label>
Image URL
<input name="image_url" type="text" placeholder="Paste a direct image URL" required>
</label>
<label class="wide-field">
Description
<textarea name="description" rows="2" placeholder="Why are you saving this idea?"></textarea>
</label>
<button class="create-button" type="submit">Create pin</button>
</form>
<section class="pin-grid" aria-label="Pin feed">
{% for pin in pins %}
<article class="pin-card">
<div class="image-wrap">
<img src="{{ pin['image_url'] }}" alt="{{ pin['title'] }}">
</div>
<div class="pin-copy">
<h2>{{ pin['title'] }}</h2>
<p>{{ pin['description'] }}</p>
</div>
</article>
{% endfor %}
</section>
</main>
</body>
</html>
:root {
color-scheme: light;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #211b1b;
background: #f7f4f1;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background: #f7f4f1;
}
button,
input,
textarea {
font: inherit;
}
.topbar {
position: sticky;
top: 0;
z-index: 10;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.9rem clamp(1rem, 4vw, 3rem);
border-bottom: 1px solid #e6dfd9;
background: rgba(255, 255, 255, 0.94);
backdrop-filter: blur(12px);
}
.brand {
display: inline-flex;
align-items: center;
gap: 0.65rem;
color: #211b1b;
font-size: 1.1rem;
font-weight: 800;
text-decoration: none;
}
.brand-mark {
display: grid;
width: 2.25rem;
height: 2.25rem;
place-items: center;
border-radius: 50%;
color: white;
background: #d7263d;
}
.tagline {
color: #756b67;
font-size: 0.9rem;
}
.page-shell {
width: min(1180px, calc(100% - 2rem));
margin: 0 auto;
padding: 3rem 0 5rem;
}
.hero {
max-width: 700px;
margin-bottom: 2rem;
}
.eyebrow {
margin: 0 0 0.5rem;
color: #d7263d;
font-size: 0.78rem;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.hero h1 {
margin: 0;
font-size: clamp(2.3rem, 7vw, 5.5rem);
line-height: 0.98;
letter-spacing: -0.055em;
}
.hero > p:last-child {
max-width: 560px;
color: #756b67;
font-size: 1.05rem;
}
.pin-form {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
margin-bottom: 2.5rem;
padding: 1.25rem;
border: 1px solid #e6dfd9;
border-radius: 1.5rem;
background: white;
box-shadow: 0 14px 40px rgba(61, 44, 39, 0.08);
}
.pin-form label {
display: grid;
gap: 0.45rem;
color: #514743;
font-size: 0.85rem;
font-weight: 700;
}
.pin-form input,
.pin-form textarea {
width: 100%;
padding: 0.8rem 0.9rem;
border: 1px solid #d9d0ca;
border-radius: 0.85rem;
color: #211b1b;
background: #fcfbfa;
outline: none;
}
.pin-form input:focus,
.pin-form textarea:focus {
border-color: #d7263d;
box-shadow: 0 0 0 3px rgba(215, 38, 61, 0.12);
}
.wide-field {
grid-column: 1 / -1;
}
.create-button {
justify-self: start;
padding: 0.8rem 1.2rem;
border: 0;
border-radius: 999px;
color: white;
font-weight: 800;
background: #211b1b;
cursor: pointer;
}
.pin-grid {
columns: 4 230px;
column-gap: 1rem;
}
.pin-card {
display: inline-block;
width: 100%;
margin: 0 0 1rem;
overflow: hidden;
break-inside: avoid;
border-radius: 1.2rem;
background: white;
box-shadow: 0 10px 30px rgba(61, 44, 39, 0.1);
}
.image-wrap {
position: relative;
background: #e9e3de;
}
.image-wrap img {
display: block;
width: 100%;
height: auto;
}
.pin-copy {
padding: 1rem;
}
.pin-copy h2 {
margin: 0 0 0.35rem;
font-size: 1rem;
}
.pin-copy p {
margin: 0;
color: #756b67;
font-size: 0.9rem;
line-height: 1.45;
}
@media (max-width: 680px) {
.tagline {
display: none;
}
.page-shell {
padding-top: 2rem;
}
.pin-form {
grid-template-columns: 1fr;
}
.wide-field {
grid-column: auto;
}
}
Prove process memory is temporary
The new card currently lives inside the running Python process. Restarting that process creates a clean copy of the original seed list.
Before you restart Flask, do you think the A café reset card will still appear afterward?
- Switch back to the Visual Studio Code terminal running Flask.
- Stop the development server with Control-C.
- Restart the development server by running this command:
flask run --debug
What does restarting prove?
The command starts a new Python process. That process rebuilds pins from the three dictionaries written in app.py.
- Return to the existing Pinboard tab in Google Chrome.
- Reload the page.
You should see the original three cards. The A café reset card has disappeared.
Why did the pin disappear?
Process memory lasts only while the program is running. The form changed the live pins list without changing a file on disk.
That disappearance is the intended result. You now have direct evidence that user-created pins need persistent storage.
Is the test result different?
- Confirm you stopped the Flask process before running the server command again.
- Check that the A café reset dictionary was not pasted into the seed list in app.py.
- Verify that create_pin() still uses pins.append.
- Help me understand why my in-memory pin survived the restart.
Your form now completes a full browser-to-Flask interaction, and the restart exposed its weak point. Next, you will store pins on disk so they survive beyond one server process.
Persist Pins with SQLite
Your Create Pin form already carries a new card through Flask and into the feed. The restart test exposed how process memory loses that card when the server stops.
SQLite stores each pin in a local relational database. This gives Pinboard durable storage without changing the form your browser already uses.
In this step, get ready to:
- Define a request-scoped SQLite connection.
- Create the pins table with its seed rows.
- Move the feed and Create Pin route onto persistent storage.
Build the database layer
A request-scoped connection gives each request access to one database connection. Flask keeps that connection in g until the request context closes.
- In app.py, replace the import section through the app = Flask(__name__) line with the code below:
import sqlite3
from flask import Flask, g, redirect, render_template, request, url_for
app = Flask(__name__)
DATABASE = "pinboard.db"
What does this setup add?
- The sqlite3 module connects Python to the local database file.
- Flask stores the active connection in g for the current request context.
- DATABASE gives every connection the same file path.
- Save app.py.
- Check the existing terminal for a successful debug reload.
The server reloads without an import problem. The temporary in-memory feed still works while you assemble its replacement.
Does the server stop during reload?
- Confirm that import sqlite3 sits at the top of app.py.
- Confirm that g appears once in the Flask import.
- Ask for help with the import change.
The database needs the same three starter cards that currently live in the temporary list. Tuples keep each title, description, and image path in the order expected by the future insert.
- In app.py, find DATABASE = "pinboard.db".
- Add the seed data directly below that line:
SEED_PINS = [
(
"Cliffside escape",
"A quiet place above the clouds.",
"/static/images/cliffs.svg",
),
(
"Creative studio",
"Warm colors for a focused workspace.",
"/static/images/studio.svg",
),
(
"Slow morning",
"Coffee, sunlight, and a notebook.",
"/static/images/cafe.svg",
),
]
How are the seed pins organized?
Each tuple contains the values for one starter card. The database can insert the whole collection with one operation.
- Save app.py.
- Check the terminal for another successful debug reload.
The reload confirms that every tuple has valid Python syntax. Your feed continues to use the temporary list for now.
Seeing a syntax problem near the seeds?
- Check that each seed tuple has matching parentheses.
- Check that SEED_PINS ends with a closing square bracket.
- Ask for help with the seed structure.
Opening a fresh connection for every database operation would repeat work inside one request. The next helper opens the connection once before returning it wherever the route needs it.
- In app.py, find the end of the temporary pins list.
- Add the connection helper directly below that list:
def get_db():
db = getattr(g, "_database", None)
if db is None:
db = g._database = sqlite3.connect(DATABASE)
db.row_factory = sqlite3.Row
return db
How does the connection helper work?
- get_db() checks whether the current context already has a connection.
- sqlite3.connect(DATABASE) opens pinboard.db when a connection is needed.
- sqlite3.Row lets the template access values through column names such as title.
- Save app.py.
- Refresh the existing Pinboard page in Google Chrome.
You still see the illustrated feed. This proves that the helper can coexist with the temporary routes during the migration.
Does the feed fail to reload?
- Confirm that get_db() sits outside the temporary list.
- Check the indentation inside the if db is None: block.
- Ask for help with the connection helper.
A request-scoped connection also needs a matching cleanup hook. Flask calls this function when the application context ends.
- Add the cleanup function directly below get_db() in app.py by using this code:
@app.teardown_appcontext
def close_connection(exception):
db = getattr(g, "_database", None)
if db is not None:
db.close()
Why close the connection here?
close_connection() checks for the connection stored in g. It closes that connection after Flask finishes the context.
- Save app.py.
- Check the terminal for a successful debug reload.
The connection lifecycle is now complete. Routes can borrow one connection without leaving it open after the context ends.
Does the teardown hook fail?
- Confirm that @app.teardown_appcontext sits directly above close_connection().
- Confirm that db.close() is indented beneath the database check.
- Ask for help with the cleanup hook.
The connection can open a database file, but that file still needs a table. Initialization creates the schema and inserts the starter pins only when the table is empty.
- Add the initialization code directly below close_connection() in app.py by using this block:
def init_db():
db = get_db()
db.execute(
"""
CREATE TABLE IF NOT EXISTS pins (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT NOT NULL,
image_url TEXT NOT NULL,
saved INTEGER NOT NULL DEFAULT 0
)
"""
)
pin_count = db.execute("SELECT COUNT(*) FROM pins").fetchone()[0]
if pin_count == 0:
db.executemany(
"""
INSERT INTO pins (title, description, image_url)
VALUES (?, ?, ?)
""",
SEED_PINS,
)
db.commit()
with app.app_context():
init_db()
What does initialization create?
- CREATE TABLE IF NOT EXISTS creates the pins table once.
- saved defaults to 0 for every new row.
- pin_count prevents duplicate seed rows after later restarts.
- db.commit() persists the schema and seed rows on disk.
- Save app.py.
- Check the terminal for a successful debug reload.
- Confirm that pinboard.db appears in the Explorer sidebar in Visual Studio Code.
That is the storage backbone in place. Pinboard now has an on-disk table containing the three seed rows.
Missing pinboard.db?
- Confirm that with app.app_context(): sits below init_db().
- Confirm that the init_db() call is indented inside the application context.
- Ask for help with database initialization.
Move the routes to SQLite
The database now contains the seed pins, but the feed still reads the temporary Python list. Updating the index route makes database rows the source of every rendered card.
- In app.py, locate the existing index() route.
- Replace that route with the database-backed version below:
@app.route("/")
def index():
pins = get_db().execute(
"SELECT id, title, description, image_url, saved FROM pins ORDER BY id DESC"
).fetchall()
return render_template("index.html", pins=pins)
How does the feed query work?
- get_db() supplies the connection for the current request.
- SELECT reads every column needed by the current feed.
- ORDER BY id DESC places the newest pin first.
- fetchall() returns the rows for the template.
- Save app.py.
- Refresh Pinboard in Chrome.
You see the three illustrated cards with Slow morning first. Those cards now come from pinboard.db.
Do the seed cards disappear?
- Confirm that the query uses the table name pins.
- Confirm that fetchall() appears before the template return statement.
- Ask for help with the feed query.
Reading from SQLite completes one half of the flow. The Create Pin route must write submitted values into that same table.
- In app.py, locate the existing create_pin() route.
- Replace that route with the database-backed version below:
@app.route("/pins", methods=["POST"])
def create_pin():
title = request.form.get("title", "").strip()
description = request.form.get("description", "").strip()
image_url = request.form.get("image_url", "").strip()
if title and image_url:
db = get_db()
db.execute(
"""
INSERT INTO pins (title, description, image_url)
VALUES (?, ?, ?)
""",
(title, description, image_url),
)
db.commit()
return redirect(url_for("index"))
How does this route persist a pin?
- request.form.get() reads the three existing form fields.
- Parameterized SQL keeps the query separate from the submitted values through ? placeholders.
- db.commit() saves the inserted row in pinboard.db.
- redirect() sends the browser back to the updated feed.
- Save app.py.
- Enter Persistent morning in the Title field.
- Enter /static/images/cafe.svg in the Image URL field.
- Enter Stored in SQLite in the Description field.
- Click Create pin.
You see Persistent morning at the top of the feed. The submitted values now live in a database row.
Does the new pin fail to appear?
- Confirm that the insert receives title, description, and image_url in that order.
- Confirm that db.commit() remains inside the form validation block.
- Ask for help with the insert route.
Both routes now use SQLite, so the mutable temporary list has finished its job. Removing it leaves one source of truth for every pin.
- Delete the old temporary pins list containing dictionaries from app.py.
- Keep the new SEED_PINS tuple list in place.
- Save app.py.
- Refresh Pinboard in Chrome.
Persistent morning remains at the top of the feed. The app no longer depends on the old in-memory collection.
Does removing the old list break the feed?
- Confirm that you deleted the dictionary-based pins list.
- Confirm that SEED_PINS still appears below DATABASE.
- Ask for help distinguishing the two collections.
Prove persistence across a restart
Before you restart Flask, do you expect Persistent morning to remain in the feed this time?
- Return to the terminal running Flask.
- Press Control-C to stop the server.
- Restart the debug server by running this command:
flask run --debug
What does the restart test prove?
The restarted process begins with fresh memory. During startup, init_db() reconnects to the existing database without duplicating its seed rows.
- Reload Pinboard in Chrome after the server starts.
- Confirm that Persistent morning remains at the top of the feed.
- Confirm that pinboard.db remains in the pinboard folder.
You have closed the data-loss gap. The full request-to-database flow now preserves new pins beyond the lifetime of the Flask process.
Does the pin disappear after restart?
- Confirm that create_pin() calls db.commit() after the insert.
- Confirm that index() reads rows through get_db().
- Ask for help tracing the persistence flow.
✔️ Awesome, I've got everything!
Your created pin survives a Flask restart. Double check that app.py is saved and pinboard.db remains in the pinboard folder.
ⓧ I'd like to double check the full code
Compare your completed app.py with this SQLite-backed version.
import sqlite3
from flask import Flask, g, redirect, render_template, request, url_for
app = Flask(__name__)
DATABASE = "pinboard.db"
SEED_PINS = [
(
"Cliffside escape",
"A quiet place above the clouds.",
"/static/images/cliffs.svg",
),
(
"Creative studio",
"Warm colors for a focused workspace.",
"/static/images/studio.svg",
),
(
"Slow morning",
"Coffee, sunlight, and a notebook.",
"/static/images/cafe.svg",
),
]
def get_db():
db = getattr(g, "_database", None)
if db is None:
db = g._database = sqlite3.connect(DATABASE)
db.row_factory = sqlite3.Row
return db
@app.teardown_appcontext
def close_connection(exception):
db = getattr(g, "_database", None)
if db is not None:
db.close()
def init_db():
db = get_db()
db.execute(
"""
CREATE TABLE IF NOT EXISTS pins (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT NOT NULL,
image_url TEXT NOT NULL,
saved INTEGER NOT NULL DEFAULT 0
)
"""
)
pin_count = db.execute("SELECT COUNT(*) FROM pins").fetchone()[0]
if pin_count == 0:
db.executemany(
"""
INSERT INTO pins (title, description, image_url)
VALUES (?, ?, ?)
""",
SEED_PINS,
)
db.commit()
with app.app_context():
init_db()
@app.route("/")
def index():
pins = get_db().execute(
"SELECT id, title, description, image_url, saved FROM pins ORDER BY id DESC"
).fetchall()
return render_template("index.html", pins=pins)
@app.route("/pins", methods=["POST"])
def create_pin():
title = request.form.get("title", "").strip()
description = request.form.get("description", "").strip()
image_url = request.form.get("image_url", "").strip()
if title and image_url:
db = get_db()
db.execute(
"""
INSERT INTO pins (title, description, image_url)
VALUES (?, ?, ?)
""",
(title, description, image_url),
)
db.commit()
return redirect(url_for("index"))
What should this file contain?
The completed file defines the database lifecycle before the two routes. Both routes use the same request-scoped connection pattern.
Your pins now survive a full server restart. Next up, you will add a Save interaction that updates SQLite without reloading the page.
Save Pins Without Reloading
Your Pinboard feed now writes every new card to SQLite. A Flask restart no longer wipes out your ideas.
A full page reload after every Save click would make the feed feel clumsy. An asynchronous browser request lets each card update in place.
In this step, get ready to:
- Add a route that toggles a pin's saved value in the database.
- Connect each card's Save button to browser-side JavaScript.
- Confirm saved state survives a browser refresh.
Add the save route
Each Save click needs a server endpoint that can identify one pin. The endpoint reads the current value before writing the opposite value back to the same database row.
- Return to app.py in Visual Studio Code.
- Add this route below the existing create_pin() function:
@app.route("/pins/<int:pin_id>/toggle-save", methods=["POST"])
def toggle_save(pin_id):
db = get_db()
pin = db.execute(
"SELECT saved FROM pins WHERE id = ?",
(pin_id,),
).fetchone()
if pin is None:
return {"error": "Pin not found"}, 404
saved = 0 if pin["saved"] else 1
db.execute(
"UPDATE pins SET saved = ? WHERE id = ?",
(saved, pin_id),
)
db.commit()
return {"saved": bool(saved)}
What does this route do?
- The route captures the pin's numeric ID from the request URL.
- The first parameterized query reads the pin's current saved value.
- A missing row returns {"error": "Pin not found"}, 404.
- The second parameterized query stores the opposite integer value. The commit() call makes that update persistent.
- The returned dictionary becomes a JSON response that the browser can read.
- Save app.py.
- Return to the Visual Studio Code terminal from earlier.
You should see the debug server reload without a Python traceback. That confirms Flask can import the new route.
Did the server stop after saving?
Check that toggle_save() begins at the left edge of app.py. Confirm every opening parenthesis has a matching closing parenthesis.
Use the final line in the terminal to locate the affected line. Help me fix my save route.
✔️ The server reloaded
Your backend now has a database-backed endpoint for changing one pin's saved state.
ⓧ I'd like to double check the full code
import sqlite3
from flask import Flask, g, redirect, render_template, request, url_for
app = Flask(__name__)
DATABASE = "pinboard.db"
SEED_PINS = [
(
"Cliffside escape",
"A quiet place above the clouds.",
"/static/images/cliffs.svg",
),
(
"Creative studio",
"Warm colors for a focused workspace.",
"/static/images/studio.svg",
),
(
"Slow morning",
"Coffee, sunlight, and a notebook.",
"/static/images/cafe.svg",
),
]
def get_db():
db = getattr(g, "_database", None)
if db is None:
db = g._database = sqlite3.connect(DATABASE)
db.row_factory = sqlite3.Row
return db
@app.teardown_appcontext
def close_connection(exception):
db = getattr(g, "_database", None)
if db is not None:
db.close()
def init_db():
db = get_db()
db.execute(
"""
CREATE TABLE IF NOT EXISTS pins (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT NOT NULL,
image_url TEXT NOT NULL,
saved INTEGER NOT NULL DEFAULT 0
)
"""
)
pin_count = db.execute("SELECT COUNT(*) FROM pins").fetchone()[0]
if pin_count == 0:
db.executemany(
"""
INSERT INTO pins (title, description, image_url)
VALUES (?, ?, ?)
""",
SEED_PINS,
)
db.commit()
with app.app_context():
init_db()
@app.route("/")
def index():
pins = get_db().execute(
"SELECT id, title, description, image_url, saved FROM pins ORDER BY id DESC"
).fetchall()
return render_template("index.html", pins=pins)
@app.route("/pins", methods=["POST"])
def create_pin():
title = request.form.get("title", "").strip()
description = request.form.get("description", "").strip()
image_url = request.form.get("image_url", "").strip()
if title and image_url:
db = get_db()
db.execute(
"""
INSERT INTO pins (title, description, image_url)
VALUES (?, ?, ?)
""",
(title, description, image_url),
)
db.commit()
return redirect(url_for("index"))
@app.route("/pins/<int:pin_id>/toggle-save", methods=["POST"])
def toggle_save(pin_id):
db = get_db()
pin = db.execute(
"SELECT saved FROM pins WHERE id = ?",
(pin_id,),
).fetchone()
if pin is None:
return {"error": "Pin not found"}, 404
saved = 0 if pin["saved"] else 1
db.execute(
"UPDATE pins SET saved = ? WHERE id = ?",
(saved, pin_id),
)
db.commit()
return {"saved": bool(saved)}
Connect each card to the browser
The route is ready to change a database row. Each card now needs a button carrying its pin ID so the browser can address the correct row.
- Switch back to templates/index.html.
- Locate the existing article inside the {% for pin in pins %} loop.
- Replace that article with this card markup:
<article class="pin-card">
<div class="image-wrap">
<img src="{{ pin['image_url'] }}" alt="{{ pin['title'] }}">
<button
class="save-button{% if pin['saved'] %} saved{% endif %}"
type="button"
data-pin-id="{{ pin['id'] }}"
>
{% if pin['saved'] %}Saved{% else %}Save{% endif %}
</button>
</div>
<div class="pin-copy">
<h2>{{ pin['title'] }}</h2>
<p>{{ pin['description'] }}</p>
</div>
</article>
How does each card reflect SQLite?
- The data-pin-id attribute stores the database ID on the matching button.
- The Jinja condition adds the saved class when SQLite holds a saved value.
- The same condition chooses between the visible Save and Saved labels.
- Save templates/index.html.
- Refresh Pinboard in Chrome.
Good progress. Every card now shows a Save button linked to its own database row.
Don't see a button on each card?
Confirm the button sits inside the {% for pin in pins %} loop. Check that the closing {% endif %} statements remain present.
Refresh Chrome after saving the template. Help me debug the missing Save buttons.
The buttons now carry the right data. Styling makes their unsaved and saved states easy to distinguish at a glance.
- Return to static/style.css.
- Add these button rules below the existing .image-wrap img rule:
.save-button {
position: absolute;
top: 0.8rem;
right: 0.8rem;
padding: 0.65rem 0.9rem;
border: 0;
border-radius: 999px;
color: white;
font-weight: 800;
background: #d7263d;
box-shadow: 0 6px 18px rgba(48, 21, 24, 0.25);
cursor: pointer;
}
.save-button.saved {
color: #211b1b;
background: white;
}
.save-button:disabled {
cursor: wait;
opacity: 0.65;
}
What do these styles show?
- The base rule places a red Save button over the top-right corner of each image.
- The .saved rule changes a saved button to a white state.
- The disabled rule fades the button while its request is running.
- Save static/style.css.
- Refresh Pinboard in Chrome.
You should see a red pill-shaped Save button over each card image. Any previously saved row appears with a white Saved button.
Are the buttons still unstyled?
Check that each selector begins with .save-button. Confirm the template button uses the matching save-button class.
Save the stylesheet before refreshing Chrome. Help me fix the Save button styling.
✔️ The buttons are styled
The cards now expose a clear visual control for changing saved state.
ⓧ I'd like to double check the full code
:root {
color-scheme: light;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #211b1b;
background: #f7f4f1;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background: #f7f4f1;
}
button,
input,
textarea {
font: inherit;
}
.topbar {
position: sticky;
top: 0;
z-index: 10;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.9rem clamp(1rem, 4vw, 3rem);
border-bottom: 1px solid #e6dfd9;
background: rgba(255, 255, 255, 0.94);
backdrop-filter: blur(12px);
}
.brand {
display: inline-flex;
align-items: center;
gap: 0.65rem;
color: #211b1b;
font-size: 1.1rem;
font-weight: 800;
text-decoration: none;
}
.brand-mark {
display: grid;
width: 2.25rem;
height: 2.25rem;
place-items: center;
border-radius: 50%;
color: white;
background: #d7263d;
}
.tagline {
color: #756b67;
font-size: 0.9rem;
}
.page-shell {
width: min(1180px, calc(100% - 2rem));
margin: 0 auto;
padding: 3rem 0 5rem;
}
.hero {
max-width: 700px;
margin-bottom: 2rem;
}
.eyebrow {
margin: 0 0 0.5rem;
color: #d7263d;
font-size: 0.78rem;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.hero h1 {
margin: 0;
font-size: clamp(2.3rem, 7vw, 5.5rem);
line-height: 0.98;
letter-spacing: -0.055em;
}
.hero > p:last-child {
max-width: 560px;
color: #756b67;
font-size: 1.05rem;
}
.pin-form {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
margin-bottom: 2.5rem;
padding: 1.25rem;
border: 1px solid #e6dfd9;
border-radius: 1.5rem;
background: white;
box-shadow: 0 14px 40px rgba(61, 44, 39, 0.08);
}
.pin-form label {
display: grid;
gap: 0.45rem;
color: #514743;
font-size: 0.85rem;
font-weight: 700;
}
.pin-form input,
.pin-form textarea {
width: 100%;
padding: 0.8rem 0.9rem;
border: 1px solid #d9d0ca;
border-radius: 0.85rem;
color: #211b1b;
background: #fcfbfa;
outline: none;
}
.pin-form input:focus,
.pin-form textarea:focus {
border-color: #d7263d;
box-shadow: 0 0 0 3px rgba(215, 38, 61, 0.12);
}
.wide-field {
grid-column: 1 / -1;
}
.create-button {
justify-self: start;
padding: 0.8rem 1.2rem;
border: 0;
border-radius: 999px;
color: white;
font-weight: 800;
background: #211b1b;
cursor: pointer;
}
.pin-grid {
columns: 4 230px;
column-gap: 1rem;
}
.pin-card {
display: inline-block;
width: 100%;
margin: 0 0 1rem;
overflow: hidden;
break-inside: avoid;
border-radius: 1.2rem;
background: white;
box-shadow: 0 10px 30px rgba(61, 44, 39, 0.1);
}
.image-wrap {
position: relative;
background: #e9e3de;
}
.image-wrap img {
display: block;
width: 100%;
height: auto;
}
.save-button {
position: absolute;
top: 0.8rem;
right: 0.8rem;
padding: 0.65rem 0.9rem;
border: 0;
border-radius: 999px;
color: white;
font-weight: 800;
background: #d7263d;
box-shadow: 0 6px 18px rgba(48, 21, 24, 0.25);
cursor: pointer;
}
.save-button.saved {
color: #211b1b;
background: white;
}
.save-button:disabled {
cursor: wait;
opacity: 0.65;
}
.pin-copy {
padding: 1rem;
}
.pin-copy h2 {
margin: 0 0 0.35rem;
font-size: 1rem;
}
.pin-copy p {
margin: 0;
color: #756b67;
font-size: 0.9rem;
line-height: 1.45;
}
@media (max-width: 680px) {
.tagline {
display: none;
}
.page-shell {
padding-top: 2rem;
}
.pin-form {
grid-template-columns: 1fr;
}
.wide-field {
grid-column: auto;
}
}
A button click now needs browser logic that sends the request without navigating away. The Fetch API handles that request before updating only the clicked button.
- Select the static folder in the Visual Studio Code Explorer sidebar.
- Click the New File icon in the Explorer sidebar.
- Enter app.js as the file name.
You should see app.js listed beside style.css inside the static folder.
- Paste this click handler into static/app.js:
document.querySelectorAll(".save-button").forEach((button) => {
button.addEventListener("click", async () => {
button.disabled = true;
try {
const response = await fetch(
`/pins/${button.dataset.pinId}/toggle-save`,
{ method: "POST" },
);
if (!response.ok) {
throw new Error("Could not update this pin.");
}
const data = await response.json();
button.classList.toggle("saved", data.saved);
button.textContent = data.saved ? "Saved" : "Save";
} catch (error) {
window.alert(error.message);
} finally {
button.disabled = false;
}
});
});
How does the click handler work?
- The selector attaches one click handler to every .save-button element.
- The button becomes disabled while its request is running.
- The request URL uses button.dataset.pinId to target the matching Flask route.
- The response.ok check catches an unsuccessful response before the interface changes.
- The returned saved value controls both the CSS class and the visible button label.
- Save static/app.js.
- Switch back to templates/index.html.
- Add this script element immediately above the closing </body> tag:
<script src="{{ url_for('static', filename='app.js') }}"></script>
Why load the script here?
The browser reads the card markup before loading app.js. The script can then find every Save button as soon as it starts.
- Save templates/index.html.
- Refresh Pinboard in Chrome.
Before you click a Save button, do you expect Chrome to reload the full page?
- Click the Save button on one card.
The button changes to Saved without a page reload. Its color also changes from red to white.
Does the button stay on Save?
Confirm app.js is inside the static folder. Check that the script element appears above </body> in the template.
Look at the Flask terminal after clicking the button. A Python traceback points to the backend route. Help me debug the asynchronous Save interaction.
✔️ The button updates in place
Your browser now sends a POST request and updates the clicked card without reloading Pinboard.
ⓧ I'd like to double check the full code
document.querySelectorAll(".save-button").forEach((button) => {
button.addEventListener("click", async () => {
button.disabled = true;
try {
const response = await fetch(
`/pins/${button.dataset.pinId}/toggle-save`,
{ method: "POST" },
);
if (!response.ok) {
throw new Error("Could not update this pin.");
}
const data = await response.json();
button.classList.toggle("saved", data.saved);
button.textContent = data.saved ? "Saved" : "Save";
} catch (error) {
window.alert(error.message);
} finally {
button.disabled = false;
}
});
});
✔️ The template is connected
Your template renders database state and loads the browser code that changes it.
ⓧ I'd like to double check the full code
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Pinboard</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<header class="topbar">
<a class="brand" href="{{ url_for('index') }}" aria-label="Pinboard home">
<span class="brand-mark">P</span>
<span>Pinboard</span>
</a>
<span class="tagline">Collect what inspires you</span>
</header>
<main class="page-shell">
<section class="hero">
<p class="eyebrow">Your visual idea space</p>
<h1>Save ideas worth returning to.</h1>
<p>Create a pin from an image URL, then keep your favorites close.</p>
</section>
<form class="pin-form" action="{{ url_for('create_pin') }}" method="post">
<label>
Title
<input name="title" type="text" placeholder="Cozy reading corner" required>
</label>
<label>
Image URL
<input name="image_url" type="text" placeholder="Paste a direct image URL" required>
</label>
<label class="wide-field">
Description
<textarea name="description" rows="2" placeholder="Why are you saving this idea?"></textarea>
</label>
<button class="create-button" type="submit">Create pin</button>
</form>
<section class="pin-grid" aria-label="Pin feed">
{% for pin in pins %}
<article class="pin-card">
<div class="image-wrap">
<img src="{{ pin['image_url'] }}" alt="{{ pin['title'] }}">
<button
class="save-button{% if pin['saved'] %} saved{% endif %}"
type="button"
data-pin-id="{{ pin['id'] }}"
>
{% if pin['saved'] %}Saved{% else %}Save{% endif %}
</button>
</div>
<div class="pin-copy">
<h2>{{ pin['title'] }}</h2>
<p>{{ pin['description'] }}</p>
</div>
</article>
{% endfor %}
</section>
</main>
<script src="{{ url_for('static', filename='app.js') }}"></script>
</body>
</html>
Prove the saved state persists
The instant label change proves the browser and route can communicate. A refresh now tests whether SQLite remains the source of truth.
- Click the Save button on one unsaved card.
- Click the Save button on another unsaved card.
- Click the Saved button on the first card.
The first card returns to Save. The second card remains Saved without a page reload.
Before you refresh Chrome, do you think the second card will still show Saved?
- Refresh Pinboard in Chrome.
The second card still shows Saved after the reload. The server rendered that state from pinboard.db.
You completed the full interaction loop. Pinboard now saves each card asynchronously and restores the same state after a browser refresh.
Secret mission
Show Saved Pins Only
Your saved pins already survive a browser refresh. This Secret Mission adds a Saved-only filter that stays synchronized when you save or unsave a card.
Clean Up Your Resources
Clean Up Your Resources
Pinboard runs entirely on your Mac. There are no ongoing cloud costs or paid services.
Choose whether to keep the local app running. You can also stop it until later or delete the complete project.
Resources you used:
- The local .venv virtual environment containing Flask 3.1.3 plus its dependencies.
- The local pinboard.db SQLite database containing your pins plus their saved state.
- The app.py backend.
- The requirements.txt dependency file.
- The templates/index.html browser template.
- The static/app.js client interaction script.
- The static/style.css responsive styles.
- The SVG illustrations inside static/images.
Keep everything running
No action needed. Choose this if you are still testing the feed or extending the Saved-only filter.
- Leave the Visual Studio Code terminal running while you continue testing Pinboard.
- Keep the pinboard folder intact so its database remains available.
- Use Google Chrome at http://127.0.0.1:5000/ while Flask remains running.
Pause - I'll come back to this later
Shut down the running Flask process to free the terminal while keeping every project file.
- Return to the Visual Studio Code terminal where Flask is running.
- Press Control-C to stop the local server.
- Refresh Pinboard in Google Chrome.
- Leave the pinboard folder intact.
The page stops loading after the server ends. Your code plus stored pins remain available for your next session.
Delete - I don't want to use this again
Deleting the folder is permanent. This option removes every local Pinboard resource in one action.
- Return to the Visual Studio Code terminal where Flask is running.
- Press Control-C to stop the local server.
- Move to your Desktop by running this command:
cd ~/Desktop
Why return to the Desktop?
This places your terminal in the folder that contains pinboard. The deletion command can now target the correct project folder.
This Deletes the Whole Project
The next command permanently removes your local Pinboard folder. Make sure you no longer need its saved pins or project files.
- Remove the complete pinboard folder by running this command:
rm -rf pinboard
What gets deleted?
- The command removes .venv plus its Flask dependencies.
- The command removes pinboard.db plus its persistent pin state.
- The command removes every source file plus each local image asset.
- List your Desktop contents to verify the deletion by running this command:
ls
What should I see?
You should no longer see pinboard in the Desktop listing. Its absence confirms that every listed resource is gone.
Still see the project folder?
Confirm that your terminal returned to the Desktop before you attempted the deletion. Repeat the deletion from that location if the folder still exists.
Get help removing the local Pinboard folder safely.
That's everything cleared away. The complete pinboard folder is now gone from your Mac.
Nice Work!
Nice Work!
Pinboard is complete! You built a local full-stack product that carries each submitted pin from the browser into persistent SQLite storage.
What you learned:
- Built a responsive Pinterest-style feed from reusable pin cards. Shaped the gallery with a masonry layout that adapts to smaller screens.
- Connected a Create Pin form to Flask routes. Saw why process memory loses new pins after a restart. Replaced that temporary state with SQLite persistence using parameterized SQL.
- Returned each saved value through a JSON response. Used JavaScript to update each Save button asynchronously without reloading the page.
- Secret Mission: Added a Saved-only filter that hides unsaved cards. Synchronized it with each database-backed save or unsave action.
Ready to quiz yourself?