From 4bbafdd460945eb506ddb07b9068731245708812 Mon Sep 17 00:00:00 2001 From: Tyler Hoang Date: Wed, 6 May 2026 17:12:46 -0700 Subject: Add search, filter, and sort functionality to film shelves - Add _SORT_COLUMNS dict to routers/films.py with 8 sort options - Extend _get_shelf_query to accept q (search) and sort parameters - Update /films/partial endpoint to accept q/sort query params and pass search_active to template to suppress month grouping when searching - Add filter bar (search input + sort select) to templates/index.html - Add data-shelf attribute to #film-feed for JS to read current shelf - Rewrite infinite scroll JS to support debounced search (300ms), feed reset on filter/sort change, and pass params on all fetches Filters text search by title OR director (case-insensitive ilike). Sort options: date watched (newest/oldest), title (A-Z/Z-A), year, stars. Month grouping disabled when search is active. Co-Authored-By: Claude Haiku 4.5 --- routers/films.py | 30 ++++++- static/app.js | 243 +++++++++++++++++++++++++++++++-------------------- templates/index.html | 22 ++++- 3 files changed, 197 insertions(+), 98 deletions(-) diff --git a/routers/films.py b/routers/films.py index afcf0ae..ec56c0a 100644 --- a/routers/films.py +++ b/routers/films.py @@ -176,9 +176,29 @@ def _notice_context( } -def _get_shelf_query(db: Session, shelf: str): +_SORT_COLUMNS = { + "date_watched_desc": [Film.date_watched.desc().nullslast(), Film.id.desc()], + "date_watched_asc": [Film.date_watched.asc().nullslast(), Film.id.asc()], + "title_asc": [Film.title.asc(), Film.id.asc()], + "title_desc": [Film.title.desc(), Film.id.desc()], + "year_desc": [Film.year.desc().nullslast(), Film.id.desc()], + "year_asc": [Film.year.asc().nullslast(), Film.id.asc()], + "stars_desc": [Film.stars.desc(), Film.id.desc()], + "stars_asc": [Film.stars.asc(), Film.id.asc()], +} + + +def _get_shelf_query(db: Session, shelf: str, q: str | None = None, sort: str | None = None): """Get a query for films on a shelf, ordered appropriately.""" query = db.query(Film).filter(Film.shelf == shelf) + + if q: + term = f"%{q}%" + query = query.filter(Film.title.ilike(term) | Film.director.ilike(term)) + + if sort and sort in _SORT_COLUMNS: + return query.order_by(*_SORT_COLUMNS[sort]) + if shelf == "diary": return query.order_by(Film.date_watched.desc(), Film.created_at.desc(), Film.id.desc()) elif shelf == "queue": @@ -379,18 +399,21 @@ def get_films_partial( shelf: str = Query("diary"), offset: int = Query(0), limit: int = Query(20), + q: str | None = Query(None), + sort: str | None = Query(None), db: Session = Depends(get_db), ): """Returns partial HTML for pagination. Used by infinite scroll.""" if shelf not in ALLOWED_SHELVES: raise HTTPException(status_code=400, detail="Invalid shelf.") - query = _get_shelf_query(db, shelf) + search_active = bool(q and q.strip()) + query = _get_shelf_query(db, shelf, q=q or None, sort=sort or None) total = query.count() films = query.offset(offset).limit(limit).all() has_more = (offset + limit) < total - if shelf == "diary": + if shelf == "diary" and not search_active: grouped_films = _group_films_by_month(films) else: grouped_films = None @@ -401,6 +424,7 @@ def get_films_partial( films=films, grouped_films=grouped_films, active_shelf=shelf, + search_active=search_active, ) response = HTMLResponse(html) diff --git a/static/app.js b/static/app.js index 7b5a88f..7cd501a 100644 --- a/static/app.js +++ b/static/app.js @@ -229,109 +229,164 @@ document.querySelectorAll(".star-control").forEach((control) => { }); }); -// Infinite scroll for film lists -const feedSentinel = document.querySelector("#feed-sentinel"); -if (feedSentinel) { +// Infinite scroll + search/filter/sort +(function () { const feedContainer = document.querySelector("#film-feed"); - let loading = false; + if (!feedContainer) return; + + const shelf = feedContainer.dataset.shelf; + if (!shelf) return; + + let currentQ = ""; + let currentSort = ""; + + function attachStarListeners(root) { + root.querySelectorAll(".star-control").forEach((control) => { + if (control.dataset.listenerAttached) return; + control.dataset.listenerAttached = "true"; + syncStarControl(control, Number(control.dataset.currentStars || 0)); + control.addEventListener("pointerleave", () => clearStarPreview(control)); + control.addEventListener("focusout", (event) => { + if (!control.contains(event.relatedTarget)) clearStarPreview(control); + }); + control.querySelectorAll(".star-button").forEach((button) => { + button.addEventListener("pointerenter", () => previewStarControl(control, Number(button.dataset.stars || 0))); + button.addEventListener("focus", () => previewStarControl(control, Number(button.dataset.stars || 0))); + button.addEventListener("click", async () => { + const currentStars = Number(control.dataset.currentStars || 0); + const selectedStars = Number(button.dataset.stars || 0); + const nextStars = currentStars === selectedStars ? 0 : selectedStars; + button.disabled = true; + try { + const resp = await fetch(`/films/${control.dataset.filmId}/stars`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ stars: nextStars }), + }); + const data = await resp.json(); + if (!resp.ok) return; + syncStarControl(control, Number(data.stars || 0)); + } catch (err) { + console.error("Failed to update stars", err); + } finally { + button.disabled = false; + } + }); + }); + }); + } - const loadMore = async () => { - if (loading) return; - loading = true; + function mergeMonthGroups() { + const groups = feedContainer.querySelectorAll(".month-group"); + for (let i = groups.length - 1; i > 0; i--) { + const cur = groups[i]; + const prev = groups[i - 1]; + if (cur.dataset.month === prev.dataset.month) { + cur.querySelectorAll(".film-card").forEach((card) => prev.appendChild(card)); + cur.remove(); + } + } + } - const shelf = feedSentinel.dataset.shelf; - const offset = Number(feedSentinel.dataset.offset); - const total = Number(feedSentinel.dataset.total); + let observer = null; + let loading = false; - try { - const response = await fetch(`/films/partial?shelf=${encodeURIComponent(shelf)}&offset=${offset}&limit=20`); - if (!response.ok) return; - - const hasMore = response.headers.get("X-Has-More") === "true"; - const html = await response.text(); - - if (html.trim()) { - feedContainer.insertAdjacentHTML("beforeend", html); - - // For diary shelf: merge month groups if they span batch boundaries - if (shelf === "diary") { - const monthGroups = feedContainer.querySelectorAll(".month-group"); - for (let i = monthGroups.length - 1; i > 0; i--) { - const current = monthGroups[i]; - const prev = monthGroups[i - 1]; - if (current.dataset.month === prev.dataset.month) { - const prevList = prev.querySelectorAll(".film-card"); - const currentCards = current.querySelectorAll(".film-card"); - currentCards.forEach((card) => prev.appendChild(card)); - current.remove(); - } - } + function startInfiniteScroll(sentinel) { + if (observer) observer.disconnect(); + loading = false; + + const loadMore = async () => { + if (loading) return; + loading = true; + const offset = Number(sentinel.dataset.offset); + const params = new URLSearchParams({ shelf, offset, limit: 20 }); + if (currentQ) params.set("q", currentQ); + if (currentSort) params.set("sort", currentSort); + try { + const response = await fetch(`/films/partial?${params}`); + if (!response.ok) return; + const hasMore = response.headers.get("X-Has-More") === "true"; + const html = await response.text(); + if (html.trim()) { + feedContainer.insertAdjacentHTML("beforeend", html); + if (shelf === "diary" && !currentQ) mergeMonthGroups(); + attachStarListeners(feedContainer); } - - // Re-attach star control listeners to newly added films - feedContainer.querySelectorAll(".star-control").forEach((control) => { - if (!control.dataset.listenerAttached) { - control.dataset.listenerAttached = "true"; - syncStarControl(control, Number(control.dataset.currentStars || 0)); - control.addEventListener("pointerleave", () => clearStarPreview(control)); - control.addEventListener("focusout", (event) => { - if (!control.contains(event.relatedTarget)) { - clearStarPreview(control); - } - }); - control.querySelectorAll(".star-button").forEach((button) => { - button.addEventListener("pointerenter", () => previewStarControl(control, Number(button.dataset.stars || 0))); - button.addEventListener("focus", () => previewStarControl(control, Number(button.dataset.stars || 0))); - button.addEventListener("click", async () => { - const currentStars = Number(control.dataset.currentStars || 0); - const selectedStars = Number(button.dataset.stars || 0); - const nextStars = currentStars === selectedStars ? 0 : selectedStars; - - button.disabled = true; - try { - const response = await fetch(`/films/${control.dataset.filmId}/stars`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ stars: nextStars }), - }); - - const data = await response.json(); - if (!response.ok) return; - - syncStarControl(control, Number(data.stars || 0)); - } catch (error) { - console.error("Failed to update stars", error); - } finally { - button.disabled = false; - } - }); - }); - } - }); + if (!hasMore) { + observer.disconnect(); + sentinel.remove(); + } else { + sentinel.dataset.offset = String(offset + 20); + } + } catch (err) { + console.error("Failed to load more films", err); + } finally { + loading = false; } + }; - if (!hasMore) { - observer.disconnect(); - feedSentinel.remove(); - } else { - feedSentinel.dataset.offset = String(offset + 20); + observer = new IntersectionObserver( + (entries) => entries.forEach((e) => { if (e.isIntersecting) loadMore(); }), + { rootMargin: "100px" } + ); + observer.observe(sentinel); + } + + const initialSentinel = document.querySelector("#feed-sentinel"); + if (initialSentinel) startInfiniteScroll(initialSentinel); + + async function resetFeed() { + if (observer) { observer.disconnect(); observer = null; } + const params = new URLSearchParams({ shelf, offset: 0, limit: 20 }); + if (currentQ) params.set("q", currentQ); + if (currentSort) params.set("sort", currentSort); + let html = ""; + let hasMore = false; + try { + const response = await fetch(`/films/partial?${params}`); + if (response.ok) { + hasMore = response.headers.get("X-Has-More") === "true"; + html = await response.text(); } - } catch (error) { - console.error("Failed to load more films", error); - } finally { - loading = false; + } catch (err) { + console.error("Failed to reset feed", err); } - }; + feedContainer.replaceChildren(); + if (html.trim()) { + feedContainer.insertAdjacentHTML("beforeend", html); + attachStarListeners(feedContainer); + } + const oldSentinel = document.querySelector("#feed-sentinel"); + if (oldSentinel) oldSentinel.remove(); + if (hasMore) { + const newSentinel = document.createElement("div"); + newSentinel.id = "feed-sentinel"; + newSentinel.dataset.shelf = shelf; + newSentinel.dataset.offset = "20"; + newSentinel.style.cssText = "height: 1px; margin: 20px 0;"; + feedContainer.insertAdjacentElement("afterend", newSentinel); + startInfiniteScroll(newSentinel); + } + } - const observer = new IntersectionObserver((entries) => { - entries.forEach((entry) => { - if (entry.isIntersecting) { - loadMore(); - } + const searchInput = document.querySelector("#film-search"); + const sortSelect = document.querySelector("#film-sort"); + + if (searchInput) { + let debounceTimer = null; + searchInput.addEventListener("input", () => { + clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + currentQ = searchInput.value.trim(); + resetFeed(); + }, 300); }); - }, { rootMargin: "100px" }); + } - observer.observe(feedSentinel); -} + if (sortSelect) { + sortSelect.addEventListener("change", () => { + currentSort = sortSelect.value; + resetFeed(); + }); + } +})(); diff --git a/templates/index.html b/templates/index.html index cb0e1fc..dc76e5d 100644 --- a/templates/index.html +++ b/templates/index.html @@ -13,6 +13,26 @@ +
+ + +
+ {% if imported is not none %}
{{ imported }} entries imported.
{% endif %} @@ -38,7 +58,7 @@ {% endif %} {% if films %} -
+
{% if active_shelf == 'diary' and grouped_films %} {% for group in grouped_films %}
-- cgit v1.3-2-g0d8e