Add manual feed subscription
Adds a URL input + Subscribe button (dx-components Input/Button) to the UI, wired to a new subscribe_feed server function that upserts the feed row and does an immediate first fetch so the reader isn't empty until the next scheduled poll. feedsignal_feeds::fetch_feed now also returns the feed's title, used to replace the URL placeholder once fetched. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XJJQb2DWZZwQ1yPiaAQQoY
This commit is contained in:
parent
afe3df1401
commit
d40c90552c
15 changed files with 743 additions and 5 deletions
123
Cargo.lock
generated
123
Cargo.lock
generated
|
|
@ -32,6 +32,15 @@ version = "1.0.104"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
.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<Vec<Article>> {
|
|||
})
|
||||
.collect();
|
||||
|
||||
Ok(articles)
|
||||
Ok((title, articles))
|
||||
}
|
||||
|
||||
/// Rough estimate from word count at ~200 wpm, used as the denominator for
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ uuid = { workspace = true, optional = true }
|
|||
chrono = { workspace = true, optional = true }
|
||||
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"]
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
70
crates/web/assets/dx-components-theme.css
Normal file
70
crates/web/assets/dx-components-theme.css
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
/* This file contains the theme variables for the styled dioxus-components components. You only
|
||||
* need to import this file once in your project root.
|
||||
*/
|
||||
|
||||
html[data-theme="dark"] {
|
||||
--dark: initial;
|
||||
--light: ;
|
||||
}
|
||||
|
||||
html[data-theme="light"] {
|
||||
--dark: ;
|
||||
--light: initial;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] [class~="dxc-system"] {
|
||||
--dxc-light-on: ;
|
||||
--dxc-dark-on: initial;
|
||||
}
|
||||
|
||||
html[data-theme="light"] [class~="dxc-system"] {
|
||||
--dxc-light-on: initial;
|
||||
--dxc-dark-on: ;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--dark: initial;
|
||||
--light: ;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
--dark: ;
|
||||
--light: initial;
|
||||
}
|
||||
}
|
||||
|
||||
:root {
|
||||
/* Primary colors */
|
||||
--primary-color: var(--dark, #000) var(--light, #fff);
|
||||
--primary-color-1: var(--dark, #0e0e0e) var(--light, #fbfbfb);
|
||||
--primary-color-2: var(--dark, #0a0a0a) var(--light, #fff);
|
||||
--primary-color-3: var(--dark, #141313) var(--light, #f8f8f8);
|
||||
--primary-color-4: var(--dark, #1a1a1a) var(--light, #f8f8f8);
|
||||
--primary-color-5: var(--dark, #262626) var(--light, #f5f5f5);
|
||||
--primary-color-6: var(--dark, #232323) var(--light, #e5e5e5);
|
||||
--primary-color-7: var(--dark, #3e3e3e) var(--light, #b0b0b0);
|
||||
|
||||
/* Secondary colors */
|
||||
--secondary-color: var(--dark, #fff) var(--light, #000);
|
||||
--secondary-color-1: var(--dark, #fafafa) var(--light, #000);
|
||||
--secondary-color-2: var(--dark, #e6e6e6) var(--light, #0d0d0d);
|
||||
--secondary-color-3: var(--dark, #dcdcdc) var(--light, #2b2b2b);
|
||||
--secondary-color-4: var(--dark, #d4d4d4) var(--light, #111);
|
||||
--secondary-color-5: var(--dark, #a1a1a1) var(--light, #848484);
|
||||
--secondary-color-6: var(--dark, #5d5d5d) var(--light, #d0d0d0);
|
||||
|
||||
/* Highlight colors */
|
||||
--focused-border-color: var(--dark, #2b7fff) var(--light, #2b7fff);
|
||||
--primary-success-color: var(--dark, #02271c) var(--light, #ecfdf5);
|
||||
--secondary-success-color: var(--dark, #b6fae3) var(--light, #10b981);
|
||||
--primary-warning-color: var(--dark, #342203) var(--light, #fffbeb);
|
||||
--secondary-warning-color: var(--dark, #feeac7) var(--light, #f59e0b);
|
||||
--primary-error-color: var(--dark, #a22e2e) var(--light, #dc2626);
|
||||
--secondary-error-color: var(--dark, #9b1c1c) var(--light, #ef4444);
|
||||
--contrast-error-color: var(--dark, var(--secondary-color-3)) var(--light, var(--primary-color));
|
||||
--primary-info-color: var(--dark, var(--primary-color-5)) var(--light, var(--primary-color));
|
||||
--secondary-info-color: var(--dark, var(--primary-color-7)) var(--light, var(--secondary-color-3));
|
||||
}
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
use crate::components::button::{Button, ButtonVariant};
|
||||
use crate::components::input::Input;
|
||||
use dioxus::prelude::*;
|
||||
use 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() {
|
||||
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]
|
||||
fn ArticleRow(article: ArticleView) -> Element {
|
||||
let score_pct = article.final_score.map(|s| (s * 100.0).round() as i32);
|
||||
|
|
|
|||
108
crates/web/src/components/button/component.rs
Normal file
108
crates/web/src/components/button/component.rs
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
use dioxus::prelude::*;
|
||||
use dioxus_primitives::dioxus_attributes::attributes;
|
||||
use dioxus_primitives::merge_attributes;
|
||||
|
||||
#[css_module("/src/components/button/style.css")]
|
||||
struct Styles;
|
||||
|
||||
#[derive(Copy, Clone, PartialEq, Default)]
|
||||
#[non_exhaustive]
|
||||
pub enum ButtonVariant {
|
||||
#[default]
|
||||
Primary,
|
||||
Secondary,
|
||||
Destructive,
|
||||
Outline,
|
||||
Ghost,
|
||||
Link,
|
||||
}
|
||||
|
||||
impl ButtonVariant {
|
||||
pub fn class(&self) -> &'static str {
|
||||
match self {
|
||||
ButtonVariant::Primary => "primary",
|
||||
ButtonVariant::Secondary => "secondary",
|
||||
ButtonVariant::Destructive => "destructive",
|
||||
ButtonVariant::Outline => "outline",
|
||||
ButtonVariant::Ghost => "ghost",
|
||||
ButtonVariant::Link => "link",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, PartialEq, Default)]
|
||||
#[non_exhaustive]
|
||||
pub enum ButtonSize {
|
||||
Xs,
|
||||
Sm,
|
||||
#[default]
|
||||
Default,
|
||||
Lg,
|
||||
Icon,
|
||||
IconXs,
|
||||
IconSm,
|
||||
IconLg,
|
||||
}
|
||||
|
||||
impl ButtonSize {
|
||||
pub fn class(&self) -> &'static str {
|
||||
match self {
|
||||
ButtonSize::Xs => "xs",
|
||||
ButtonSize::Sm => "sm",
|
||||
ButtonSize::Default => "default",
|
||||
ButtonSize::Lg => "lg",
|
||||
ButtonSize::Icon => "icon",
|
||||
ButtonSize::IconXs => "icon-xs",
|
||||
ButtonSize::IconSm => "icon-sm",
|
||||
ButtonSize::IconLg => "icon-lg",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Button(
|
||||
#[props(default)] variant: ButtonVariant,
|
||||
#[props(default)] size: ButtonSize,
|
||||
#[props(extends=GlobalAttributes)]
|
||||
#[props(extends=button)]
|
||||
attributes: Vec<Attribute>,
|
||||
onclick: Option<EventHandler<MouseEvent>>,
|
||||
onmousedown: Option<EventHandler<MouseEvent>>,
|
||||
onmouseup: Option<EventHandler<MouseEvent>>,
|
||||
onkeydown: Option<EventHandler<KeyboardEvent>>,
|
||||
children: Element,
|
||||
) -> Element {
|
||||
let base = attributes!(button {
|
||||
class: Styles::dx_button,
|
||||
"data-style": variant.class(),
|
||||
"data-size": size.class(),
|
||||
});
|
||||
let merged = merge_attributes(vec![base, attributes]);
|
||||
|
||||
rsx! {
|
||||
button {
|
||||
onclick: move |event| {
|
||||
if let Some(f) = &onclick {
|
||||
f.call(event);
|
||||
}
|
||||
},
|
||||
onmousedown: move |event| {
|
||||
if let Some(f) = &onmousedown {
|
||||
f.call(event);
|
||||
}
|
||||
},
|
||||
onmouseup: move |event| {
|
||||
if let Some(f) = &onmouseup {
|
||||
f.call(event);
|
||||
}
|
||||
},
|
||||
onkeydown: move |event| {
|
||||
if let Some(f) = &onkeydown {
|
||||
f.call(event);
|
||||
}
|
||||
},
|
||||
..merged,
|
||||
{children}
|
||||
}
|
||||
}
|
||||
}
|
||||
2
crates/web/src/components/button/mod.rs
Normal file
2
crates/web/src/components/button/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
mod component;
|
||||
pub use component::*;
|
||||
220
crates/web/src/components/button/style.css
Normal file
220
crates/web/src/components/button/style.css
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
.dx-button {
|
||||
display: inline-flex;
|
||||
box-sizing: border-box;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
cursor: default;
|
||||
font-family: inherit;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
gap: 0.5rem;
|
||||
line-height: 1.25rem;
|
||||
outline: none;
|
||||
transition-duration: 150ms;
|
||||
transition-property: all;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dx-button svg {
|
||||
flex-shrink: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.dx-button svg:not([class*="size-"]) {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.dx-button[data-size="xs"] {
|
||||
height: 1.5rem;
|
||||
padding: 0 0.5rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
gap: 0.25rem;
|
||||
line-height: 1rem;
|
||||
}
|
||||
|
||||
.dx-button[data-size="xs"]:has(> svg) {
|
||||
padding-inline: 0.375rem;
|
||||
}
|
||||
|
||||
.dx-button[data-size="xs"] svg:not([class*="size-"]) {
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
}
|
||||
|
||||
.dx-button[data-size="sm"] {
|
||||
height: 2rem;
|
||||
padding: 0 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.dx-button[data-size="sm"]:has(> svg) {
|
||||
padding-inline: 0.625rem;
|
||||
}
|
||||
|
||||
.dx-button[data-size="default"] {
|
||||
height: 2.25rem;
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.dx-button[data-size="default"]:has(> svg) {
|
||||
padding-inline: 0.75rem;
|
||||
}
|
||||
|
||||
.dx-button[data-size="lg"] {
|
||||
height: 2.5rem;
|
||||
padding: 0 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dx-button[data-size="lg"]:has(> svg) {
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
|
||||
.dx-button[data-size="icon"] {
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
}
|
||||
|
||||
.dx-button[data-size="icon"] svg:not([class*="size-"]) {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.dx-button[data-size="icon-xs"] {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dx-button[data-size="icon-xs"] svg:not([class*="size-"]) {
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
}
|
||||
|
||||
.dx-button[data-size="icon-sm"] {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
}
|
||||
|
||||
.dx-button[data-size="icon-sm"] svg:not([class*="size-"]) {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.dx-button[data-size="icon-lg"] {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
}
|
||||
|
||||
.dx-button[data-size="icon-lg"] svg:not([class*="size-"]) {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.dx-button:disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.dx-button[data-style="primary"] {
|
||||
background-color: var(--secondary-color-2);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.dx-button[data-style="primary"]:not(:disabled):hover {
|
||||
background-color: color-mix(
|
||||
in oklab,
|
||||
var(--secondary-color-2) 90%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.dx-button[data-style="secondary"] {
|
||||
background-color: var(--primary-color-5);
|
||||
color: var(--secondary-color-1);
|
||||
}
|
||||
|
||||
.dx-button[data-style="secondary"]:not(:disabled):hover {
|
||||
background-color: color-mix(in oklab, var(--primary-color-5) 80%, transparent);
|
||||
}
|
||||
|
||||
.dx-button[data-style="ghost"] {
|
||||
background-color: transparent;
|
||||
color: var(--secondary-color-4);
|
||||
}
|
||||
|
||||
.dx-button[data-style="ghost"]:not(:disabled):hover {
|
||||
background-color: var(--light, var(--primary-color-5))
|
||||
var(--dark, color-mix(in oklab, var(--primary-color-5) 50%, transparent));
|
||||
color: var(--secondary-color-1);
|
||||
}
|
||||
|
||||
.dx-button[data-style="outline"] {
|
||||
border: 1px solid var(--light, var(--primary-color-6))
|
||||
var(--dark, var(--primary-color-7));
|
||||
background-color: var(--light, var(--primary-color))
|
||||
var(--dark, color-mix(in oklab, var(--primary-color-7) 30%, transparent));
|
||||
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 5%);
|
||||
color: var(--secondary-color-4);
|
||||
}
|
||||
|
||||
.dx-button[data-style="outline"]:not(:disabled):hover {
|
||||
background-color: var(--light, var(--primary-color-5))
|
||||
var(--dark, color-mix(in oklab, var(--primary-color-7) 50%, transparent));
|
||||
color: var(--secondary-color-1);
|
||||
}
|
||||
|
||||
.dx-button[data-style="destructive"] {
|
||||
background-color: var(--light, var(--primary-error-color))
|
||||
var(--dark, color-mix(in oklab, var(--primary-error-color) 60%, transparent));
|
||||
color: var(--contrast-error-color);
|
||||
}
|
||||
|
||||
.dx-button[data-style="destructive"]:not(:disabled):hover {
|
||||
background-color: color-mix(
|
||||
in oklab,
|
||||
var(--primary-error-color) 90%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.dx-button[data-style="destructive"]:focus-visible {
|
||||
box-shadow: 0 0 0 3px
|
||||
var(--light, color-mix(in oklab, var(--primary-error-color) 20%, transparent))
|
||||
var(--dark, color-mix(in oklab, var(--primary-error-color) 40%, transparent));
|
||||
}
|
||||
|
||||
.dx-button[data-style="link"] {
|
||||
background-color: transparent;
|
||||
color: var(--secondary-color-2);
|
||||
text-decoration: none;
|
||||
text-underline-offset: 0.25rem;
|
||||
}
|
||||
|
||||
.dx-button[data-style="link"]:not(:disabled):hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.dx-button:focus-visible {
|
||||
border-color: var(--focused-border-color);
|
||||
box-shadow: 0 0 0 3px
|
||||
color-mix(in oklab, var(--focused-border-color) 50%, transparent);
|
||||
}
|
||||
|
||||
.dx-button[aria-invalid="true"] {
|
||||
border-color: var(--primary-error-color);
|
||||
}
|
||||
|
||||
.dx-button[aria-invalid="true"]:focus-visible {
|
||||
box-shadow: 0 0 0 3px
|
||||
var(--light, color-mix(in oklab, var(--primary-error-color) 20%, transparent))
|
||||
var(--dark, color-mix(in oklab, var(--primary-error-color) 40%, transparent));
|
||||
}
|
||||
57
crates/web/src/components/input/component.rs
Normal file
57
crates/web/src/components/input/component.rs
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
use dioxus::prelude::*;
|
||||
#[css_module("/src/components/input/style.css")]
|
||||
struct Styles;
|
||||
|
||||
#[component]
|
||||
pub fn Input(
|
||||
oninput: Option<EventHandler<FormEvent>>,
|
||||
onchange: Option<EventHandler<FormEvent>>,
|
||||
oninvalid: Option<EventHandler<FormEvent>>,
|
||||
onselect: Option<EventHandler<SelectionEvent>>,
|
||||
onselectionchange: Option<EventHandler<SelectionEvent>>,
|
||||
onfocus: Option<EventHandler<FocusEvent>>,
|
||||
onblur: Option<EventHandler<FocusEvent>>,
|
||||
onfocusin: Option<EventHandler<FocusEvent>>,
|
||||
onfocusout: Option<EventHandler<FocusEvent>>,
|
||||
onkeydown: Option<EventHandler<KeyboardEvent>>,
|
||||
onkeypress: Option<EventHandler<KeyboardEvent>>,
|
||||
onkeyup: Option<EventHandler<KeyboardEvent>>,
|
||||
onwheel: Option<EventHandler<WheelEvent>>,
|
||||
oncompositionstart: Option<EventHandler<CompositionEvent>>,
|
||||
oncompositionupdate: Option<EventHandler<CompositionEvent>>,
|
||||
oncompositionend: Option<EventHandler<CompositionEvent>>,
|
||||
oncopy: Option<EventHandler<ClipboardEvent>>,
|
||||
oncut: Option<EventHandler<ClipboardEvent>>,
|
||||
onpaste: Option<EventHandler<ClipboardEvent>>,
|
||||
#[props(extends=GlobalAttributes)]
|
||||
#[props(extends=input)]
|
||||
attributes: Vec<Attribute>,
|
||||
children: Element,
|
||||
) -> Element {
|
||||
rsx! {
|
||||
input {
|
||||
class: Styles::dx_input,
|
||||
oninput: move |e| _ = oninput.map(|callback| callback(e)),
|
||||
onchange: move |e| _ = onchange.map(|callback| callback(e)),
|
||||
oninvalid: move |e| _ = oninvalid.map(|callback| callback(e)),
|
||||
onselect: move |e| _ = onselect.map(|callback| callback(e)),
|
||||
onselectionchange: move |e| _ = onselectionchange.map(|callback| callback(e)),
|
||||
onfocus: move |e| _ = onfocus.map(|callback| callback(e)),
|
||||
onblur: move |e| _ = onblur.map(|callback| callback(e)),
|
||||
onfocusin: move |e| _ = onfocusin.map(|callback| callback(e)),
|
||||
onfocusout: move |e| _ = onfocusout.map(|callback| callback(e)),
|
||||
onkeydown: move |e| _ = onkeydown.map(|callback| callback(e)),
|
||||
onkeypress: move |e| _ = onkeypress.map(|callback| callback(e)),
|
||||
onkeyup: move |e| _ = onkeyup.map(|callback| callback(e)),
|
||||
onwheel: move |e| _ = onwheel.map(|callback| callback(e)),
|
||||
oncompositionstart: move |e| _ = oncompositionstart.map(|callback| callback(e)),
|
||||
oncompositionupdate: move |e| _ = oncompositionupdate.map(|callback| callback(e)),
|
||||
oncompositionend: move |e| _ = oncompositionend.map(|callback| callback(e)),
|
||||
oncopy: move |e| _ = oncopy.map(|callback| callback(e)),
|
||||
oncut: move |e| _ = oncut.map(|callback| callback(e)),
|
||||
onpaste: move |e| _ = onpaste.map(|callback| callback(e)),
|
||||
..attributes,
|
||||
{children}
|
||||
}
|
||||
}
|
||||
}
|
||||
2
crates/web/src/components/input/mod.rs
Normal file
2
crates/web/src/components/input/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
mod component;
|
||||
pub use component::*;
|
||||
38
crates/web/src/components/input/style.css
Normal file
38
crates/web/src/components/input/style.css
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
/* Input Styles */
|
||||
.dx-input {
|
||||
position: relative;
|
||||
display: flex;
|
||||
box-sizing: border-box;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.25rem;
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
border-radius: calc(0.5rem);
|
||||
background: none;
|
||||
background-color: var(--light, var(--primary-color)) var(--dark, color-mix(in oklab, #FFFFFF26 30%, transparent));
|
||||
box-shadow: inset 0 0 0 1px var(--light, var(--primary-color-6))
|
||||
var(--dark, var(--primary-color-7));
|
||||
color: var(--secondary-color-4);
|
||||
gap: 0.25rem;
|
||||
transition: background-color 100ms ease-out;
|
||||
}
|
||||
|
||||
.dx-input::placeholder {
|
||||
color: var(--secondary-color-5);
|
||||
}
|
||||
|
||||
.dx-input:disabled {
|
||||
color: var(--secondary-color-5);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.dx-input:hover:not(:disabled),
|
||||
.dx-input:focus-visible {
|
||||
background: var(--light, var(--primary-color-4))
|
||||
var(--dark, color-mix(in oklab, #FFFFFF26 50%, transparent));
|
||||
color: var(--secondary-color-1);
|
||||
outline: none;
|
||||
}
|
||||
3
crates/web/src/components/mod.rs
Normal file
3
crates/web/src/components/mod.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
// AUTOGENERATED Components module
|
||||
pub mod button;
|
||||
pub mod input;
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
mod app;
|
||||
mod components;
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
mod server;
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
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(),
|
||||
|
|
|
|||
Loading…
Reference in a new issue