diff --git a/Cargo.lock b/Cargo.lock index 77ae511..41a0123 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/crates/feeds/src/lib.rs b/crates/feeds/src/lib.rs index 0272e5c..cfb72d4 100644 --- a/crates/feeds/src/lib.rs +++ b/crates/feeds/src/lib.rs @@ -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> { +/// 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
)> { let bytes = reqwest::get(feed_url).await?.bytes().await?; 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 .entries .into_iter() @@ -39,7 +47,7 @@ pub async fn fetch_feed(feed_url: &str, feed_id: Uuid) -> Result> { }) .collect(); - Ok(articles) + Ok((title, articles)) } /// Rough estimate from word count at ~200 wpm, used as the denominator for diff --git a/crates/web/Cargo.toml b/crates/web/Cargo.toml index a885fae..0c319eb 100644 --- a/crates/web/Cargo.toml +++ b/crates/web/Cargo.toml @@ -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"] diff --git a/crates/web/assets/app.css b/crates/web/assets/app.css index 6a7a308..ca7e7c7 100644 --- a/crates/web/assets/app.css +++ b/crates/web/assets/app.css @@ -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; } diff --git a/crates/web/assets/dx-components-theme.css b/crates/web/assets/dx-components-theme.css new file mode 100644 index 0000000..46b1aef --- /dev/null +++ b/crates/web/assets/dx-components-theme.css @@ -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)); +} diff --git a/crates/web/src/app.rs b/crates/web/src/app.rs index d6784e0..de3e831 100644 --- a/crates/web/src/app.rs +++ b/crates/web/src/app.rs @@ -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::>::None); + let mut submitting = use_signal(|| false); + + let submit = move |_| { + let feed_url = url.read().clone(); + if feed_url.trim().is_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 { + 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); diff --git a/crates/web/src/components/button/component.rs b/crates/web/src/components/button/component.rs new file mode 100644 index 0000000..bbf3b9c --- /dev/null +++ b/crates/web/src/components/button/component.rs @@ -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, + onclick: Option>, + onmousedown: Option>, + onmouseup: Option>, + onkeydown: Option>, + 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} + } + } +} diff --git a/crates/web/src/components/button/mod.rs b/crates/web/src/components/button/mod.rs new file mode 100644 index 0000000..9a8ae55 --- /dev/null +++ b/crates/web/src/components/button/mod.rs @@ -0,0 +1,2 @@ +mod component; +pub use component::*; \ No newline at end of file diff --git a/crates/web/src/components/button/style.css b/crates/web/src/components/button/style.css new file mode 100644 index 0000000..239aff3 --- /dev/null +++ b/crates/web/src/components/button/style.css @@ -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)); +} diff --git a/crates/web/src/components/input/component.rs b/crates/web/src/components/input/component.rs new file mode 100644 index 0000000..541c307 --- /dev/null +++ b/crates/web/src/components/input/component.rs @@ -0,0 +1,57 @@ +use dioxus::prelude::*; +#[css_module("/src/components/input/style.css")] +struct Styles; + +#[component] +pub fn Input( + oninput: Option>, + onchange: Option>, + oninvalid: Option>, + onselect: Option>, + onselectionchange: Option>, + onfocus: Option>, + onblur: Option>, + onfocusin: Option>, + onfocusout: Option>, + onkeydown: Option>, + onkeypress: Option>, + onkeyup: Option>, + onwheel: Option>, + oncompositionstart: Option>, + oncompositionupdate: Option>, + oncompositionend: Option>, + oncopy: Option>, + oncut: Option>, + onpaste: Option>, + #[props(extends=GlobalAttributes)] + #[props(extends=input)] + attributes: Vec, + 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} + } + } +} diff --git a/crates/web/src/components/input/mod.rs b/crates/web/src/components/input/mod.rs new file mode 100644 index 0000000..9a8ae55 --- /dev/null +++ b/crates/web/src/components/input/mod.rs @@ -0,0 +1,2 @@ +mod component; +pub use component::*; \ No newline at end of file diff --git a/crates/web/src/components/input/style.css b/crates/web/src/components/input/style.css new file mode 100644 index 0000000..0dd0753 --- /dev/null +++ b/crates/web/src/components/input/style.css @@ -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; +} diff --git a/crates/web/src/components/mod.rs b/crates/web/src/components/mod.rs new file mode 100644 index 0000000..546ad73 --- /dev/null +++ b/crates/web/src/components/mod.rs @@ -0,0 +1,3 @@ +// AUTOGENERATED Components module +pub mod button; +pub mod input; diff --git a/crates/web/src/main.rs b/crates/web/src/main.rs index 0c78a67..b0ed95d 100644 --- a/crates/web/src/main.rs +++ b/crates/web/src/main.rs @@ -1,4 +1,5 @@ mod app; +mod components; #[cfg(feature = "server")] mod server; diff --git a/crates/web/src/server.rs b/crates/web/src/server.rs index 86dcf89..9fcdd31 100644 --- a/crates/web/src/server.rs +++ b/crates/web/src/server.rs @@ -68,6 +68,28 @@ pub async fn list_ranked_articles_impl(db: Db) -> Result> { .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 { + 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<()> { let event = ReadingEvent { id: Uuid::new_v4(),