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 --- main.py | 30 +++++++++-- requirements.txt | 2 + routers/auth.py | 58 ++++++++++++++++++++ routers/films.py | 59 +++++++++++++++++--- routers/profile.py | 91 +++++++++++++++++++++++++++++++ static/app.js | 107 +++++++++++++++++++++++++++++++++++++ templates/_feed_partial.html | 14 +++++ templates/_film_card.html | 2 +- templates/base.html | 3 ++ templates/detail.html | 2 +- templates/index.html | 7 ++- templates/login.html | 60 +++++++++++++++++++++ templates/profile.html | 124 +++++++++++++++++++++++++++++++++++++++++++ templates/year_review.html | 8 +-- 14 files changed, 550 insertions(+), 17 deletions(-) create mode 100644 routers/auth.py create mode 100644 routers/profile.py create mode 100644 templates/_feed_partial.html create mode 100644 templates/login.html create mode 100644 templates/profile.html diff --git a/main.py b/main.py index 257d2b6..b04ad96 100644 --- a/main.py +++ b/main.py @@ -1,15 +1,33 @@ +import os from contextlib import asynccontextmanager from dotenv import load_dotenv -from fastapi import FastAPI +from fastapi import FastAPI, Request +from fastapi.responses import RedirectResponse from fastapi.staticfiles import StaticFiles +from starlette.middleware.sessions import SessionMiddleware +from starlette.middleware.base import BaseHTTPMiddleware from database import init_db -from routers import films, imports as imports_router, stats, tmdb +from routers import auth, films, imports as imports_router, profile, stats, tmdb load_dotenv() +class AuthMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next): + public_paths = {"/login", "/logout", "/tyler"} + path = request.url.path + + if path.startswith("/static") or path in public_paths: + return await call_next(request) + + if not request.session.get("authenticated"): + return RedirectResponse("/login", status_code=303) + + return await call_next(request) + + @asynccontextmanager async def lifespan(app: FastAPI): init_db() @@ -18,8 +36,14 @@ async def lifespan(app: FastAPI): app = FastAPI(title="Lumière", lifespan=lifespan) -app.mount("/static", StaticFiles(directory="static"), name="static") +# Middleware order: SessionMiddleware first, then AuthMiddleware +session_secret = os.getenv("SESSION_SECRET", "change-me-in-production") +app.add_middleware(AuthMiddleware) +app.add_middleware(SessionMiddleware, secret_key=session_secret) +app.mount("/static", StaticFiles(directory="static"), name="static") +app.include_router(auth.router) +app.include_router(profile.router) app.include_router(tmdb.router) app.include_router(imports_router.router) app.include_router(stats.router) diff --git a/requirements.txt b/requirements.txt index dc23061..de11721 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,5 @@ jinja2 python-dotenv python-multipart httpx +itsdangerous +argon2-cffi diff --git a/routers/auth.py b/routers/auth.py new file mode 100644 index 0000000..d3fb963 --- /dev/null +++ b/routers/auth.py @@ -0,0 +1,58 @@ +import os +from fastapi import APIRouter, Request +from fastapi.responses import RedirectResponse +from fastapi.templating import Jinja2Templates +from argon2 import PasswordHasher +from argon2.exceptions import VerifyMismatchError + +templates = Jinja2Templates(directory="templates") + +router = APIRouter() + +OWNER_PASSWORD_HASH = os.getenv("OWNER_PASSWORD_HASH", "") +ph = PasswordHasher() + + +@router.get("/login") +async def login_form(request: Request): + if request.session.get("authenticated"): + return RedirectResponse("/", status_code=303) + return templates.TemplateResponse(request=request, name="login.html", context={"request": request}) + + +@router.post("/login") +async def login(request: Request): + if request.session.get("authenticated"): + return RedirectResponse("/", status_code=303) + + form = await request.form() + password = form.get("password", "") + + if not OWNER_PASSWORD_HASH: + error = "Server not configured: OWNER_PASSWORD_HASH not set" + return templates.TemplateResponse( + request=request, + name="login.html", + context={"request": request, "error": error}, + status_code=500 + ) + + try: + ph.verify(OWNER_PASSWORD_HASH, password) + request.session["authenticated"] = True + return RedirectResponse("/", status_code=303) + except VerifyMismatchError: + pass + + return templates.TemplateResponse( + request=request, + name="login.html", + context={"request": request, "error": "Invalid password"}, + status_code=401 + ) + + +@router.post("/logout") +async def logout(request: Request): + request.session.clear() + return RedirectResponse("/login", status_code=303) diff --git a/routers/films.py b/routers/films.py index fc087b1..4bb7cc9 100644 --- a/routers/films.py +++ b/routers/films.py @@ -176,6 +176,17 @@ def _notice_context( } +def _get_shelf_query(db: Session, shelf: str): + """Get a query for films on a shelf, ordered appropriately.""" + query = db.query(Film).filter(Film.shelf == shelf) + if shelf == "diary": + return query.order_by(Film.date_watched.desc(), Film.created_at.desc(), Film.id.desc()) + elif shelf == "queue": + return query.order_by(Film.created_at.desc(), Film.id.desc()) + else: # abandoned + return query.order_by(Film.updated_at.desc(), Film.created_at.desc(), Film.id.desc()) + + def _group_films_by_month(films: list[Film]) -> list[dict]: def month_key(film: Film) -> str: return film.date_watched.strftime("%B %Y") if film.date_watched else "Unknown" @@ -192,15 +203,14 @@ def _render_shelf( db: Session, notices: dict, ): - query = db.query(Film).filter(Film.shelf == shelf) + query = _get_shelf_query(db, shelf) + total_films = query.count() + films = query.limit(20).all() + has_more = total_films > 20 + if shelf == "diary": - films = query.order_by(Film.date_watched.desc(), Film.created_at.desc(), Film.id.desc()).all() grouped_films = _group_films_by_month(films) - elif shelf == "queue": - films = query.order_by(Film.created_at.desc(), Film.id.desc()).all() - grouped_films = None else: - films = query.order_by(Film.updated_at.desc(), Film.created_at.desc(), Film.id.desc()).all() grouped_films = None return templates.TemplateResponse( @@ -212,6 +222,8 @@ def _render_shelf( "grouped_films": grouped_films, "active_shelf": shelf, "shelf_meta": SHELF_META[shelf], + "total_films": total_films, + "has_more": has_more, **notices, }, ) @@ -361,6 +373,41 @@ async def _film_tmdb_context(film: Film) -> dict | None: return context +@router.get("/films/partial") +def get_films_partial( + request: Request, + shelf: str = Query("diary"), + offset: int = Query(0), + limit: int = Query(20), + 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) + total = query.count() + films = query.offset(offset).limit(limit).all() + has_more = (offset + limit) < total + + if shelf == "diary": + grouped_films = _group_films_by_month(films) + else: + grouped_films = None + + from fastapi.responses import HTMLResponse + html = templates.get_template("_feed_partial.html").render( + request=request, + films=films, + grouped_films=grouped_films, + active_shelf=shelf, + ) + + response = HTMLResponse(html) + response.headers["X-Has-More"] = "true" if has_more else "false" + return response + + @router.get("/films/{film_id}") async def film_detail(film_id: int, request: Request, db: Session = Depends(get_db)): film = _get_film_or_404(db, film_id) diff --git a/routers/profile.py b/routers/profile.py new file mode 100644 index 0000000..1986257 --- /dev/null +++ b/routers/profile.py @@ -0,0 +1,91 @@ +from fastapi import APIRouter, Depends, Request +from fastapi.templating import Jinja2Templates +from sqlalchemy.orm import Session + +from database import get_db +from models import Film + +router = APIRouter(tags=["profile"]) +templates = Jinja2Templates(directory="templates") + + +def _diary_films(db: Session) -> list[Film]: + return ( + db.query(Film) + .filter(Film.shelf == "diary", Film.date_watched.is_not(None)) + .order_by(Film.date_watched.desc()) + .all() + ) + + +def _build_profile_payload(films: list[Film]) -> dict: + from collections import Counter + from services.countries import split_country_names + from services.film_people import split_credit_names + + if not films: + return { + "total_watched": 0, + "average_stars": 0, + "most_watched_directors": [], + "star_distribution": [{"stars": stars, "count": 0} for stars in (0, 1, 2, 3)], + "films_per_country": [], + "recent_films": [], + } + + countries = Counter() + directors = Counter() + star_counts = Counter({0: 0, 1: 0, 2: 0, 3: 0}) + + total_stars = 0 + valid_ratings = 0 + + for film in films: + country_names = split_country_names(film.country) + countries.update(country_names) + + directors.update(split_credit_names(film.director)) + + stars = film.stars if film.stars in {0, 1, 2, 3} else 0 + star_counts[stars] += 1 + if film.stars in {0, 1, 2, 3}: + total_stars += film.stars + valid_ratings += 1 + + total_watched = len(films) + average_stars = round(total_stars / valid_ratings, 1) if valid_ratings else 0 + + return { + "total_watched": total_watched, + "average_stars": average_stars, + "most_watched_directors": [ + {"director": director, "count": count} + for director, count in sorted(directors.items(), key=lambda item: (-item[1], item[0]))[:5] + ], + "star_distribution": [{"stars": stars, "count": star_counts[stars]} for stars in (0, 1, 2, 3)], + "films_per_country": [ + {"country": country, "count": count} + for country, count in sorted(countries.items(), key=lambda item: (-item[1], item[0]))[:10] + ], + "recent_films": [ + { + "id": film.id, + "title": film.title, + "poster_url": film.poster_url, + "date_watched": film.date_watched.isoformat() if film.date_watched else None, + "stars": film.stars, + } + for film in films[:4] + ], + } + + +@router.get("/tyler") +async def public_profile(request: Request, db: Session = Depends(get_db)): + films = _diary_films(db) + payload = _build_profile_payload(films) + return templates.TemplateResponse( + request=request, + name="profile.html", + context={"request": request, **payload}, + ) 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); +} diff --git a/templates/_feed_partial.html b/templates/_feed_partial.html new file mode 100644 index 0000000..e22cdde --- /dev/null +++ b/templates/_feed_partial.html @@ -0,0 +1,14 @@ +{% if active_shelf == 'diary' and grouped_films %} + {% for group in grouped_films %} +
+

{{ group.month }}

+ {% for film in group.films %} + {% include "_film_card.html" %} + {% endfor %} +
+ {% endfor %} +{% else %} + {% for film in films %} + {% include "_film_card.html" %} + {% endfor %} +{% endif %} diff --git a/templates/_film_card.html b/templates/_film_card.html index 7147a1e..5e8e6e4 100644 --- a/templates/_film_card.html +++ b/templates/_film_card.html @@ -1,7 +1,7 @@
{% if film.poster_url %} - {{ film.title }} poster + {{ film.title }} poster {% else %} {{ film.title[:1] }} {% endif %} diff --git a/templates/base.html b/templates/base.html index 168efde..1041c25 100644 --- a/templates/base.html +++ b/templates/base.html @@ -18,6 +18,9 @@ Stats Import Add Film +
+ +
diff --git a/templates/detail.html b/templates/detail.html index df3f96a..59779d8 100644 --- a/templates/detail.html +++ b/templates/detail.html @@ -7,7 +7,7 @@