Week 6 of 16

Build: Prompt Vault Web App

Your CLI prompt tool becomes a browser app — Flask routes, Jinja2 templates, and your existing helpers

Day 28 75 minutes Build

Day 28 of 80

What You're Building

The Prompt Vault in the Browser

Everything you built in Weeks 4 and 5 — the JSON storage, the helpers.py functions, the platform validation — stays exactly as-is. Today you add a web interface on top of it. By the end of this session you'll have a working browser app that shows all your prompts as cards, lets you add new ones via a form, and delete them with a click.

Three routes. One template. Zero new data logic — you're just connecting what you already built to a browser.

Updated Project Structure

prompt-vault/ Folder Tree
prompt-vault/
├── app.py              # NEW — Flask web app (you build this today)
├── helpers.py          # unchanged from Week 5
├── prompts.json        # unchanged — same data file
├── main.py             # unchanged — CLI interface still works
├── requirements.txt    # add flask if not already there
└── templates/
    └── index.html      # NEW — Jinja2 template (you build this today)
Flask expects templates in a folder called templates/ by default. That's not optional — it must be named exactly that, inside your project folder. When you call render_template("index.html"), Flask automatically looks in templates/index.html.

Step 1: Create app.py

Create a new file called app.py in your prompt-vault/ folder. This is the entire Flask application — three routes, about 30 lines.

app.py Python
from flask import Flask, render_template, request, redirect
from helpers import load_prompts, save_prompts, validate_platform

app = Flask(__name__)

# ── Route 1: Home page ──────────────────────────────────────────
@app.route("/")
def home():
    prompts = load_prompts()
    return render_template("index.html", prompts=prompts)

# ── Route 2: Add a prompt (form submission) ─────────────────────
@app.route("/add", methods=["POST"])
def add_prompt():
    prompts = load_prompts()
    raw_platform = request.form["platform"]
    platform = validate_platform(raw_platform) or raw_platform
    new_prompt = {
        "platform": platform,
        "shot": request.form["shot"],
        "prompt": request.form["prompt"]
    }
    prompts.append(new_prompt)
    save_prompts(prompts)
    return redirect("/")

# ── Route 3: Delete by index ────────────────────────────────────
@app.route("/delete/<int:index>")
def delete_prompt(index):
    prompts = load_prompts()
    if 0 <= index < len(prompts):
        prompts.pop(index)
        save_prompts(prompts)
    return redirect("/")

if __name__ == "__main__":
    app.run(debug=True)
Route 1 — home(): Loads prompts from JSON and passes them to the template. The variable name prompts=prompts — the left side is the name available in Jinja2, the right side is the Python variable.

Route 2 — add_prompt(): Reads three fields from the submitted form, runs them through your existing validate_platform() function, builds the same dictionary structure you've been using since Week 4, and redirects back to home. Notice: validate_platform(raw_platform) or raw_platform — if validation returns None or empty string (falsy), fall back to what the user typed.

Route 3 — delete_prompt(index): Flask extracts the number from the URL and passes it in as index. The bounds check prevents a crash if someone types a bad URL manually. list.pop(index) removes by position — same as your CLI app.

debug=True: Auto-reloads when you save changes. Never use in production.

Step 2: Create templates/index.html

Create the templates/ folder inside prompt-vault/, then create index.html inside it. This uses Jinja2 — Flask's template language. The key syntax: {{ variable }} to output a value, {% tag %} for logic like loops and conditionals.

