summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--routers/films.py30
-rw-r--r--static/app.js235
-rw-r--r--templates/index.html22
3 files changed, 193 insertions, 94 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;
-
- const loadMore = async () => {
- if (loading) return;
- loading = true;
-
- const shelf = feedSentinel.dataset.shelf;
- const offset = Number(feedSentinel.dataset.offset);
- const total = Number(feedSentinel.dataset.total);
-
- try {
- const response = await fetch(`/films/partial?shelf=${encodeURIComponent(shelf)}&offset=${offset}&limit=20`);
- if (!response.ok) return;
+ if (!feedContainer) return;
- const hasMore = response.headers.get("X-Has-More") === "true";
- const html = await response.text();
+ const shelf = feedContainer.dataset.shelf;
+ if (!shelf) return;
- if (html.trim()) {
- feedContainer.insertAdjacentHTML("beforeend", html);
+ let currentQ = "";
+ let currentSort = "";
- // 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 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;
}
- }
+ });
+ });
+ });
+ }
- // 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;
+ 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();
+ }
+ }
+ }
- 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 }),
- });
+ let observer = null;
+ let loading = false;
- const data = await response.json();
- if (!response.ok) return;
+ function startInfiniteScroll(sentinel) {
+ if (observer) observer.disconnect();
+ loading = false;
- syncStarControl(control, Number(data.stars || 0));
- } catch (error) {
- console.error("Failed to update stars", error);
- } finally {
- button.disabled = 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);
+ }
+ 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 @@
</div>
</section>
+ <div class="search-row" style="margin-bottom: 20px;">
+ <input
+ type="search"
+ id="film-search"
+ placeholder="Search by title or director…"
+ autocomplete="off"
+ >
+ <select id="film-sort">
+ <option value="">Default order</option>
+ <option value="date_watched_desc">Date watched — newest</option>
+ <option value="date_watched_asc">Date watched — oldest</option>
+ <option value="title_asc">Title — A → Z</option>
+ <option value="title_desc">Title — Z → A</option>
+ <option value="year_desc">Year — newest</option>
+ <option value="year_asc">Year — oldest</option>
+ <option value="stars_desc">Stars — highest</option>
+ <option value="stars_asc">Stars — lowest</option>
+ </select>
+ </div>
+
{% if imported is not none %}
<div class="notice">{{ imported }} entries imported.</div>
{% endif %}
@@ -38,7 +58,7 @@
{% endif %}
{% if films %}
- <section class="diary-feed" id="film-feed" aria-label="Diary entries">
+ <section class="diary-feed" id="film-feed" data-shelf="{{ active_shelf }}" aria-label="Diary entries">
{% if active_shelf == 'diary' and grouped_films %}
{% for group in grouped_films %}
<div class="month-group" data-month="{{ group.month }}">