Add manual feed subscription #3
15 changed files with 743 additions and 5 deletions
123
Cargo.lock
generated
123
Cargo.lock
generated
|
|
@ -32,6 +32,15 @@ version = "1.0.104"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "approx"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6"
|
||||||
|
dependencies = [
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "as-any"
|
name = "as-any"
|
||||||
version = "0.3.2"
|
version = "0.3.2"
|
||||||
|
|
@ -267,6 +276,12 @@ version = "3.20.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "by_address"
|
||||||
|
version = "1.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "byteorder"
|
name = "byteorder"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
|
|
@ -822,6 +837,17 @@ dependencies = [
|
||||||
"web-sys",
|
"web-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dioxus-attributes"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "git+https://github.com/DioxusLabs/components#bf007c15d0cf4d04d3181cc46cf12325aa773955"
|
||||||
|
dependencies = [
|
||||||
|
"dioxus-rsx",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dioxus-cli-config"
|
name = "dioxus-cli-config"
|
||||||
version = "0.7.10"
|
version = "0.7.10"
|
||||||
|
|
@ -1169,6 +1195,22 @@ dependencies = [
|
||||||
"tracing-wasm",
|
"tracing-wasm",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dioxus-primitives"
|
||||||
|
version = "0.0.1"
|
||||||
|
source = "git+https://github.com/DioxusLabs/components#bf007c15d0cf4d04d3181cc46cf12325aa773955"
|
||||||
|
dependencies = [
|
||||||
|
"dioxus",
|
||||||
|
"dioxus-attributes",
|
||||||
|
"dioxus-sdk-time",
|
||||||
|
"lazy-js-bundle",
|
||||||
|
"num-integer",
|
||||||
|
"palette",
|
||||||
|
"serde",
|
||||||
|
"time",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dioxus-router"
|
name = "dioxus-router"
|
||||||
version = "0.7.10"
|
version = "0.7.10"
|
||||||
|
|
@ -1218,6 +1260,18 @@ dependencies = [
|
||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dioxus-sdk-time"
|
||||||
|
version = "0.7.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "80c25ae93a3f72e734873b97fbd09d9b1b6adff97205fb0ffd8543e3564fb78e"
|
||||||
|
dependencies = [
|
||||||
|
"dioxus",
|
||||||
|
"futures",
|
||||||
|
"gloo-timers 0.3.0",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dioxus-server"
|
name = "dioxus-server"
|
||||||
version = "0.7.10"
|
version = "0.7.10"
|
||||||
|
|
@ -1568,6 +1622,7 @@ dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
"dioxus",
|
"dioxus",
|
||||||
|
"dioxus-primitives",
|
||||||
"feedsignal-core",
|
"feedsignal-core",
|
||||||
"feedsignal-db",
|
"feedsignal-db",
|
||||||
"feedsignal-feeds",
|
"feedsignal-feeds",
|
||||||
|
|
@ -2660,6 +2715,15 @@ dependencies = [
|
||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-integer"
|
||||||
|
version = "0.1.47"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
|
||||||
|
dependencies = [
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-traits"
|
name = "num-traits"
|
||||||
version = "0.2.19"
|
version = "0.2.19"
|
||||||
|
|
@ -2691,6 +2755,15 @@ dependencies = [
|
||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num_threads"
|
||||||
|
version = "0.1.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2"
|
name = "objc2"
|
||||||
version = "0.6.4"
|
version = "0.6.4"
|
||||||
|
|
@ -2727,6 +2800,36 @@ dependencies = [
|
||||||
"num-traits",
|
"num-traits",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "palette"
|
||||||
|
version = "0.7.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ddeed8580d347d2abf3dcf06a5f0b3dc020258338526b277847cd4248a70fc64"
|
||||||
|
dependencies = [
|
||||||
|
"approx",
|
||||||
|
"palette_derive",
|
||||||
|
"palette_math",
|
||||||
|
"phf",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "palette_derive"
|
||||||
|
version = "0.7.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "88537020289b719d81be994ccf1bbf4990f477e2f69ee52fe3e45f43a02e56be"
|
||||||
|
dependencies = [
|
||||||
|
"by_address",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "palette_math"
|
||||||
|
version = "0.7.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6e6eb142958d64335fb0e345c5b9ead2ecd6fc438c307e9d7d3c4fd428dbaf12"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "parking_lot"
|
name = "parking_lot"
|
||||||
version = "0.12.5"
|
version = "0.12.5"
|
||||||
|
|
@ -2756,6 +2859,24 @@ version = "2.3.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "phf"
|
||||||
|
version = "0.13.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
|
||||||
|
dependencies = [
|
||||||
|
"phf_shared",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "phf_shared"
|
||||||
|
version = "0.13.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
|
||||||
|
dependencies = [
|
||||||
|
"siphasher",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pin-project"
|
name = "pin-project"
|
||||||
version = "1.1.13"
|
version = "1.1.13"
|
||||||
|
|
@ -3872,7 +3993,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
|
checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"deranged",
|
"deranged",
|
||||||
|
"libc",
|
||||||
"num-conv",
|
"num-conv",
|
||||||
|
"num_threads",
|
||||||
"powerfmt",
|
"powerfmt",
|
||||||
"serde_core",
|
"serde_core",
|
||||||
"time-core",
|
"time-core",
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,20 @@ use chrono::{DateTime, Utc};
|
||||||
use feedsignal_core::Article;
|
use feedsignal_core::Article;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
/// Fetches and parses one RSS/Atom feed. `feed-rs` normalizes both formats
|
/// Fetches and parses one RSS/Atom feed, returning its title alongside the
|
||||||
/// into a common model so callers don't need to branch on feed type.
|
/// entries. `feed-rs` normalizes both RSS and Atom into a common model so
|
||||||
pub async fn fetch_feed(feed_url: &str, feed_id: Uuid) -> Result<Vec<Article>> {
|
/// callers don't need to branch on feed type.
|
||||||
|
pub async fn fetch_feed(feed_url: &str, feed_id: Uuid) -> Result<(String, Vec<Article>)> {
|
||||||
let bytes = reqwest::get(feed_url).await?.bytes().await?;
|
let bytes = reqwest::get(feed_url).await?.bytes().await?;
|
||||||
let parsed = feed_rs::parser::parse(&bytes[..])?;
|
let parsed = feed_rs::parser::parse(&bytes[..])?;
|
||||||
|
|
||||||
|
let title = parsed
|
||||||
|
schaefera marked this conversation as resolved
Outdated
|
|||||||
|
.title
|
||||||
|
.as_ref()
|
||||||
|
.map(|t| t.content.clone())
|
||||||
|
.filter(|t| !t.trim().is_empty())
|
||||||
|
schaefera marked this conversation as resolved
Outdated
schaefera
commented
Any way to invert this logic so the check is positive instead of negative? Would help grok it faster. Not major either way. Any way to invert this logic so the check is positive instead of negative? Would help grok it faster. Not major either way.
|
|||||||
|
.unwrap_or_else(|| feed_url.to_string());
|
||||||
|
|
||||||
let articles = parsed
|
let articles = parsed
|
||||||
.entries
|
.entries
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
|
@ -39,7 +47,7 @@ pub async fn fetch_feed(feed_url: &str, feed_id: Uuid) -> Result<Vec<Article>> {
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
Ok(articles)
|
Ok((title, articles))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rough estimate from word count at ~200 wpm, used as the denominator for
|
/// Rough estimate from word count at ~200 wpm, used as the denominator for
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ uuid = { workspace = true, optional = true }
|
||||||
chrono = { workspace = true, optional = true }
|
chrono = { workspace = true, optional = true }
|
||||||
serde_json = { workspace = true, optional = true }
|
serde_json = { workspace = true, optional = true }
|
||||||
tokio-cron-scheduler = { version = "0.13", optional = true }
|
tokio-cron-scheduler = { version = "0.13", optional = true }
|
||||||
|
dioxus-primitives = { git = "https://github.com/DioxusLabs/components", version = "0.0.1", default-features = false }
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["web"]
|
default = ["web"]
|
||||||
|
|
|
||||||
|
|
@ -6,3 +6,8 @@ body { font-family: system-ui, sans-serif; max-width: 720px; margin: 2rem auto;
|
||||||
.summary { color: #444; margin: 0.4rem 0; }
|
.summary { color: #444; margin: 0.4rem 0; }
|
||||||
.topic-tag { display: inline-block; font-size: 0.75rem; background: #eee; border-radius: 4px; padding: 0.1rem 0.4rem; margin-right: 0.3rem; }
|
.topic-tag { display: inline-block; font-size: 0.75rem; background: #eee; border-radius: 4px; padding: 0.1rem 0.4rem; margin-right: 0.3rem; }
|
||||||
.error { color: #b00020; }
|
.error { color: #b00020; }
|
||||||
|
.subscribe-form { display: flex; gap: 0.5rem; margin: 1rem 0; }
|
||||||
|
.subscribe-form .dx-input { flex: 1; }
|
||||||
|
.subscribe-status { margin: 0.25rem 0 1rem; font-size: 0.9rem; }
|
||||||
|
.subscribe-status.success { color: #1a7f37; }
|
||||||
|
.subscribe-status.error { color: #b00020; }
|
||||||
|
|
|
||||||
70
crates/web/assets/dx-components-theme.css
Normal file
70
crates/web/assets/dx-components-theme.css
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
/* This file contains the theme variables for the styled dioxus-components components. You only
|
||||||
|
* need to import this file once in your project root.
|
||||||
|
*/
|
||||||
|
|
||||||
|
html[data-theme="dark"] {
|
||||||
|
--dark: initial;
|
||||||
|
--light: ;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="light"] {
|
||||||
|
--dark: ;
|
||||||
|
--light: initial;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="dark"] [class~="dxc-system"] {
|
||||||
|
--dxc-light-on: ;
|
||||||
|
--dxc-dark-on: initial;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="light"] [class~="dxc-system"] {
|
||||||
|
--dxc-light-on: initial;
|
||||||
|
--dxc-dark-on: ;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--dark: initial;
|
||||||
|
--light: ;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
:root {
|
||||||
|
--dark: ;
|
||||||
|
--light: initial;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* Primary colors */
|
||||||
|
--primary-color: var(--dark, #000) var(--light, #fff);
|
||||||
|
--primary-color-1: var(--dark, #0e0e0e) var(--light, #fbfbfb);
|
||||||
|
--primary-color-2: var(--dark, #0a0a0a) var(--light, #fff);
|
||||||
|
--primary-color-3: var(--dark, #141313) var(--light, #f8f8f8);
|
||||||
|
--primary-color-4: var(--dark, #1a1a1a) var(--light, #f8f8f8);
|
||||||
|
--primary-color-5: var(--dark, #262626) var(--light, #f5f5f5);
|
||||||
|
--primary-color-6: var(--dark, #232323) var(--light, #e5e5e5);
|
||||||
|
--primary-color-7: var(--dark, #3e3e3e) var(--light, #b0b0b0);
|
||||||
|
|
||||||
|
/* Secondary colors */
|
||||||
|
--secondary-color: var(--dark, #fff) var(--light, #000);
|
||||||
|
--secondary-color-1: var(--dark, #fafafa) var(--light, #000);
|
||||||
|
--secondary-color-2: var(--dark, #e6e6e6) var(--light, #0d0d0d);
|
||||||
|
--secondary-color-3: var(--dark, #dcdcdc) var(--light, #2b2b2b);
|
||||||
|
--secondary-color-4: var(--dark, #d4d4d4) var(--light, #111);
|
||||||
|
--secondary-color-5: var(--dark, #a1a1a1) var(--light, #848484);
|
||||||
|
--secondary-color-6: var(--dark, #5d5d5d) var(--light, #d0d0d0);
|
||||||
|
|
||||||
|
/* Highlight colors */
|
||||||
|
--focused-border-color: var(--dark, #2b7fff) var(--light, #2b7fff);
|
||||||
|
--primary-success-color: var(--dark, #02271c) var(--light, #ecfdf5);
|
||||||
|
--secondary-success-color: var(--dark, #b6fae3) var(--light, #10b981);
|
||||||
|
--primary-warning-color: var(--dark, #342203) var(--light, #fffbeb);
|
||||||
|
--secondary-warning-color: var(--dark, #feeac7) var(--light, #f59e0b);
|
||||||
|
--primary-error-color: var(--dark, #a22e2e) var(--light, #dc2626);
|
||||||
|
--secondary-error-color: var(--dark, #9b1c1c) var(--light, #ef4444);
|
||||||
|
--contrast-error-color: var(--dark, var(--secondary-color-3)) var(--light, var(--primary-color));
|
||||||
|
--primary-info-color: var(--dark, var(--primary-color-5)) var(--light, var(--primary-color));
|
||||||
|
--secondary-info-color: var(--dark, var(--primary-color-7)) var(--light, var(--secondary-color-3));
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
use crate::components::button::{Button, ButtonVariant};
|
||||||
|
use crate::components::input::Input;
|
||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
|
@ -16,12 +18,14 @@ pub struct ArticleView {
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn App() -> Element {
|
pub fn App() -> Element {
|
||||||
let articles = use_server_future(list_ranked_articles)?;
|
let mut articles = use_server_future(list_ranked_articles)?;
|
||||||
|
|
||||||
rsx! {
|
rsx! {
|
||||||
Stylesheet { href: asset!("/assets/app.css") }
|
Stylesheet { href: asset!("/assets/app.css") }
|
||||||
|
Stylesheet { href: asset!("/assets/dx-components-theme.css") }
|
||||||
main {
|
main {
|
||||||
h1 { "feedsignal" }
|
h1 { "feedsignal" }
|
||||||
|
SubscribeForm { on_subscribed: move |_| { articles.restart(); } }
|
||||||
match articles.read().as_ref() {
|
match articles.read().as_ref() {
|
||||||
Some(Ok(articles)) => rsx! {
|
Some(Ok(articles)) => rsx! {
|
||||||
ul { class: "article-list",
|
ul { class: "article-list",
|
||||||
|
|
@ -37,6 +41,80 @@ pub fn App() -> Element {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Form for manually subscribing to a feed by URL. Fetches the feed
|
||||||
|
/// immediately on submit (rather than waiting for the next scheduled poll)
|
||||||
|
/// so the reader isn't empty right after subscribing.
|
||||||
|
#[component]
|
||||||
|
fn SubscribeForm(on_subscribed: EventHandler<()>) -> Element {
|
||||||
|
let mut url = use_signal(String::new);
|
||||||
|
let mut status = use_signal(|| Option::<Result<String, String>>::None);
|
||||||
|
let mut submitting = use_signal(|| false);
|
||||||
|
|
||||||
|
let submit = move |_| {
|
||||||
|
let feed_url = url.read().clone();
|
||||||
|
if feed_url.trim().is_empty() {
|
||||||
|
schaefera marked this conversation as resolved
schaefera
commented
Good guard, would be also nice if the submit button wasn't clickable if the feed_url text box was empty Good guard, would be also nice if the submit button wasn't clickable if the feed_url text box was empty
|
|||||||
|
return;
|
||||||
|
}
|
||||||
|
spawn(async move {
|
||||||
|
submitting.set(true);
|
||||||
|
status.set(None);
|
||||||
|
match subscribe_feed(feed_url).await {
|
||||||
|
Ok(count) => {
|
||||||
|
status.set(Some(Ok(format!("Subscribed — pulled in {count} article(s)."))));
|
||||||
|
url.set(String::new());
|
||||||
|
on_subscribed.call(());
|
||||||
|
}
|
||||||
|
Err(err) => status.set(Some(Err(err.to_string()))),
|
||||||
|
}
|
||||||
|
submitting.set(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
form {
|
||||||
|
class: "subscribe-form",
|
||||||
|
onsubmit: move |ev| {
|
||||||
|
schaefera marked this conversation as resolved
Outdated
schaefera
commented
Would indicate ev's type here for clarity as well Would indicate ev's type here for clarity as well
|
|||||||
|
ev.prevent_default();
|
||||||
|
submit(());
|
||||||
|
},
|
||||||
|
Input {
|
||||||
|
r#type: "url",
|
||||||
|
placeholder: "https://example.com/feed.xml",
|
||||||
|
value: "{url}",
|
||||||
|
required: true,
|
||||||
|
disabled: submitting(),
|
||||||
|
oninput: move |ev: FormEvent| url.set(ev.value()),
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
r#type: "submit",
|
||||||
|
variant: ButtonVariant::Primary,
|
||||||
|
disabled: submitting(),
|
||||||
|
if submitting() {
|
||||||
|
"Subscribing..."
|
||||||
|
} else {
|
||||||
|
"Subscribe"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match status.read().as_ref() {
|
||||||
|
Some(Ok(msg)) => rsx! { p { class: "subscribe-status success", "{msg}" } },
|
||||||
|
Some(Err(err)) => rsx! { p { class: "subscribe-status error", "Failed to subscribe: {err}" } },
|
||||||
|
None => rsx! {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Registers a feed and fetches it immediately. Runs on the server (native,
|
||||||
|
/// has DB + network access); the `#[server]` macro generates the HTTP call
|
||||||
|
/// the browser/WASM build uses instead.
|
||||||
|
#[server]
|
||||||
|
async fn subscribe_feed(url: String) -> Result<usize, ServerFnError> {
|
||||||
|
let db = crate::server::db().await?;
|
||||||
|
crate::server::subscribe_feed_impl(db, url)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
fn ArticleRow(article: ArticleView) -> Element {
|
fn ArticleRow(article: ArticleView) -> Element {
|
||||||
let score_pct = article.final_score.map(|s| (s * 100.0).round() as i32);
|
let score_pct = article.final_score.map(|s| (s * 100.0).round() as i32);
|
||||||
|
|
|
||||||
108
crates/web/src/components/button/component.rs
Normal file
108
crates/web/src/components/button/component.rs
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
use dioxus::prelude::*;
|
||||||
|
use dioxus_primitives::dioxus_attributes::attributes;
|
||||||
|
use dioxus_primitives::merge_attributes;
|
||||||
|
|
||||||
|
#[css_module("/src/components/button/style.css")]
|
||||||
|
struct Styles;
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, PartialEq, Default)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum ButtonVariant {
|
||||||
|
#[default]
|
||||||
|
Primary,
|
||||||
|
Secondary,
|
||||||
|
Destructive,
|
||||||
|
Outline,
|
||||||
|
Ghost,
|
||||||
|
Link,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ButtonVariant {
|
||||||
|
pub fn class(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
ButtonVariant::Primary => "primary",
|
||||||
|
ButtonVariant::Secondary => "secondary",
|
||||||
|
ButtonVariant::Destructive => "destructive",
|
||||||
|
ButtonVariant::Outline => "outline",
|
||||||
|
ButtonVariant::Ghost => "ghost",
|
||||||
|
ButtonVariant::Link => "link",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, PartialEq, Default)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum ButtonSize {
|
||||||
|
Xs,
|
||||||
|
Sm,
|
||||||
|
#[default]
|
||||||
|
Default,
|
||||||
|
Lg,
|
||||||
|
Icon,
|
||||||
|
IconXs,
|
||||||
|
IconSm,
|
||||||
|
IconLg,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ButtonSize {
|
||||||
|
pub fn class(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
ButtonSize::Xs => "xs",
|
||||||
|
ButtonSize::Sm => "sm",
|
||||||
|
ButtonSize::Default => "default",
|
||||||
|
ButtonSize::Lg => "lg",
|
||||||
|
ButtonSize::Icon => "icon",
|
||||||
|
ButtonSize::IconXs => "icon-xs",
|
||||||
|
ButtonSize::IconSm => "icon-sm",
|
||||||
|
ButtonSize::IconLg => "icon-lg",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn Button(
|
||||||
|
#[props(default)] variant: ButtonVariant,
|
||||||
|
#[props(default)] size: ButtonSize,
|
||||||
|
#[props(extends=GlobalAttributes)]
|
||||||
|
#[props(extends=button)]
|
||||||
|
attributes: Vec<Attribute>,
|
||||||
|
onclick: Option<EventHandler<MouseEvent>>,
|
||||||
|
onmousedown: Option<EventHandler<MouseEvent>>,
|
||||||
|
onmouseup: Option<EventHandler<MouseEvent>>,
|
||||||
|
onkeydown: Option<EventHandler<KeyboardEvent>>,
|
||||||
|
children: Element,
|
||||||
|
) -> Element {
|
||||||
|
let base = attributes!(button {
|
||||||
|
class: Styles::dx_button,
|
||||||
|
"data-style": variant.class(),
|
||||||
|
"data-size": size.class(),
|
||||||
|
});
|
||||||
|
let merged = merge_attributes(vec![base, attributes]);
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
button {
|
||||||
|
onclick: move |event| {
|
||||||
|
if let Some(f) = &onclick {
|
||||||
|
f.call(event);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onmousedown: move |event| {
|
||||||
|
if let Some(f) = &onmousedown {
|
||||||
|
f.call(event);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onmouseup: move |event| {
|
||||||
|
if let Some(f) = &onmouseup {
|
||||||
|
f.call(event);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onkeydown: move |event| {
|
||||||
|
if let Some(f) = &onkeydown {
|
||||||
|
f.call(event);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
..merged,
|
||||||
|
{children}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2
crates/web/src/components/button/mod.rs
Normal file
2
crates/web/src/components/button/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
mod component;
|
||||||
|
pub use component::*;
|
||||||
220
crates/web/src/components/button/style.css
Normal file
220
crates/web/src/components/button/style.css
Normal file
|
|
@ -0,0 +1,220 @@
|
||||||
|
.dx-button {
|
||||||
|
display: inline-flex;
|
||||||
|
box-sizing: border-box;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
cursor: default;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
gap: 0.5rem;
|
||||||
|
line-height: 1.25rem;
|
||||||
|
outline: none;
|
||||||
|
transition-duration: 150ms;
|
||||||
|
transition-property: all;
|
||||||
|
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dx-button svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dx-button svg:not([class*="size-"]) {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dx-button[data-size="xs"] {
|
||||||
|
height: 1.5rem;
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
gap: 0.25rem;
|
||||||
|
line-height: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dx-button[data-size="xs"]:has(> svg) {
|
||||||
|
padding-inline: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dx-button[data-size="xs"] svg:not([class*="size-"]) {
|
||||||
|
width: 0.75rem;
|
||||||
|
height: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dx-button[data-size="sm"] {
|
||||||
|
height: 2rem;
|
||||||
|
padding: 0 0.75rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
gap: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dx-button[data-size="sm"]:has(> svg) {
|
||||||
|
padding-inline: 0.625rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dx-button[data-size="default"] {
|
||||||
|
height: 2.25rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dx-button[data-size="default"]:has(> svg) {
|
||||||
|
padding-inline: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dx-button[data-size="lg"] {
|
||||||
|
height: 2.5rem;
|
||||||
|
padding: 0 1.5rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dx-button[data-size="lg"]:has(> svg) {
|
||||||
|
padding-inline: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dx-button[data-size="icon"] {
|
||||||
|
width: 2.25rem;
|
||||||
|
height: 2.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dx-button[data-size="icon"] svg:not([class*="size-"]) {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dx-button[data-size="icon-xs"] {
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dx-button[data-size="icon-xs"] svg:not([class*="size-"]) {
|
||||||
|
width: 0.75rem;
|
||||||
|
height: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dx-button[data-size="icon-sm"] {
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dx-button[data-size="icon-sm"] svg:not([class*="size-"]) {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dx-button[data-size="icon-lg"] {
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dx-button[data-size="icon-lg"] svg:not([class*="size-"]) {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dx-button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dx-button[data-style="primary"] {
|
||||||
|
background-color: var(--secondary-color-2);
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dx-button[data-style="primary"]:not(:disabled):hover {
|
||||||
|
background-color: color-mix(
|
||||||
|
in oklab,
|
||||||
|
var(--secondary-color-2) 90%,
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dx-button[data-style="secondary"] {
|
||||||
|
background-color: var(--primary-color-5);
|
||||||
|
color: var(--secondary-color-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dx-button[data-style="secondary"]:not(:disabled):hover {
|
||||||
|
background-color: color-mix(in oklab, var(--primary-color-5) 80%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dx-button[data-style="ghost"] {
|
||||||
|
background-color: transparent;
|
||||||
|
color: var(--secondary-color-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dx-button[data-style="ghost"]:not(:disabled):hover {
|
||||||
|
background-color: var(--light, var(--primary-color-5))
|
||||||
|
var(--dark, color-mix(in oklab, var(--primary-color-5) 50%, transparent));
|
||||||
|
color: var(--secondary-color-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dx-button[data-style="outline"] {
|
||||||
|
border: 1px solid var(--light, var(--primary-color-6))
|
||||||
|
var(--dark, var(--primary-color-7));
|
||||||
|
background-color: var(--light, var(--primary-color))
|
||||||
|
var(--dark, color-mix(in oklab, var(--primary-color-7) 30%, transparent));
|
||||||
|
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 5%);
|
||||||
|
color: var(--secondary-color-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dx-button[data-style="outline"]:not(:disabled):hover {
|
||||||
|
background-color: var(--light, var(--primary-color-5))
|
||||||
|
var(--dark, color-mix(in oklab, var(--primary-color-7) 50%, transparent));
|
||||||
|
color: var(--secondary-color-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dx-button[data-style="destructive"] {
|
||||||
|
background-color: var(--light, var(--primary-error-color))
|
||||||
|
var(--dark, color-mix(in oklab, var(--primary-error-color) 60%, transparent));
|
||||||
|
color: var(--contrast-error-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dx-button[data-style="destructive"]:not(:disabled):hover {
|
||||||
|
background-color: color-mix(
|
||||||
|
in oklab,
|
||||||
|
var(--primary-error-color) 90%,
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dx-button[data-style="destructive"]:focus-visible {
|
||||||
|
box-shadow: 0 0 0 3px
|
||||||
|
var(--light, color-mix(in oklab, var(--primary-error-color) 20%, transparent))
|
||||||
|
var(--dark, color-mix(in oklab, var(--primary-error-color) 40%, transparent));
|
||||||
|
}
|
||||||
|
|
||||||
|
.dx-button[data-style="link"] {
|
||||||
|
background-color: transparent;
|
||||||
|
color: var(--secondary-color-2);
|
||||||
|
text-decoration: none;
|
||||||
|
text-underline-offset: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dx-button[data-style="link"]:not(:disabled):hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dx-button:focus-visible {
|
||||||
|
border-color: var(--focused-border-color);
|
||||||
|
box-shadow: 0 0 0 3px
|
||||||
|
color-mix(in oklab, var(--focused-border-color) 50%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dx-button[aria-invalid="true"] {
|
||||||
|
border-color: var(--primary-error-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dx-button[aria-invalid="true"]:focus-visible {
|
||||||
|
box-shadow: 0 0 0 3px
|
||||||
|
var(--light, color-mix(in oklab, var(--primary-error-color) 20%, transparent))
|
||||||
|
var(--dark, color-mix(in oklab, var(--primary-error-color) 40%, transparent));
|
||||||
|
}
|
||||||
57
crates/web/src/components/input/component.rs
Normal file
57
crates/web/src/components/input/component.rs
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
use dioxus::prelude::*;
|
||||||
|
#[css_module("/src/components/input/style.css")]
|
||||||
|
struct Styles;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn Input(
|
||||||
|
oninput: Option<EventHandler<FormEvent>>,
|
||||||
|
onchange: Option<EventHandler<FormEvent>>,
|
||||||
|
oninvalid: Option<EventHandler<FormEvent>>,
|
||||||
|
onselect: Option<EventHandler<SelectionEvent>>,
|
||||||
|
onselectionchange: Option<EventHandler<SelectionEvent>>,
|
||||||
|
onfocus: Option<EventHandler<FocusEvent>>,
|
||||||
|
onblur: Option<EventHandler<FocusEvent>>,
|
||||||
|
onfocusin: Option<EventHandler<FocusEvent>>,
|
||||||
|
onfocusout: Option<EventHandler<FocusEvent>>,
|
||||||
|
onkeydown: Option<EventHandler<KeyboardEvent>>,
|
||||||
|
onkeypress: Option<EventHandler<KeyboardEvent>>,
|
||||||
|
onkeyup: Option<EventHandler<KeyboardEvent>>,
|
||||||
|
onwheel: Option<EventHandler<WheelEvent>>,
|
||||||
|
oncompositionstart: Option<EventHandler<CompositionEvent>>,
|
||||||
|
oncompositionupdate: Option<EventHandler<CompositionEvent>>,
|
||||||
|
oncompositionend: Option<EventHandler<CompositionEvent>>,
|
||||||
|
oncopy: Option<EventHandler<ClipboardEvent>>,
|
||||||
|
oncut: Option<EventHandler<ClipboardEvent>>,
|
||||||
|
onpaste: Option<EventHandler<ClipboardEvent>>,
|
||||||
|
#[props(extends=GlobalAttributes)]
|
||||||
|
#[props(extends=input)]
|
||||||
|
attributes: Vec<Attribute>,
|
||||||
|
children: Element,
|
||||||
|
) -> Element {
|
||||||
|
rsx! {
|
||||||
|
input {
|
||||||
|
class: Styles::dx_input,
|
||||||
|
oninput: move |e| _ = oninput.map(|callback| callback(e)),
|
||||||
|
onchange: move |e| _ = onchange.map(|callback| callback(e)),
|
||||||
|
oninvalid: move |e| _ = oninvalid.map(|callback| callback(e)),
|
||||||
|
onselect: move |e| _ = onselect.map(|callback| callback(e)),
|
||||||
|
onselectionchange: move |e| _ = onselectionchange.map(|callback| callback(e)),
|
||||||
|
onfocus: move |e| _ = onfocus.map(|callback| callback(e)),
|
||||||
|
onblur: move |e| _ = onblur.map(|callback| callback(e)),
|
||||||
|
onfocusin: move |e| _ = onfocusin.map(|callback| callback(e)),
|
||||||
|
onfocusout: move |e| _ = onfocusout.map(|callback| callback(e)),
|
||||||
|
onkeydown: move |e| _ = onkeydown.map(|callback| callback(e)),
|
||||||
|
onkeypress: move |e| _ = onkeypress.map(|callback| callback(e)),
|
||||||
|
onkeyup: move |e| _ = onkeyup.map(|callback| callback(e)),
|
||||||
|
onwheel: move |e| _ = onwheel.map(|callback| callback(e)),
|
||||||
|
oncompositionstart: move |e| _ = oncompositionstart.map(|callback| callback(e)),
|
||||||
|
oncompositionupdate: move |e| _ = oncompositionupdate.map(|callback| callback(e)),
|
||||||
|
oncompositionend: move |e| _ = oncompositionend.map(|callback| callback(e)),
|
||||||
|
oncopy: move |e| _ = oncopy.map(|callback| callback(e)),
|
||||||
|
oncut: move |e| _ = oncut.map(|callback| callback(e)),
|
||||||
|
onpaste: move |e| _ = onpaste.map(|callback| callback(e)),
|
||||||
|
..attributes,
|
||||||
|
{children}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2
crates/web/src/components/input/mod.rs
Normal file
2
crates/web/src/components/input/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
mod component;
|
||||||
|
pub use component::*;
|
||||||
38
crates/web/src/components/input/style.css
Normal file
38
crates/web/src/components/input/style.css
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
/* Input Styles */
|
||||||
|
.dx-input {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
box-sizing: border-box;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.25rem;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border-radius: calc(0.5rem);
|
||||||
|
background: none;
|
||||||
|
background-color: var(--light, var(--primary-color)) var(--dark, color-mix(in oklab, #FFFFFF26 30%, transparent));
|
||||||
|
box-shadow: inset 0 0 0 1px var(--light, var(--primary-color-6))
|
||||||
|
var(--dark, var(--primary-color-7));
|
||||||
|
color: var(--secondary-color-4);
|
||||||
|
gap: 0.25rem;
|
||||||
|
transition: background-color 100ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dx-input::placeholder {
|
||||||
|
color: var(--secondary-color-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dx-input:disabled {
|
||||||
|
color: var(--secondary-color-5);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dx-input:hover:not(:disabled),
|
||||||
|
.dx-input:focus-visible {
|
||||||
|
background: var(--light, var(--primary-color-4))
|
||||||
|
var(--dark, color-mix(in oklab, #FFFFFF26 50%, transparent));
|
||||||
|
color: var(--secondary-color-1);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
3
crates/web/src/components/mod.rs
Normal file
3
crates/web/src/components/mod.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
// AUTOGENERATED Components module
|
||||||
|
pub mod button;
|
||||||
|
pub mod input;
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
mod app;
|
mod app;
|
||||||
|
mod components;
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
mod server;
|
mod server;
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,28 @@ pub async fn list_ranked_articles_impl(db: Db) -> Result<Vec<ArticleView>> {
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Subscribes to a feed by URL: registers it (or updates its title if
|
||||||
|
/// already subscribed) and does an immediate first fetch so the reader
|
||||||
|
/// isn't empty until the next scheduled poll. Returns the number of
|
||||||
|
/// articles pulled in on this fetch.
|
||||||
|
pub async fn subscribe_feed_impl(db: Db, url: String) -> Result<usize> {
|
||||||
|
schaefera marked this conversation as resolved
Outdated
schaefera
commented
This feels pseudo duplicated compared to some of the earlier code. What is the reason for both subscription-centric methods existing, just for my own understanding? This feels pseudo duplicated compared to some of the earlier code. What is the reason for both subscription-centric methods existing, just for my own understanding?
|
|||||||
|
let url = url.trim();
|
||||||
|
anyhow::ensure!(!url.is_empty(), "feed URL is required");
|
||||||
|
schaefera marked this conversation as resolved
Outdated
schaefera
commented
Wouldn't this make sense to have before the trim with a is_blank() style check? Aka check if it's not just pure whitespace? Wouldn't this make sense to have before the trim with a is_blank() style check? Aka check if it's not just pure whitespace?
|
|||||||
|
|
||||||
|
// Register under a placeholder title first so the feed exists even if
|
||||||
|
schaefera marked this conversation as resolved
Outdated
schaefera
commented
What if the url is correctly formed but otherwise points to nothing or a feed which no longer exists? Is this case handled? What if the url is correctly formed but otherwise points to nothing or a feed which no longer exists? Is this case handled?
|
|||||||
|
// the fetch below fails (e.g. transient network error) — it'll be
|
||||||
|
// picked up by a later poll once feed polling is wired in.
|
||||||
|
let feed_id = db.upsert_feed(url, url).await?;
|
||||||
|
let (title, articles) = feedsignal_feeds::fetch_feed(url, feed_id).await?;
|
||||||
|
db.upsert_feed(url, &title).await?;
|
||||||
|
|
||||||
|
let count = articles.len();
|
||||||
|
for article in &articles {
|
||||||
|
db.insert_article(article).await?;
|
||||||
|
}
|
||||||
|
Ok(count)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn mark_dismissed_impl(db: Db, article_id: String) -> Result<()> {
|
pub async fn mark_dismissed_impl(db: Db, article_id: String) -> Result<()> {
|
||||||
let event = ReadingEvent {
|
let event = ReadingEvent {
|
||||||
id: Uuid::new_v4(),
|
id: Uuid::new_v4(),
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue
Rename to
feed_titlefor clarity of purpose