diff options
| -rw-r--r-- | main.py | 30 | ||||
| -rw-r--r-- | requirements.txt | 2 | ||||
| -rw-r--r-- | routers/auth.py | 58 | ||||
| -rw-r--r-- | routers/films.py | 59 | ||||
| -rw-r--r-- | routers/profile.py | 91 | ||||
| -rw-r--r-- | static/app.js | 107 | ||||
| -rw-r--r-- | templates/_feed_partial.html | 14 | ||||
| -rw-r--r-- | templates/_film_card.html | 2 | ||||
| -rw-r--r-- | templates/base.html | 3 | ||||
| -rw-r--r-- | templates/detail.html | 2 | ||||
| -rw-r--r-- | templates/index.html | 7 | ||||
| -rw-r--r-- | templates/login.html | 60 | ||||
| -rw-r--r-- | templates/profile.html | 124 | ||||
| -rw-r--r-- | templates/year_review.html | 8 |
14 files changed, 550 insertions, 17 deletions
@@ -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 %} |
