diff options
Diffstat (limited to 'templates')
| -rw-r--r-- | templates/_film_card.html | 70 | ||||
| -rw-r--r-- | templates/base.html | 30 | ||||
| -rw-r--r-- | templates/detail.html | 83 | ||||
| -rw-r--r-- | templates/director.html | 31 | ||||
| -rw-r--r-- | templates/form.html | 144 | ||||
| -rw-r--r-- | templates/import.html | 108 | ||||
| -rw-r--r-- | templates/index.html | 64 | ||||
| -rw-r--r-- | templates/stats.html | 268 |
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 %} |
