From 1bdf4ca8c0f51718124ffe5247ac133973d4f251 Mon Sep 17 00:00:00 2001 From: Tyler Hoang Date: Wed, 6 May 2026 15:25:36 -0700 Subject: Add authentication, public profile, and infinite scroll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Implement session-based auth with argon2 password hashing - Add login form and logout button in nav - Create public /tyler profile page with curated stats - Implement infinite scroll for film lists (load 20 at a time) - Add lazy loading for poster images - Fix profile page CSS to use dark theme variables - Use consistent star character (✦) across all pages - Add /films/partial endpoint for pagination Co-Authored-By: Claude Haiku 4.5 --- static/app.js | 107 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) (limited to 'static/app.js') diff --git a/static/app.js b/static/app.js index b942d7e..02f70f8 100644 --- a/static/app.js +++ b/static/app.js @@ -201,3 +201,110 @@ document.querySelectorAll(".star-control").forEach((control) => { }); }); }); + +// Infinite scroll for film lists +const feedSentinel = document.querySelector("#feed-sentinel"); +if (feedSentinel) { + 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; + + 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(); + } + } + } + + // 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(); + feedSentinel.remove(); + } else { + feedSentinel.dataset.offset = String(offset + 20); + } + } catch (error) { + console.error("Failed to load more films", error); + } finally { + loading = false; + } + }; + + const observer = new IntersectionObserver((entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + loadMore(); + } + }); + }, { rootMargin: "100px" }); + + observer.observe(feedSentinel); +} -- cgit v1.3-2-g0d8e