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",
"domain",
"serde",
"uuid",
]
[[package]]

View File

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

View File

@@ -17,6 +17,8 @@ struct DiaryTemplate<'a> {
limit: u32,
has_more: bool,
ctx: &'a HtmlPageContext,
total_pages: u32,
current_page: u32,
}
#[derive(Template)]
@@ -48,6 +50,8 @@ struct ActivityFeedTemplate<'a> {
limit: u32,
has_more: bool,
ctx: &'a HtmlPageContext,
total_pages: u32,
current_page: u32,
}
#[derive(Template)]
@@ -67,6 +71,7 @@ struct MonthlyRatingRow<'a> {
struct ProfileTemplate<'a> {
ctx: &'a HtmlPageContext,
profile_display_name: String,
profile_user_id: uuid::Uuid,
stats: &'a UserStats,
view: &'a str,
entries: Option<&'a Paginated<DiaryEntry>>,
@@ -77,6 +82,8 @@ struct ProfileTemplate<'a> {
trends: Option<&'a UserTrends>,
monthly_rating_rows: Vec<MonthlyRatingRow<'a>>,
heatmap: Vec<HeatmapCell>,
total_pages: u32,
current_page: u32,
}
struct HeatmapCell {
@@ -140,12 +147,21 @@ impl AskamaHtmlRenderer {
impl HtmlRenderer for AskamaHtmlRenderer {
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 (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 {
entries: &data.items,
current_offset: data.offset,
limit: data.limit,
has_more,
ctx: &ctx,
total_pages,
current_page,
}
.render()
.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> {
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 {
entries: &data.entries.items,
current_offset: data.current_offset,
limit: data.limit,
limit,
has_more: data.has_more,
ctx: &data.ctx,
total_pages,
current_page,
}
.render()
.map_err(|e| e.to_string())
@@ -211,9 +233,14 @@ impl HtmlRenderer for AskamaHtmlRenderer {
rating: r,
}).collect())
.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 {
ctx: &data.ctx,
profile_display_name,
profile_user_id: data.profile_user_id,
stats: &data.stats,
view: &data.view,
entries: data.entries.as_ref(),
@@ -224,6 +251,8 @@ impl HtmlRenderer for AskamaHtmlRenderer {
trends: data.trends.as_ref(),
monthly_rating_rows,
heatmap,
total_pages,
current_page,
}
.render()
.map_err(|e| e.to_string())

View File

@@ -30,6 +30,7 @@
</div>
{% if ctx.is_current_user(entry.review().user_id().value()) %}
<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>
</form>
{% endif %}
@@ -41,10 +42,17 @@
</div>
<nav class="pagination">
{% 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 %}
{% 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 %}
<a href="/?offset={{ current_offset + limit }}">Next &rarr;</a>
<a href="/?offset={{ current_offset + limit }}" class="page-nav">Next &rarr;</a>
{% endif %}
</nav>
{% endblock %}

View File

@@ -42,10 +42,17 @@
</div>
<nav class="pagination">
{% 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 %}
{% 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 %}
<a href="/?offset={{ current_offset + limit }}">Next &rarr;</a>
<a href="/?offset={{ current_offset + limit }}" class="page-nav">Next &rarr;</a>
{% endif %}
</nav>
{% endblock %}

View File

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

View File

@@ -141,6 +141,7 @@
<div class="watched-at">{{ entry.review().watched_at().format("%Y-%m-%d") }}</div>
{% if ctx.is_current_user(entry.review().user_id().value()) %}
<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>
</form>
{% endif %}
@@ -152,10 +153,17 @@
</div>
<nav class="pagination">
{% 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 %}
{% 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 %}
<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 %}
</nav>
{% endif %}

View File

@@ -59,6 +59,12 @@ pub struct ErrorQuery {
pub error: Option<String>,
}
#[derive(Deserialize, Default)]
pub struct DeleteRedirectForm {
#[serde(default)]
pub redirect_after: Option<String>,
}
#[derive(Deserialize)]
pub struct LogReviewRequest {
pub external_metadata_id: Option<String>,
@@ -150,10 +156,14 @@ impl TryFrom<LogReviewForm> for LogReviewData {
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")
.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 {
field: "watched_at",
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
),
})?;
@@ -371,4 +381,11 @@ mod tests {
let req: LoginRequest = serde_json::from_str(json).unwrap();
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>,
RequiredCookieUser(user_id): RequiredCookieUser,
Path(review_id): Path<Uuid>,
Form(form): Form<crate::dtos::DeleteRedirectForm>,
) -> impl IntoResponse {
let cmd = DeleteReviewCommand {
review_id,
requesting_user_id: user_id.value(),
};
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::Unauthorized(_)) => StatusCode::FORBIDDEN.into_response(),
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"