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 743 additions and 5 deletions
Showing only changes of commit d40c90552c - Show all commits

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,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
.title
.as_ref()
.map(|t| t.content.clone())
.filter(|t| !t.trim().is_empty())
.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

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,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
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 {
class: "subscribe-form",
onsubmit: move |ev| {
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);

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

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,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> {
let url = url.trim();
anyhow::ensure!(!url.is_empty(), "feed URL is required");
// Register under a placeholder title first so the feed exists even if
// 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(),