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"
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]]
name = "as-any"
version = "0.3.2"
@ -267,6 +276,12 @@ version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "by_address"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06"
[[package]]
name = "byteorder"
version = "1.5.0"
@ -822,6 +837,17 @@ dependencies = [
"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]]
name = "dioxus-cli-config"
version = "0.7.10"
@ -1169,6 +1195,22 @@ dependencies = [
"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]]
name = "dioxus-router"
version = "0.7.10"
@ -1218,6 +1260,18 @@ dependencies = [
"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]]
name = "dioxus-server"
version = "0.7.10"
@ -1568,6 +1622,7 @@ dependencies = [
"anyhow",
"chrono",
"dioxus",
"dioxus-primitives",
"feedsignal-core",
"feedsignal-db",
"feedsignal-feeds",
@ -2660,6 +2715,15 @@ dependencies = [
"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]]
name = "num-traits"
version = "0.2.19"
@ -2691,6 +2755,15 @@ dependencies = [
"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]]
name = "objc2"
version = "0.6.4"
@ -2727,6 +2800,36 @@ dependencies = [
"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]]
name = "parking_lot"
version = "0.12.5"
@ -2756,6 +2859,24 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "pin-project"
version = "1.1.13"
@ -3872,7 +3993,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
dependencies = [
"deranged",
"libc",
"num-conv",
"num_threads",
"powerfmt",
"serde_core",
"time-core",

View file

@ -3,12 +3,20 @@ use chrono::{DateTime, Utc};
use feedsignal_core::Article;
use uuid::Uuid;
/// Fetches and parses one RSS/Atom feed. `feed-rs` normalizes both formats
/// into a common model so callers don't need to branch on feed type.
pub async fn fetch_feed(feed_url: &str, feed_id: Uuid) -> Result<Vec<Article>> {
/// Fetches and parses one RSS/Atom feed, returning its title alongside the
/// entries. `feed-rs` normalizes both RSS and Atom into a common model so
/// 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 parsed = feed_rs::parser::parse(&bytes[..])?;
let title = parsed
schaefera marked this conversation as resolved Outdated

Rename to feed_title for clarity of purpose

Rename to `feed_title` for clarity of purpose
.title
.as_ref()
.map(|t| t.content.clone())
.filter(|t| !t.trim().is_empty())
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.
.unwrap_or_else(|| feed_url.to_string());
let articles = parsed
.entries
.into_iter()
@ -39,7 +47,7 @@ pub async fn fetch_feed(feed_url: &str, feed_id: Uuid) -> Result<Vec<Article>> {
})
.collect();
Ok(articles)
Ok((title, articles))
}
/// 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 }
serde_json = { workspace = true, 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]
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; }
.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; }
.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 serde::{Deserialize, Serialize};
@ -16,12 +18,14 @@ pub struct ArticleView {
#[component]
pub fn App() -> Element {
let articles = use_server_future(list_ranked_articles)?;
let mut articles = use_server_future(list_ranked_articles)?;
rsx! {
Stylesheet { href: asset!("/assets/app.css") }
Stylesheet { href: asset!("/assets/dx-components-theme.css") }
main {
h1 { "feedsignal" }
SubscribeForm { on_subscribed: move |_| { articles.restart(); } }
match articles.read().as_ref() {
Some(Ok(articles)) => rsx! {
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| {
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
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]
fn ArticleRow(article: ArticleView) -> Element {
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 components;
#[cfg(feature = "server")]
mod server;

View file

@ -68,6 +68,28 @@ pub async fn list_ranked_articles_impl(db: Db) -> Result<Vec<ArticleView>> {
.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

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

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

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<()> {
let event = ReadingEvent {
id: Uuid::new_v4(),