diff options
| author | Tyler Hoang <tyler@tylerhoang.xyz> | 2026-05-06 15:25:36 -0700 |
|---|---|---|
| committer | Tyler Hoang <tyler@tylerhoang.xyz> | 2026-05-06 15:25:36 -0700 |
| commit | 1bdf4ca8c0f51718124ffe5247ac133973d4f251 (patch) | |
| tree | 2904145bdbc5d2a2164cd3cb6c95346e48afd4a5 /routers | |
| parent | 051775337251b0c7036959901eacb58471100862 (diff) | |
Add authentication, public profile, and infinite scroll
- 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 <noreply@anthropic.com>
Diffstat (limited to 'routers')
| -rw-r--r-- | routers/auth.py | 58 | ||||
| -rw-r--r-- | routers/films.py | 59 | ||||
| -rw-r--r-- | routers/profile.py | 91 |
3 files changed, 202 insertions, 6 deletions
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}, + ) |
