Week 7 of 16

Build: Add Search to the Web App

A keyword search bar that filters prompts across platform, shot, and text — GET route, list comprehension, bookmarkable URLs

Day 33 60 minutes Build

Day 33 of 80

Today's Goal

Keyword Search Across All Fields

A search bar at the top of the Prompt Vault. Type any keyword — "aerial", "golf", "kling", "sunset" — and the page updates to show only prompts where that word appears anywhere: in the platform name, the shot description, or the prompt text. The search is case-insensitive. The URL updates to /search?q=aerial so you can bookmark or share your search.

Fewer than 25 new lines in Python. Another 10 in HTML. This is a fast build day.

GET vs POST for Search — The Right Choice

Why Search Uses GET

There's a consistent rule in web development: actions that read data use GET, actions that write data use POST. Search reads — it doesn't change anything on the server. Using GET for search means the query ends up in the URL: /search?q=aerial. That's a feature, not a side effect. You can bookmark it, share it, hit the back button and return to exactly the same results. POST searches lose the query on refresh.

Action HTTP Method Why
View prompts GET Reading only — no data changes
Search prompts GET Reading only — query belongs in URL
Filter by platform GET Reading only — filter in URL is bookmarkable
Add a prompt POST Writing data — form body, not URL
Delete a prompt GET* *Technically should be DELETE, but links can only GET — acceptable for a personal tool
Generate with Claude POST Sending data (the shot description) to be processed

The Search Route

app.py — /search route Python
@app.route("/search")
def search():
    query = request.args.get("q", "").lower()
    all_prompts = load_prompts()

    if query:
        results = [
            p for p in all_prompts
            if query in p["platform"].lower()
            or query in p["shot"].lower()
            or query in p["prompt"].lower()
        ]
    else:
        results = all_prompts

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

    return render_template(
        "index.html",
        prompts=results,
        query=query,
        all_count=len(all_prompts),
        counts=counts,
        active_filter="all"
    )
request.args.get("q", "")request.args is Flask's dictionary of GET parameters from the URL query string. If someone visits /search?q=aerial, then request.args.get("q") returns "aerial". The default "" handles a visit to /search with no query.

List comprehension with multi-field search — The if ... or ... or ... inside the comprehension checks all three fields. If the query matches any of them, the prompt is included. Both the query and the field values are lowercased so the match is case-insensitive.

Counts from all prompts — Same principle as the filter route: count totals from the full list so the filter bar always shows accurate numbers, not just counts within the search results.

query=query — Pass the query string back to the template so you can show "Showing results for: aerial" and pre-fill the search input with the current query.

Add the Search Form to index.html

Add this search bar to templates/index.html — put it just below the page title/subtitle and above the filter bar:

templates/index.html — search form HTML + Jinja2
{# ── Search Bar ── #}
<form action="/search" method="GET" class="search-form">
  <input
    type="search"
    name="q"
    placeholder="Search prompts..."
    value="{{ query if query else '' }}"
    autocomplete="off"
  >
  <button type="submit">Search</button>
  {% if query %}
    <a href="/" class="clear-search">× Clear</a>
  {% endif %}
</form>

{# Show active search indicator #}
{% if query %}
  <p class="search-status">
    Showing {{ prompts | length }} result{% if prompts | length != 1 %}s{% endif %}
    for <strong>"{{ query }}"</strong>
  </p>
{% endif %}
value="{{ query if query else '' }}" — Pre-fills the search input with the current query. After searching for "golf", when the page reloads the search box still shows "golf". Without this, the input clears on every submit — frustrating UX.

Clear button — Only shown when a query is active. Links back to / (the home route), which shows all prompts with no filter. Simple and intuitive.

Result count — Shows "3 results for 'golf'" so the user knows the list is filtered. Uses the same singular/plural conditional pattern you've seen before.

Add these CSS rules to your <style> block:

CSS for search bar CSS
/* Search */
.search-form { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 1rem; }
.search-form input[type="search"] {
  flex: 1; max-width: 400px;
  background: #1a1a1a; border: 1px solid #333; color: #e5e5e5;
  border-radius: 6px; padding: 0.6rem 0.9rem; font-size: 0.9rem;
}
.search-form input[type="search"]:focus { outline: none; border-color: #2563eb; }
.search-form button { background: #222; border: 1px solid #333; color: #aaa; padding: 0.6rem 1rem; border-radius: 6px; cursor: pointer; font-size: 0.9rem; }
.search-form button:hover { color: #e5e5e5; border-color: #555; }
.clear-search { color: #888; font-size: 0.85rem; text-decoration: none; }
.clear-search:hover { color: #e5e5e5; }
.search-status { color: #888; font-size: 0.85rem; margin-bottom: 1rem; }
Keep the search bar visually subtle — it's a utility control, not a primary action. Dark background, muted border, neutral button. The active search indicator (search-status) is also muted. The result cards are the star of the page.

Test It

Terminal
$ python app.py
 * Running on http://127.0.0.1:5000
# Search for "golf"    → only golf prompts appear, URL = /search?q=golf
# Search for "kling"   → only Kling platform prompts, URL = /search?q=kling
# Search for "aerial"  → any prompt mentioning aerial
# Search for ""        → all prompts (same as home page)
# Click Clear          → back to /, all prompts, search input empty
Bookmark Test

Search for a term you use often. Copy the URL from the browser address bar — something like http://localhost:5000/search?q=aerial. Paste it into a new tab. Your search results load immediately. This is the benefit of GET for search: the query is part of the URL, making it a first-class shareable resource.

How Search Connects to the Rest of the App

All Routes Share the Same Template

Notice that home(), filter_by_platform(), and search() all render the same index.html. They pass different data — all prompts vs filtered prompts vs search results — but the template handles all cases. This is the power of keeping display logic in Jinja2 and business logic in Python. One template, multiple views.

Route prompts contains query active_filter
/ All prompts not passed (falsy) "all"
/filter/Kling Kling prompts only not passed "Kling"
/search?q=aerial Matching prompts "aerial" "all"

End of Day Checklist

Tomorrow — Polish the UI

Tomorrow you apply your DVP design skills to the frontend. Color system, typography, card styling, platform tag colors, hover effects. HTML and CSS are your territory — Day 34 is the day you make this thing look like a real product.