Add feed form

This commit is contained in:
Tyler Hallada 2023-07-08 01:05:49 -04:00
parent f69d0f2752
commit 478e72d8f0
12 changed files with 272 additions and 17 deletions

View File

@ -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"] }

View File

@ -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;
}

View File

@ -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,
}
}

View File

@ -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: " }

View File

@ -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<Base62Uuid>,
@ -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<String>,
#[serde_as(as = "NoneAsEmptyString")]
description: Option<String>,
}
#[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<T, E = AddFeedError> = ::std::result::Result<T, E>;
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<PgPool>,
Form(add_feed): Form<AddFeed>,
) -> AddFeedResult<Response> {
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())
}

View File

@ -5,17 +5,31 @@ 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<PgPool>, layout: Layout) -> Result<Response> {
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";
}
}
}
}))

View File

@ -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;

View File

@ -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))

View File

@ -27,6 +27,16 @@ impl FromStr for FeedType {
}
}
impl From<feed_rs::model::FeedType> 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,

12
src/partials/feed_link.rs Normal file
View File

@ -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) }
}
}

View File

@ -1,3 +1,4 @@
pub mod entry_list;
pub mod feed_link;
pub mod header;
pub mod layout;

34
src/turbo_stream.rs Normal file
View File

@ -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<T>(pub T);
impl<T> IntoResponse for TurboStream<T>
where
T: Into<Full<Bytes>>,
{
fn into_response(self) -> Response {
(
[(
header::CONTENT_TYPE,
HeaderValue::from_static("text/vnd.turbo-stream.html"),
)],
self.0.into(),
)
.into_response()
}
}
impl<T> From<T> for TurboStream<T> {
fn from(inner: T) -> Self {
Self(inner)
}
}