diff options
| -rw-r--r-- | routers/films.py | 1 | ||||
| -rw-r--r-- | routers/stats.py | 207 | ||||
| -rw-r--r-- | static/app.js | 78 | ||||
| -rw-r--r-- | static/styles.css | 221 | ||||
| -rw-r--r-- | templates/_film_card.html | 14 | ||||
| -rw-r--r-- | templates/detail.html | 80 | ||||
| -rw-r--r-- | templates/stats.html | 9 | ||||
| -rw-r--r-- | templates/year_review.html | 175 |
8 files changed, 762 insertions, 23 deletions
diff --git a/routers/films.py b/routers/films.py index 0a6491f..fc087b1 100644 --- a/routers/films.py +++ b/routers/films.py @@ -6,7 +6,6 @@ from types import SimpleNamespace import httpx from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi.responses import RedirectResponse -from fastapi.responses import JSONResponse from sqlalchemy import func from fastapi.templating import Jinja2Templates from sqlalchemy.orm import Session diff --git a/routers/stats.py b/routers/stats.py index 78e9621..0604646 100644 --- a/routers/stats.py +++ b/routers/stats.py @@ -1,4 +1,5 @@ from collections import Counter +from calendar import month_name from datetime import date, timedelta from fastapi import APIRouter, Depends, Request @@ -112,6 +113,190 @@ def _diary_films(db: Session) -> list[Film]: ) +def _available_years(films: list[Film]) -> list[int]: + years = {film.date_watched.year for film in films if film.date_watched} + return sorted(years, reverse=True) + + +def _format_film_excerpt(notes: str | None, limit: int = 140) -> str | None: + if not notes: + return None + excerpt = " ".join(notes.split()) + if len(excerpt) <= limit: + return excerpt + return excerpt[: limit - 1].rstrip() + "..." + + +def _film_highlight_payload(film: Film) -> dict: + return { + "id": film.id, + "title": film.title, + "poster_url": film.poster_url, + "director": film.director, + "year": film.year, + "date_watched": film.date_watched.isoformat() if film.date_watched else None, + "stars": film.stars, + "notes_excerpt": _format_film_excerpt(film.notes), + "runtime": film.runtime, + "country": film.country, + "language": film.language, + } + + +def _select_year(selected_year: int | None, available_years: list[int]) -> int | None: + if selected_year is not None: + return selected_year + if available_years: + return available_years[0] + return None + + +def _films_for_year(films: list[Film], year: int) -> list[Film]: + return [ + film + for film in films + if film.date_watched and film.date_watched.year == year + ] + + +def _year_review_payload(db: Session, year: int | None) -> dict: + diary_films = _diary_films(db) + available_years = _available_years(diary_films) + selected_year = _select_year(year, available_years) + if selected_year is None: + return { + "selected_year": date.today().year if year is None else year, + "available_years": [], + "total_watched": 0, + "average_stars": 0, + "most_watched_directors": [], + "star_distribution": [{"stars": stars, "count": 0} for stars in (0, 1, 2, 3)], + "films_per_month": [{"month": month_name[index], "count": 0} for index in range(1, 13)], + "rewatch_rate": {"rewatched": 0, "total_watched": 0, "rate": 0}, + "watched_with_breakdown": [], + "top_director": None, + "top_month": None, + "highlight_films": { + "highest_rated": [], + "first_watch": None, + "last_watch": None, + "most_rewatched": None, + }, + } + + year_films = _films_for_year(diary_films, selected_year) + countries = Counter() + directors = Counter() + star_counts = Counter({0: 0, 1: 0, 2: 0, 3: 0}) + months = Counter({month_index: 0 for month_index in range(1, 13)}) + watched_with = Counter() + + for film in year_films: + countries.update(split_country_names(film.country)) + 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.date_watched: + months[film.date_watched.month] += 1 + companions = split_credit_names(film.watched_with) + if companions: + watched_with.update(companions) + else: + watched_with["solo"] += 1 + + total_watched = len(year_films) + rewatched = sum(1 for film in year_films if film.rewatch or film.rewatch_count > 0) + average_stars = round(sum(film.stars for film in year_films) / total_watched, 1) if total_watched else 0 + + top_director = None + if directors: + top_director_name, top_director_count = sorted(directors.items(), key=lambda item: (-item[1], item[0]))[0] + top_director = {"director": top_director_name, "count": top_director_count} + + top_month = None + if total_watched: + top_month_index, top_month_count = sorted(months.items(), key=lambda item: (-item[1], item[0]))[0] + top_month = { + "month": month_name[top_month_index], + "count": top_month_count, + } + + year_films_sorted = sorted( + year_films, + key=lambda film: ( + -(film.stars or 0), + -(film.date_watched.toordinal()) if film.date_watched else 0, + film.title.casefold(), + film.id, + ), + ) + highest_rated = [_film_highlight_payload(film) for film in year_films_sorted[:4]] + + first_watch = None + last_watch = None + if year_films: + first_watch_film = min( + year_films, + key=lambda film: (film.date_watched or date.max, film.id), + ) + last_watch_film = max( + year_films, + key=lambda film: (film.date_watched or date.min, film.id), + ) + first_watch = _film_highlight_payload(first_watch_film) + last_watch = _film_highlight_payload(last_watch_film) + + most_rewatched = None + rewatched_candidates = [ + film + for film in year_films + if film.rewatch_count > 0 or film.rewatch + ] + if rewatched_candidates: + rewatched_candidates.sort( + key=lambda film: ( + -film.rewatch_count, + -(film.date_watched.toordinal()) if film.date_watched else 0, + film.title.casefold(), + film.id, + ) + ) + most_rewatched = _film_highlight_payload(rewatched_candidates[0]) + + return { + "selected_year": selected_year, + "available_years": available_years, + "total_watched": total_watched, + "average_stars": average_stars, + "films_per_month": [ + {"month": month_name[index], "count": months[index]} + for index in range(1, 13) + ], + "star_distribution": [{"stars": stars, "count": star_counts[stars]} for stars in (0, 1, 2, 3)], + "most_watched_directors": [ + {"director": director, "count": count} + for director, count in sorted(directors.items(), key=lambda item: (-item[1], item[0])) + ], + "watched_with_breakdown": [ + {"watched_with": watched_with_value, "count": count} + for watched_with_value, count in sorted(watched_with.items(), key=lambda item: (-item[1], item[0])) + ], + "rewatch_rate": { + "rewatched": rewatched, + "total_watched": total_watched, + "rate": round(rewatched / total_watched, 4) if total_watched else 0, + }, + "top_director": top_director, + "top_month": top_month, + "highlight_films": { + "highest_rated": highest_rated, + "first_watch": first_watch, + "last_watch": last_watch, + "most_rewatched": most_rewatched, + }, + } + + @router.get("/stats") def stats_page(request: Request): return templates.TemplateResponse( @@ -124,3 +309,25 @@ def stats_page(request: Request): @router.get("/stats/data") def stats_data(db: Session = Depends(get_db)): return _build_stats_payload(_diary_films(db)) + + +@router.get("/stats/year-in-review") +def year_in_review_page( + request: Request, + year: int | None = None, + db: Session = Depends(get_db), +): + review = _year_review_payload(db, year) + return templates.TemplateResponse( + request=request, + name="year_review.html", + context={"request": request, "active_page": "stats", **review}, + ) + + +@router.get("/stats/year-in-review/data") +def year_in_review_data( + year: int | None = None, + db: Session = Depends(get_db), +): + return _year_review_payload(db, year) diff --git a/static/app.js b/static/app.js index 01b1d79..e2213f3 100644 --- a/static/app.js +++ b/static/app.js @@ -2,6 +2,37 @@ const tmdbQuery = document.querySelector("#tmdb-query"); const tmdbButton = document.querySelector("#tmdb-search"); const tmdbResults = document.querySelector("#tmdb-results"); +const syncStarControl = (control, stars) => { + control.dataset.currentStars = String(stars); + control.dataset.previewStars = ""; + control.querySelectorAll(".star-button").forEach((button) => { + const value = Number(button.dataset.stars || 0); + const active = stars >= value; + button.classList.toggle("is-active", active); + button.classList.remove("is-preview"); + button.setAttribute("aria-pressed", active ? "true" : "false"); + }); +}; + +const previewStarControl = (control, stars) => { + control.dataset.previewStars = String(stars); + control.querySelectorAll(".star-button").forEach((button) => { + const value = Number(button.dataset.stars || 0); + const previewed = stars >= value; + button.classList.toggle("is-preview", previewed); + }); +}; + +const clearStarPreview = (control) => { + control.dataset.previewStars = ""; + const stars = Number(control.dataset.currentStars || 0); + control.querySelectorAll(".star-button").forEach((button) => { + const value = Number(button.dataset.stars || 0); + button.classList.toggle("is-preview", false); + button.classList.toggle("is-active", stars >= value); + }); +}; + const setValue = (selector, value) => { const element = document.querySelector(selector); if (element && value !== null && value !== undefined) { @@ -129,3 +160,50 @@ document.querySelectorAll("form[data-confirm]").forEach((form) => { } }); }); + +document.addEventListener("click", async (event) => { + const button = event.target.closest(".star-button"); + if (!button) return; + + const control = button.closest(".star-control"); + if (!control || !control.dataset.filmId) return; + + const stars = Number(button.dataset.stars || 0); + const filmId = control.dataset.filmId; + + button.disabled = true; + try { + const response = await fetch(`/films/${filmId}/stars`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ stars }), + }); + + 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; + } +}); + +document.querySelectorAll(".star-control").forEach((control) => { + 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))); + }); +}); diff --git a/static/styles.css b/static/styles.css index f7c6fbc..b9ab7dd 100644 --- a/static/styles.css +++ b/static/styles.css @@ -278,6 +278,57 @@ h2 { font-weight: 800; } +.star-control { + display: inline-flex; + align-items: center; + gap: 0; + width: auto; + margin-left: auto; +} + +.star-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.15em; + min-height: 0; + padding: 0; + margin: 0; + border: 0 !important; + border-radius: 0; + background: none !important; + box-shadow: none !important; + appearance: none; + color: var(--subtle); + font-size: 1.1rem; + font-weight: 700; + line-height: 1; + opacity: 0.6; +} + +.star-button.is-active { + color: var(--accent); + opacity: 1; +} + +.star-button.is-preview { + color: var(--accent-strong); + opacity: 1; +} + +.star-button:hover { + color: var(--accent-strong); + opacity: 1; + background: none !important; +} + +.star-button:focus-visible { + outline: none; + color: var(--accent-strong); + opacity: 1; + background: none !important; +} + .meta-row, .detail-meta { display: flex; @@ -380,6 +431,27 @@ h2 { width: 100%; } +.detail-poster { + display: grid; + gap: 16px; + align-content: start; +} + +.detail-aside-meta { + display: grid; + gap: 12px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); + padding: 16px; +} + +.detail-aside-meta strong { + display: block; + margin-top: 4px; + color: var(--text); +} + .detail-body h1 { margin-bottom: 10px; } @@ -392,6 +464,45 @@ h2 { margin-top: 18px; } +.detail-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; + margin-top: 18px; +} + +.detail-panel { + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); + padding: 16px; +} + +.detail-panel .eyebrow { + margin-bottom: 10px; +} + +.detail-tagline { + margin: 0 0 12px; + color: var(--accent-strong); + font-family: Georgia, "Times New Roman", serif; + font-size: 1.08rem; +} + +.detail-overview { + margin: 0; + color: #e3dacd; +} + +.detail-cast { + margin-top: 16px; +} + +.detail-cast p { + margin: 6px 0 0; + color: var(--muted); +} + .notes-body { margin-top: 28px; max-width: 68ch; @@ -731,6 +842,101 @@ textarea:focus { color: var(--muted); } +.review-hero { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 18px; + padding: 48px 0 22px; +} + +.review-intro { + max-width: 60ch; + color: var(--muted); +} + +.year-picker { + display: grid; + gap: 8px; + min-width: 180px; +} + +.year-picker label { + margin: 0; +} + +.review-metrics { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 14px; + margin-bottom: 18px; +} + +.review-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 18px; +} + +.review-panel { + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); + padding: 18px; +} + +.review-panel-wide { + grid-column: 1 / -1; +} + +.year-bars { + display: grid; + gap: 10px; + margin-top: 10px; +} + +.year-bar-row { + display: grid; + grid-template-columns: 34px minmax(0, 1fr) auto; + gap: 12px; + align-items: center; +} + +.year-bar-track { + height: 10px; + overflow: hidden; + border-radius: 999px; + background: var(--panel-soft); +} + +.year-bar-fill { + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, rgba(240, 184, 77, 0.35), var(--accent)); +} + +.highlight-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 14px; + margin-top: 12px; +} + +.highlight-card { + display: grid; + grid-template-columns: 72px minmax(0, 1fr); + gap: 14px; + align-items: start; +} + +.highlight-card h2 { + margin-top: 0; +} + +.highlight-meta { + color: var(--subtle); +} + @media (max-width: 760px) { .shell { width: min(100% - 24px, 1120px); @@ -783,6 +989,21 @@ textarea:focus { flex-direction: column; } + .review-hero { + align-items: flex-start; + flex-direction: column; + } + + .review-metrics, + .review-grid { + grid-template-columns: 1fr; + } + + .highlight-card { + grid-template-columns: 1fr; + align-items: start; + } + .heatmap-months { margin-left: 38px; } diff --git a/templates/_film_card.html b/templates/_film_card.html index 9fcd89b..7147a1e 100644 --- a/templates/_film_card.html +++ b/templates/_film_card.html @@ -21,7 +21,19 @@ {% endif %} </p> </div> - {% if film.stars %} + {% if film.shelf == 'diary' %} + <div class="star-control" role="group" aria-label="Rate film" data-film-id="{{ film.id }}" data-current-stars="{{ film.stars }}"> + {% for value in range(1, 4) %} + <button + type="button" + class="star-button {% if film.stars >= value %}is-active{% endif %}" + data-stars="{{ value }}" + aria-label="{{ value }} star{% if value > 1 %}s{% endif %}" + aria-pressed="{% if film.stars >= value %}true{% else %}false{% endif %}" + >✦</button> + {% endfor %} + </div> + {% elif film.stars %} <span class="rating">{% for _ in range(film.stars) %}✦{% endfor %}</span> {% endif %} </div> diff --git a/templates/detail.html b/templates/detail.html index b937c6b..df3f96a 100644 --- a/templates/detail.html +++ b/templates/detail.html @@ -12,6 +12,20 @@ <span>{{ film.title[:1] }}</span> {% endif %} </div> + <div class="detail-aside-meta"> + <div> + <span class="summary-label">Shelf</span> + <strong>{{ film.shelf|title }}</strong> + </div> + <div> + <span class="summary-label">Watched</span> + <strong>{% if film.date_watched %}{{ film.date_watched }}{% else %}Not set{% endif %}</strong> + </div> + <div> + <span class="summary-label">Stars</span> + <strong>{% if film.stars %}{% for _ in range(film.stars) %}✦{% endfor %}{% else %}Unstarred{% endif %}</strong> + </div> + </div> </aside> <section class="detail-body"> @@ -31,29 +45,57 @@ {% endif %} </p> - <div class="detail-meta"> - {% if film.stars %}<span class="rating">{% for _ in range(film.stars) %}✦{% endfor %}</span>{% endif %} - {% if film.date_watched %}<span>{{ film.date_watched }}</span>{% endif %} - {% if film.runtime %}<span>{{ film.runtime }} min</span>{% endif %} - {% if film.rewatch %}<span>Rewatch{% if film.rewatch_count %} #{{ film.rewatch_count }}{% endif %}</span>{% endif %} - {% if film.country %}<span>{{ film.country }}</span>{% endif %} - {% if film.language %}<span>{{ film.language }}</span>{% endif %} - </div> + <section class="detail-grid"> + <article class="detail-panel"> + <p class="eyebrow">Watch log</p> + <div class="detail-meta"> + {% if film.date_watched %}<span>{{ film.date_watched }}</span>{% endif %} + {% if film.runtime %}<span>{{ film.runtime }} min</span>{% endif %} + {% if film.rewatch %}<span>Rewatch{% if film.rewatch_count %} #{{ film.rewatch_count }}{% endif %}</span>{% endif %} + {% if film.watched_with %}<span>With {{ film.watched_with }}</span>{% endif %} + {% if film.context %}<span>{{ film.context }}</span>{% endif %} + {% if film.how_found %}<span>{{ film.how_found }}</span>{% endif %} + </div> + </article> - {% if film.context or film.how_found or film.watched_with %} - <div class="tag-row detail-tags"> - {% if film.context %}<span>{{ film.context }}</span>{% endif %} - {% if film.how_found %}<span>{{ film.how_found }}</span>{% endif %} - {% if film.watched_with %}<span>With {{ film.watched_with }}</span>{% endif %} - </div> - {% endif %} + <article class="detail-panel"> + <p class="eyebrow">Production</p> + <div class="detail-meta"> + {% if film.country %}<span>{{ film.country }}</span>{% endif %} + {% if film.language %}<span>{{ film.language }}</span>{% endif %} + {% if film.year %}<span>{{ film.year }}</span>{% endif %} + {% if film.tmdb_id %}<span>TMDB {{ film.tmdb_id }}</span>{% endif %} + </div> + </article> + </section> - {% if film.notes %} - <div class="notes-body"> - {{ film.notes }} - </div> + {% if tmdb_context %} + <section class="detail-panel"> + <p class="eyebrow">Summary</p> + {% if tmdb_context.tagline %} + <p class="detail-tagline">{{ tmdb_context.tagline }}</p> + {% endif %} + {% if tmdb_context.overview %} + <p class="detail-overview">{{ tmdb_context.overview }}</p> + {% endif %} + {% if tmdb_context.cast %} + <div class="detail-cast"> + <span class="summary-label">Cast</span> + <p>{{ tmdb_context.cast|join(", ") }}</p> + </div> + {% endif %} + </section> {% endif %} + <section class="detail-panel"> + <p class="eyebrow">Notes</p> + {% if film.notes %} + <div class="notes-body">{{ film.notes }}</div> + {% else %} + <p class="muted">No notes saved.</p> + {% endif %} + </section> + <div class="detail-actions"> <a class="secondary-button" href="/films/{{ film.id }}/edit">Edit</a> {% if film.shelf == 'queue' %} diff --git a/templates/stats.html b/templates/stats.html index 7b54634..2983418 100644 --- a/templates/stats.html +++ b/templates/stats.html @@ -4,8 +4,13 @@ {% block content %} <section class="page-heading"> - <p class="eyebrow">Stats</p> - <h1>Watching patterns</h1> + <div class="page-heading-row"> + <div> + <p class="eyebrow">Stats</p> + <h1>Watching patterns</h1> + </div> + <a class="button-link" href="/stats/year-in-review">Year in review</a> + </div> </section> <section class="stats-layout"> diff --git a/templates/year_review.html b/templates/year_review.html new file mode 100644 index 0000000..5339c34 --- /dev/null +++ b/templates/year_review.html @@ -0,0 +1,175 @@ +{% extends "base.html" %} + +{% block title %}{{ selected_year }} Year in Review · Lumière{% endblock %} + +{% block content %} + <section class="review-hero"> + <div> + <p class="eyebrow">Year in Review</p> + <h1>{{ selected_year }}</h1> + <p class="review-intro">A snapshot of the films you logged that year.</p> + </div> + + <form class="year-picker" method="get" action="/stats/year-in-review"> + <label for="year">Year</label> + <select id="year" name="year" onchange="this.form.submit()"> + {% for option in available_years %} + <option value="{{ option }}" {% if option == selected_year %}selected{% endif %}>{{ option }}</option> + {% endfor %} + </select> + </form> + </section> + + {% if total_watched == 0 %} + <section class="empty-state"> + <p class="eyebrow">No entries</p> + <h2>No diary films for this year</h2> + <p>Pick another year or add more diary entries.</p> + </section> + {% else %} + <section class="review-metrics"> + <article class="summary-card"> + <span class="summary-label">Films watched</span> + <strong>{{ total_watched }}</strong> + </article> + <article class="summary-card"> + <span class="summary-label">Average stars</span> + <strong>{{ "%.1f"|format(average_stars) }}</strong> + </article> + <article class="summary-card"> + <span class="summary-label">Rewatch rate</span> + <strong>{{ (rewatch_rate.rate * 100)|round(0) }}%</strong> + </article> + <article class="summary-card"> + <span class="summary-label">Top director</span> + <strong>{% if top_director %}{{ top_director.director }}{% else %}—{% endif %}</strong> + </article> + <article class="summary-card"> + <span class="summary-label">Top month</span> + <strong>{% if top_month %}{{ top_month.month }}{% else %}—{% endif %}</strong> + </article> + </section> + + <section class="review-grid"> + <article class="review-panel review-panel-wide"> + <p class="eyebrow">Monthly activity</p> + <div class="year-bars"> + {% set max_month = films_per_month | map(attribute='count') | max if films_per_month else 1 %} + {% for item in films_per_month %} + <div class="year-bar-row"> + <span>{{ item.month[:3] }}</span> + <div class="year-bar-track"> + <div class="year-bar-fill" style="width: {{ (item.count / max_month * 100) if max_month else 0 }}%"></div> + </div> + <strong>{{ item.count }}</strong> + </div> + {% endfor %} + </div> + </article> + + <article class="review-panel"> + <p class="eyebrow">Stars</p> + <div class="stats-bars"> + {% set max_stars = star_distribution | map(attribute='count') | max if star_distribution else 1 %} + {% for item in star_distribution %} + <div class="stats-bar-row"> + <span>{{ item.stars }} star</span> + <div class="stats-bar-track"><div class="stats-bar-fill" style="width: {{ (item.count / max_stars * 100) if max_stars else 0 }}%"></div></div> + <strong>{{ item.count }}</strong> + </div> + {% endfor %} + </div> + </article> + + <article class="review-panel"> + <p class="eyebrow">Companions</p> + <ol class="stats-list"> + {% for item in watched_with_breakdown %} + <li><span>{{ item.watched_with }}</span><strong>{{ item.count }}</strong></li> + {% endfor %} + </ol> + </article> + </section> + + <section class="review-panel"> + <p class="eyebrow">Highlights</p> + <div class="highlight-grid"> + {% if highlight_films.highest_rated %} + {% for film in highlight_films.highest_rated %} + <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"> + {% else %} + <span>{{ film.title[:1] }}</span> + {% endif %} + </a> + <div> + <h2><a href="/films/{{ film.id }}">{{ film.title }}</a></h2> + <p class="muted">{{ film.director or "Unknown director" }}</p> + <p class="highlight-meta"> + {% if film.date_watched %}{{ film.date_watched }}{% endif %} + {% if film.stars %} · {% for _ in range(film.stars) %}✦{% endfor %}{% endif %} + </p> + {% if film.notes_excerpt %} + <p class="notes-preview">{{ film.notes_excerpt }}</p> + {% endif %} + </div> + </article> + {% endfor %} + {% endif %} + + {% if highlight_films.first_watch %} + <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"> + {% else %} + <span>{{ highlight_films.first_watch.title[:1] }}</span> + {% endif %} + </a> + <div> + <p class="summary-label">First watch</p> + <h2><a href="/films/{{ highlight_films.first_watch.id }}">{{ highlight_films.first_watch.title }}</a></h2> + <p class="muted">{{ highlight_films.first_watch.date_watched }}</p> + </div> + </article> + {% endif %} + + {% if highlight_films.last_watch %} + <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"> + {% else %} + <span>{{ highlight_films.last_watch.title[:1] }}</span> + {% endif %} + </a> + <div> + <p class="summary-label">Last watch</p> + <h2><a href="/films/{{ highlight_films.last_watch.id }}">{{ highlight_films.last_watch.title }}</a></h2> + <p class="muted">{{ highlight_films.last_watch.date_watched }}</p> + </div> + </article> + {% endif %} + + {% if highlight_films.most_rewatched %} + <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"> + {% else %} + <span>{{ highlight_films.most_rewatched.title[:1] }}</span> + {% endif %} + </a> + <div> + <p class="summary-label">Most rewatched</p> + <h2><a href="/films/{{ highlight_films.most_rewatched.id }}">{{ highlight_films.most_rewatched.title }}</a></h2> + <p class="muted">{{ highlight_films.most_rewatched.rewatch_count }} rewatches</p> + </div> + </article> + {% endif %} + </div> + </section> + {% endif %} +{% endblock %} |
