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"
|
||||
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"] }
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
@ -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: " }
|
||||
|
@ -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())
|
||||
}
|
||||
|
@ -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<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";
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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))
|
||||
|
@ -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
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 feed_link;
|
||||
pub mod header;
|
||||
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