Compare commits
12 Commits
e28f628c80
...
317898d51b
| Author | SHA1 | Date | |
|---|---|---|---|
| 317898d51b | |||
| 790bb6fbb5 | |||
| 658df38788 | |||
| cff0f854fa | |||
| 66ade70273 | |||
| cbd2ac5b3e | |||
| 0433cd4d9b | |||
| b5a8ea2395 | |||
| 49b79799c1 | |||
| f4aba551a2 | |||
| 91df35dbd3 | |||
| 623f90e43f |
@@ -1,4 +1,5 @@
|
|||||||
DATABASE_URL=sqlite:./dev.db
|
DATABASE_URL=sqlite:./dev.db
|
||||||
|
BASE_URL=http://localhost:3000
|
||||||
PORT=3000
|
PORT=3000
|
||||||
JWT_SECRET=
|
JWT_SECRET=
|
||||||
JWT_TTL_SECONDS=
|
JWT_TTL_SECONDS=
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "SQLite",
|
"db_name": "SQLite",
|
||||||
"query": "SELECT COUNT(*) AS \"total!: i64\",\n AVG(CAST(rating AS REAL)) AS avg_rating\n FROM reviews WHERE user_id = ?",
|
"query": "SELECT COUNT(DISTINCT movie_id) AS \"total!: i64\",\n AVG(CAST(rating AS REAL)) AS avg_rating\n FROM reviews WHERE user_id = ?",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -22,5 +22,5 @@
|
|||||||
true
|
true
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "d59e1a103fc56b9b4579add523f0f77b68500cf4c96002a4a17b1e40093504ba"
|
"hash": "a01336632a54099e31686a9cbe6fc53fef1299fc7c7b52be44f99c2302490a22"
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "SQLite",
|
"db_name": "SQLite",
|
||||||
"query": "SELECT u.id,\n u.email,\n COUNT(r.id) AS \"total_movies!: i64\",\n AVG(CAST(r.rating AS REAL)) AS avg_rating\n FROM users u\n LEFT JOIN reviews r ON r.user_id = u.id\n GROUP BY u.id, u.email\n ORDER BY u.email ASC",
|
"query": "SELECT u.id,\n u.email,\n COUNT(DISTINCT r.movie_id) AS \"total_movies!: i64\",\n AVG(CAST(r.rating AS REAL)) AS avg_rating\n FROM users u\n LEFT JOIN reviews r ON r.user_id = u.id\n GROUP BY u.id, u.email\n ORDER BY u.email ASC",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -34,5 +34,5 @@
|
|||||||
true
|
true
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "41273bd5f2ad4e86bb2f60d7b3144279f2ae77a95a8ea61bbf3dbfef2d861dd8"
|
"hash": "f259059d76f29cade94e249735d37ef4993fe5bff095dc43e681b848a398f318"
|
||||||
}
|
}
|
||||||
@@ -3,22 +3,21 @@ use domain::models::DiaryEntry;
|
|||||||
use rss_feed::{ChannelBuilder, GuidBuilder, ItemBuilder};
|
use rss_feed::{ChannelBuilder, GuidBuilder, ItemBuilder};
|
||||||
|
|
||||||
pub struct RssAdapter {
|
pub struct RssAdapter {
|
||||||
feed_title: String,
|
|
||||||
feed_link: String,
|
feed_link: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RssAdapter {
|
impl RssAdapter {
|
||||||
pub fn new(feed_title: String, feed_link: String) -> Self {
|
pub fn new(feed_link: String) -> Self {
|
||||||
Self { feed_title, feed_link }
|
Self { feed_link }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RssFeedRenderer for RssAdapter {
|
impl RssFeedRenderer for RssAdapter {
|
||||||
fn render_feed(&self, entries: &[DiaryEntry]) -> Result<String, String> {
|
fn render_feed(&self, entries: &[DiaryEntry], title: &str) -> Result<String, String> {
|
||||||
let items = entries
|
let items = entries
|
||||||
.iter()
|
.iter()
|
||||||
.map(|e| {
|
.map(|e| {
|
||||||
let title = format!(
|
let item_title = format!(
|
||||||
"{} ({})",
|
"{} ({})",
|
||||||
e.movie().title().value(),
|
e.movie().title().value(),
|
||||||
e.movie().release_year().value()
|
e.movie().release_year().value()
|
||||||
@@ -38,7 +37,7 @@ impl RssFeedRenderer for RssAdapter {
|
|||||||
.permalink(false)
|
.permalink(false)
|
||||||
.build();
|
.build();
|
||||||
ItemBuilder::default()
|
ItemBuilder::default()
|
||||||
.title(Some(title))
|
.title(Some(item_title))
|
||||||
.description(Some(description))
|
.description(Some(description))
|
||||||
.pub_date(Some(pub_date))
|
.pub_date(Some(pub_date))
|
||||||
.guid(Some(guid))
|
.guid(Some(guid))
|
||||||
@@ -47,12 +46,31 @@ impl RssFeedRenderer for RssAdapter {
|
|||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
let channel = ChannelBuilder::default()
|
let channel = ChannelBuilder::default()
|
||||||
.title(self.feed_title.clone())
|
.title(title.to_string())
|
||||||
.link(self.feed_link.clone())
|
.link(self.feed_link.clone())
|
||||||
.description(self.feed_title.clone())
|
.description(title.to_string())
|
||||||
.items(items)
|
.items(items)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
Ok(channel.to_string())
|
Ok(channel.to_string())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_feed_uses_provided_title() {
|
||||||
|
let adapter = RssAdapter::new("http://example.com".into());
|
||||||
|
let xml = adapter.render_feed(&[], "Custom Title").unwrap();
|
||||||
|
assert!(xml.contains("<title>Custom Title</title>"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_feed_empty_entries_produces_valid_xml() {
|
||||||
|
let adapter = RssAdapter::new("http://example.com".into());
|
||||||
|
let xml = adapter.render_feed(&[], "My Feed").unwrap();
|
||||||
|
assert!(xml.starts_with("<?xml") || xml.starts_with("<rss"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -240,7 +240,7 @@ impl SqliteMovieRepository {
|
|||||||
async fn fetch_user_totals(&self, user_id: &str) -> Result<UserTotalsRow, DomainError> {
|
async fn fetch_user_totals(&self, user_id: &str) -> Result<UserTotalsRow, DomainError> {
|
||||||
sqlx::query_as!(
|
sqlx::query_as!(
|
||||||
UserTotalsRow,
|
UserTotalsRow,
|
||||||
r#"SELECT COUNT(*) AS "total!: i64",
|
r#"SELECT COUNT(DISTINCT movie_id) AS "total!: i64",
|
||||||
AVG(CAST(rating AS REAL)) AS avg_rating
|
AVG(CAST(rating AS REAL)) AS avg_rating
|
||||||
FROM reviews WHERE user_id = ?"#,
|
FROM reviews WHERE user_id = ?"#,
|
||||||
user_id
|
user_id
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ impl UserRepository for SqliteUserRepository {
|
|||||||
UserSummaryRow,
|
UserSummaryRow,
|
||||||
r#"SELECT u.id,
|
r#"SELECT u.id,
|
||||||
u.email,
|
u.email,
|
||||||
COUNT(r.id) AS "total_movies!: i64",
|
COUNT(DISTINCT r.movie_id) AS "total_movies!: i64",
|
||||||
AVG(CAST(r.rating AS REAL)) AS avg_rating
|
AVG(CAST(r.rating AS REAL)) AS avg_rating
|
||||||
FROM users u
|
FROM users u
|
||||||
LEFT JOIN reviews r ON r.user_id = u.id
|
LEFT JOIN reviews r ON r.user_id = u.id
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
<nav>
|
<nav>
|
||||||
<a href="/">Feed</a>
|
<a href="/">Feed</a>
|
||||||
<a href="/users">Users</a>
|
<a href="/users">Users</a>
|
||||||
<a href="/feed.rss">RSS</a>
|
<a href="{{ ctx.rss_url }}">RSS</a>
|
||||||
{% if let Some(email) = ctx.user_email %}
|
{% if let Some(email) = ctx.user_email %}
|
||||||
<a href="/reviews/new">Add Review</a>
|
<a href="/reviews/new">Add Review</a>
|
||||||
<a href="/logout">Logout</a>
|
<a href="/logout">Logout</a>
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
<div class="heatmap-label">Movies watched this year</div>
|
<div class="heatmap-label">Movies watched this year</div>
|
||||||
<div class="heatmap">
|
<div class="heatmap">
|
||||||
{% for cell in heatmap %}
|
{% for cell in heatmap %}
|
||||||
<div class="heatmap-cell" style="background: rgba(74, 158, 255, {{ cell.alpha }})">
|
<div class="heatmap-cell" style="--alpha: {{ cell.alpha }}">
|
||||||
<div class="heatmap-count">{{ cell.count }}</div>
|
<div class="heatmap-count">{{ cell.count }}</div>
|
||||||
<div class="heatmap-month">{{ cell.month_label }}</div>
|
<div class="heatmap-month">{{ cell.month_label }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ pub struct HtmlPageContext {
|
|||||||
pub user_email: Option<String>,
|
pub user_email: Option<String>,
|
||||||
pub user_id: Option<Uuid>,
|
pub user_id: Option<Uuid>,
|
||||||
pub register_enabled: bool,
|
pub register_enabled: bool,
|
||||||
|
pub rss_url: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HtmlPageContext {
|
impl HtmlPageContext {
|
||||||
@@ -67,5 +68,5 @@ pub trait HtmlRenderer: Send + Sync {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub trait RssFeedRenderer: Send + Sync {
|
pub trait RssFeedRenderer: Send + Sync {
|
||||||
fn render_feed(&self, entries: &[DiaryEntry]) -> Result<String, String>;
|
fn render_feed(&self, entries: &[DiaryEntry], title: &str) -> Result<String, String>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ pub struct GetDiaryQuery {
|
|||||||
pub offset: Option<u32>,
|
pub offset: Option<u32>,
|
||||||
pub sort_by: Option<SortDirection>,
|
pub sort_by: Option<SortDirection>,
|
||||||
pub movie_id: Option<Uuid>,
|
pub movie_id: Option<Uuid>,
|
||||||
|
pub user_id: Option<Uuid>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct GetReviewHistoryQuery {
|
pub struct GetReviewHistoryQuery {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use domain::{
|
|||||||
DiaryEntry, DiaryFilter, SortDirection,
|
DiaryEntry, DiaryFilter, SortDirection,
|
||||||
collections::{PageParams, Paginated},
|
collections::{PageParams, Paginated},
|
||||||
},
|
},
|
||||||
value_objects::MovieId,
|
value_objects::{MovieId, UserId},
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{context::AppContext, queries::GetDiaryQuery};
|
use crate::{context::AppContext, queries::GetDiaryQuery};
|
||||||
@@ -14,17 +14,15 @@ pub async fn execute(
|
|||||||
query: GetDiaryQuery,
|
query: GetDiaryQuery,
|
||||||
) -> Result<Paginated<DiaryEntry>, DomainError> {
|
) -> Result<Paginated<DiaryEntry>, DomainError> {
|
||||||
let page = PageParams::new(query.limit, query.offset)?;
|
let page = PageParams::new(query.limit, query.offset)?;
|
||||||
|
|
||||||
let movie_id = query.movie_id.map(MovieId::from_uuid);
|
let movie_id = query.movie_id.map(MovieId::from_uuid);
|
||||||
|
let user_id = query.user_id.map(UserId::from_uuid);
|
||||||
|
|
||||||
let filter = DiaryFilter {
|
let filter = DiaryFilter {
|
||||||
sort_by: query.sort_by.unwrap_or(SortDirection::Descending),
|
sort_by: query.sort_by.unwrap_or(SortDirection::Descending),
|
||||||
page,
|
page,
|
||||||
movie_id,
|
movie_id,
|
||||||
user_id: None,
|
user_id,
|
||||||
};
|
};
|
||||||
|
|
||||||
let paginated_results = ctx.repository.query_diary(&filter).await?;
|
ctx.repository.query_diary(&filter).await
|
||||||
|
|
||||||
Ok(paginated_results)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -133,14 +133,6 @@ impl Review {
|
|||||||
comment: Option<Comment>,
|
comment: Option<Comment>,
|
||||||
watched_at: NaiveDateTime,
|
watched_at: NaiveDateTime,
|
||||||
) -> Result<Self, DomainError> {
|
) -> Result<Self, DomainError> {
|
||||||
let now = Utc::now().naive_utc();
|
|
||||||
|
|
||||||
if watched_at > now {
|
|
||||||
return Err(DomainError::ValidationError(
|
|
||||||
"watched_at cannot be in the future".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
id: ReviewId::generate(),
|
id: ReviewId::generate(),
|
||||||
movie_id,
|
movie_id,
|
||||||
@@ -148,7 +140,7 @@ impl Review {
|
|||||||
rating,
|
rating,
|
||||||
comment,
|
comment,
|
||||||
watched_at,
|
watched_at,
|
||||||
created_at: now,
|
created_at: Utc::now().naive_utc(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -219,6 +219,7 @@ impl From<DiaryQueryParams> for GetDiaryQuery {
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
movie_id: p.movie_id,
|
movie_id: p.movie_id,
|
||||||
|
user_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ mod tests {
|
|||||||
|
|
||||||
struct PanicRssRenderer;
|
struct PanicRssRenderer;
|
||||||
impl crate::ports::RssFeedRenderer for PanicRssRenderer {
|
impl crate::ports::RssFeedRenderer for PanicRssRenderer {
|
||||||
fn render_feed(&self, _: &[domain::models::DiaryEntry]) -> Result<String, String> { panic!() }
|
fn render_feed(&self, _: &[domain::models::DiaryEntry], _: &str) -> Result<String, String> { panic!() }
|
||||||
}
|
}
|
||||||
|
|
||||||
struct PanicMeta; struct PanicFetcher; struct PanicStorage; struct PanicEvent; struct PanicHasher; struct PanicAuth; struct PanicUserRepo;
|
struct PanicMeta; struct PanicFetcher; struct PanicStorage; struct PanicEvent; struct PanicHasher; struct PanicAuth; struct PanicUserRepo;
|
||||||
@@ -269,7 +269,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
struct PanicRssRenderer2;
|
struct PanicRssRenderer2;
|
||||||
impl crate::ports::RssFeedRenderer for PanicRssRenderer2 {
|
impl crate::ports::RssFeedRenderer for PanicRssRenderer2 {
|
||||||
fn render_feed(&self, _: &[domain::models::DiaryEntry]) -> Result<String, String> { panic!() }
|
fn render_feed(&self, _: &[domain::models::DiaryEntry], _: &str) -> Result<String, String> { panic!() }
|
||||||
}
|
}
|
||||||
struct PanicAuth2;
|
struct PanicAuth2;
|
||||||
crate::state::AppState {
|
crate::state::AppState {
|
||||||
@@ -329,7 +329,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
struct PanicRssRenderer3;
|
struct PanicRssRenderer3;
|
||||||
impl crate::ports::RssFeedRenderer for PanicRssRenderer3 {
|
impl crate::ports::RssFeedRenderer for PanicRssRenderer3 {
|
||||||
fn render_feed(&self, _: &[domain::models::DiaryEntry]) -> Result<String, String> { panic!() }
|
fn render_feed(&self, _: &[domain::models::DiaryEntry], _: &str) -> Result<String, String> { panic!() }
|
||||||
}
|
}
|
||||||
crate::state::AppState {
|
crate::state::AppState {
|
||||||
app_ctx: AppContext {
|
app_ctx: AppContext {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
const DEFAULT_PAGE_LIMIT: u32 = 5;
|
const DEFAULT_PAGE_LIMIT: u32 = 5;
|
||||||
|
const RSS_FEED_LIMIT: u32 = 50;
|
||||||
|
|
||||||
pub mod html {
|
pub mod html {
|
||||||
use axum::{
|
use axum::{
|
||||||
@@ -41,6 +42,7 @@ pub mod html {
|
|||||||
user_email,
|
user_email,
|
||||||
user_id: uuid,
|
user_id: uuid,
|
||||||
register_enabled: state.app_ctx.config.allow_registration,
|
register_enabled: state.app_ctx.config.allow_registration,
|
||||||
|
rss_url: "/feed.rss".to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,6 +69,7 @@ pub mod html {
|
|||||||
user_email: None,
|
user_email: None,
|
||||||
user_id: None,
|
user_id: None,
|
||||||
register_enabled: state.app_ctx.config.allow_registration,
|
register_enabled: state.app_ctx.config.allow_registration,
|
||||||
|
rss_url: "/feed.rss".to_string(),
|
||||||
};
|
};
|
||||||
let html = state
|
let html = state
|
||||||
.html_renderer
|
.html_renderer
|
||||||
@@ -119,6 +122,7 @@ pub mod html {
|
|||||||
user_email: None,
|
user_email: None,
|
||||||
user_id: None,
|
user_id: None,
|
||||||
register_enabled: true,
|
register_enabled: true,
|
||||||
|
rss_url: "/feed.rss".to_string(),
|
||||||
};
|
};
|
||||||
let html = state
|
let html = state
|
||||||
.html_renderer
|
.html_renderer
|
||||||
@@ -276,7 +280,7 @@ pub mod html {
|
|||||||
Path(profile_user_uuid): Path<Uuid>,
|
Path(profile_user_uuid): Path<Uuid>,
|
||||||
Query(params): Query<crate::dtos::ProfileQueryParams>,
|
Query(params): Query<crate::dtos::ProfileQueryParams>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let ctx = build_page_context(&state, user_id).await;
|
let mut ctx = build_page_context(&state, user_id).await;
|
||||||
let view = params.view.unwrap_or_else(|| "recent".to_string());
|
let view = params.view.unwrap_or_else(|| "recent".to_string());
|
||||||
|
|
||||||
let profile_user = match state.app_ctx.user_repository
|
let profile_user = match state.app_ctx.user_repository
|
||||||
@@ -303,6 +307,7 @@ pub mod html {
|
|||||||
(e.offset, has_more, e.limit)
|
(e.offset, has_more, e.limit)
|
||||||
})
|
})
|
||||||
.unwrap_or((0, false, super::DEFAULT_PAGE_LIMIT));
|
.unwrap_or((0, false, super::DEFAULT_PAGE_LIMIT));
|
||||||
|
ctx.rss_url = format!("/users/{}/feed.rss", profile_user_uuid);
|
||||||
let data = application::ports::ProfilePageData {
|
let data = application::ports::ProfilePageData {
|
||||||
ctx,
|
ctx,
|
||||||
profile_user_id: profile_user_uuid,
|
profile_user_id: profile_user_uuid,
|
||||||
@@ -359,30 +364,64 @@ pub mod posters {
|
|||||||
|
|
||||||
pub mod rss {
|
pub mod rss {
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::State,
|
extract::{Path, State},
|
||||||
http::header,
|
http::header,
|
||||||
response::IntoResponse,
|
response::IntoResponse,
|
||||||
};
|
};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
use application::{queries::GetDiaryQuery, use_cases::get_diary};
|
use application::{queries::GetDiaryQuery, use_cases::get_diary};
|
||||||
use domain::{errors::DomainError, models::SortDirection};
|
use domain::{errors::DomainError, models::SortDirection, value_objects::UserId};
|
||||||
|
|
||||||
use crate::{errors::ApiError, state::AppState};
|
use crate::{errors::ApiError, state::AppState};
|
||||||
|
|
||||||
pub async fn get_feed(State(state): State<AppState>) -> Result<impl IntoResponse, ApiError> {
|
pub async fn get_feed(State(state): State<AppState>) -> Result<impl IntoResponse, ApiError> {
|
||||||
let query = GetDiaryQuery {
|
let query = GetDiaryQuery {
|
||||||
limit: Some(50),
|
limit: Some(super::RSS_FEED_LIMIT),
|
||||||
offset: Some(0),
|
offset: Some(0),
|
||||||
sort_by: Some(SortDirection::Descending),
|
sort_by: Some(SortDirection::Descending),
|
||||||
movie_id: None,
|
movie_id: None,
|
||||||
|
user_id: None,
|
||||||
};
|
};
|
||||||
let page = get_diary::execute(&state.app_ctx, query).await?;
|
let page = get_diary::execute(&state.app_ctx, query).await?;
|
||||||
let xml = state
|
let xml = state
|
||||||
.rss_renderer
|
.rss_renderer
|
||||||
.render_feed(&page.items)
|
.render_feed(&page.items, "Movie Diary")
|
||||||
.map_err(|e| ApiError(DomainError::InfrastructureError(e)))?;
|
.map_err(|e| ApiError(DomainError::InfrastructureError(e)))?;
|
||||||
Ok(([(header::CONTENT_TYPE, "application/rss+xml; charset=utf-8")], xml))
|
Ok(([(header::CONTENT_TYPE, "application/rss+xml; charset=utf-8")], xml))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_user_feed(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(user_id): Path<Uuid>,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
let user = state
|
||||||
|
.app_ctx
|
||||||
|
.user_repository
|
||||||
|
.find_by_id(&UserId::from_uuid(user_id))
|
||||||
|
.await
|
||||||
|
.map_err(ApiError)?
|
||||||
|
.ok_or_else(|| ApiError(DomainError::NotFound(format!("User {user_id}"))))?;
|
||||||
|
|
||||||
|
let query = GetDiaryQuery {
|
||||||
|
limit: Some(super::RSS_FEED_LIMIT),
|
||||||
|
offset: Some(0),
|
||||||
|
sort_by: Some(SortDirection::Descending),
|
||||||
|
movie_id: None,
|
||||||
|
user_id: Some(user_id),
|
||||||
|
};
|
||||||
|
let page = get_diary::execute(&state.app_ctx, query).await?;
|
||||||
|
|
||||||
|
let display_name = user.email().value().split('@').next().unwrap_or("User");
|
||||||
|
let title = format!("{}'s Movie Diary", display_name);
|
||||||
|
|
||||||
|
let xml = state
|
||||||
|
.rss_renderer
|
||||||
|
.render_feed(&page.items, &title)
|
||||||
|
.map_err(|e| ApiError(DomainError::InfrastructureError(e)))?;
|
||||||
|
|
||||||
|
Ok(([(header::CONTENT_TYPE, "application/rss+xml; charset=utf-8")], xml))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub mod api {
|
pub mod api {
|
||||||
|
|||||||
@@ -109,8 +109,7 @@ async fn wire_dependencies() -> anyhow::Result<AppState> {
|
|||||||
app_ctx,
|
app_ctx,
|
||||||
html_renderer: Arc::new(AskamaHtmlRenderer::new()),
|
html_renderer: Arc::new(AskamaHtmlRenderer::new()),
|
||||||
rss_renderer: Arc::new(RssAdapter::new(
|
rss_renderer: Arc::new(RssAdapter::new(
|
||||||
"Movie Diary".into(),
|
std::env::var("BASE_URL").unwrap_or_else(|_| "http://localhost:3000".into()),
|
||||||
"http://localhost:3000".into(),
|
|
||||||
)),
|
)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ fn html_routes() -> Router<AppState> {
|
|||||||
.route("/reviews/{id}/delete", routing::post(handlers::html::post_delete_review))
|
.route("/reviews/{id}/delete", routing::post(handlers::html::post_delete_review))
|
||||||
.route("/posters/{path}", routing::get(handlers::posters::get_poster))
|
.route("/posters/{path}", routing::get(handlers::posters::get_poster))
|
||||||
.route("/feed.rss", routing::get(handlers::rss::get_feed))
|
.route("/feed.rss", routing::get(handlers::rss::get_feed))
|
||||||
|
.route("/users/{id}/feed.rss", routing::get(handlers::rss::get_user_feed))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn api_routes() -> Router<AppState> {
|
fn api_routes() -> Router<AppState> {
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ async fn test_app() -> Router {
|
|||||||
config: AppConfig { allow_registration: false },
|
config: AppConfig { allow_registration: false },
|
||||||
},
|
},
|
||||||
html_renderer: Arc::new(AskamaHtmlRenderer::new()),
|
html_renderer: Arc::new(AskamaHtmlRenderer::new()),
|
||||||
rss_renderer: Arc::new(RssAdapter::new("Movie Diary".into(), "http://localhost:3000".into())),
|
rss_renderer: Arc::new(RssAdapter::new("http://localhost:3000".into())),
|
||||||
};
|
};
|
||||||
|
|
||||||
routes::build_router(state)
|
routes::build_router(state)
|
||||||
|
|||||||
@@ -471,6 +471,7 @@ form button[type="submit"]:hover {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
min-height: 48px;
|
min-height: 48px;
|
||||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||||
|
background: oklch(85.2% 0.199 91.936 / var(--alpha, 0.05));
|
||||||
}
|
}
|
||||||
.heatmap-count { font-size: 0.85rem; font-weight: 700; }
|
.heatmap-count { font-size: 0.85rem; font-weight: 700; }
|
||||||
.heatmap-month { font-size: 0.65rem; opacity: 0.6; margin-top: 2px; }
|
.heatmap-month { font-size: 0.65rem; opacity: 0.6; margin-top: 2px; }
|
||||||
|
|||||||
Reference in New Issue
Block a user