Add manual feed subscription #3
123
Cargo.lock
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,24 @@ use chrono::{DateTime, Utc};
|
||||||
use feedsignal_core::Article;
|
use feedsignal_core::Article;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
/// Fetches and parses one RSS/Atom feed. `feed-rs` normalizes both formats
|
/// Fetches and parses one RSS/Atom feed, returning its title alongside the
|
||||||
/// into a common model so callers don't need to branch on feed type.
|
/// entries. `feed-rs` normalizes both RSS and Atom into a common model so
|
||||||
pub async fn fetch_feed(feed_url: &str, feed_id: Uuid) -> Result<Vec<Article>> {
|
/// callers don't need to branch on feed type.
|
||||||
let bytes = reqwest::get(feed_url).await?.bytes().await?;
|
pub async fn fetch_feed(feed_url: &str, feed_id: Uuid) -> Result<(String, Vec<Article>)> {
|
||||||
|
// `.error_for_status()` turns a 404/5xx into a clear `Err` here rather
|
||||||
|
// than letting a non-feed error page fall through to a confusing
|
||||||
|
// feed-rs parse failure below.
|
||||||
|
let response = reqwest::get(feed_url).await?.error_for_status()?;
|
||||||
|
schaefera marked this conversation as resolved
Outdated
|
|||||||
|
let bytes = response.bytes().await?;
|
||||||
let parsed = feed_rs::parser::parse(&bytes[..])?;
|
let parsed = feed_rs::parser::parse(&bytes[..])?;
|
||||||
|
|
||||||
|
let feed_title = parsed
|
||||||
|
schaefera marked this conversation as resolved
Outdated
schaefera
commented
Any way to invert this logic so the check is positive instead of negative? Would help grok it faster. Not major either way. Any way to invert this logic so the check is positive instead of negative? Would help grok it faster. Not major either way.
|
|||||||
|
.title
|
||||||
|
.as_ref()
|
||||||
|
.map(|t| t.content.clone())
|
||||||
|
.filter(|t| has_content(t))
|
||||||
|
.unwrap_or_else(|| feed_url.to_string());
|
||||||
|
|
||||||
let articles = parsed
|
let articles = parsed
|
||||||
.entries
|
.entries
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
|
@ -39,7 +51,13 @@ pub async fn fetch_feed(feed_url: &str, feed_id: Uuid) -> Result<Vec<Article>> {
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
Ok(articles)
|
Ok((feed_title, articles))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True if `s` has any non-whitespace content — used to reject a
|
||||||
|
/// present-but-blank feed `<title>` rather than surface it as-is.
|
||||||
|
fn has_content(s: &str) -> bool {
|
||||||
|
!s.trim().is_empty()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rough estimate from word count at ~200 wpm, used as the denominator for
|
/// Rough estimate from word count at ~200 wpm, used as the denominator for
|
||||||
|
|
@ -51,3 +69,27 @@ fn estimate_read_seconds(text: &str) -> u32 {
|
||||||
let words = text.split_whitespace().count() as u32;
|
let words = text.split_whitespace().count() as u32;
|
||||||
((words.max(1) as f32 / 200.0) * 60.0) as u32
|
((words.max(1) as f32 / 200.0) * 60.0) as u32
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// An empty feed title should be treated the same as a missing one.
|
||||||
|
#[test]
|
||||||
|
fn has_content_rejects_empty_string() {
|
||||||
|
assert!(!has_content(""));
|
||||||
|
schaefera marked this conversation as resolved
schaefera
commented
Make three separate unit tests instead of one so we know which case breaks if a regression occurs. Make three separate unit tests instead of one so we know which case breaks if a regression occurs.
|
|||||||
|
}
|
||||||
|
|
||||||
|
/// A whitespace-only feed title should be treated the same as a
|
||||||
|
/// missing one, not surfaced as a blank-looking name in the UI.
|
||||||
|
#[test]
|
||||||
|
fn has_content_rejects_whitespace_only_string() {
|
||||||
|
assert!(!has_content(" \n\t"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A real title is accepted as-is.
|
||||||
|
#[test]
|
||||||
|
fn has_content_accepts_non_blank_string() {
|
||||||
|
assert!(has_content("Rust Blog"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
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 dioxus::prelude::*;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
|
@ -16,12 +18,14 @@ pub struct ArticleView {
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn App() -> Element {
|
pub fn App() -> Element {
|
||||||
let articles = use_server_future(list_ranked_articles)?;
|
let mut articles = use_server_future(list_ranked_articles)?;
|
||||||
|
|
||||||
rsx! {
|
rsx! {
|
||||||
Stylesheet { href: asset!("/assets/app.css") }
|
Stylesheet { href: asset!("/assets/app.css") }
|
||||||
|
Stylesheet { href: asset!("/assets/dx-components-theme.css") }
|
||||||
main {
|
main {
|
||||||
h1 { "feedsignal" }
|
h1 { "feedsignal" }
|
||||||
|
SubscribeForm { on_subscribed: move |_| { articles.restart(); } }
|
||||||
match articles.read().as_ref() {
|
match articles.read().as_ref() {
|
||||||
Some(Ok(articles)) => rsx! {
|
Some(Ok(articles)) => rsx! {
|
||||||
ul { class: "article-list",
|
ul { class: "article-list",
|
||||||
|
|
@ -37,6 +41,82 @@ pub fn App() -> Element {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Form for manually subscribing to a feed by URL. Fetches the feed
|
||||||
|
/// immediately on submit (rather than waiting for the next scheduled poll)
|
||||||
|
/// so the reader isn't empty right after subscribing.
|
||||||
|
#[component]
|
||||||
|
fn SubscribeForm(on_subscribed: EventHandler<()>) -> Element {
|
||||||
|
let mut url = use_signal(String::new);
|
||||||
|
let mut status = use_signal(|| Option::<Result<String, String>>::None);
|
||||||
|
let mut submitting = use_signal(|| false);
|
||||||
|
|
||||||
|
let submit = move |_| {
|
||||||
|
let feed_url = url.read().clone();
|
||||||
|
if feed_url.trim().is_empty() {
|
||||||
|
schaefera marked this conversation as resolved
schaefera
commented
Good guard, would be also nice if the submit button wasn't clickable if the feed_url text box was empty Good guard, would be also nice if the submit button wasn't clickable if the feed_url text box was empty
|
|||||||
|
return;
|
||||||
|
}
|
||||||
|
spawn(async move {
|
||||||
|
submitting.set(true);
|
||||||
|
status.set(None);
|
||||||
|
match subscribe_feed(feed_url).await {
|
||||||
|
Ok(count) => {
|
||||||
|
status.set(Some(Ok(format!(
|
||||||
|
"Subscribed — pulled in {count} article(s)."
|
||||||
|
))));
|
||||||
|
url.set(String::new());
|
||||||
|
on_subscribed.call(());
|
||||||
|
}
|
||||||
|
Err(err) => status.set(Some(Err(err.to_string()))),
|
||||||
|
}
|
||||||
|
submitting.set(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
form {
|
||||||
|
schaefera marked this conversation as resolved
Outdated
schaefera
commented
Would indicate ev's type here for clarity as well Would indicate ev's type here for clarity as well
|
|||||||
|
class: "subscribe-form",
|
||||||
|
onsubmit: move |ev: FormEvent| {
|
||||||
|
ev.prevent_default();
|
||||||
|
submit(());
|
||||||
|
},
|
||||||
|
Input {
|
||||||
|
r#type: "url",
|
||||||
|
placeholder: "https://example.com/feed.xml",
|
||||||
|
value: "{url}",
|
||||||
|
required: true,
|
||||||
|
disabled: submitting(),
|
||||||
|
oninput: move |ev: FormEvent| url.set(ev.value()),
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
r#type: "submit",
|
||||||
|
variant: ButtonVariant::Primary,
|
||||||
|
disabled: submitting() || url.read().trim().is_empty(),
|
||||||
|
if submitting() {
|
||||||
|
"Subscribing..."
|
||||||
|
} else {
|
||||||
|
"Subscribe"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match status.read().as_ref() {
|
||||||
|
Some(Ok(msg)) => rsx! { p { class: "subscribe-status success", "{msg}" } },
|
||||||
|
Some(Err(err)) => rsx! { p { class: "subscribe-status error", "Failed to subscribe: {err}" } },
|
||||||
|
None => rsx! {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Registers a feed and fetches it immediately. Runs on the server (native,
|
||||||
|
/// has DB + network access); the `#[server]` macro generates the HTTP call
|
||||||
|
/// the browser/WASM build uses instead.
|
||||||
|
#[server]
|
||||||
|
async fn subscribe_feed(url: String) -> Result<usize, ServerFnError> {
|
||||||
|
let db = crate::server::db().await?;
|
||||||
|
crate::server::subscribe_feed_impl(db, url)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
fn ArticleRow(article: ArticleView) -> Element {
|
fn ArticleRow(article: ArticleView) -> Element {
|
||||||
let score_pct = article.final_score.map(|s| (s * 100.0).round() as i32);
|
let score_pct = article.final_score.map(|s| (s * 100.0).round() as i32);
|
||||||
|
|
|
||||||
114
crates/web/src/components/button/component.rs
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
use dioxus::prelude::*;
|
||||||
|
use dioxus_primitives::dioxus_attributes::attributes;
|
||||||
|
use dioxus_primitives::merge_attributes;
|
||||||
|
|
||||||
|
#[css_module("/src/components/button/style.css")]
|
||||||
|
struct Styles;
|
||||||
|
|
||||||
|
// Variants beyond the ones this app currently uses are part of the
|
||||||
|
// component library's public API, not unused code — CI runs clippy with
|
||||||
|
// `-D warnings`, which would otherwise turn this dead_code lint into a
|
||||||
|
// build failure.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
#[derive(Copy, Clone, PartialEq, Default)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum ButtonVariant {
|
||||||
|
#[default]
|
||||||
|
Primary,
|
||||||
|
Secondary,
|
||||||
|
Destructive,
|
||||||
|
Outline,
|
||||||
|
Ghost,
|
||||||
|
Link,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ButtonVariant {
|
||||||
|
pub fn class(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
ButtonVariant::Primary => "primary",
|
||||||
|
ButtonVariant::Secondary => "secondary",
|
||||||
|
ButtonVariant::Destructive => "destructive",
|
||||||
|
ButtonVariant::Outline => "outline",
|
||||||
|
ButtonVariant::Ghost => "ghost",
|
||||||
|
ButtonVariant::Link => "link",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
#[derive(Copy, Clone, PartialEq, Default)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum ButtonSize {
|
||||||
|
Xs,
|
||||||
|
Sm,
|
||||||
|
#[default]
|
||||||
|
Default,
|
||||||
|
Lg,
|
||||||
|
Icon,
|
||||||
|
IconXs,
|
||||||
|
IconSm,
|
||||||
|
IconLg,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ButtonSize {
|
||||||
|
pub fn class(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
ButtonSize::Xs => "xs",
|
||||||
|
ButtonSize::Sm => "sm",
|
||||||
|
ButtonSize::Default => "default",
|
||||||
|
ButtonSize::Lg => "lg",
|
||||||
|
ButtonSize::Icon => "icon",
|
||||||
|
ButtonSize::IconXs => "icon-xs",
|
||||||
|
ButtonSize::IconSm => "icon-sm",
|
||||||
|
ButtonSize::IconLg => "icon-lg",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn Button(
|
||||||
|
#[props(default)] variant: ButtonVariant,
|
||||||
|
#[props(default)] size: ButtonSize,
|
||||||
|
#[props(extends=GlobalAttributes)]
|
||||||
|
#[props(extends=button)]
|
||||||
|
attributes: Vec<Attribute>,
|
||||||
|
onclick: Option<EventHandler<MouseEvent>>,
|
||||||
|
onmousedown: Option<EventHandler<MouseEvent>>,
|
||||||
|
onmouseup: Option<EventHandler<MouseEvent>>,
|
||||||
|
onkeydown: Option<EventHandler<KeyboardEvent>>,
|
||||||
|
children: Element,
|
||||||
|
) -> Element {
|
||||||
|
let base = attributes!(button {
|
||||||
|
class: Styles::dx_button,
|
||||||
|
"data-style": variant.class(),
|
||||||
|
"data-size": size.class(),
|
||||||
|
});
|
||||||
|
let merged = merge_attributes(vec![base, attributes]);
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
button {
|
||||||
|
onclick: move |event| {
|
||||||
|
if let Some(f) = &onclick {
|
||||||
|
f.call(event);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onmousedown: move |event| {
|
||||||
|
if let Some(f) = &onmousedown {
|
||||||
|
f.call(event);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onmouseup: move |event| {
|
||||||
|
if let Some(f) = &onmouseup {
|
||||||
|
f.call(event);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onkeydown: move |event| {
|
||||||
|
if let Some(f) = &onkeydown {
|
||||||
|
f.call(event);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
..merged,
|
||||||
|
{children}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1,2 @@
|
||||||
|
mod component;
|
||||||
|
pub use component::*;
|
||||||
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
|
|
@ -0,0 +1,3 @@
|
||||||
|
// AUTOGENERATED Components module
|
||||||
|
pub mod button;
|
||||||
|
pub mod input;
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
mod app;
|
mod app;
|
||||||
|
mod components;
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
mod server;
|
mod server;
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,36 @@ pub async fn list_ranked_articles_impl(db: Db) -> Result<Vec<ArticleView>> {
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Subscribes to a feed by URL: registers it (or updates its title if
|
||||||
|
/// already subscribed) and does an immediate first fetch so the reader
|
||||||
|
/// isn't empty until the next scheduled poll. Returns the number of
|
||||||
|
/// articles pulled in on this fetch.
|
||||||
|
///
|
||||||
|
schaefera marked this conversation as resolved
Outdated
schaefera
commented
This feels pseudo duplicated compared to some of the earlier code. What is the reason for both subscription-centric methods existing, just for my own understanding? This feels pseudo duplicated compared to some of the earlier code. What is the reason for both subscription-centric methods existing, just for my own understanding?
|
|||||||
|
/// Fetches under a throwaway id before writing anything, so there's exactly
|
||||||
|
/// one write to the `feeds` table (`upsert_feed` is the single source of
|
||||||
|
schaefera marked this conversation as resolved
Outdated
schaefera
commented
Wouldn't this make sense to have before the trim with a is_blank() style check? Aka check if it's not just pure whitespace? Wouldn't this make sense to have before the trim with a is_blank() style check? Aka check if it's not just pure whitespace?
|
|||||||
|
/// truth for the real id — the existing one on a re-subscribe, a fresh one
|
||||||
|
/// otherwise) and no half-registered row left behind if the fetch fails
|
||||||
|
schaefera marked this conversation as resolved
Outdated
schaefera
commented
What if the url is correctly formed but otherwise points to nothing or a feed which no longer exists? Is this case handled? What if the url is correctly formed but otherwise points to nothing or a feed which no longer exists? Is this case handled?
|
|||||||
|
/// (e.g. the URL is well-formed but points at nothing, or the feed no
|
||||||
|
/// longer exists — `fetch_feed` surfaces both as an `Err` via
|
||||||
|
/// `error_for_status`/feed-rs parse failure, which becomes the error
|
||||||
|
/// message shown in the subscribe form).
|
||||||
|
pub async fn subscribe_feed_impl(db: Db, url: String) -> Result<usize> {
|
||||||
|
anyhow::ensure!(!url.trim().is_empty(), "feed URL is required");
|
||||||
|
let url = url.trim();
|
||||||
|
|
||||||
|
let (title, mut articles) = feedsignal_feeds::fetch_feed(url, Uuid::new_v4()).await?;
|
||||||
|
let feed_id = db.upsert_feed(url, &title).await?;
|
||||||
|
for article in &mut articles {
|
||||||
|
article.feed_id = feed_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
let count = articles.len();
|
||||||
|
for article in &articles {
|
||||||
|
db.insert_article(article).await?;
|
||||||
|
}
|
||||||
|
Ok(count)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn mark_dismissed_impl(db: Db, article_id: String) -> Result<()> {
|
pub async fn mark_dismissed_impl(db: Db, article_id: String) -> Result<()> {
|
||||||
let event = ReadingEvent {
|
let event = ReadingEvent {
|
||||||
id: Uuid::new_v4(),
|
id: Uuid::new_v4(),
|
||||||
|
|
|
||||||
Rename to
feed_titlefor clarity of purpose