feat: HTML wrap-up page with Askama template
This commit is contained in:
@@ -425,3 +425,16 @@ pub enum ImportRowStatus {
|
||||
Duplicate,
|
||||
Invalid(String),
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "wrapup.html")]
|
||||
pub struct WrapUpPageTemplate<'a> {
|
||||
pub ctx: &'a HtmlPageContext,
|
||||
pub report: &'a domain::models::wrapup::WrapUpReport,
|
||||
pub year_label: String,
|
||||
pub watch_time_display: String,
|
||||
pub rating_max: u32,
|
||||
pub genre_max: u32,
|
||||
pub rating_pcts: [f64; 5],
|
||||
pub genre_pcts: Vec<f64>,
|
||||
}
|
||||
|
||||
200
crates/adapters/template-askama/templates/wrapup.html
Normal file
200
crates/adapters/template-askama/templates/wrapup.html
Normal file
@@ -0,0 +1,200 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="wu-container">
|
||||
|
||||
<section class="wu-section wu-hero">
|
||||
<h1 class="wu-year">{{ year_label }}</h1>
|
||||
<div class="wu-big-number">{{ report.total_movies }}</div>
|
||||
<div class="wu-subtitle">movies watched</div>
|
||||
{% if report.total_watch_time_minutes > 0 %}
|
||||
<div class="wu-detail">{{ watch_time_display }} total watch time</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="wu-section">
|
||||
<h2>Ratings</h2>
|
||||
{% if let Some(avg) = report.avg_rating %}
|
||||
<div class="wu-big-number">{{ avg|fmt("{:.1}") }}</div>
|
||||
<div class="wu-subtitle">average rating</div>
|
||||
{% endif %}
|
||||
<div class="wu-rating-bars">
|
||||
{% for i in 0..5 %}
|
||||
<div class="wu-rating-row">
|
||||
<span class="wu-star-label">{{ i + 1 }}★</span>
|
||||
<div class="wu-bar-track">
|
||||
<div class="wu-bar" style="width: {{ rating_pcts[i] }}%"></div>
|
||||
</div>
|
||||
<span class="wu-bar-count">{{ report.rating_distribution[i] }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if let Some(month) = report.busiest_month %}
|
||||
<div class="wu-detail" style="margin-top:1rem">Busiest month: <strong>{{ month }}</strong></div>
|
||||
{% endif %}
|
||||
{% if let Some(day) = report.busiest_day_of_week %}
|
||||
<div class="wu-detail">Favorite day: <strong>{{ day }}</strong></div>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{% if !report.top_directors.is_empty() %}
|
||||
<section class="wu-section">
|
||||
<h2>Top Directors</h2>
|
||||
<div class="wu-detail" style="margin-bottom:1rem">{{ report.director_diversity }} unique directors</div>
|
||||
{% for d in report.top_directors.iter().take(5) %}
|
||||
<div class="wu-stat-row">
|
||||
<span class="wu-stat-name">{{ d.name }}</span>
|
||||
<span class="wu-stat-count">{{ d.count }} films · {{ d.avg_rating|fmt("{:.1}") }}★</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if !report.top_actors.is_empty() %}
|
||||
<section class="wu-section">
|
||||
<h2>Top Actors</h2>
|
||||
<div class="wu-detail" style="margin-bottom:1rem">{{ report.actor_diversity }} unique actors</div>
|
||||
{% for a in report.top_actors.iter().take(5) %}
|
||||
<div class="wu-stat-row">
|
||||
<span class="wu-stat-name">{{ a.name }}</span>
|
||||
<span class="wu-stat-count">{{ a.count }} films · {{ a.avg_rating|fmt("{:.1}") }}★</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if !report.top_genres.is_empty() %}
|
||||
<section class="wu-section">
|
||||
<h2>Genre Breakdown</h2>
|
||||
<div class="wu-detail" style="margin-bottom:1rem">{{ report.genre_diversity }} genres explored</div>
|
||||
{% for g in report.top_genres.iter().take(8).enumerate() %}
|
||||
<div class="wu-stat-row">
|
||||
<span class="wu-stat-name">{{ g.1.genre }}</span>
|
||||
<span class="wu-stat-count">{{ g.1.count }}</span>
|
||||
</div>
|
||||
<div class="wu-bar-track">
|
||||
<div class="wu-bar" style="width: {{ genre_pcts[g.0] }}%"></div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% if let Some(best) = report.highest_rated_genre %}
|
||||
<div class="wu-detail" style="margin-top:1rem">Highest rated: <strong>{{ best }}</strong></div>
|
||||
{% endif %}
|
||||
{% if let Some(worst) = report.lowest_rated_genre %}
|
||||
<div class="wu-detail">Lowest rated: <strong>{{ worst }}</strong></div>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<section class="wu-section">
|
||||
<h2>Highlights</h2>
|
||||
<div class="wu-highlight-grid">
|
||||
{% if let Some(m) = report.highest_rated_movie %}
|
||||
<div class="wu-highlight-card">
|
||||
<div class="wu-highlight-label">Highest Rated</div>
|
||||
{% if let Some(p) = m.poster_path %}
|
||||
<img src="{{ p|poster_src }}" class="wu-highlight-poster" alt="{{ m.title }}">
|
||||
{% endif %}
|
||||
<div class="wu-highlight-title">{{ m.title }}</div>
|
||||
<div class="wu-detail">{{ m.year }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if let Some(m) = report.lowest_rated_movie %}
|
||||
<div class="wu-highlight-card">
|
||||
<div class="wu-highlight-label">Lowest Rated</div>
|
||||
{% if let Some(p) = m.poster_path %}
|
||||
<img src="{{ p|poster_src }}" class="wu-highlight-poster" alt="{{ m.title }}">
|
||||
{% endif %}
|
||||
<div class="wu-highlight-title">{{ m.title }}</div>
|
||||
<div class="wu-detail">{{ m.year }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if let Some(m) = report.oldest_movie %}
|
||||
<div class="wu-highlight-card">
|
||||
<div class="wu-highlight-label">Oldest</div>
|
||||
{% if let Some(p) = m.poster_path %}
|
||||
<img src="{{ p|poster_src }}" class="wu-highlight-poster" alt="{{ m.title }}">
|
||||
{% endif %}
|
||||
<div class="wu-highlight-title">{{ m.title }}</div>
|
||||
<div class="wu-detail">{{ m.year }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if let Some(m) = report.newest_movie %}
|
||||
<div class="wu-highlight-card">
|
||||
<div class="wu-highlight-label">Newest</div>
|
||||
{% if let Some(p) = m.poster_path %}
|
||||
<img src="{{ p|poster_src }}" class="wu-highlight-poster" alt="{{ m.title }}">
|
||||
{% endif %}
|
||||
<div class="wu-highlight-title">{{ m.title }}</div>
|
||||
<div class="wu-detail">{{ m.year }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if let Some(m) = report.longest_movie %}
|
||||
<div class="wu-highlight-card">
|
||||
<div class="wu-highlight-label">Longest</div>
|
||||
{% if let Some(p) = m.poster_path %}
|
||||
<img src="{{ p|poster_src }}" class="wu-highlight-poster" alt="{{ m.title }}">
|
||||
{% endif %}
|
||||
<div class="wu-highlight-title">{{ m.title }}</div>
|
||||
{% if let Some(rt) = m.runtime_minutes %}
|
||||
<div class="wu-detail">{{ rt }} min</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if let Some(m) = report.shortest_movie %}
|
||||
<div class="wu-highlight-card">
|
||||
<div class="wu-highlight-label">Shortest</div>
|
||||
{% if let Some(p) = m.poster_path %}
|
||||
<img src="{{ p|poster_src }}" class="wu-highlight-poster" alt="{{ m.title }}">
|
||||
{% endif %}
|
||||
<div class="wu-highlight-title">{{ m.title }}</div>
|
||||
{% if let Some(rt) = m.runtime_minutes %}
|
||||
<div class="wu-detail">{{ rt }} min</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if let Some(m) = report.first_movie_of_period %}
|
||||
<div class="wu-highlight-card">
|
||||
<div class="wu-highlight-label">First Watched</div>
|
||||
{% if let Some(p) = m.poster_path %}
|
||||
<img src="{{ p|poster_src }}" class="wu-highlight-poster" alt="{{ m.title }}">
|
||||
{% endif %}
|
||||
<div class="wu-highlight-title">{{ m.title }}</div>
|
||||
<div class="wu-detail">{{ m.year }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if let Some(m) = report.last_movie_of_period %}
|
||||
<div class="wu-highlight-card">
|
||||
<div class="wu-highlight-label">Last Watched</div>
|
||||
{% if let Some(p) = m.poster_path %}
|
||||
<img src="{{ p|poster_src }}" class="wu-highlight-poster" alt="{{ m.title }}">
|
||||
{% endif %}
|
||||
<div class="wu-highlight-title">{{ m.title }}</div>
|
||||
<div class="wu-detail">{{ m.year }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% if report.total_rewatches > 0 %}
|
||||
<section class="wu-section">
|
||||
<h2>Rewatches</h2>
|
||||
<div class="wu-big-number">{{ report.total_rewatches }}</div>
|
||||
<div class="wu-subtitle">movies rewatched</div>
|
||||
{% if let Some(m) = report.most_rewatched_movie %}
|
||||
<div class="wu-detail" style="margin-top:1rem">Most rewatched: <strong>{{ m.title }}</strong> ({{ m.year }})</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if !report.poster_paths.is_empty() %}
|
||||
<section class="wu-section">
|
||||
<h2>Your Year in Posters</h2>
|
||||
<div class="wu-poster-mosaic">
|
||||
{% for path in report.poster_paths.iter() %}
|
||||
<img src="{{ path|poster_src }}" alt="" loading="lazy">
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,6 +1,6 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
extract::{Extension, Path, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
};
|
||||
@@ -13,10 +13,16 @@ use application::wrapup::{
|
||||
list_wrapups::{self, ListWrapUpsQuery},
|
||||
};
|
||||
use domain::errors::DomainError;
|
||||
use domain::models::wrapup::{WrapUpRecord, WrapUpStatus};
|
||||
use domain::models::wrapup::{WrapUpRecord, WrapUpReport, WrapUpStatus};
|
||||
use domain::value_objects::WrapUpId;
|
||||
|
||||
use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState};
|
||||
use crate::{
|
||||
csrf::CsrfToken,
|
||||
errors::ApiError,
|
||||
extractors::{AuthenticatedUser, OptionalCookieUser},
|
||||
render::render_page,
|
||||
state::AppState,
|
||||
};
|
||||
use api_types::wrapup::{
|
||||
GenerateWrapUpRequest, WrapUpGeneratedResponse, WrapUpListResponse, WrapUpStatusResponse,
|
||||
};
|
||||
@@ -145,3 +151,122 @@ pub async fn get_report(
|
||||
Err(e) => crate::errors::domain_error_response(e),
|
||||
}
|
||||
}
|
||||
|
||||
// ── HTML handlers ───────────────────────────────────────────────────────────
|
||||
|
||||
fn format_watch_time(minutes: u32) -> String {
|
||||
let h = minutes / 60;
|
||||
let m = minutes % 60;
|
||||
if h > 0 && m > 0 {
|
||||
format!("{}h {}m", h, m)
|
||||
} else if h > 0 {
|
||||
format!("{}h", h)
|
||||
} else {
|
||||
format!("{}m", m)
|
||||
}
|
||||
}
|
||||
|
||||
fn render_wrapup(
|
||||
report: &WrapUpReport,
|
||||
year: i32,
|
||||
ctx: &application::ports::HtmlPageContext,
|
||||
) -> axum::response::Response {
|
||||
let rating_max = report.rating_distribution.iter().copied().max().unwrap_or(1).max(1);
|
||||
let rating_pcts: [f64; 5] = std::array::from_fn(|i| {
|
||||
report.rating_distribution[i] as f64 / rating_max as f64 * 100.0
|
||||
});
|
||||
let genre_max = report.top_genres.first().map(|g| g.count).unwrap_or(1).max(1);
|
||||
let genre_pcts: Vec<f64> = report
|
||||
.top_genres
|
||||
.iter()
|
||||
.take(8)
|
||||
.map(|g| g.count as f64 / genre_max as f64 * 100.0)
|
||||
.collect();
|
||||
let tmpl = template_askama::WrapUpPageTemplate {
|
||||
ctx,
|
||||
report,
|
||||
year_label: year.to_string(),
|
||||
watch_time_display: format_watch_time(report.total_watch_time_minutes),
|
||||
rating_max,
|
||||
genre_max,
|
||||
rating_pcts,
|
||||
genre_pcts,
|
||||
};
|
||||
render_page(tmpl)
|
||||
}
|
||||
|
||||
pub async fn get_user_wrapup_html(
|
||||
OptionalCookieUser(viewer): OptionalCookieUser,
|
||||
State(state): State<AppState>,
|
||||
Path((user_id, year)): Path<(Uuid, i32)>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
) -> impl IntoResponse {
|
||||
let start = match NaiveDate::from_ymd_opt(year, 1, 1) {
|
||||
Some(d) => d,
|
||||
None => return StatusCode::BAD_REQUEST.into_response(),
|
||||
};
|
||||
let end = match NaiveDate::from_ymd_opt(year + 1, 1, 1) {
|
||||
Some(d) => d,
|
||||
None => return StatusCode::BAD_REQUEST.into_response(),
|
||||
};
|
||||
|
||||
let record = match state
|
||||
.app_ctx
|
||||
.repos
|
||||
.wrapup_repo
|
||||
.find_existing(Some(user_id), start, end)
|
||||
.await
|
||||
{
|
||||
Ok(Some(r)) if r.status == WrapUpStatus::Ready => r,
|
||||
_ => return StatusCode::NOT_FOUND.into_response(),
|
||||
};
|
||||
|
||||
let report: WrapUpReport = match &record.report_json {
|
||||
Some(json) => match serde_json::from_str(json) {
|
||||
Ok(r) => r,
|
||||
Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||
},
|
||||
None => return StatusCode::NOT_FOUND.into_response(),
|
||||
};
|
||||
|
||||
let ctx = super::html::build_page_context(&state, viewer, csrf.0).await;
|
||||
render_wrapup(&report, year, &ctx)
|
||||
}
|
||||
|
||||
pub async fn get_global_wrapup_html(
|
||||
OptionalCookieUser(viewer): OptionalCookieUser,
|
||||
State(state): State<AppState>,
|
||||
Path(year): Path<i32>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
) -> impl IntoResponse {
|
||||
let start = match NaiveDate::from_ymd_opt(year, 1, 1) {
|
||||
Some(d) => d,
|
||||
None => return StatusCode::BAD_REQUEST.into_response(),
|
||||
};
|
||||
let end = match NaiveDate::from_ymd_opt(year + 1, 1, 1) {
|
||||
Some(d) => d,
|
||||
None => return StatusCode::BAD_REQUEST.into_response(),
|
||||
};
|
||||
|
||||
let record = match state
|
||||
.app_ctx
|
||||
.repos
|
||||
.wrapup_repo
|
||||
.find_existing(None, start, end)
|
||||
.await
|
||||
{
|
||||
Ok(Some(r)) if r.status == WrapUpStatus::Ready => r,
|
||||
_ => return StatusCode::NOT_FOUND.into_response(),
|
||||
};
|
||||
|
||||
let report: WrapUpReport = match &record.report_json {
|
||||
Some(json) => match serde_json::from_str(json) {
|
||||
Ok(r) => r,
|
||||
Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||
},
|
||||
None => return StatusCode::NOT_FOUND.into_response(),
|
||||
};
|
||||
|
||||
let ctx = super::html::build_page_context(&state, viewer, csrf.0).await;
|
||||
render_wrapup(&report, year, &ctx)
|
||||
}
|
||||
|
||||
@@ -163,6 +163,14 @@ fn html_routes(rate_limit: u64) -> Router<AppState> {
|
||||
.route(
|
||||
"/watch-queue/{id}/dismiss",
|
||||
routing::post(handlers::html::post_dismiss_single),
|
||||
)
|
||||
.route(
|
||||
"/wrapups/{user_id}/{year}",
|
||||
routing::get(handlers::wrapup::get_user_wrapup_html),
|
||||
)
|
||||
.route(
|
||||
"/wrapups/global/{year}",
|
||||
routing::get(handlers::wrapup::get_global_wrapup_html),
|
||||
);
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
|
||||
@@ -1184,3 +1184,46 @@ form button[type="submit"]:hover {
|
||||
.movie-title-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ── Wrap-up ─────────────────────────────────────────────────────────── */
|
||||
.wu-container { max-width: 600px; margin: 0 auto; scroll-snap-type: y proximity; }
|
||||
.wu-section {
|
||||
min-height: 80vh;
|
||||
display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center;
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
scroll-snap-align: start;
|
||||
animation: wu-fade-in 0.6s ease-out both;
|
||||
}
|
||||
.wu-hero { min-height: 60vh; }
|
||||
.wu-year { font-size: 2.4rem; opacity: 0.5; margin-bottom: 0.5rem; }
|
||||
.wu-big-number { font-size: 5rem; font-weight: 800; color: var(--primary); line-height: 1.1; }
|
||||
.wu-subtitle { font-size: 1.4rem; opacity: 0.8; }
|
||||
.wu-detail { font-size: 1rem; opacity: 0.6; margin-top: 0.5rem; }
|
||||
.wu-section h2 { font-size: 1.8rem; margin-bottom: 1.5rem; color: var(--primary); }
|
||||
.wu-stat-row { display: flex; justify-content: space-between; width: 100%; padding: 0.5rem 0; border-bottom: 1px solid rgba(255,255,255,0.1); }
|
||||
.wu-stat-name { font-weight: 600; }
|
||||
.wu-stat-count { opacity: 0.7; }
|
||||
.wu-rating-bars { width: 100%; max-width: 400px; margin-top: 1.5rem; }
|
||||
.wu-rating-row { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.4rem; }
|
||||
.wu-star-label { width: 2rem; text-align: right; font-size: 0.9rem; opacity: 0.7; }
|
||||
.wu-bar-track { flex: 1; height: 8px; background: rgba(255,255,255,0.08); border-radius: 4px; overflow: hidden; }
|
||||
.wu-bar { height: 8px; background: var(--primary); border-radius: 4px; min-width: 2px; transition: width 0.4s ease; }
|
||||
.wu-bar-count { width: 2rem; font-size: 0.85rem; opacity: 0.6; }
|
||||
.wu-highlight-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; width: 100%; }
|
||||
.wu-highlight-card { background: rgba(255,255,255,0.05); border-radius: 12px; padding: 1rem; text-align: left; }
|
||||
.wu-highlight-label { font-size: 0.8rem; opacity: 0.6; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.wu-highlight-title { font-weight: 700; margin-top: 0.25rem; }
|
||||
.wu-highlight-poster { width: 100%; border-radius: 6px; aspect-ratio: 2/3; object-fit: cover; margin: 0.5rem 0; }
|
||||
.wu-poster-mosaic { display: grid; grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); gap: 4px; width: 100%; }
|
||||
.wu-poster-mosaic img { width: 100%; border-radius: 4px; aspect-ratio: 2/3; object-fit: cover; }
|
||||
@keyframes wu-fade-in { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: none; } }
|
||||
.wu-section:nth-child(2) { animation-delay: 0.1s; }
|
||||
.wu-section:nth-child(3) { animation-delay: 0.2s; }
|
||||
.wu-section:nth-child(4) { animation-delay: 0.3s; }
|
||||
.wu-section:nth-child(5) { animation-delay: 0.4s; }
|
||||
@media (max-width: 480px) {
|
||||
.wu-big-number { font-size: 3.5rem; }
|
||||
.wu-highlight-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user