summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--main.py30
-rw-r--r--requirements.txt2
-rw-r--r--routers/auth.py58
-rw-r--r--routers/films.py59
-rw-r--r--routers/profile.py91
-rw-r--r--static/app.js107
-rw-r--r--templates/_feed_partial.html14
-rw-r--r--templates/_film_card.html2
-rw-r--r--templates/base.html3
-rw-r--r--templates/detail.html2
-rw-r--r--templates/index.html7
-rw-r--r--templates/login.html60
-rw-r--r--templates/profile.html124
-rw-r--r--templates/year_review.html8
14 files changed, 550 insertions, 17 deletions
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 %}
+ <div class="month-group" data-month="{{ group.month }}">
+ <p class="month-label">{{ group.month }}</p>
+ {% for film in group.films %}
+ {% include "_film_card.html" %}
+ {% endfor %}
+ </div>
+ {% 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 @@
<article class="film-card">
<a class="poster-frame" href="/films/{{ film.id }}" aria-label="{{ film.title }}">
{% if film.poster_url %}
- <img src="{{ film.poster_url }}" alt="{{ film.title }} poster">
+ <img src="{{ film.poster_url }}" alt="{{ film.title }} poster" loading="lazy">
{% else %}
<span>{{ film.title[:1] }}</span>
{% 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 @@
<a class="{% if active_page == 'stats' %}is-active{% endif %}" href="/stats">Stats</a>
<a class="{% if active_page == 'import' %}is-active{% endif %}" href="/import">Import</a>
<a class="button-link" href="/films/new">Add Film</a>
+ <form method="post" action="/logout" style="display: contents;">
+ <button type="submit" style="background: none; border: none; color: var(--muted); cursor: pointer; font-size: inherit; padding: 0;">Logout</button>
+ </form>
</nav>
</header>
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 @@
<aside class="detail-poster">
<div class="poster-frame poster-large">
{% if film.poster_url %}
- <img src="{{ film.poster_url }}" alt="{{ film.title }} poster">
+ <img src="{{ film.poster_url }}" alt="{{ film.title }} poster" loading="lazy">
{% else %}
<span>{{ film.title[:1] }}</span>
{% endif %}
diff --git a/templates/index.html b/templates/index.html
index 52c633f..cb0e1fc 100644
--- a/templates/index.html
+++ b/templates/index.html
@@ -38,10 +38,10 @@
{% endif %}
{% if films %}
- <section class="diary-feed" aria-label="Diary entries">
+ <section class="diary-feed" id="film-feed" aria-label="Diary entries">
{% if active_shelf == 'diary' and grouped_films %}
{% for group in grouped_films %}
- <div class="month-group">
+ <div class="month-group" data-month="{{ group.month }}">
<p class="month-label">{{ group.month }}</p>
{% for film in group.films %}
{% include "_film_card.html" %}
@@ -54,6 +54,9 @@
{% endfor %}
{% endif %}
</section>
+ {% if has_more %}
+ <div id="feed-sentinel" data-shelf="{{ active_shelf }}" data-offset="20" data-total="{{ total_films }}" style="height: 1px; margin: 20px 0;"></div>
+ {% endif %}
{% else %}
<section class="empty-state">
<h2>{{ shelf_meta.empty_title }}</h2>
diff --git a/templates/login.html b/templates/login.html
new file mode 100644
index 0000000..c164531
--- /dev/null
+++ b/templates/login.html
@@ -0,0 +1,60 @@
+{% extends "base.html" %}
+
+{% block title %}Login - Lumière{% endblock %}
+
+{% block content %}
+<div style="max-width: 320px; margin: 60px auto; padding: 0 20px;">
+ <div style="text-align: center; margin-bottom: 40px;">
+ <h1 style="margin: 0; font-size: 32px; color: var(--text);">Lumière</h1>
+ <p style="color: var(--muted); margin: 8px 0 0 0;">Film Diary</p>
+ </div>
+
+ <form method="post" style="display: flex; flex-direction: column; gap: 16px;">
+ {% if error %}
+ <div style="
+ background: rgba(223, 110, 98, 0.1);
+ border: 1px solid rgba(223, 110, 98, 0.4);
+ color: #ffc7c0;
+ padding: 12px;
+ border-radius: 6px;
+ font-size: 14px;
+ ">{{ error }}</div>
+ {% endif %}
+
+ <div style="display: flex; flex-direction: column; gap: 6px;">
+ <label for="password" style="font-weight: 500; font-size: 14px; color: var(--text);">Password</label>
+ <input
+ type="password"
+ id="password"
+ name="password"
+ required
+ autofocus
+ style="
+ padding: 11px 12px;
+ border: 1px solid var(--line);
+ border-radius: 6px;
+ font-size: 16px;
+ background: #11100e;
+ color: var(--text);
+ "
+ />
+ </div>
+
+ <button
+ type="submit"
+ style="
+ padding: 11px 12px;
+ background: var(--accent);
+ color: #0e0a04;
+ border: none;
+ border-radius: 6px;
+ font-size: 16px;
+ font-weight: 800;
+ cursor: pointer;
+ "
+ >
+ Sign In
+ </button>
+ </form>
+</div>
+{% endblock %}
diff --git a/templates/profile.html b/templates/profile.html
new file mode 100644
index 0000000..9fac27f
--- /dev/null
+++ b/templates/profile.html
@@ -0,0 +1,124 @@
+<!doctype html>
+<html lang="en">
+ <head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title>Tyler's Film Diary - Lumière</title>
+ <link rel="stylesheet" href="/static/styles.css" />
+ </head>
+ <body>
+ <div class="shell">
+ <header class="topbar">
+ <a class="brand" href="/">Lumière</a>
+ <nav class="nav-actions" aria-label="Primary">
+ <a href="/tyler">Profile</a>
+ </nav>
+ </header>
+
+ <main>
+ <div style="max-width: 1200px; margin: 0 auto; padding: 40px 20px;">
+ <!-- Hero Section -->
+ <div style="margin-bottom: 60px; padding-bottom: 40px; border-bottom: 1px solid var(--line);">
+ <h1 style="margin: 0 0 8px 0; font-size: 40px; color: var(--text);">Tyler's Film Diary</h1>
+ <p style="margin: 0; color: var(--muted); font-size: 18px;">A curated collection of films watched and loved</p>
+
+ <!-- Summary Stats -->
+ <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 24px; margin-top: 32px;">
+ <div style="padding: 20px; background: var(--panel); border: 1px solid var(--line); border-radius: 8px;">
+ <div style="font-size: 32px; font-weight: bold; color: var(--text);">{{ total_watched }}</div>
+ <div style="color: var(--muted); margin-top: 4px;">Films Watched</div>
+ </div>
+ <div style="padding: 20px; background: var(--panel); border: 1px solid var(--line); border-radius: 8px;">
+ <div style="font-size: 32px; font-weight: bold; color: var(--text);"><span class="rating">{% for _ in range(average_stars|int) %}✦{% endfor %}</span> {{ average_stars }}</div>
+ <div style="color: var(--muted); margin-top: 4px;">Average Rating</div>
+ </div>
+ </div>
+ </div>
+
+ <!-- Top Directors -->
+ {% if most_watched_directors %}
+ <div style="margin-bottom: 60px;">
+ <h2 style="margin: 0 0 24px 0; font-size: 24px; color: var(--text);">Top Directors</h2>
+ <div style="display: grid; gap: 12px;">
+ {% for item in most_watched_directors %}
+ <div style="padding: 12px 16px; background: var(--panel); border: 1px solid var(--line); border-radius: 6px; display: flex; justify-content: space-between; align-items: center;">
+ <span style="color: var(--text);">{{ item.director }}</span>
+ <span style="color: var(--muted); font-size: 14px;">{{ item.count }} film{{ 's' if item.count > 1 else '' }}</span>
+ </div>
+ {% endfor %}
+ </div>
+ </div>
+ {% endif %}
+
+ <!-- Star Distribution -->
+ <div style="margin-bottom: 60px;">
+ <h2 style="margin: 0 0 24px 0; font-size: 24px; color: var(--text);">Rating Distribution</h2>
+ <div style="display: grid; gap: 16px;">
+ {% for item in star_distribution %}
+ <div>
+ <div style="display: flex; justify-content: space-between; margin-bottom: 8px;">
+ <span style="color: var(--text);">
+ {% if item.stars == 0 %}No rating{% elif item.stars == 1 %}<span class="rating">✦</span>{% elif item.stars == 2 %}<span class="rating">✦✦</span>{% elif item.stars == 3 %}<span class="rating">✦✦✦</span>{% endif %}
+ </span>
+ <span style="color: var(--muted);">{{ item.count }}</span>
+ </div>
+ <div style="background: var(--panel-soft); height: 24px; border-radius: 4px; overflow: hidden;">
+ {% if total_watched > 0 %}
+ <div style="background: var(--accent); height: 100%; width: {{ (item.count / total_watched * 100) }}%; border-radius: 4px;"></div>
+ {% endif %}
+ </div>
+ </div>
+ {% endfor %}
+ </div>
+ </div>
+
+ <!-- Top Countries -->
+ {% if films_per_country %}
+ <div style="margin-bottom: 60px;">
+ <h2 style="margin: 0 0 24px 0; font-size: 24px; color: var(--text);">Top Countries</h2>
+ <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 16px;">
+ {% for item in films_per_country %}
+ <div style="padding: 16px; background: var(--panel); border: 1px solid var(--line); border-radius: 6px; text-align: center;">
+ <div style="font-size: 20px; font-weight: bold; color: var(--text);">{{ item.count }}</div>
+ <div style="color: var(--muted); margin-top: 4px; font-size: 14px;">{{ item.country }}</div>
+ </div>
+ {% endfor %}
+ </div>
+ </div>
+ {% endif %}
+
+ <!-- Recent Films -->
+ {% if recent_films %}
+ <div>
+ <h2 style="margin: 0 0 24px 0; font-size: 24px; color: var(--text);">Recently Watched</h2>
+ <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 24px;">
+ {% for film in recent_films %}
+ <div style="text-align: center;">
+ {% if film.poster_url %}
+ <img src="{{ film.poster_url }}" alt="{{ film.title }}" loading="lazy" style="width: 100%; border-radius: 8px; margin-bottom: 12px; aspect-ratio: 2/3; object-fit: cover;" />
+ {% else %}
+ <div style="width: 100%; aspect-ratio: 2/3; background: var(--panel); border: 1px solid var(--line); border-radius: 8px; margin-bottom: 12px; display: flex; align-items: center; justify-content: center; color: var(--muted);">No poster</div>
+ {% endif %}
+ <h3 style="margin: 0; font-size: 14px; line-height: 1.4; color: var(--text);">{{ film.title }}</h3>
+ {% if film.stars %}
+ <div style="color: var(--accent); font-size: 12px; margin-top: 4px; font-weight: 800;">
+ <span class="rating">{% for i in range(film.stars) %}✦{% endfor %}</span>
+ </div>
+ {% endif %}
+ {% if film.date_watched %}
+ <div style="color: var(--muted); font-size: 12px; margin-top: 4px;">{{ film.date_watched[:10] }}</div>
+ {% endif %}
+ </div>
+ {% endfor %}
+ </div>
+ </div>
+ {% endif %}
+ </div>
+ </main>
+
+ <footer style="text-align: center; padding: 40px 20px; color: var(--muted); font-size: 12px; border-top: 1px solid var(--line); margin-top: 60px;">
+ <p style="margin: 0;">Made with <a href="https://github.com/tylerhoang/lumiere" style="color: var(--accent); text-decoration: none;">Lumière</a></p>
+ </footer>
+ </div>
+ </body>
+</html>
diff --git a/templates/year_review.html b/templates/year_review.html
index 5339c34..1234e80 100644
--- a/templates/year_review.html
+++ b/templates/year_review.html
@@ -99,7 +99,7 @@
<article class="highlight-card">
<a class="poster-frame" href="/films/{{ film.id }}">
{% if film.poster_url %}
- <img src="{{ film.poster_url }}" alt="{{ film.title }} poster">
+ <img src="{{ film.poster_url }}" alt="{{ film.title }} poster" loading="lazy">
{% else %}
<span>{{ film.title[:1] }}</span>
{% endif %}
@@ -123,7 +123,7 @@
<article class="highlight-card">
<a class="poster-frame" href="/films/{{ highlight_films.first_watch.id }}">
{% if highlight_films.first_watch.poster_url %}
- <img src="{{ highlight_films.first_watch.poster_url }}" alt="{{ highlight_films.first_watch.title }} poster">
+ <img src="{{ highlight_films.first_watch.poster_url }}" alt="{{ highlight_films.first_watch.title }} poster" loading="lazy">
{% else %}
<span>{{ highlight_films.first_watch.title[:1] }}</span>
{% endif %}
@@ -140,7 +140,7 @@
<article class="highlight-card">
<a class="poster-frame" href="/films/{{ highlight_films.last_watch.id }}">
{% if highlight_films.last_watch.poster_url %}
- <img src="{{ highlight_films.last_watch.poster_url }}" alt="{{ highlight_films.last_watch.title }} poster">
+ <img src="{{ highlight_films.last_watch.poster_url }}" alt="{{ highlight_films.last_watch.title }} poster" loading="lazy">
{% else %}
<span>{{ highlight_films.last_watch.title[:1] }}</span>
{% endif %}
@@ -157,7 +157,7 @@
<article class="highlight-card">
<a class="poster-frame" href="/films/{{ highlight_films.most_rewatched.id }}">
{% if highlight_films.most_rewatched.poster_url %}
- <img src="{{ highlight_films.most_rewatched.poster_url }}" alt="{{ highlight_films.most_rewatched.title }} poster">
+ <img src="{{ highlight_films.most_rewatched.poster_url }}" alt="{{ highlight_films.most_rewatched.title }} poster" loading="lazy">
{% else %}
<span>{{ highlight_films.most_rewatched.title[:1] }}</span>
{% endif %}