Add feed form
This commit is contained in:
parent
f69d0f2752
commit
478e72d8f0
@ -15,7 +15,7 @@ path = "src/lib.rs"
|
|||||||
ansi-to-html = "0.1"
|
ansi-to-html = "0.1"
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
article_scraper = "2.0.0-alpha.0"
|
article_scraper = "2.0.0-alpha.0"
|
||||||
axum = "0.6"
|
axum = { version = "0.6", features = ["form"] }
|
||||||
bytes = "1.4"
|
bytes = "1.4"
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
clap = { version = "4.3", features = ["derive", "env"] }
|
clap = { version = "4.3", features = ["derive", "env"] }
|
||||||
|
@ -3,7 +3,7 @@
|
|||||||
html {
|
html {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
line-height: 1.6em;
|
line-height: 1.6em;
|
||||||
font-family: Helvetica, Arial, sans-serif;
|
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Header */
|
/* Header */
|
||||||
@ -47,6 +47,10 @@ ul.entries li {
|
|||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ul.entries li a {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
ul.entries li em.domain {
|
ul.entries li em.domain {
|
||||||
margin-left: 8px;
|
margin-left: 8px;
|
||||||
color: rgba(0, 0, 0, 0.75);
|
color: rgba(0, 0, 0, 0.75);
|
||||||
@ -63,7 +67,7 @@ pre#log {
|
|||||||
|
|
||||||
article {
|
article {
|
||||||
max-width: 35em;
|
max-width: 35em;
|
||||||
margin: 0 auto;
|
margin: 24px auto;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -78,3 +82,71 @@ article img {
|
|||||||
width: auto;
|
width: auto;
|
||||||
height: 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;
|
||||||
|
}
|
||||||
|
@ -20,6 +20,9 @@ pub enum Error {
|
|||||||
#[error("an internal server error occurred")]
|
#[error("an internal server error occurred")]
|
||||||
Anyhow(#[from] anyhow::Error),
|
Anyhow(#[from] anyhow::Error),
|
||||||
|
|
||||||
|
#[error("an internal server error occurred")]
|
||||||
|
Reqwest(#[from] reqwest::Error),
|
||||||
|
|
||||||
#[error("validation error in request body")]
|
#[error("validation error in request body")]
|
||||||
InvalidEntity(#[from] ValidationErrors),
|
InvalidEntity(#[from] ValidationErrors),
|
||||||
|
|
||||||
@ -69,7 +72,7 @@ impl Error {
|
|||||||
|
|
||||||
match self {
|
match self {
|
||||||
NotFound(_, _) => StatusCode::NOT_FOUND,
|
NotFound(_, _) => StatusCode::NOT_FOUND,
|
||||||
Sqlx(_) | Anyhow(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
Sqlx(_) | Anyhow(_) | Reqwest(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
InvalidEntity(_) | RelationNotFound(_) => StatusCode::UNPROCESSABLE_ENTITY,
|
InvalidEntity(_) | RelationNotFound(_) => StatusCode::UNPROCESSABLE_ENTITY,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -23,7 +23,7 @@ pub async fn get(
|
|||||||
Ok(layout.render(html! {
|
Ok(layout.render(html! {
|
||||||
article {
|
article {
|
||||||
@let title = entry.title.unwrap_or_else(|| "Untitled".to_string());
|
@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);
|
@let published_at = entry.published_at.to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
|
||||||
span class="published" {
|
span class="published" {
|
||||||
strong { "Published: " }
|
strong { "Published: " }
|
||||||
|
@ -1,13 +1,20 @@
|
|||||||
use axum::extract::{Path, State};
|
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 maud::html;
|
||||||
|
use reqwest::Client;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_with::{serde_as, NoneAsEmptyString};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
|
||||||
use crate::error::Result;
|
use crate::error::{Error, Result};
|
||||||
use crate::models::entry::get_entries_for_feed;
|
use crate::models::entry::get_entries_for_feed;
|
||||||
use crate::models::feed::get_feed;
|
use crate::models::feed::{create_feed, get_feed, CreateFeed};
|
||||||
use crate::partials::{entry_list::entry_list, layout::Layout};
|
use crate::partials::{entry_list::entry_list, feed_link::feed_link, layout::Layout};
|
||||||
use crate::uuid::Base62Uuid;
|
use crate::uuid::Base62Uuid;
|
||||||
|
use crate::turbo_stream::TurboStream;
|
||||||
|
|
||||||
pub async fn get(
|
pub async fn get(
|
||||||
Path(id): Path<Base62Uuid>,
|
Path(id): Path<Base62Uuid>,
|
||||||
@ -17,7 +24,107 @@ pub async fn get(
|
|||||||
let feed = get_feed(&pool, id.as_uuid()).await?;
|
let feed = get_feed(&pool, id.as_uuid()).await?;
|
||||||
let entries = get_entries_for_feed(&pool, feed.feed_id, Default::default()).await?;
|
let entries = get_entries_for_feed(&pool, feed.feed_id, Default::default()).await?;
|
||||||
Ok(layout.render(html! {
|
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))
|
(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())
|
||||||
|
}
|
||||||
|
@ -5,17 +5,31 @@ use sqlx::PgPool;
|
|||||||
|
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::models::feed::get_feeds;
|
use crate::models::feed::get_feeds;
|
||||||
use crate::partials::layout::Layout;
|
use crate::partials::{feed_link::feed_link, layout::Layout};
|
||||||
use crate::uuid::Base62Uuid;
|
|
||||||
|
|
||||||
pub async fn get(State(pool): State<PgPool>, layout: Layout) -> Result<Response> {
|
pub async fn get(State(pool): State<PgPool>, layout: Layout) -> Result<Response> {
|
||||||
let feeds = get_feeds(&pool).await?;
|
let feeds = get_feeds(&pool).await?;
|
||||||
Ok(layout.render(html! {
|
Ok(layout.render(html! {
|
||||||
ul {
|
h2 { "Feeds" }
|
||||||
|
div class="feeds" {
|
||||||
|
ul id="feeds" {
|
||||||
@for feed in feeds {
|
@for feed in feeds {
|
||||||
@let title = feed.title.unwrap_or_else(|| "Untitled Feed".to_string());
|
li { (feed_link(&feed)) }
|
||||||
@let feed_url = format!("/feed/{}", Base62Uuid::from(feed.feed_id));
|
}
|
||||||
li { a href=(feed_url) { (title) } }
|
}
|
||||||
|
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";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
|
@ -6,6 +6,7 @@ pub mod log;
|
|||||||
pub mod models;
|
pub mod models;
|
||||||
pub mod partials;
|
pub mod partials;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
|
pub mod turbo_stream;
|
||||||
pub mod utils;
|
pub mod utils;
|
||||||
pub mod uuid;
|
pub mod uuid;
|
||||||
|
|
||||||
|
@ -55,6 +55,7 @@ async fn main() -> Result<()> {
|
|||||||
.route("/api/v1/entry/:id", get(handlers::api::entry::get))
|
.route("/api/v1/entry/:id", get(handlers::api::entry::get))
|
||||||
.route("/", get(handlers::home::get))
|
.route("/", get(handlers::home::get))
|
||||||
.route("/feeds", get(handlers::feeds::get))
|
.route("/feeds", get(handlers::feeds::get))
|
||||||
|
.route("/feed", post(handlers::feed::post))
|
||||||
.route("/feed/:id", get(handlers::feed::get))
|
.route("/feed/:id", get(handlers::feed::get))
|
||||||
.route("/entry/:id", get(handlers::entry::get))
|
.route("/entry/:id", get(handlers::entry::get))
|
||||||
.route("/log", get(handlers::log::get))
|
.route("/log", get(handlers::log::get))
|
||||||
|
@ -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)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct Feed {
|
pub struct Feed {
|
||||||
pub feed_id: Uuid,
|
pub feed_id: Uuid,
|
||||||
|
12
src/partials/feed_link.rs
Normal file
12
src/partials/feed_link.rs
Normal 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) }
|
||||||
|
}
|
||||||
|
}
|
@ -1,3 +1,4 @@
|
|||||||
pub mod entry_list;
|
pub mod entry_list;
|
||||||
|
pub mod feed_link;
|
||||||
pub mod header;
|
pub mod header;
|
||||||
pub mod layout;
|
pub mod layout;
|
||||||
|
34
src/turbo_stream.rs
Normal file
34
src/turbo_stream.rs
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user