Compare commits

..

6 Commits

10 changed files with 95 additions and 10 deletions

1
Cargo.lock generated
View File

@@ -3951,6 +3951,7 @@ dependencies = [
"chrono", "chrono",
"domain", "domain",
"serde", "serde",
"uuid",
] ]
[[package]] [[package]]

View File

@@ -8,6 +8,7 @@ askama = { version = "0.16.0" }
serde = { workspace = true } serde = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }
uuid = { workspace = true }
domain = { workspace = true } domain = { workspace = true }
application = { workspace = true } application = { workspace = true }

View File

@@ -17,6 +17,8 @@ struct DiaryTemplate<'a> {
limit: u32, limit: u32,
has_more: bool, has_more: bool,
ctx: &'a HtmlPageContext, ctx: &'a HtmlPageContext,
total_pages: u32,
current_page: u32,
} }
#[derive(Template)] #[derive(Template)]
@@ -48,6 +50,8 @@ struct ActivityFeedTemplate<'a> {
limit: u32, limit: u32,
has_more: bool, has_more: bool,
ctx: &'a HtmlPageContext, ctx: &'a HtmlPageContext,
total_pages: u32,
current_page: u32,
} }
#[derive(Template)] #[derive(Template)]
@@ -67,6 +71,7 @@ struct MonthlyRatingRow<'a> {
struct ProfileTemplate<'a> { struct ProfileTemplate<'a> {
ctx: &'a HtmlPageContext, ctx: &'a HtmlPageContext,
profile_display_name: String, profile_display_name: String,
profile_user_id: uuid::Uuid,
stats: &'a UserStats, stats: &'a UserStats,
view: &'a str, view: &'a str,
entries: Option<&'a Paginated<DiaryEntry>>, entries: Option<&'a Paginated<DiaryEntry>>,
@@ -77,6 +82,8 @@ struct ProfileTemplate<'a> {
trends: Option<&'a UserTrends>, trends: Option<&'a UserTrends>,
monthly_rating_rows: Vec<MonthlyRatingRow<'a>>, monthly_rating_rows: Vec<MonthlyRatingRow<'a>>,
heatmap: Vec<HeatmapCell>, heatmap: Vec<HeatmapCell>,
total_pages: u32,
current_page: u32,
} }
struct HeatmapCell { struct HeatmapCell {
@@ -140,12 +147,21 @@ impl AskamaHtmlRenderer {
impl HtmlRenderer for AskamaHtmlRenderer { impl HtmlRenderer for AskamaHtmlRenderer {
fn render_diary_page(&self, data: &Paginated<DiaryEntry>, ctx: HtmlPageContext) -> Result<String, String> { fn render_diary_page(&self, data: &Paginated<DiaryEntry>, ctx: HtmlPageContext) -> Result<String, String> {
let has_more = (data.offset + data.limit) < data.total_count as u32; let has_more = (data.offset + data.limit) < data.total_count as u32;
let (total_pages, current_page) = if data.limit > 0 {
let total_pages = ((data.total_count + data.limit as u64 - 1) / data.limit as u64) as u32;
let current_page = data.offset / data.limit;
(total_pages, current_page)
} else {
(0, 0)
};
DiaryTemplate { DiaryTemplate {
entries: &data.items, entries: &data.items,
current_offset: data.offset, current_offset: data.offset,
limit: data.limit, limit: data.limit,
has_more, has_more,
ctx: &ctx, ctx: &ctx,
total_pages,
current_page,
} }
.render() .render()
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
@@ -179,12 +195,18 @@ impl HtmlRenderer for AskamaHtmlRenderer {
} }
fn render_activity_feed_page(&self, data: ActivityFeedPageData) -> Result<String, String> { fn render_activity_feed_page(&self, data: ActivityFeedPageData) -> Result<String, String> {
let total_count = data.entries.total_count;
let limit = data.limit;
let total_pages = ((total_count + limit as u64 - 1) / limit as u64) as u32;
let current_page = if limit > 0 { data.current_offset / limit } else { 0 };
ActivityFeedTemplate { ActivityFeedTemplate {
entries: &data.entries.items, entries: &data.entries.items,
current_offset: data.current_offset, current_offset: data.current_offset,
limit: data.limit, limit,
has_more: data.has_more, has_more: data.has_more,
ctx: &data.ctx, ctx: &data.ctx,
total_pages,
current_page,
} }
.render() .render()
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
@@ -211,9 +233,14 @@ impl HtmlRenderer for AskamaHtmlRenderer {
rating: r, rating: r,
}).collect()) }).collect())
.unwrap_or_default(); .unwrap_or_default();
let total_pages = data.entries.as_ref()
.map(|e| ((e.total_count + e.limit as u64 - 1) / e.limit as u64) as u32)
.unwrap_or(0);
let current_page = if data.limit > 0 { data.current_offset / data.limit } else { 0 };
ProfileTemplate { ProfileTemplate {
ctx: &data.ctx, ctx: &data.ctx,
profile_display_name, profile_display_name,
profile_user_id: data.profile_user_id,
stats: &data.stats, stats: &data.stats,
view: &data.view, view: &data.view,
entries: data.entries.as_ref(), entries: data.entries.as_ref(),
@@ -224,6 +251,8 @@ impl HtmlRenderer for AskamaHtmlRenderer {
trends: data.trends.as_ref(), trends: data.trends.as_ref(),
monthly_rating_rows, monthly_rating_rows,
heatmap, heatmap,
total_pages,
current_page,
} }
.render() .render()
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())

View File

@@ -30,6 +30,7 @@
</div> </div>
{% if ctx.is_current_user(entry.review().user_id().value()) %} {% if ctx.is_current_user(entry.review().user_id().value()) %}
<form method="post" action="/reviews/{{ entry.review().id().value() }}/delete" class="delete-form"> <form method="post" action="/reviews/{{ entry.review().id().value() }}/delete" class="delete-form">
<input type="hidden" name="redirect_after" value="/?offset={{ current_offset }}">
<button type="submit">Delete</button> <button type="submit">Delete</button>
</form> </form>
{% endif %} {% endif %}
@@ -41,10 +42,17 @@
</div> </div>
<nav class="pagination"> <nav class="pagination">
{% if current_offset >= limit %} {% if current_offset >= limit %}
<a href="/?offset={{ current_offset - limit }}">&larr; Prev</a> <a href="/?offset={{ current_offset - limit }}" class="page-nav">&larr; Prev</a>
{% endif %} {% endif %}
{% for p in (0..total_pages) %}
{% if p == current_page %}
<span class="page-num current">{{ p + 1 }}</span>
{% else %}
<a href="/?offset={{ p * limit }}" class="page-num">{{ p + 1 }}</a>
{% endif %}
{% endfor %}
{% if has_more %} {% if has_more %}
<a href="/?offset={{ current_offset + limit }}">Next &rarr;</a> <a href="/?offset={{ current_offset + limit }}" class="page-nav">Next &rarr;</a>
{% endif %} {% endif %}
</nav> </nav>
{% endblock %} {% endblock %}

View File

@@ -42,10 +42,17 @@
</div> </div>
<nav class="pagination"> <nav class="pagination">
{% if current_offset > 0 %} {% if current_offset > 0 %}
<a href="/?offset={{ current_offset - limit }}">&larr; Prev</a> <a href="/?offset={{ current_offset - limit }}" class="page-nav">&larr; Prev</a>
{% endif %} {% endif %}
{% for p in (0..total_pages) %}
{% if p == current_page %}
<span class="page-num current">{{ p + 1 }}</span>
{% else %}
<a href="/?offset={{ p * limit }}" class="page-num">{{ p + 1 }}</a>
{% endif %}
{% endfor %}
{% if has_more %} {% if has_more %}
<a href="/?offset={{ current_offset + limit }}">Next &rarr;</a> <a href="/?offset={{ current_offset + limit }}" class="page-nav">Next &rarr;</a>
{% endif %} {% endif %}
</nav> </nav>
{% endblock %} {% endblock %}

View File

@@ -29,7 +29,7 @@
</label> </label>
<label> <label>
Watched<br> Watched<br>
<input type="datetime-local" name="watched_at" required> <input type="date" name="watched_at" required>
</label> </label>
<label> <label>
Comment<br> Comment<br>

View File

@@ -141,6 +141,7 @@
<div class="watched-at">{{ entry.review().watched_at().format("%Y-%m-%d") }}</div> <div class="watched-at">{{ entry.review().watched_at().format("%Y-%m-%d") }}</div>
{% if ctx.is_current_user(entry.review().user_id().value()) %} {% if ctx.is_current_user(entry.review().user_id().value()) %}
<form method="post" action="/reviews/{{ entry.review().id().value() }}/delete" class="delete-form"> <form method="post" action="/reviews/{{ entry.review().id().value() }}/delete" class="delete-form">
<input type="hidden" name="redirect_after" value="/users/{{ profile_user_id }}?view={{ view }}&offset={{ current_offset }}">
<button type="submit">Delete</button> <button type="submit">Delete</button>
</form> </form>
{% endif %} {% endif %}
@@ -152,10 +153,17 @@
</div> </div>
<nav class="pagination"> <nav class="pagination">
{% if current_offset >= limit %} {% if current_offset >= limit %}
<a href="?view={{ view }}&offset={{ current_offset - limit }}">&larr; Prev</a> <a href="?view={{ view }}&offset={{ current_offset - limit }}" class="page-nav">&larr; Prev</a>
{% endif %} {% endif %}
{% for p in (0..total_pages) %}
{% if p == current_page %}
<span class="page-num current">{{ p + 1 }}</span>
{% else %}
<a href="?view={{ view }}&offset={{ p * limit }}" class="page-num">{{ p + 1 }}</a>
{% endif %}
{% endfor %}
{% if has_more %} {% if has_more %}
<a href="?view={{ view }}&offset={{ current_offset + limit }}">Next &rarr;</a> <a href="?view={{ view }}&offset={{ current_offset + limit }}" class="page-nav">Next &rarr;</a>
{% endif %} {% endif %}
</nav> </nav>
{% endif %} {% endif %}

View File

@@ -59,6 +59,12 @@ pub struct ErrorQuery {
pub error: Option<String>, pub error: Option<String>,
} }
#[derive(Deserialize, Default)]
pub struct DeleteRedirectForm {
#[serde(default)]
pub redirect_after: Option<String>,
}
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct LogReviewRequest { pub struct LogReviewRequest {
pub external_metadata_id: Option<String>, pub external_metadata_id: Option<String>,
@@ -150,10 +156,14 @@ impl TryFrom<LogReviewForm> for LogReviewData {
fn try_from(form: LogReviewForm) -> Result<Self, Self::Error> { fn try_from(form: LogReviewForm) -> Result<Self, Self::Error> {
let watched_at = NaiveDateTime::parse_from_str(&form.watched_at, "%Y-%m-%dT%H:%M:%S") let watched_at = NaiveDateTime::parse_from_str(&form.watched_at, "%Y-%m-%dT%H:%M:%S")
.or_else(|_| NaiveDateTime::parse_from_str(&form.watched_at, "%Y-%m-%dT%H:%M")) .or_else(|_| NaiveDateTime::parse_from_str(&form.watched_at, "%Y-%m-%dT%H:%M"))
.or_else(|_| {
chrono::NaiveDate::parse_from_str(&form.watched_at, "%Y-%m-%d")
.map(|d| d.and_hms_opt(0, 0, 0).expect("midnight always valid"))
})
.map_err(|_| ParseReviewError { .map_err(|_| ParseReviewError {
field: "watched_at", field: "watched_at",
message: format!( message: format!(
"invalid date '{}'; expected YYYY-MM-DDTHH:MM[:SS]", "invalid date '{}'; expected YYYY-MM-DD or YYYY-MM-DDTHH:MM[:SS]",
form.watched_at form.watched_at
), ),
})?; })?;
@@ -371,4 +381,11 @@ mod tests {
let req: LoginRequest = serde_json::from_str(json).unwrap(); let req: LoginRequest = serde_json::from_str(json).unwrap();
assert_eq!(req.email, "a@b.com"); assert_eq!(req.email, "a@b.com");
} }
#[test]
fn form_accepts_date_only() {
let data = LogReviewData::try_from(make_form("2024-03-15")).unwrap();
assert_eq!(data.watched_at.format("%H:%M:%S").to_string(), "00:00:00");
assert_eq!(data.watched_at.format("%Y-%m-%d").to_string(), "2024-03-15");
}
} }

View File

@@ -219,13 +219,20 @@ pub mod html {
State(state): State<AppState>, State(state): State<AppState>,
RequiredCookieUser(user_id): RequiredCookieUser, RequiredCookieUser(user_id): RequiredCookieUser,
Path(review_id): Path<Uuid>, Path(review_id): Path<Uuid>,
Form(form): Form<crate::dtos::DeleteRedirectForm>,
) -> impl IntoResponse { ) -> impl IntoResponse {
let cmd = DeleteReviewCommand { let cmd = DeleteReviewCommand {
review_id, review_id,
requesting_user_id: user_id.value(), requesting_user_id: user_id.value(),
}; };
match delete_review::execute(&state.app_ctx, cmd).await { match delete_review::execute(&state.app_ctx, cmd).await {
Ok(()) => Redirect::to("/").into_response(), Ok(()) => {
let redirect_url = form
.redirect_after
.filter(|url| (url.starts_with('/') && !url.starts_with("//")) || url.starts_with('?'))
.unwrap_or_else(|| "/".to_string());
Redirect::to(&redirect_url).into_response()
}
Err(DomainError::NotFound(_)) => StatusCode::NOT_FOUND.into_response(), Err(DomainError::NotFound(_)) => StatusCode::NOT_FOUND.into_response(),
Err(DomainError::Unauthorized(_)) => StatusCode::FORBIDDEN.into_response(), Err(DomainError::Unauthorized(_)) => StatusCode::FORBIDDEN.into_response(),
Err(e) => { Err(e) => {

7
deploy.sh Executable file
View File

@@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
IMAGE="registry.gabrielkaszewski.dev/movies-diary:latest"
docker buildx build --platform linux/amd64 -t "$IMAGE" --push .
echo "pushed $IMAGE"