feat: MovieDto enrichment, movie detail page, PWA, watchlist, watchlist federation
This commit is contained in:
@@ -3,7 +3,7 @@ use application::ports::{
|
||||
BlockedDomainsPageData, FollowersPageData, FollowingPageData, HtmlPageContext, HtmlRenderer,
|
||||
ImportMappingPageData, ImportPreviewPageData, ImportPreviewRow, ImportProfileView,
|
||||
ImportRowStatus, ImportUploadPageData, LoginPageData, MovieDetailPageData, NewReviewPageData,
|
||||
ProfilePageData, ProfileSettingsPageData, RegisterPageData, UsersPageData,
|
||||
ProfilePageData, ProfileSettingsPageData, RegisterPageData, UsersPageData, WatchlistPageData,
|
||||
};
|
||||
use askama::Template;
|
||||
use chrono::Datelike;
|
||||
@@ -101,13 +101,28 @@ struct MovieDetailTemplate<'a> {
|
||||
ctx: &'a HtmlPageContext,
|
||||
movie: &'a domain::models::Movie,
|
||||
stats: &'a domain::models::MovieStats,
|
||||
profile: Option<&'a domain::models::MovieProfile>,
|
||||
reviews: &'a [domain::models::FeedEntry],
|
||||
on_watchlist: bool,
|
||||
current_offset: u32,
|
||||
has_more: bool,
|
||||
limit: u32,
|
||||
histogram_max: u64,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "watchlist.html")]
|
||||
struct WatchlistTemplate<'a> {
|
||||
ctx: &'a HtmlPageContext,
|
||||
owner_id: uuid::Uuid,
|
||||
display_entries: &'a [application::ports::WatchlistDisplayEntry],
|
||||
current_offset: u32,
|
||||
has_more: bool,
|
||||
limit: u32,
|
||||
is_owner: bool,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
impl<'a> ActivityFeedTemplate<'a> {
|
||||
pub fn filter_qs(&self) -> String {
|
||||
let mut parts = vec![
|
||||
@@ -358,6 +373,7 @@ struct ImportPreviewTemplate<'a> {
|
||||
rows: &'a [ImportPreviewRow],
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct AskamaHtmlRenderer;
|
||||
|
||||
impl AskamaHtmlRenderer {
|
||||
@@ -374,7 +390,7 @@ impl HtmlRenderer for AskamaHtmlRenderer {
|
||||
) -> Result<String, String> {
|
||||
let has_more = (data.offset + data.limit) < data.total_count as u32;
|
||||
let (total_pages, current_page) = if data.limit > 0 {
|
||||
let tp = ((data.total_count + data.limit as u64 - 1) / data.limit as u64) as u32;
|
||||
let tp = data.total_count.div_ceil(data.limit as u64) as u32;
|
||||
(tp, data.offset / data.limit)
|
||||
} else {
|
||||
(0, 0)
|
||||
@@ -420,16 +436,8 @@ impl HtmlRenderer for AskamaHtmlRenderer {
|
||||
|
||||
fn render_activity_feed_page(&self, data: ActivityFeedPageData) -> Result<String, String> {
|
||||
let limit = data.limit;
|
||||
let total_pages = if limit > 0 {
|
||||
((data.entries.total_count + limit as u64 - 1) / limit as u64) as u32
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let current_page = if limit > 0 {
|
||||
data.current_offset / limit
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let total_pages = data.entries.total_count.div_ceil(limit.max(1) as u64) as u32;
|
||||
let current_page = data.current_offset.checked_div(limit).unwrap_or(0);
|
||||
ActivityFeedTemplate {
|
||||
entries: &data.entries.items,
|
||||
current_offset: data.current_offset,
|
||||
@@ -496,7 +504,7 @@ impl HtmlRenderer for AskamaHtmlRenderer {
|
||||
let heatmap = data
|
||||
.history
|
||||
.as_deref()
|
||||
.map(|h| build_heatmap(h))
|
||||
.map(build_heatmap)
|
||||
.unwrap_or_default();
|
||||
let profile_display_name = data
|
||||
.profile_user_email
|
||||
@@ -521,18 +529,10 @@ impl HtmlRenderer for AskamaHtmlRenderer {
|
||||
.entries
|
||||
.as_ref()
|
||||
.map(|e| {
|
||||
if e.limit > 0 {
|
||||
((e.total_count + e.limit as u64 - 1) / e.limit as u64) as u32
|
||||
} else {
|
||||
0
|
||||
}
|
||||
e.total_count.div_ceil(e.limit.max(1) as u64) as u32
|
||||
})
|
||||
.unwrap_or(0);
|
||||
let current_page = if data.limit > 0 {
|
||||
data.current_offset / data.limit
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let current_page = data.current_offset.checked_div(data.limit).unwrap_or(0);
|
||||
let avg_rating_display = data
|
||||
.stats
|
||||
.avg_rating
|
||||
@@ -594,7 +594,9 @@ impl HtmlRenderer for AskamaHtmlRenderer {
|
||||
ctx: &data.ctx,
|
||||
movie: &data.movie,
|
||||
stats: &data.stats,
|
||||
profile: data.profile.as_ref(),
|
||||
reviews: &data.reviews.items,
|
||||
on_watchlist: data.on_watchlist,
|
||||
current_offset: data.current_offset,
|
||||
has_more: data.has_more,
|
||||
limit: data.limit,
|
||||
@@ -604,6 +606,21 @@ impl HtmlRenderer for AskamaHtmlRenderer {
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn render_watchlist_page(&self, data: WatchlistPageData) -> Result<String, String> {
|
||||
WatchlistTemplate {
|
||||
ctx: &data.ctx,
|
||||
owner_id: data.owner_id,
|
||||
display_entries: &data.display_entries,
|
||||
current_offset: data.current_offset,
|
||||
has_more: data.has_more,
|
||||
limit: data.limit,
|
||||
is_owner: data.is_owner,
|
||||
error: data.error,
|
||||
}
|
||||
.render()
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn render_following_page(&self, data: FollowingPageData) -> Result<String, String> {
|
||||
FollowingTemplate {
|
||||
ctx: data.ctx,
|
||||
|
||||
@@ -22,6 +22,9 @@
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link rel="icon" type="image/webp" href="/static/logo.webp" />
|
||||
<link rel="apple-touch-icon" href="/static/logo.webp" />
|
||||
<link rel="manifest" href="/static/manifest.json" />
|
||||
<meta name="theme-color" content="#e5c034" />
|
||||
<link rel="stylesheet" href="/static/style.css" />
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
{% block content %}
|
||||
<div class="movie-detail">
|
||||
|
||||
{# ── Hero ── #}
|
||||
<article class="entry" style="margin-bottom:1.5rem">
|
||||
{% if let Some(poster) = movie.poster_path() %}
|
||||
<div class="poster"><img src="/posters/{{ poster.value() }}" alt=""></div>
|
||||
@@ -11,15 +12,46 @@
|
||||
{{ movie.title().value() }}
|
||||
<span class="year">({{ movie.release_year().value() }})</span>
|
||||
</div>
|
||||
{% if let Some(dir) = movie.director() %}
|
||||
<div class="director">{{ dir }}</div>
|
||||
<div class="movie-meta">
|
||||
{% if let Some(dir) = movie.director() %}{{ dir }}{% endif %}
|
||||
{% if let Some(p) = profile %}
|
||||
{% if let Some(runtime) = p.runtime_minutes %}· {{ runtime }} min{% endif %}
|
||||
{% if let Some(lang) = &p.original_language %}· {{ lang|upper }}{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if let Some(p) = profile %}
|
||||
{% if !p.genres.is_empty() %}
|
||||
<div class="genre-pills">
|
||||
{% for g in &p.genres %}<span class="genre-pill">{{ g.name }}</span>{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if let Some(tagline) = &p.tagline %}{% if !tagline.is_empty() %}
|
||||
<div class="movie-tagline">"{{ tagline }}"</div>
|
||||
{% endif %}{% endif %}
|
||||
{% endif %}
|
||||
<div style="margin-top:0.75rem">
|
||||
<a href="/reviews/new" class="btn-small">+ Log a review</a>
|
||||
{% if ctx.user_id.is_some() %}
|
||||
{% if on_watchlist %}
|
||||
<form method="post" action="/watchlist/{{ movie.id().value() }}/remove" style="display:inline">
|
||||
<input type="hidden" name="redirect_after" value="/movies/{{ movie.id().value() }}">
|
||||
<input type="hidden" name="_csrf" value="{{ ctx.csrf_token }}">
|
||||
<button type="submit" class="btn-small" style="color:#4aaa77;border-color:rgba(74,170,119,.3)">✓ On watchlist · Remove</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form method="post" action="/watchlist/add" style="display:inline">
|
||||
<input type="hidden" name="movie_id" value="{{ movie.id().value() }}">
|
||||
<input type="hidden" name="redirect_after" value="/movies/{{ movie.id().value() }}">
|
||||
<input type="hidden" name="_csrf" value="{{ ctx.csrf_token }}">
|
||||
<button type="submit" class="btn-small">+ Want to watch</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{# ── Stats ── #}
|
||||
<div class="stats-bar">
|
||||
{% if let Some(avg) = stats.avg_rating %}
|
||||
<div class="stat-box">
|
||||
@@ -47,6 +79,46 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if let Some(p) = profile %}
|
||||
|
||||
{# ── Overview ── #}
|
||||
{% if let Some(overview) = &p.overview %}{% if !overview.is_empty() %}
|
||||
<p class="movie-overview">{{ overview }}</p>
|
||||
{% endif %}{% endif %}
|
||||
|
||||
{# ── Cast ── #}
|
||||
{% if !p.cast.is_empty() %}
|
||||
<div class="feed-section-label">CAST</div>
|
||||
<div class="cast-strip">
|
||||
{% for (i, member) in p.cast.iter().enumerate() %}{% if i < 10 %}
|
||||
<div class="cast-card">
|
||||
{% if let Some(path) = &member.profile_path %}
|
||||
<img src="https://image.tmdb.org/t/p/w185{{ path }}" alt="{{ member.name }}" loading="lazy">
|
||||
{% else %}
|
||||
<img src="/static/person-placeholder.svg" alt="{{ member.name }}">
|
||||
{% endif %}
|
||||
<div class="cast-name">{{ member.name }}</div>
|
||||
<div class="cast-char">{{ member.character }}</div>
|
||||
</div>
|
||||
{% endif %}{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ── Crew ── #}
|
||||
{% if !p.crew.is_empty() %}
|
||||
<div class="feed-section-label">CREW</div>
|
||||
<ul class="crew-list">
|
||||
{% for member in &p.crew %}
|
||||
{% if member.job == "Screenplay" || member.job == "Story" || member.job == "Original Music Composer" || member.job == "Director of Photography" %}
|
||||
<li><span class="crew-role">{{ member.job }}</span>{{ member.name }}</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
{% endif %}
|
||||
|
||||
{# ── Reviews ── #}
|
||||
<div class="feed-section-label">REVIEWS</div>
|
||||
<div class="diary">
|
||||
{% for entry in reviews %}
|
||||
|
||||
@@ -68,6 +68,7 @@
|
||||
</section>
|
||||
<section class="profile-manage">
|
||||
<h3>Account</h3>
|
||||
<a href="/users/{{ profile_user_id }}/watchlist">Watchlist</a>
|
||||
<a href="/settings/profile">Profile settings</a>
|
||||
<a href="/social/blocked">Blocked users</a>
|
||||
{% if ctx.is_admin %}
|
||||
|
||||
68
crates/adapters/template-askama/templates/watchlist.html
Normal file
68
crates/adapters/template-askama/templates/watchlist.html
Normal file
@@ -0,0 +1,68 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="movie-detail">
|
||||
<div class="entry-title" style="margin-bottom:1rem">Watchlist</div>
|
||||
|
||||
{% if is_owner %}
|
||||
{% if let Some(err) = &error %}
|
||||
<p class="form-error">{{ err }}</p>
|
||||
{% endif %}
|
||||
<form method="post" action="/watchlist/add" style="display:flex;gap:0.5rem;flex-wrap:wrap;margin-bottom:1.5rem;align-items:flex-end">
|
||||
<div style="flex:1;min-width:200px">
|
||||
<label style="display:block;font-size:0.8em;opacity:.6;margin-bottom:0.25rem">Title or TMDB ID</label>
|
||||
<input type="text" name="query" placeholder='e.g. "Dune" or tmdb:438631' style="width:100%;box-sizing:border-box">
|
||||
</div>
|
||||
<div style="width:90px">
|
||||
<label style="display:block;font-size:0.8em;opacity:.6;margin-bottom:0.25rem">Year</label>
|
||||
<input type="number" name="year" placeholder="2021" min="1888" max="2099" style="width:100%;box-sizing:border-box">
|
||||
</div>
|
||||
<input type="hidden" name="_csrf" value="{{ ctx.csrf_token }}">
|
||||
<input type="hidden" name="redirect_after" value="/users/{{ owner_id }}/watchlist">
|
||||
<button type="submit" class="btn-small" style="height:2.25rem">Add</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{% if display_entries.is_empty() %}
|
||||
<p class="empty">No movies on the watchlist yet.</p>
|
||||
{% else %}
|
||||
<div class="diary">
|
||||
{% for entry in display_entries %}
|
||||
<article class="entry">
|
||||
{% if let Some(url) = &entry.poster_url %}
|
||||
<div class="poster"><img src="{{ url }}" alt=""></div>
|
||||
{% endif %}
|
||||
<div class="entry-body">
|
||||
<div class="entry-title">
|
||||
{% if let Some(url) = &entry.movie_url %}
|
||||
<a href="{{ url }}" class="movie-title-link">{{ entry.movie_title }}</a>
|
||||
{% else %}
|
||||
{{ entry.movie_title }}
|
||||
{% endif %}
|
||||
<span class="year">({{ entry.release_year }})</span>
|
||||
</div>
|
||||
<div class="feed-meta" style="margin-top:0.4rem">
|
||||
<span class="feed-time">Added {{ entry.added_at }}</span>
|
||||
</div>
|
||||
{% if let Some(remove_url) = &entry.remove_url %}
|
||||
<form method="post" action="{{ remove_url }}" style="margin-top:0.5rem">
|
||||
<input type="hidden" name="redirect_after" value="/users/{{ owner_id }}/watchlist">
|
||||
<input type="hidden" name="_csrf" value="{{ ctx.csrf_token }}">
|
||||
<button type="submit" class="btn-small" style="color:#e57a7a;border-color:rgba(229,122,122,.3)">Remove</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<nav class="pagination">
|
||||
{% if current_offset >= limit %}
|
||||
<a href="/users/{{ owner_id }}/watchlist?offset={{ current_offset - limit }}&limit={{ limit }}" class="page-nav">← Prev</a>
|
||||
{% endif %}
|
||||
{% if has_more %}
|
||||
<a href="/users/{{ owner_id }}/watchlist?offset={{ current_offset + limit }}&limit={{ limit }}" class="page-nav">Next →</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user