Week 6 of 16

Add Filtering and Platform Stats

Filter buttons, prompt counts per platform, and active state highlighting — all from Python dictionaries

Day 29 60 minutes Build

Day 29 of 80

Today's Goal

Making the App Interactive

Yesterday's app shows all prompts. Today you add a filter bar at the top: "All (12)", "Kling (5)", "Runway (4)", "Veo (3)". Clicking a button shows only prompts from that platform. The active button is highlighted. This requires two things: a counts dictionary (built in Python) and a filter route (a new URL pattern).

The grouping pattern is identical to the scene planner you built in Week 2. Same logic, new context.

The Counts Pattern

Before writing the route, understand the data structure you need. You want a dictionary like {"Kling": 5, "Runway": 4, "Veo": 3}. Here's how to build it:

The grouping pattern — same as Week 2 Python
prompts = load_prompts()

# Start with an empty dictionary
counts = {}

for p in prompts:
    plat = p["platform"]
    # .get(plat, 0) returns current count, or 0 if platform not seen yet
    counts[plat] = counts.get(plat, 0) + 1

# Result: {"Kling": 5, "Runway": 4, "Veo": 3}
print(counts)
counts.get(plat, 0) — The first time you see "Kling", counts["Kling"] doesn't exist yet and would raise a KeyError. Using .get(plat, 0) returns 0 as a default instead of crashing. Then you add 1, giving you 1 for the first occurrence. Each subsequent "Kling" increments the count.

This is a fundamental Python pattern called "counting with a dictionary." You'll use it constantly. An alternative is collections.Counter(p["platform"] for p in prompts) — but the manual version makes the logic explicit.

Step 1: Add the Filter Route

Add this new route to app.py, below your existing three routes:

app.py — add this route Python
@app.route("/filter/<platform>")
def filter_by_platform(platform):
    all_prompts = load_prompts()

    # Build counts across ALL prompts (not filtered)
    counts = {}
    for p in all_prompts:
        plat = p["platform"]
        counts[plat] = counts.get(plat, 0) + 1

    # Filter down to the requested platform
    filtered = [p for p in all_prompts if p["platform"] == platform]

    return render_template(
        "index.html",
        prompts=filtered,
        all_count=len(all_prompts),
        counts=counts,
        active_filter=platform
    )
<platform> — No type converter here (unlike <int:index>). Flask passes the URL segment as a string. So /filter/Kling calls this function with platform="Kling".

Count all, filter separately — The counts are calculated from all prompts so the filter bar always shows the full totals. If you counted only from filtered, the non-active platform tabs would show 0.

active_filter=platform — You pass the currently selected platform name to the template so it can highlight the right button.

Step 2: Update the home() Route

The home route now also needs to pass counts, all_count, and active_filter so the filter bar renders correctly on the main page too:

app.py — updated home() route Python
@app.route("/")
def home():
    prompts = load_prompts()

    counts = {}
    for p in prompts:
        plat = p["platform"]
        counts[plat] = counts.get(plat, 0) + 1

    return render_template(
        "index.html",
        prompts=prompts,
        all_count=len(prompts),
        counts=counts,
        active_filter="all"
    )
active_filter="all" — The home route shows everything, so the "All" button should appear active/highlighted. Passing a string "all" lets the template check {% if active_filter == "all" %}.

Step 3: Add the Filter Bar to index.html

Add this block to templates/index.html, right after the subtitle line and before the prompt grid:

templates/index.html — filter bar HTML + Jinja2
{# ── Filter Bar ── #}
<div class="filter-bar">
  <a href="/"
     style="background: {{ '#2563eb' if active_filter == 'all' else '#1a1a1a' }}"
     class="filter-btn">
    All <span class="count">{{ all_count }}</span>
  </a>

  {% for platform, count in counts.items() %}
    <a href="/filter/{{ platform }}"
       style="background: {{ '#2563eb' if active_filter == platform else '#1a1a1a' }}"
       class="filter-btn">
      {{ platform }} <span class="count">{{ count }}</span>
    </a>
  {% endfor %}
</div>
Jinja2 ternary syntax: {{ 'value_if_true' if condition else 'value_if_false' }}. This is the Jinja2 equivalent of Python's 'a' if x else 'b'. Here it switches the button background between active blue and dark grey.

counts.items() — Works identically to Python. In Jinja2, you can call most dictionary methods directly. This iterates key-value pairs so you get both the platform name and its count.

Dynamic filter buttons — You never hardcode "Kling", "Runway", "Veo". If you add a new platform to a prompt, the button appears automatically.

Add these CSS rules to the <style> block in index.html:

CSS for the filter bar CSS
/* Filter bar */
.filter-bar { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 1.5rem; }
.filter-btn {
  display: inline-flex; align-items: center; gap: 0.4rem;
  padding: 0.4rem 1rem; border-radius: 20px; font-size: 0.85rem;
  font-weight: 500; color: #e5e5e5; text-decoration: none;
  border: 1px solid #2a2a2a; transition: background 0.15s;
}
.filter-btn:hover { border-color: #444; }
.filter-btn .count {
  background: rgba(255,255,255,0.15); border-radius: 10px;
  padding: 0.1rem 0.45rem; font-size: 0.75rem;
}
The active/inactive background is set via the inline style attribute from Jinja2 above, which overrides the CSS. This keeps the CSS clean — you only style the shared shape, and Python controls which one is "active."

Run It and Try the Filters

Terminal
$ python app.py
 * Running on http://127.0.0.1:5000
# Visit http://localhost:5000 — filter bar appears at top
# Click "Kling" — URL becomes /filter/Kling, only Kling cards show
# Click "All" — URL back to /, all cards show

Stretch Goal: Copy to Clipboard

Add a "Copy" button to each prompt card that copies the prompt text to the clipboard. Add this to your index.html:

templates/index.html — copy button HTML + JavaScript
<!-- Inside each .prompt-card, after the prompt-text paragraph -->
<button class="copy-btn"
        onclick="copyPrompt(this, '{{ p.prompt | replace(\"'\", \"\\\\'\") }}')">
  Copy
</button>

<script>
function copyPrompt(btn, text) {
  navigator.clipboard.writeText(text).then(() => {
    btn.textContent = "Copied!";
    setTimeout(() => btn.textContent = "Copy", 1500);
  });
}
</script>
navigator.clipboard.writeText() — The modern browser API for copying text. Returns a Promise, so you chain .then() to run code after it succeeds. The button text changes to "Copied!" for 1.5 seconds, then resets.

Note on the Jinja2 escaping: The replace("'", "\\'") filter prevents prompt text with single quotes from breaking the JavaScript string. In production you'd use a data attribute (data-prompt="{{ p.prompt }}") instead — safer and cleaner. Try that as a further stretch.

End of Day Checklist

Tomorrow — Week 6 Review

Tomorrow is the Week 6 review and milestone check. You'll consolidate everything you've built this week, make sure the Prompt Vault web app is solid, and get a preview of Week 7: wiring Claude into the browser so you can generate prompts with a button click.