diff --git a/Cargo.toml b/Cargo.toml index d44cd3c..5fd6b73 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ path = "src/lib.rs" ansi-to-html = "0.1" anyhow = "1" article_scraper = "2.0.0-alpha.0" -axum = "0.6" +axum = { version = "0.6", features = ["form"] } bytes = "1.4" chrono = { version = "0.4", features = ["serde"] } clap = { version = "4.3", features = ["derive", "env"] } diff --git a/frontend/css/styles.css b/frontend/css/styles.css index 3f6fd4f..d543cdf 100644 --- a/frontend/css/styles.css +++ b/frontend/css/styles.css @@ -3,7 +3,7 @@ html { font-size: 18px; line-height: 1.6em; - font-family: Helvetica, Arial, sans-serif; + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; } /* Header */ @@ -47,6 +47,10 @@ ul.entries li { margin-bottom: 8px; } +ul.entries li a { + text-decoration: none; +} + ul.entries li em.domain { margin-left: 8px; color: rgba(0, 0, 0, 0.75); @@ -63,7 +67,7 @@ pre#log { article { max-width: 35em; - margin: 0 auto; + margin: 24px auto; font-size: 18px; } @@ -78,3 +82,71 @@ article img { width: auto; height: auto; } + +/* Feeds */ + +span.error { + color: crimson; +} + +div.feeds { + display: grid; + grid-template-columns: minmax(auto, 1fr) minmax(200px, 500px); + grid-template-areas: 'feeds add-feed'; + grid-gap: 24px; +} + +@media (max-width: 768px) { + div.feeds { + grid-template-columns: minmax(0, 1fr); + grid-template-areas: 'feeds' 'add-feed'; + } +} + +ul#feeds { + grid-area: 'feeds'; + list-style: none; + padding: 0; + margin-left: 8px; +} + +ul#feeds li { + margin-bottom: 8px; +} + +ul#feeds li a { + text-decoration: none; +} + +div.add-feed { + grid-area: 'add-feed'; +} + +form.add-feed-form .form-grid { + display: grid; + grid-template-columns: fit-content(100%) minmax(100px, 400px); + grid-gap: 16px; +} + +form.add-feed-form .form-grid label { + font-size: 16px; + font-weight: bold; + grid-column: 1 / 2; +} + +form.add-feed-form .form-grid input, form.add-feed-form .form-grid textarea { + font-size: 14px; + grid-column: 2 / 3; +} + +form.add-feed-form .form-grid textarea { + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; + resize: vertical; +} + +form.add-feed-form input[type="submit"] { + font-size: 14px; + margin-top: 24px; + padding: 4px 16px; + float: right; +} diff --git a/src/error.rs b/src/error.rs index 1806142..fd1dec7 100644 --- a/src/error.rs +++ b/src/error.rs @@ -20,6 +20,9 @@ pub enum Error { #[error("an internal server error occurred")] Anyhow(#[from] anyhow::Error), + #[error("an internal server error occurred")] + Reqwest(#[from] reqwest::Error), + #[error("validation error in request body")] InvalidEntity(#[from] ValidationErrors), @@ -69,7 +72,7 @@ impl Error { match self { NotFound(_, _) => StatusCode::NOT_FOUND, - Sqlx(_) | Anyhow(_) => StatusCode::INTERNAL_SERVER_ERROR, + Sqlx(_) | Anyhow(_) | Reqwest(_) => StatusCode::INTERNAL_SERVER_ERROR, InvalidEntity(_) | RelationNotFound(_) => StatusCode::UNPROCESSABLE_ENTITY, } } diff --git a/src/handlers/entry.rs b/src/handlers/entry.rs index a2edf97..bb0ea64 100644 --- a/src/handlers/entry.rs +++ b/src/handlers/entry.rs @@ -23,7 +23,7 @@ pub async fn get( Ok(layout.render(html! { article { @let title = entry.title.unwrap_or_else(|| "Untitled".to_string()); - h1 { a href=(entry.url) { (title) } } + h2 { a href=(entry.url) { (title) } } @let published_at = entry.published_at.to_rfc3339_opts(chrono::SecondsFormat::Millis, true); span class="published" { strong { "Published: " } diff --git a/src/handlers/feed.rs b/src/handlers/feed.rs index 83a9a2e..d37a293 100644 --- a/src/handlers/feed.rs +++ b/src/handlers/feed.rs @@ -1,13 +1,20 @@ use axum::extract::{Path, State}; -use axum::response::Response; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::Form; +use feed_rs::parser; use maud::html; +use reqwest::Client; +use serde::Deserialize; +use serde_with::{serde_as, NoneAsEmptyString}; use sqlx::PgPool; -use crate::error::Result; +use crate::error::{Error, Result}; use crate::models::entry::get_entries_for_feed; -use crate::models::feed::get_feed; -use crate::partials::{entry_list::entry_list, layout::Layout}; +use crate::models::feed::{create_feed, get_feed, CreateFeed}; +use crate::partials::{entry_list::entry_list, feed_link::feed_link, layout::Layout}; use crate::uuid::Base62Uuid; +use crate::turbo_stream::TurboStream; pub async fn get( Path(id): Path, @@ -17,7 +24,107 @@ pub async fn get( let feed = get_feed(&pool, id.as_uuid()).await?; let entries = get_entries_for_feed(&pool, feed.feed_id, Default::default()).await?; Ok(layout.render(html! { - h1 { (feed.title.unwrap_or_else(|| "Untitled Feed".to_string())) } + h2 { (feed.title.unwrap_or_else(|| "Untitled Feed".to_string())) } + @if let Some(description) = feed.description { + p { (description) } + } (entry_list(entries)) })) } + +#[serde_as] +#[derive(Deserialize)] +pub struct AddFeed { + url: String, + #[serde_as(as = "NoneAsEmptyString")] + title: Option, + #[serde_as(as = "NoneAsEmptyString")] + description: Option, +} + +#[derive(thiserror::Error, Debug)] +pub enum AddFeedError { + #[error("failed to fetch feed: {0}")] + FetchError(String, #[source] reqwest::Error), + #[error("failed to parse feed: {0}")] + ParseError(String, #[source] parser::ParseFeedError), + #[error("failed to create feed: {0}")] + CreateFeedError(String, #[source] Error), +} +pub type AddFeedResult = ::std::result::Result; + +impl AddFeedError { + fn status_code(&self) -> StatusCode { + use AddFeedError::*; + + match self { + FetchError(..) | ParseError(..) => StatusCode::UNPROCESSABLE_ENTITY, + CreateFeedError(..) => StatusCode::INTERNAL_SERVER_ERROR, + } + } +} + +impl IntoResponse for AddFeedError { + fn into_response(self) -> Response { + ( + self.status_code(), + TurboStream( + html! { + turbo-stream action="append" target="feeds" { + template { + li { span class="error" { (self) } } + } + } + } + .into_string(), + ), + ) + .into_response() + } +} + +pub async fn post( + State(pool): State, + Form(add_feed): Form, +) -> AddFeedResult { + let client = Client::new(); + let bytes = client + .get(&add_feed.url) + .send() + .await + .map_err(|err| AddFeedError::FetchError(add_feed.url.clone(), err))? + .bytes() + .await + .map_err(|err| AddFeedError::FetchError(add_feed.url.clone(), err))?; + let parsed_feed = parser::parse(&bytes[..]) + .map_err(|err| AddFeedError::ParseError(add_feed.url.clone(), err))?; + let feed = create_feed( + &pool, + CreateFeed { + title: add_feed + .title + .map_or_else(|| parsed_feed.title.map(|text| text.content), Some), + url: add_feed.url.clone(), + feed_type: parsed_feed.feed_type.into(), + description: add_feed + .description + .map_or_else(|| parsed_feed.description.map(|text| text.content), Some), + }, + ) + .await + .map_err(|err| AddFeedError::CreateFeedError(add_feed.url.clone(), err))?; + Ok(( + StatusCode::CREATED, + TurboStream( + html! { + turbo-stream action="append" target="feeds" { + template { + li { (feed_link(&feed)) } + } + } + } + .into_string(), + ), + ) + .into_response()) +} diff --git a/src/handlers/feeds.rs b/src/handlers/feeds.rs index 9b90b79..05da5f5 100644 --- a/src/handlers/feeds.rs +++ b/src/handlers/feeds.rs @@ -5,18 +5,32 @@ use sqlx::PgPool; use crate::error::Result; use crate::models::feed::get_feeds; -use crate::partials::layout::Layout; -use crate::uuid::Base62Uuid; +use crate::partials::{feed_link::feed_link, layout::Layout}; pub async fn get(State(pool): State, layout: Layout) -> Result { let feeds = get_feeds(&pool).await?; Ok(layout.render(html! { - ul { - @for feed in feeds { - @let title = feed.title.unwrap_or_else(|| "Untitled Feed".to_string()); - @let feed_url = format!("/feed/{}", Base62Uuid::from(feed.feed_id)); - li { a href=(feed_url) { (title) } } + h2 { "Feeds" } + div class="feeds" { + ul id="feeds" { + @for feed in feeds { + li { (feed_link(&feed)) } + } } + div class="add-feed" { + h3 { "Add Feed" } + form action="/feed" method="post" class="add-feed-form" { + div class="form-grid" { + label for="url" { "URL (required): " } + input type="text" id="url" name="url" placeholder="https://example.com/feed.xml" required="true"; + label for="title" { "Title: " } + input type="text" id="title" name="title" placeholder="Feed title"; + label { "Description: " } + textarea id="description" name="description" placeholder="Feed description" {} + } + input type="submit" value="Add Feed"; + } + } } })) } diff --git a/src/lib.rs b/src/lib.rs index a8607f2..dc8405b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,6 +6,7 @@ pub mod log; pub mod models; pub mod partials; pub mod state; +pub mod turbo_stream; pub mod utils; pub mod uuid; diff --git a/src/main.rs b/src/main.rs index 6df0a49..65e9e03 100644 --- a/src/main.rs +++ b/src/main.rs @@ -55,6 +55,7 @@ async fn main() -> Result<()> { .route("/api/v1/entry/:id", get(handlers::api::entry::get)) .route("/", get(handlers::home::get)) .route("/feeds", get(handlers::feeds::get)) + .route("/feed", post(handlers::feed::post)) .route("/feed/:id", get(handlers::feed::get)) .route("/entry/:id", get(handlers::entry::get)) .route("/log", get(handlers::log::get)) diff --git a/src/models/feed.rs b/src/models/feed.rs index 537c1d4..4772909 100644 --- a/src/models/feed.rs +++ b/src/models/feed.rs @@ -27,6 +27,16 @@ impl FromStr for FeedType { } } +impl From for FeedType { + fn from(value: feed_rs::model::FeedType) -> Self { + match value { + feed_rs::model::FeedType::Atom => FeedType::Atom, + // TODO: this isn't really accurate + _ => FeedType::Rss, + } + } +} + #[derive(Debug, Serialize, Deserialize)] pub struct Feed { pub feed_id: Uuid, diff --git a/src/partials/feed_link.rs b/src/partials/feed_link.rs new file mode 100644 index 0000000..dab1b86 --- /dev/null +++ b/src/partials/feed_link.rs @@ -0,0 +1,12 @@ +use maud::{html, Markup}; + +use crate::models::feed::Feed; +use crate::uuid::Base62Uuid; + +pub fn feed_link(feed: &Feed) -> Markup { + let title = feed.title.clone().unwrap_or_else(|| "Untitled Feed".to_string()); + let feed_url = format!("/feed/{}", Base62Uuid::from(feed.feed_id)); + html! { + a href=(feed_url) { (title) } + } +} diff --git a/src/partials/mod.rs b/src/partials/mod.rs index 040bf02..063d2a2 100644 --- a/src/partials/mod.rs +++ b/src/partials/mod.rs @@ -1,3 +1,4 @@ pub mod entry_list; +pub mod feed_link; pub mod header; pub mod layout; diff --git a/src/turbo_stream.rs b/src/turbo_stream.rs new file mode 100644 index 0000000..1b103ca --- /dev/null +++ b/src/turbo_stream.rs @@ -0,0 +1,34 @@ +use axum::response::{IntoResponse, Response}; +use axum::http::{header, HeaderValue}; +use axum::body::{Bytes, Full}; + +/// A Turbo Stream HTML response. +/// +/// See [the Turbo Streams specification](https://turbo.hotwire.dev/handbook/streams) for more +/// details. +/// +/// Will automatically get `Content-Type: text/vnd.turbo-stream.html`. +#[derive(Clone, Copy, Debug)] +pub struct TurboStream(pub T); + +impl IntoResponse for TurboStream +where + T: Into>, +{ + fn into_response(self) -> Response { + ( + [( + header::CONTENT_TYPE, + HeaderValue::from_static("text/vnd.turbo-stream.html"), + )], + self.0.into(), + ) + .into_response() + } +} + +impl From for TurboStream { + fn from(inner: T) -> Self { + Self(inner) + } +}