templates/index.html HTML + Jinja2
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Prompt Vault</title>
  <style>
    /* Dark base */
    * { box-sizing: border-box; margin: 0; padding: 0; }
    body { background: #0a0a0a; color: #e5e5e5; font-family: var(--font-body, 'Hanken Grotesk', system-ui, sans-serif); padding: 2rem; }
    h1 { font-size: 1.8rem; margin-bottom: 0.25rem; }
    .subtitle { color: #888; margin-bottom: 2rem; }

    /* Cards */
    .prompt-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 1rem; margin: 2rem 0; }
    .prompt-card { background: #1a1a1a; border: 1px solid #2a2a2a; border-radius: 8px; padding: 1.25rem; }
    .card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem; }
    .platform-tag { font-size: 0.75rem; font-weight: 600; padding: 0.2rem 0.6rem; border-radius: 4px; text-transform: uppercase; }
    .platform-kling  { background: #1d4ed8; color: white; }
    .platform-runway { background: #6d28d9; color: white; }
    .platform-veo    { background: #047857; color: white; }
    .shot-label { font-size: 0.85rem; font-weight: 600; color: #aaa; margin-bottom: 0.5rem; }
    .prompt-text { font-size: 0.9rem; color: #ccc; line-height: 1.5; }
    .delete-btn { color: #ef4444; font-size: 0.8rem; text-decoration: none; }
    .delete-btn:hover { color: #f87171; }

    /* Add form */
    .add-form { background: #111; border: 1px solid #2a2a2a; border-radius: 8px; padding: 1.5rem; max-width: 600px; margin-top: 2rem; }
    .add-form h2 { font-size: 1rem; margin-bottom: 1rem; }
    .add-form select, .add-form input, .add-form textarea {
      width: 100%; background: #1a1a1a; border: 1px solid #333; color: #e5e5e5;
      border-radius: 6px; padding: 0.6rem 0.8rem; margin-bottom: 0.75rem; font-family: inherit; font-size: 0.9rem;
    }
    .add-form textarea { min-height: 80px; resize: vertical; }
    .btn-primary { background: #2563eb; color: white; border: none; padding: 0.6rem 1.5rem; border-radius: 6px; cursor: pointer; font-weight: 600; }
    .btn-primary:hover { background: #1d4ed8; }
    .empty-state { color: #555; text-align: center; padding: 3rem; }
  </style>
</head>
<body>

  <h1>Prompt Vault</h1>
  <p class="subtitle">{{ prompts | length }} prompt{% if prompts | length != 1 %}s{% endif %} saved</p>

  {# ── Prompt Cards ── #}
  {% if prompts %}
    <div class="prompt-grid">
      {% for p in prompts %}
        <div class="prompt-card">
          <div class="card-header">
            <span class="platform-tag platform-{{ p.platform | lower }}">
              {{ p.platform }}
            </span>
            <a href="/delete/{{ loop.index0 }}" class="delete-btn"
               onclick="return confirm('Delete this prompt?')">Delete</a>
          </div>
          <p class="shot-label">{{ p.shot }}</p>
          <p class="prompt-text">{{ p.prompt }}</p>
        </div>
      {% endfor %}
    </div>
  {% else %}
    <p class="empty-state">No prompts yet. Add your first one below.</p>
  {% endif %}

  {# ── Add Form ── #}
  <div class="add-form">
    <h2>Add a Prompt</h2>
    <form method="POST" action="/add">
      <select name="platform">
        <option value="Kling">Kling</option>
        <option value="Runway">Runway</option>
        <option value="Veo">Veo</option>
      </select>
      <input type="text" name="shot" placeholder="Shot description (e.g. aerial over golf course, golden hour)" required>
      <textarea name="prompt" placeholder="Full prompt text..." required></textarea>
      <button type="submit" class="btn-primary">Add Prompt</button>
    </form>
  </div>

</body>
</html>
{{ prompts | length }} — Jinja2 filters use the pipe character. length is built-in. This outputs the count of prompts as a number.

{% if prompts %}...{% else %}...{% endif %} — Jinja2 treats an empty list as falsy, same as Python. If there are no prompts, show the empty state message.

{% for p in prompts %} — Iterates the list. Inside the loop, p is a single prompt dictionary. Access fields with dot notation: p.platform, p.shot, p.prompt.

loop.index0 — Jinja2 provides a loop object inside every for loop. loop.index0 is the zero-based index of the current item — exactly what your delete route needs. loop.index (no zero) starts at 1 if you ever need that.

platform-{{ p.platform | lower }} — The lower filter lowercases the string. This turns "Kling" into platform-kling, matching your CSS class name.

onclick="return confirm(...)" — A small JavaScript guard so users don't accidentally delete prompts. The browser shows a confirm dialog; if they click Cancel, the link doesn't navigate.

Step 3: Run It

Terminal
$ cd prompt-vault
$ python app.py
 * Serving Flask app 'app'
 * Debug mode: on
 * Running on http://127.0.0.1:5000
 * Restarting with stat
 * Debugger is active!
$ # Open your browser to http://localhost:5000

Open http://localhost:5000 in your browser. You should see your existing prompts from prompts.json rendered as cards. Try adding a new prompt and deleting one — the page reloads and the JSON file updates on disk.

debug=True Auto-Reloads

With debug=True, every time you save app.py or templates/index.html, Flask automatically restarts the server. You don't need to stop and restart manually — just save and refresh the browser. This makes iteration fast. Never use debug=True in a production deployment — it exposes an interactive debugger in the browser.

How It All Connects

Browser Action HTTP Method Flask Route What Happens
Visit / GET home() Loads JSON, renders template
Submit add form POST add_prompt() Reads form, saves JSON, redirects
Click delete link GET delete_prompt(index) Pops list item, saves JSON, redirects
Nothing New in helpers.py

Open helpers.py — you haven't touched it. load_prompts(), save_prompts(), and validate_platform() work identically whether they're called from your CLI main.py or your web app.py. This is what good module design looks like: the logic is separate from the interface.

End of Day Checklist

Tomorrow — Filtering and Stats

Tomorrow you make the app interactive: add a filter bar so you can view only Kling prompts, or only Runway prompts. You'll add prompt counts per platform and highlight the active filter. The data grouping pattern is one you've already seen — the same one from Week 2's scene planner.