pub use askama; use askama::Template; use application::ports::HtmlPageContext; use chrono::Datelike; use domain::models::{ DiaryEntry, FeedEntry, MonthActivity, MonthlyRating, ReviewSource, UserStats, UserTrends, collections::Paginated, }; mod filters { #[askama::filter_fn] pub fn poster_src( path: T, _env: &dyn askama::Values, ) -> askama::Result { let p = path.to_string(); if p.starts_with("http://") || p.starts_with("https://") { Ok(p) } else { Ok(format!("/images/{}", p)) } } } pub struct PageItem { pub number: u32, pub is_current: bool, pub is_ellipsis: bool, } pub fn build_page_items(total_pages: u32, current_page: u32) -> Vec { if total_pages <= 1 { return vec![]; } let mut set = std::collections::BTreeSet::new(); set.insert(0u32); set.insert(total_pages - 1); let start = current_page.saturating_sub(2); let end = (current_page + 2).min(total_pages - 1); for p in start..=end { set.insert(p); } let pages: Vec = set.into_iter().collect(); let mut items = Vec::new(); for (i, &p) in pages.iter().enumerate() { if i > 0 && p > pages[i - 1] + 1 { items.push(PageItem { number: 0, is_current: false, is_ellipsis: true, }); } items.push(PageItem { number: p, is_current: p == current_page, is_ellipsis: false, }); } items } #[derive(Template)] #[template(path = "diary.html")] pub struct DiaryTemplate<'a> { pub entries: &'a [DiaryEntry], pub current_offset: u32, pub limit: u32, pub has_more: bool, pub ctx: &'a HtmlPageContext, pub page_items: Vec, } #[derive(Template)] #[template(path = "login.html")] pub struct LoginTemplate<'a> { pub error: Option<&'a str>, pub ctx: &'a HtmlPageContext, } #[derive(Template)] #[template(path = "register.html")] pub struct RegisterTemplate<'a> { pub error: Option<&'a str>, pub ctx: &'a HtmlPageContext, } #[derive(Template)] #[template(path = "new_review.html")] pub struct NewReviewTemplate<'a> { pub error: Option<&'a str>, pub ctx: &'a HtmlPageContext, } #[derive(Template)] #[template(path = "activity_feed.html")] pub struct ActivityFeedTemplate<'a> { pub entries: &'a [FeedEntry], pub current_offset: u32, pub limit: u32, pub has_more: bool, pub ctx: &'a HtmlPageContext, pub page_items: Vec, pub filter: String, pub sort_by: String, pub search: String, } #[derive(Template)] #[template(path = "movie_detail.html")] pub struct MovieDetailTemplate<'a> { pub ctx: &'a HtmlPageContext, pub movie: &'a domain::models::Movie, pub stats: &'a domain::models::MovieStats, pub profile: Option<&'a domain::models::MovieProfile>, pub reviews: &'a [domain::models::FeedEntry], pub on_watchlist: bool, pub current_offset: u32, pub has_more: bool, pub limit: u32, pub histogram_max: u64, } #[derive(Template)] #[template(path = "watchlist.html")] pub struct WatchlistTemplate<'a> { pub ctx: &'a HtmlPageContext, pub owner_id: uuid::Uuid, pub display_entries: &'a [application::ports::WatchlistDisplayEntry], pub current_offset: u32, pub has_more: bool, pub limit: u32, pub is_owner: bool, pub error: Option, } impl<'a> ActivityFeedTemplate<'a> { pub fn filter_qs(&self) -> String { let mut parts = vec![ format!("filter={}", self.filter), format!("sort_by={}", self.sort_by), ]; if !self.search.is_empty() { let encoded = self .search .replace(' ', "+") .replace('#', "%23") .replace('&', "%26") .replace('=', "%3D"); parts.push(format!("search={}", encoded)); } format!("&{}", parts.join("&")) } } pub struct RemoteActorDisplay { pub handle: String, pub display_name: String, pub initial: char, pub url: String, } pub struct UserSummaryView { pub user_id: uuid::Uuid, pub display_name: String, pub initial: char, pub avg_rating_display: String, pub total_movies: i64, pub avatar_url: Option, } #[derive(Template)] #[template(path = "users.html")] pub struct UsersTemplate<'a> { pub users: Vec, pub ctx: &'a HtmlPageContext, pub remote_actors: Vec, } pub struct MonthlyRatingRow<'a> { pub rating: &'a MonthlyRating, pub bar_height_px: i64, } #[derive(Template)] #[template(path = "profile.html")] pub struct ProfileTemplate<'a> { pub ctx: &'a HtmlPageContext, pub profile_display_name: String, pub profile_user_id: uuid::Uuid, pub stats: &'a UserStats, pub avg_rating_display: String, pub favorite_director_display: String, pub most_active_month_display: String, pub view: &'a str, pub entries: Option<&'a Paginated>, pub current_offset: u32, pub has_more: bool, pub limit: u32, pub history: Option<&'a Vec>, pub trends: Option<&'a UserTrends>, pub monthly_rating_rows: Vec>, pub heatmap: Vec, pub page_items: Vec, pub is_own_profile: bool, pub error: Option, pub following_count: usize, pub followers_count: usize, pub pending_followers: Vec, pub sort_by: String, pub search: String, } impl<'a> ProfileTemplate<'a> { pub fn filter_qs(&self) -> String { let mut parts = vec![ format!("view={}", self.view), format!("sort_by={}", self.sort_by), ]; if !self.search.is_empty() { let encoded = self .search .replace(' ', "+") .replace('#', "%23") .replace('&', "%26") .replace('=', "%3D"); parts.push(format!("search={}", encoded)); } format!("&{}", parts.join("&")) } } pub struct RemoteActorData { pub handle: String, pub display_name: Option, pub url: String, pub avatar_url: Option, } #[derive(Template)] #[template(path = "following.html")] pub struct FollowingTemplate { pub ctx: HtmlPageContext, pub user_id: uuid::Uuid, pub actors: Vec, pub error: Option, } #[derive(Template)] #[template(path = "followers.html")] pub struct FollowersTemplate { pub ctx: HtmlPageContext, pub user_id: uuid::Uuid, pub actors: Vec, pub error: Option, } #[derive(Template)] #[template(path = "blocked_domains.html")] pub struct BlockedDomainsTemplate<'a> { pub ctx: &'a HtmlPageContext, pub domains: &'a [BlockedDomainEntry], } #[derive(Template)] #[template(path = "blocked_actors.html")] pub struct BlockedActorsTemplate<'a> { pub ctx: &'a HtmlPageContext, pub actors: &'a [BlockedActorEntry], } pub struct BlockedDomainEntry { pub domain: String, pub reason: Option, pub blocked_at: String, } pub struct BlockedActorEntry { pub url: String, pub handle: String, pub display_name: Option, pub avatar_url: Option, } pub struct HeatmapCell { pub month_label: String, pub count: i64, pub alpha: f64, } pub fn build_heatmap(history: &[MonthActivity]) -> Vec { let current_year = chrono::Utc::now().year(); let count_for = |m: &str| -> i64 { history .iter() .find(|a| a.year_month == format!("{}-{}", current_year, m)) .map(|a| a.count) .unwrap_or(0) }; let months = [ ("01", "Jan"), ("02", "Feb"), ("03", "Mar"), ("04", "Apr"), ("05", "May"), ("06", "Jun"), ("07", "Jul"), ("08", "Aug"), ("09", "Sep"), ("10", "Oct"), ("11", "Nov"), ("12", "Dec"), ]; let counts: Vec = months.iter().map(|(m, _)| count_for(m)).collect(); let max = counts.iter().copied().max().unwrap_or(0).max(1); months .iter() .zip(counts.iter()) .map(|((_, label), &count)| { let alpha = if count == 0 { 0.05 } else { 0.15 + 0.75 * (count as f64 / max as f64) }; HeatmapCell { month_label: label.to_string(), count, alpha, } }) .collect() } pub fn bar_height_px(avg_rating: f64) -> i64 { (avg_rating / 5.0 * 60.0) as i64 } #[derive(Template)] #[template(path = "profile_settings.html")] pub struct ProfileSettingsTemplate<'a> { pub ctx: &'a HtmlPageContext, pub bio: Option<&'a str>, pub avatar_url: Option<&'a str>, pub banner_url: Option<&'a str>, pub also_known_as: Option<&'a str>, pub profile_fields: &'a [(String, String)], pub saved: bool, } #[derive(Template)] #[template(path = "integrations.html")] pub struct IntegrationsTemplate<'a> { pub ctx: &'a HtmlPageContext, pub tokens: &'a [WebhookTokenView], pub webhook_base_url: &'a str, pub new_token: Option<&'a str>, } pub struct WebhookTokenView { pub id: String, pub provider: String, pub label: Option, pub created_at: String, pub last_used_at: Option, } #[derive(Template)] #[template(path = "watch_queue.html")] pub struct WatchQueueTemplate<'a> { pub ctx: &'a HtmlPageContext, pub entries: &'a [WatchQueueDisplayEntry], pub error: Option<&'a str>, } pub struct WatchQueueDisplayEntry { pub id: String, pub title: String, pub year: Option, pub source: String, pub watched_at: String, pub movie_url: Option, } #[derive(Template)] #[template(path = "import_upload.html")] pub struct ImportUploadTemplate<'a> { pub ctx: &'a HtmlPageContext, pub profiles: &'a [ImportProfileView], pub error: Option<&'a str>, } pub struct ImportProfileView { pub id: String, pub name: String, } #[derive(Template)] #[template(path = "import_mapping.html")] pub struct ImportMappingTemplate<'a> { pub ctx: &'a HtmlPageContext, pub session_id: &'a str, pub columns: &'a [String], pub sample_rows: &'a [Vec], pub domain_fields: &'a [(&'static str, &'static str)], pub error: Option<&'a str>, } #[derive(Template)] #[template(path = "import_preview.html")] pub struct ImportPreviewTemplate<'a> { pub ctx: &'a HtmlPageContext, pub session_id: &'a str, pub columns: &'a [String], pub rows: &'a [ImportPreviewRow], } pub struct ImportPreviewRow { pub index: usize, pub status: ImportRowStatus, pub cells: Vec, } pub enum ImportRowStatus { Valid, 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, }