summaryrefslogtreecommitdiff
path: root/templates
diff options
context:
space:
mode:
Diffstat (limited to 'templates')
-rw-r--r--templates/_film_card.html70
-rw-r--r--templates/base.html30
-rw-r--r--templates/detail.html83
-rw-r--r--templates/director.html31
-rw-r--r--templates/form.html144
-rw-r--r--templates/import.html108
-rw-r--r--templates/index.html64
-rw-r--r--templates/stats.html268
8 files changed, 798 insertions, 0 deletions
diff --git a/templates/_film_card.html b/templates/_film_card.html
new file mode 100644
index 0000000..9fcd89b
--- /dev/null
+++ b/templates/_film_card.html
@@ -0,0 +1,70 @@
+<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">
+ {% else %}
+ <span>{{ film.title[:1] }}</span>
+ {% endif %}
+ </a>
+ <div class="film-card-body">
+ <div class="film-card-header">
+ <div>
+ <h2><a href="/films/{{ film.id }}">{{ film.title }}</a></h2>
+ <p class="muted">
+ {% if film.year %}{{ film.year }}{% endif %}
+ {% set directors = split_credit_names(film.director) %}
+ {% if directors %}
+ {% if film.year %} · {% endif %}
+ {% for director in directors %}
+ <a class="inline-link" href="{{ director_href(director) }}">{{ director }}</a>{% if not loop.last %}, {% endif %}
+ {% endfor %}
+ {% endif %}
+ </p>
+ </div>
+ {% if film.stars %}
+ <span class="rating">{% for _ in range(film.stars) %}✦{% endfor %}</span>
+ {% endif %}
+ </div>
+
+ <div class="meta-row">
+ <span>{{ film.shelf|title }}</span>
+ {% if film.date_watched %}<span>{{ film.date_watched }}</span>{% endif %}
+ {% if film.runtime %}<span>{{ film.runtime }} min</span>{% endif %}
+ {% if film.language %}<span>{{ film.language }}</span>{% endif %}
+ {% if film.rewatch %}<span>Rewatch</span>{% endif %}
+ </div>
+
+ {% if film.context or film.how_found or film.watched_with %}
+ <div class="tag-row">
+ {% 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 %}
+
+ {% if film.notes %}
+ <p class="notes-preview">{{ film.notes[:220] }}{% if film.notes|length > 220 %}...{% endif %}</p>
+ {% endif %}
+
+ <div class="inline-actions">
+ {% if film.shelf == 'queue' %}
+ <a class="small-button" href="/films/{{ film.id }}/edit?shelf=diary">Mark watched</a>
+ <form method="post" action="/films/{{ film.id }}/shelf/abandoned">
+ <button class="secondary-button" type="submit">Abandon</button>
+ </form>
+ {% elif film.shelf == 'diary' %}
+ <form method="post" action="/films/{{ film.id }}/shelf/queue">
+ <button class="secondary-button" type="submit">Move to queue</button>
+ </form>
+ <form method="post" action="/films/{{ film.id }}/shelf/abandoned">
+ <button class="secondary-button" type="submit">Mark abandoned</button>
+ </form>
+ {% else %}
+ <a class="small-button" href="/films/{{ film.id }}/edit?shelf=diary">Mark watched</a>
+ <form method="post" action="/films/{{ film.id }}/shelf/queue">
+ <button class="secondary-button" type="submit">Move to queue</button>
+ </form>
+ {% endif %}
+ </div>
+ </div>
+</article>
diff --git a/templates/base.html b/templates/base.html
new file mode 100644
index 0000000..168efde
--- /dev/null
+++ b/templates/base.html
@@ -0,0 +1,30 @@
+<!doctype html>
+<html lang="en">
+ <head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title>{% block title %}Lumière{% endblock %}</title>
+ <link rel="stylesheet" href="/static/styles.css" />
+ <script src="{{ url_for('static', path='/app.js') }}" defer></script>
+ </head>
+ <body>
+ <div class="shell">
+ <header class="topbar">
+ <a class="brand" href="/">Lumière</a>
+ <nav class="nav-actions" aria-label="Primary">
+ <a class="{% if active_shelf == 'diary' %}is-active{% endif %}" href="/diary">Diary</a>
+ <a class="{% if active_shelf == 'queue' %}is-active{% endif %}" href="/queue">Queue</a>
+ <a class="{% if active_shelf == 'abandoned' %}is-active{% endif %}" href="/abandoned">Abandoned</a>
+ <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>
+ </nav>
+ </header>
+
+ <main>
+ {% block content %}{% endblock %}
+ </main>
+ </div>
+ {% block scripts %}{% endblock %}
+ </body>
+</html>
diff --git a/templates/detail.html b/templates/detail.html
new file mode 100644
index 0000000..b937c6b
--- /dev/null
+++ b/templates/detail.html
@@ -0,0 +1,83 @@
+{% extends "base.html" %}
+
+{% block title %}{{ film.title }} · Lumière{% endblock %}
+
+{% block content %}
+ <article class="detail-layout">
+ <aside class="detail-poster">
+ <div class="poster-frame poster-large">
+ {% if film.poster_url %}
+ <img src="{{ film.poster_url }}" alt="{{ film.title }} poster">
+ {% else %}
+ <span>{{ film.title[:1] }}</span>
+ {% endif %}
+ </div>
+ </aside>
+
+ <section class="detail-body">
+ <p class="eyebrow">{{ film.shelf|title }} Entry</p>
+ <h1>{{ film.title }}</h1>
+ {% if film.original_title %}
+ <p class="original-title">{{ film.original_title }}</p>
+ {% endif %}
+ <p class="subtitle">
+ {% if film.year %}{{ film.year }}{% endif %}
+ {% set directors = split_credit_names(film.director) %}
+ {% if directors %}
+ {% if film.year %} · {% endif %}
+ {% for director in directors %}
+ <a class="inline-link" href="{{ director_href(director) }}">{{ director }}</a>{% if not loop.last %}, {% endif %}
+ {% endfor %}
+ {% 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>
+
+ {% 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 %}
+
+ {% if film.notes %}
+ <div class="notes-body">
+ {{ film.notes }}
+ </div>
+ {% endif %}
+
+ <div class="detail-actions">
+ <a class="secondary-button" href="/films/{{ film.id }}/edit">Edit</a>
+ {% if film.shelf == 'queue' %}
+ <a class="button-link" href="/films/{{ film.id }}/edit?shelf=diary">Mark watched</a>
+ <form method="post" action="/films/{{ film.id }}/shelf/abandoned">
+ <button class="secondary-button" type="submit">Abandon</button>
+ </form>
+ {% elif film.shelf == 'diary' %}
+ <form method="post" action="/films/{{ film.id }}/shelf/queue">
+ <button class="secondary-button" type="submit">Move to queue</button>
+ </form>
+ <form method="post" action="/films/{{ film.id }}/shelf/abandoned">
+ <button class="secondary-button" type="submit">Mark abandoned</button>
+ </form>
+ {% else %}
+ <a class="button-link" href="/films/{{ film.id }}/edit?shelf=diary">Mark watched</a>
+ <form method="post" action="/films/{{ film.id }}/shelf/queue">
+ <button class="secondary-button" type="submit">Move to queue</button>
+ </form>
+ {% endif %}
+ <form method="post" action="/films/{{ film.id }}/delete">
+ <button class="danger-button" type="submit">Delete</button>
+ </form>
+ </div>
+ </section>
+ </article>
+{% endblock %}
diff --git a/templates/director.html b/templates/director.html
new file mode 100644
index 0000000..3257ee9
--- /dev/null
+++ b/templates/director.html
@@ -0,0 +1,31 @@
+{% extends "base.html" %}
+
+{% block title %}{{ director_name }} · Lumière{% endblock %}
+
+{% block content %}
+ <section class="page-heading">
+ <p class="eyebrow">Director</p>
+ <h1>{{ director_name }}</h1>
+ </section>
+
+ <section class="director-summary" aria-label="Director summary">
+ <article class="summary-card">
+ <span class="summary-label">Films logged</span>
+ <strong>{{ director_summary.total_films_logged }}</strong>
+ </article>
+ <article class="summary-card">
+ <span class="summary-label">Average stars</span>
+ <strong>{{ "%.1f"|format(director_summary.average_stars) }}</strong>
+ </article>
+ <article class="summary-card">
+ <span class="summary-label">Common shelf</span>
+ <strong>{{ director_summary.most_common_shelf|title }}</strong>
+ </article>
+ </section>
+
+ <section class="diary-feed" aria-label="{{ director_name }} filmography">
+ {% for film in films %}
+ {% include "_film_card.html" %}
+ {% endfor %}
+ </section>
+{% endblock %}
diff --git a/templates/form.html b/templates/form.html
new file mode 100644
index 0000000..4009e87
--- /dev/null
+++ b/templates/form.html
@@ -0,0 +1,144 @@
+{% extends "base.html" %}
+
+{% block title %}{{ page_title }} · Lumière{% endblock %}
+
+{% block content %}
+ <section class="form-shell">
+ <div class="form-heading">
+ <p class="eyebrow">Diary Entry</p>
+ <h1>{{ page_title }}</h1>
+ </div>
+
+ {% if error %}
+ <div class="notice error">{{ error }}</div>
+ {% endif %}
+
+ <div class="tmdb-panel">
+ <label for="tmdb-query">TMDB title search</label>
+ <div class="search-row">
+ <input id="tmdb-query" type="search" autocomplete="off" placeholder="Search by title">
+ <button id="tmdb-search" type="button">Search</button>
+ </div>
+ <div id="tmdb-results" class="tmdb-results" aria-live="polite"></div>
+ </div>
+
+ <form class="film-form" method="post" action="{{ action }}">
+ <input id="tmdb_id" name="tmdb_id" type="hidden" value="{{ film.tmdb_id if film and film.tmdb_id else '' }}">
+
+ <div class="form-grid">
+ <div class="field span-2">
+ <label for="title">Display title</label>
+ <input id="title" name="title" required value="{{ film.title if film else '' }}">
+ </div>
+
+ <div class="field span-2">
+ <label for="original_title">Original title</label>
+ <input id="original_title" name="original_title" value="{{ film.original_title if film and film.original_title else '' }}">
+ </div>
+
+ <div class="field">
+ <label for="director">Director</label>
+ <input id="director" name="director" value="{{ film.director if film and film.director else '' }}">
+ </div>
+
+ <div class="field">
+ <label for="year">Year</label>
+ <input id="year" name="year" inputmode="numeric" value="{{ film.year if film and film.year else '' }}">
+ </div>
+
+ <div class="field">
+ <label for="country">Country</label>
+ <input id="country" name="country" value="{{ film.country if film and film.country else '' }}">
+ </div>
+
+ <div class="field">
+ <label for="language">Language</label>
+ <input id="language" name="language" value="{{ film.language if film and film.language else '' }}">
+ </div>
+
+ <div class="field">
+ <label for="runtime">Runtime</label>
+ <input id="runtime" name="runtime" type="number" min="0" inputmode="numeric" value="{{ film.runtime if film and film.runtime is not none else '' }}">
+ </div>
+
+ <div class="field">
+ <label for="date_watched">Watched</label>
+ <input id="date_watched" name="date_watched" type="date" value="{{ film.date_watched if film and film.date_watched else '' }}">
+ </div>
+
+ <div class="field">
+ <label for="shelf">Shelf</label>
+ {% set current_shelf = shelf_override if shelf_override else (film.shelf if film and film.shelf else 'diary') %}
+ <select id="shelf" name="shelf">
+ <option value="diary" {% if current_shelf == 'diary' %}selected{% endif %}>Diary</option>
+ <option value="queue" {% if current_shelf == 'queue' %}selected{% endif %}>Queue</option>
+ <option value="abandoned" {% if current_shelf == 'abandoned' %}selected{% endif %}>Abandoned</option>
+ </select>
+ </div>
+
+ <div class="field">
+ <label for="stars">Stars</label>
+ {% set current_stars = film.stars if film else 0 %}
+ <select id="stars" name="stars">
+ <option value="0" {% if current_stars|string == '0' %}selected{% endif %}>Unstarred</option>
+ <option value="1" {% if current_stars|string == '1' %}selected{% endif %}>1 star</option>
+ <option value="2" {% if current_stars|string == '2' %}selected{% endif %}>2 stars</option>
+ <option value="3" {% if current_stars|string == '3' %}selected{% endif %}>3 stars</option>
+ </select>
+ </div>
+
+ <div class="field checkbox-field">
+ <label class="check-label" for="rewatch">
+ <input id="rewatch" name="rewatch" type="checkbox" value="on" {% if film and film.rewatch %}checked{% endif %}>
+ Rewatch
+ </label>
+ </div>
+
+ <div class="field">
+ <label for="rewatch_count">Rewatch count</label>
+ <input id="rewatch_count" name="rewatch_count" type="number" min="0" inputmode="numeric" value="{{ film.rewatch_count if film and film.rewatch_count is not none else 0 }}">
+ </div>
+
+ <div class="field span-2">
+ <label for="watched_with">Watched with</label>
+ <input id="watched_with" name="watched_with" placeholder="Solo, Maya, Film club" value="{{ film.watched_with if film and film.watched_with else '' }}">
+ </div>
+
+ <div class="field">
+ <label for="how_found">How found</label>
+ <input id="how_found" name="how_found" placeholder="Recommendation, festival, streaming queue" value="{{ film.how_found if film and film.how_found else '' }}">
+ </div>
+
+ <div class="field">
+ <label for="context">Context</label>
+ <input id="context" name="context" placeholder="Criterion, Festival, Kiarostami deep-dive" value="{{ film.context if film and film.context else '' }}">
+ </div>
+
+ <div class="field span-2">
+ <label for="poster_url">Poster URL</label>
+ <input id="poster_url" name="poster_url" value="{{ film.poster_url if film and film.poster_url else '' }}">
+ </div>
+
+ <div class="poster-preview-field">
+ <div class="poster-frame poster-preview">
+ {% if film and film.poster_url %}
+ <img id="poster-preview" src="{{ film.poster_url }}" alt="Poster preview">
+ {% else %}
+ <img id="poster-preview" alt="Poster preview">
+ {% endif %}
+ </div>
+ </div>
+
+ <div class="field notes-field">
+ <label for="notes">Notes</label>
+ <textarea id="notes" name="notes" rows="10">{{ film.notes if film and film.notes else '' }}</textarea>
+ </div>
+ </div>
+
+ <div class="form-actions">
+ <a href="{{ '/films/' ~ film.id if film and film.id else '/' }}">Cancel</a>
+ <button type="submit">{{ submit_label }}</button>
+ </div>
+ </form>
+ </section>
+{% endblock %}
diff --git a/templates/import.html b/templates/import.html
new file mode 100644
index 0000000..50c27d6
--- /dev/null
+++ b/templates/import.html
@@ -0,0 +1,108 @@
+{% extends "base.html" %}
+
+{% block title %}Import · Lumière{% endblock %}
+
+{% block content %}
+ <section class="form-shell narrow">
+ <div class="form-heading">
+ <p class="eyebrow">Letterboxd</p>
+ <h1>Import diary CSV</h1>
+ </div>
+
+ {% if error %}
+ <div class="notice error">{{ error }}</div>
+ {% endif %}
+
+ <form class="film-form" method="post" action="/import/letterboxd" enctype="multipart/form-data">
+ <div class="field">
+ <label for="file">CSV export</label>
+ <input id="file" name="file" type="file" accept=".csv,text/csv" required>
+ </div>
+
+ <div class="form-actions">
+ <a href="/">Cancel</a>
+ <button type="submit">Import entries</button>
+ </div>
+ </form>
+ </section>
+
+ <section class="form-shell narrow data-tools">
+ <div class="form-heading compact-heading">
+ <p class="eyebrow">Queue</p>
+ <h1>Import watchlist CSV</h1>
+ </div>
+
+ <form class="film-form" method="post" action="/import/watchlist" enctype="multipart/form-data">
+ <div class="field">
+ <label for="watchlist-file">Watchlist CSV</label>
+ <input id="watchlist-file" name="file" type="file" accept=".csv,text/csv" required>
+ </div>
+
+ <div class="form-actions">
+ <a href="/queue">Cancel</a>
+ <button type="submit">Import to queue</button>
+ </div>
+ </form>
+ </section>
+
+ <section class="form-shell narrow data-tools">
+ <div class="form-heading compact-heading">
+ <p class="eyebrow">TMDB</p>
+ <h1>Fetch missing posters</h1>
+ </div>
+
+ <p class="muted">
+ Match imported films against TMDB and fill blank posters plus missing metadata.
+ </p>
+
+ <form class="film-form" method="post" action="/data/enrich-posters">
+ <div class="form-actions">
+ <button type="submit">Fetch missing posters</button>
+ </div>
+ </form>
+ </section>
+
+ <section class="form-shell narrow data-tools">
+ <div class="form-heading compact-heading">
+ <p class="eyebrow">Data</p>
+ <h1>Clear duplicates</h1>
+ </div>
+
+ <p class="muted">
+ Remove repeated imports that match the same film and watched date, keeping the oldest entry.
+ </p>
+
+ <form
+ class="film-form"
+ method="post"
+ action="/data/clear-duplicates"
+ data-confirm="Remove duplicate film entries? Lumière will keep the oldest entry for each film/date."
+ >
+ <div class="form-actions">
+ <button type="submit">Clear duplicates</button>
+ </div>
+ </form>
+ </section>
+
+ <section class="form-shell narrow danger-zone">
+ <div class="form-heading compact-heading">
+ <p class="eyebrow">Data</p>
+ <h1>Clear diary</h1>
+ </div>
+
+ <p class="muted">
+ Remove every film entry from Lumière and reset the local database counter.
+ </p>
+
+ <form
+ class="film-form"
+ method="post"
+ action="/data/clear"
+ data-confirm="Clear all Lumière film data? This cannot be undone."
+ >
+ <div class="form-actions">
+ <button class="danger-button" type="submit">Clear all data</button>
+ </div>
+ </form>
+ </section>
+{% endblock %}
diff --git a/templates/index.html b/templates/index.html
new file mode 100644
index 0000000..52c633f
--- /dev/null
+++ b/templates/index.html
@@ -0,0 +1,64 @@
+{% extends "base.html" %}
+
+{% block title %}{{ shelf_meta.title }} · Lumière{% endblock %}
+
+{% block content %}
+ <section class="page-heading">
+ <p class="eyebrow">{{ shelf_meta.eyebrow }}</p>
+ <div class="page-heading-row">
+ <h1>{{ shelf_meta.title }}</h1>
+ {% if active_shelf == 'queue' %}
+ <a class="button-link" href="/queue/random">Surprise me</a>
+ {% endif %}
+ </div>
+ </section>
+
+ {% if imported is not none %}
+ <div class="notice">{{ imported }} entries imported.</div>
+ {% endif %}
+
+ {% if skipped is not none and skipped %}
+ <div class="notice">{{ skipped }} duplicate entries skipped.</div>
+ {% endif %}
+
+ {% if cleared is not none %}
+ <div class="notice">{{ cleared }} entries cleared.</div>
+ {% endif %}
+
+ {% if deduped is not none %}
+ <div class="notice">{{ deduped }} duplicate entries removed.</div>
+ {% endif %}
+
+ {% if empty_queue is not none %}
+ <div class="notice">The queue is empty. Add a film to get a random pick.</div>
+ {% endif %}
+
+ {% if enriched is not none %}
+ <div class="notice">{{ enriched }} entries enriched from TMDB.</div>
+ {% endif %}
+
+ {% if films %}
+ <section class="diary-feed" aria-label="Diary entries">
+ {% if active_shelf == 'diary' and grouped_films %}
+ {% for group in grouped_films %}
+ <div class="month-group">
+ <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 %}
+ </section>
+ {% else %}
+ <section class="empty-state">
+ <h2>{{ shelf_meta.empty_title }}</h2>
+ <p>{{ shelf_meta.empty_text }}</p>
+ <a class="button-link" href="/films/new">Add Film</a>
+ </section>
+ {% endif %}
+{% endblock %}
diff --git a/templates/stats.html b/templates/stats.html
new file mode 100644
index 0000000..7b54634
--- /dev/null
+++ b/templates/stats.html
@@ -0,0 +1,268 @@
+{% extends "base.html" %}
+
+{% block title %}Stats · Lumière{% endblock %}
+
+{% block content %}
+ <section class="page-heading">
+ <p class="eyebrow">Stats</p>
+ <h1>Watching patterns</h1>
+ </section>
+
+ <section class="stats-layout">
+ <section class="stats-panel stats-panel-wide">
+ <div class="stats-panel-header">
+ <div>
+ <p class="eyebrow">World Map</p>
+ <h2>Films per country</h2>
+ </div>
+ </div>
+ <div id="world-map" class="stats-map" aria-label="World map of watched films"></div>
+ <div id="map-readout" class="map-readout" aria-live="polite">
+ <strong>Hover a country</strong>
+ <span>Film count will appear here.</span>
+ </div>
+ <div id="map-tooltip" class="stats-tooltip" hidden></div>
+ </section>
+
+ <section class="stats-panel stats-panel-wide">
+ <div class="stats-panel-header">
+ <div>
+ <p class="eyebrow">Heatmap</p>
+ <h2>Past 365 days</h2>
+ </div>
+ </div>
+ <div id="watch-heatmap" class="heatmap-shell" aria-label="Daily watch heatmap"></div>
+ </section>
+
+ <section class="stats-panel">
+ <div class="stats-panel-header">
+ <div>
+ <p class="eyebrow">Directors</p>
+ <h2>Most watched</h2>
+ </div>
+ </div>
+ <ol id="top-directors" class="stats-list"></ol>
+ </section>
+
+ <section class="stats-panel">
+ <div class="stats-panel-header">
+ <div>
+ <p class="eyebrow">Stars</p>
+ <h2>Distribution</h2>
+ </div>
+ </div>
+ <div id="star-distribution" class="stats-bars"></div>
+ </section>
+
+ <section class="stats-panel">
+ <div class="stats-panel-header">
+ <div>
+ <p class="eyebrow">Rewatch</p>
+ <h2>Rate</h2>
+ </div>
+ </div>
+ <div id="rewatch-rate" class="stats-metric"></div>
+ </section>
+
+ <section class="stats-panel">
+ <div class="stats-panel-header">
+ <div>
+ <p class="eyebrow">Companions</p>
+ <h2>Watched with</h2>
+ </div>
+ </div>
+ <ol id="watched-with" class="stats-list"></ol>
+ </section>
+ </section>
+{% endblock %}
+
+{% block scripts %}
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.8.5/d3.min.js"></script>
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/topojson/3.0.2/topojson.min.js"></script>
+ <script>
+ const PANEL_COLOR = getComputedStyle(document.documentElement).getPropertyValue("--panel").trim();
+ const PANEL_SOFT_COLOR = getComputedStyle(document.documentElement).getPropertyValue("--panel-soft").trim();
+ const ACCENT_COLOR = getComputedStyle(document.documentElement).getPropertyValue("--accent").trim();
+
+ function heatmapColor(count) {
+ if (count <= 0) return PANEL_SOFT_COLOR;
+ if (count === 1) return "rgba(240, 184, 77, 0.35)";
+ if (count === 2) return "rgba(240, 184, 77, 0.65)";
+ return ACCENT_COLOR;
+ }
+
+ function formatMonthLabel(dateString) {
+ return new Date(`${dateString}T00:00:00`).toLocaleDateString(undefined, { month: "short" });
+ }
+
+ function formatCountryCode(value) {
+ return String(value).padStart(3, "0");
+ }
+
+ function renderLists(data) {
+ const topDirectors = document.getElementById("top-directors");
+ topDirectors.innerHTML = data.most_watched_directors.slice(0, 8).map((item) => `
+ <li><span>${item.director}</span><strong>${item.count}</strong></li>
+ `).join("");
+
+ const watchedWith = document.getElementById("watched-with");
+ watchedWith.innerHTML = data.watched_with_breakdown.slice(0, 8).map((item) => `
+ <li><span>${item.watched_with}</span><strong>${item.count}</strong></li>
+ `).join("");
+
+ const starDistribution = document.getElementById("star-distribution");
+ const maxStars = Math.max(1, ...data.star_distribution.map((item) => item.count));
+ starDistribution.innerHTML = data.star_distribution.map((item) => `
+ <div class="stats-bar-row">
+ <span>${item.stars} star</span>
+ <div class="stats-bar-track"><div class="stats-bar-fill" style="width:${(item.count / maxStars) * 100}%"></div></div>
+ <strong>${item.count}</strong>
+ </div>
+ `).join("");
+
+ const rewatchRate = document.getElementById("rewatch-rate");
+ const rate = data.rewatch_rate.total_watched ? Math.round(data.rewatch_rate.rate * 100) : 0;
+ rewatchRate.innerHTML = `
+ <strong>${rate}%</strong>
+ <span>${data.rewatch_rate.rewatched} of ${data.rewatch_rate.total_watched} watched films were rewatches.</span>
+ `;
+ }
+
+ function renderHeatmap(days) {
+ const container = document.getElementById("watch-heatmap");
+ const weekdays = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
+ const parsedDays = days.map((item) => {
+ const date = new Date(`${item.date}T00:00:00`);
+ return { ...item, dateObj: date };
+ });
+
+ const firstWeekStart = new Date(parsedDays[0].dateObj);
+ const mondayOffset = (firstWeekStart.getDay() + 6) % 7;
+ firstWeekStart.setDate(firstWeekStart.getDate() - mondayOffset);
+
+ const monthLabels = [];
+ const seenMonths = new Set();
+ const cells = parsedDays.map((item) => {
+ const diffDays = Math.floor((item.dateObj - firstWeekStart) / 86400000);
+ const week = Math.floor(diffDays / 7);
+ const weekday = (item.dateObj.getDay() + 6) % 7;
+ const monthKey = item.date.slice(0, 7);
+ if (!seenMonths.has(monthKey)) {
+ seenMonths.add(monthKey);
+ monthLabels.push({ week, label: formatMonthLabel(item.date) });
+ }
+ return `
+ <button
+ class="heatmap-cell"
+ style="grid-column:${week + 1};grid-row:${weekday + 1};background:${heatmapColor(item.count)}"
+ title="${item.date}: ${item.count} film${item.count === 1 ? "" : "s"}"
+ aria-label="${item.date}: ${item.count} film${item.count === 1 ? "" : "s"}"
+ type="button"
+ ></button>
+ `;
+ }).join("");
+
+ container.innerHTML = `
+ <div class="heatmap-months">
+ ${monthLabels.map((item) => `<span style="grid-column:${item.week + 1}">${item.label}</span>`).join("")}
+ </div>
+ <div class="heatmap-body">
+ <div class="heatmap-weekdays">
+ ${weekdays.map((label) => `<span>${label}</span>`).join("")}
+ </div>
+ <div class="heatmap-grid">${cells}</div>
+ </div>
+ `;
+ }
+
+ async function renderMap(data) {
+ const container = document.getElementById("world-map");
+ const readout = document.getElementById("map-readout");
+ const tooltip = document.getElementById("map-tooltip");
+ const [worldResponse, namesResponse] = await Promise.all([
+ fetch("https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json"),
+ fetch("https://cdn.jsdelivr.net/gh/lukes/ISO-3166-Countries-with-Regional-Codes@master/all/all.json"),
+ ]);
+ const [world, countryRows] = await Promise.all([worldResponse.json(), namesResponse.json()]);
+ const counts = new Map(data.films_per_country_codes.map((item) => [String(item.code), item.count]));
+ const labels = data.country_labels_by_code || {};
+ const names = countryRows.reduce((map, row) => {
+ map.set(formatCountryCode(row["country-code"]), row.name);
+ return map;
+ }, new Map());
+ const maxCount = Math.max(1, ...data.films_per_country_codes.map((item) => item.count));
+
+ const width = container.clientWidth || 960;
+ const height = Math.min(520, Math.max(320, Math.round(width * 0.55)));
+ const projection = d3.geoNaturalEarth1().fitSize([width, height], topojson.feature(world, world.objects.countries));
+ const path = d3.geoPath(projection);
+ const color = d3.scaleLinear().domain([0, maxCount]).range([PANEL_COLOR, ACCENT_COLOR]);
+
+ const svg = d3.create("svg")
+ .attr("viewBox", `0 0 ${width} ${height}`)
+ .attr("role", "img")
+ .attr("aria-label", "World map shaded by films watched");
+ let activeCountryPath = null;
+
+ function updateReadout(name, count) {
+ readout.innerHTML = `<strong>${name}</strong><span>${count} film${count === 1 ? "" : "s"}</span>`;
+ }
+
+ function resetMapHover() {
+ if (activeCountryPath) {
+ activeCountryPath.attr("stroke", "rgba(244, 239, 230, 0.18)").attr("stroke-width", 0.7);
+ activeCountryPath = null;
+ }
+ readout.innerHTML = `<strong>Hover a country</strong><span>Film count will appear here.</span>`;
+ tooltip.hidden = true;
+ }
+
+ svg.append("g")
+ .selectAll("path")
+ .data(topojson.feature(world, world.objects.countries).features)
+ .join("path")
+ .attr("d", path)
+ .attr("fill", (feature) => color(counts.get(String(feature.id)) || 0))
+ .attr("stroke", "rgba(244, 239, 230, 0.18)")
+ .attr("stroke-width", 0.7)
+ .style("cursor", "pointer")
+ .on("pointerenter", function(event, feature) {
+ const count = counts.get(String(feature.id)) || 0;
+ const featureCode = formatCountryCode(feature.id);
+ const name = names.get(featureCode) || labels[String(feature.id)] || `Country ${feature.id}`;
+ if (activeCountryPath) {
+ activeCountryPath.attr("stroke", "rgba(244, 239, 230, 0.18)").attr("stroke-width", 0.7);
+ }
+ activeCountryPath = d3.select(this);
+ activeCountryPath.attr("stroke", ACCENT_COLOR).attr("stroke-width", 1.4);
+ updateReadout(name, count);
+ tooltip.hidden = false;
+ tooltip.innerHTML = `<strong>${name}</strong><span>${count} film${count === 1 ? "" : "s"}</span>`;
+ tooltip.style.left = `${event.pageX + 12}px`;
+ tooltip.style.top = `${event.pageY + 12}px`;
+ })
+ .on("pointermove", function(event) {
+ tooltip.style.left = `${event.pageX + 12}px`;
+ tooltip.style.top = `${event.pageY + 12}px`;
+ })
+ .on("pointerleave", function() {
+ resetMapHover();
+ });
+
+ container.innerHTML = "";
+ container.appendChild(svg.node());
+ svg.on("pointerleave", resetMapHover);
+ container.addEventListener("mouseleave", resetMapHover);
+ }
+
+ async function bootStats() {
+ const response = await fetch("/stats/data");
+ const data = await response.json();
+ renderLists(data);
+ renderHeatmap(data.films_per_day_365);
+ await renderMap(data);
+ }
+
+ bootStats();
+ </script>
+{% endblock %}