Add manual feed subscription #3

Merged
claude-bot merged 4 commits from worktree-manual-feed-subscribe into main 2026-08-21 14:54:28 +00:00
15 changed files with 794 additions and 6 deletions

123
Cargo.lock generated
View file

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

View file

@ -3,12 +3,24 @@ 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.
let bytes = reqwest::get(feed_url).await?.bytes().await?; pub async fn fetch_feed(feed_url: &str, feed_id: Uuid) -> Result<(String, Vec<Article>)> {
// `.error_for_status()` turns a 404/5xx into a clear `Err` here rather
// than letting a non-feed error page fall through to a confusing
// feed-rs parse failure below.
let response = reqwest::get(feed_url).await?.error_for_status()?;
schaefera marked this conversation as resolved Outdated

Rename to feed_title for clarity of purpose

Rename to `feed_title` for clarity of purpose
let bytes = response.bytes().await?;
let parsed = feed_rs::parser::parse(&bytes[..])?; let parsed = feed_rs::parser::parse(&bytes[..])?;
let feed_title = parsed
schaefera marked this conversation as resolved Outdated

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.
.title
.as_ref()
.map(|t| t.content.clone())
.filter(|t| has_content(t))
.unwrap_or_else(|| feed_url.to_string());
let articles = parsed let articles = parsed
.entries .entries
.into_iter() .into_iter()
@ -39,7 +51,13 @@ pub async fn fetch_feed(feed_url: &str, feed_id: Uuid) -> Result<Vec<Article>> {
}) })
.collect(); .collect();
Ok(articles) Ok((feed_title, articles))
}
/// True if `s` has any non-whitespace content — used to reject a
/// present-but-blank feed `<title>` rather than surface it as-is.
fn has_content(s: &str) -> bool {
!s.trim().is_empty()
} }
/// 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
@ -51,3 +69,27 @@ fn estimate_read_seconds(text: &str) -> u32 {
let words = text.split_whitespace().count() as u32; let words = text.split_whitespace().count() as u32;
((words.max(1) as f32 / 200.0) * 60.0) as u32 ((words.max(1) as f32 / 200.0) * 60.0) as u32
} }
#[cfg(test)]
mod tests {
use super::*;
/// An empty feed title should be treated the same as a missing one.
#[test]
fn has_content_rejects_empty_string() {
assert!(!has_content(""));
schaefera marked this conversation as resolved
Review

Make three separate unit tests instead of one so we know which case breaks if a regression occurs.

Make three separate unit tests instead of one so we know which case breaks if a regression occurs.
}
/// A whitespace-only feed title should be treated the same as a
/// missing one, not surfaced as a blank-looking name in the UI.
#[test]
fn has_content_rejects_whitespace_only_string() {
assert!(!has_content(" \n\t"));
}
/// A real title is accepted as-is.
#[test]
fn has_content_accepts_non_blank_string() {
assert!(has_content("Rust Blog"));
}
}

View file

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

View file

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

View 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));
}

View file

@ -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,82 @@ 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
Review

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 {
schaefera marked this conversation as resolved Outdated

Would indicate ev's type here for clarity as well

Would indicate ev's type here for clarity as well
class: "subscribe-form",
onsubmit: move |ev: FormEvent| {
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() || url.read().trim().is_empty(),
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);

View file

@ -0,0 +1,114 @@
use dioxus::prelude::*;
use dioxus_primitives::dioxus_attributes::attributes;
use dioxus_primitives::merge_attributes;
#[css_module("/src/components/button/style.css")]
struct Styles;
// Variants beyond the ones this app currently uses are part of the
// component library's public API, not unused code — CI runs clippy with
// `-D warnings`, which would otherwise turn this dead_code lint into a
// build failure.
#[allow(dead_code)]
#[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",
}
}
}
#[allow(dead_code)]
#[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}
}
}
}

View file

@ -0,0 +1,2 @@
mod component;
pub use component::*;

View 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));
}

View 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}
}
}
}

View file

@ -0,0 +1,2 @@
mod component;
pub use component::*;

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

View file

@ -0,0 +1,3 @@
// AUTOGENERATED Components module
pub mod button;
pub mod input;

View file

@ -1,4 +1,5 @@
mod app; mod app;
mod components;
#[cfg(feature = "server")] #[cfg(feature = "server")]
mod server; mod server;

View file

@ -68,6 +68,36 @@ 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.
///
schaefera marked this conversation as resolved Outdated

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?
/// Fetches under a throwaway id before writing anything, so there's exactly
/// one write to the `feeds` table (`upsert_feed` is the single source of
schaefera marked this conversation as resolved Outdated

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?
/// truth for the real id — the existing one on a re-subscribe, a fresh one
/// otherwise) and no half-registered row left behind if the fetch fails
schaefera marked this conversation as resolved Outdated

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?
/// (e.g. the URL is well-formed but points at nothing, or the feed no
/// longer exists — `fetch_feed` surfaces both as an `Err` via
/// `error_for_status`/feed-rs parse failure, which becomes the error
/// message shown in the subscribe form).
pub async fn subscribe_feed_impl(db: Db, url: String) -> Result<usize> {
anyhow::ensure!(!url.trim().is_empty(), "feed URL is required");
let url = url.trim();
let (title, mut articles) = feedsignal_feeds::fetch_feed(url, Uuid::new_v4()).await?;
let feed_id = db.upsert_feed(url, &title).await?;
for article in &mut articles {
article.feed_id = feed_id;
}
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(),