Compare commits
No commits in common. "main" and "worktree-feed-sidebar-nav" have entirely different histories.
main
...
worktree-f
42 changed files with 36 additions and 222 deletions
19
Cargo.lock
generated
19
Cargo.lock
generated
|
|
@ -1603,15 +1603,6 @@ dependencies = [
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "feedsignal-dioxus-components"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"dioxus",
|
|
||||||
"dioxus-icons",
|
|
||||||
"dioxus-primitives",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "feedsignal-feeds"
|
name = "feedsignal-feeds"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|
@ -1642,10 +1633,10 @@ dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
"dioxus",
|
"dioxus",
|
||||||
|
"dioxus-icons",
|
||||||
"dioxus-primitives",
|
"dioxus-primitives",
|
||||||
"feedsignal-core",
|
"feedsignal-core",
|
||||||
"feedsignal-db",
|
"feedsignal-db",
|
||||||
"feedsignal-dioxus-components",
|
|
||||||
"feedsignal-feeds",
|
"feedsignal-feeds",
|
||||||
"feedsignal-llm",
|
"feedsignal-llm",
|
||||||
"serde",
|
"serde",
|
||||||
|
|
@ -2433,9 +2424,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libsqlite3-sys"
|
name = "libsqlite3-sys"
|
||||||
version = "0.38.2"
|
version = "0.30.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8"
|
checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cc",
|
"cc",
|
||||||
"pkg-config",
|
"pkg-config",
|
||||||
|
|
@ -3409,9 +3400,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls"
|
name = "rustls"
|
||||||
version = "0.23.45"
|
version = "0.23.43"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
|
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aws-lc-rs",
|
"aws-lc-rs",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ resolver = "2"
|
||||||
members = [
|
members = [
|
||||||
"crates/core",
|
"crates/core",
|
||||||
"crates/db",
|
"crates/db",
|
||||||
"crates/dioxus-components",
|
|
||||||
"crates/feeds",
|
"crates/feeds",
|
||||||
"crates/llm",
|
"crates/llm",
|
||||||
"crates/web",
|
"crates/web",
|
||||||
|
|
@ -28,10 +27,9 @@ diesel = { version = "2.3", features = ["sqlite", "chrono"] }
|
||||||
diesel-async = { version = "0.9", features = ["sqlite", "bb8"] }
|
diesel-async = { version = "0.9", features = ["sqlite", "bb8"] }
|
||||||
diesel_migrations = { version = "2.3", features = ["sqlite"] }
|
diesel_migrations = { version = "2.3", features = ["sqlite"] }
|
||||||
bb8 = "0.9"
|
bb8 = "0.9"
|
||||||
libsqlite3-sys = { version = "0.38.2", features = ["bundled"] }
|
libsqlite3-sys = { version = "0.30", features = ["bundled"] }
|
||||||
|
|
||||||
feedsignal-core = { path = "crates/core" }
|
feedsignal-core = { path = "crates/core" }
|
||||||
feedsignal-db = { path = "crates/db" }
|
feedsignal-db = { path = "crates/db" }
|
||||||
feedsignal-dioxus-components = { path = "crates/dioxus-components" }
|
|
||||||
feedsignal-feeds = { path = "crates/feeds" }
|
feedsignal-feeds = { path = "crates/feeds" }
|
||||||
feedsignal-llm = { path = "crates/llm" }
|
feedsignal-llm = { path = "crates/llm" }
|
||||||
|
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
[package]
|
|
||||||
name = "feedsignal-dioxus-components"
|
|
||||||
version.workspace = true
|
|
||||||
edition.workspace = true
|
|
||||||
license.workspace = true
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
dioxus = "0.7"
|
|
||||||
dioxus-primitives = { git = "https://github.com/DioxusLabs/components", version = "0.0.1", default-features = false }
|
|
||||||
dioxus-icons = { version = "0.1.0", default-features = false }
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
// Vendored copies of the dioxus-primitives component registry
|
|
||||||
// (https://github.com/DioxusLabs/components, preview/src/components).
|
|
||||||
// That registry is a "copy this source into your project" style
|
|
||||||
// collection, not a published crate — so these files are pulled in
|
|
||||||
// verbatim rather than depended on directly. Don't edit component
|
|
||||||
// internals for app-specific needs; layer overrides on via the `class`/
|
|
||||||
// `attributes` props each component already accepts.
|
|
||||||
mod components;
|
|
||||||
pub use components::*;
|
|
||||||
|
|
@ -24,7 +24,7 @@ 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 }
|
dioxus-primitives = { git = "https://github.com/DioxusLabs/components", version = "0.0.1", default-features = false }
|
||||||
feedsignal-dioxus-components.workspace = true
|
dioxus-icons = { version = "0.1.0", default-features = false }
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["web"]
|
default = ["web"]
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,12 @@
|
||||||
use super::handlers::score_percent;
|
|
||||||
use crate::api;
|
use crate::api;
|
||||||
use crate::api::ArticleView;
|
use crate::api::ArticleView;
|
||||||
|
use crate::components::badge::{Badge, BadgeVariant};
|
||||||
|
use crate::components::button::{Button, ButtonSize, ButtonVariant};
|
||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
use feedsignal_dioxus_components::badge::{Badge, BadgeVariant};
|
|
||||||
use feedsignal_dioxus_components::button::{Button, ButtonSize, ButtonVariant};
|
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn ArticleRow(article: ArticleView) -> Element {
|
pub fn ArticleRow(article: ArticleView) -> Element {
|
||||||
let score_pct = score_percent(article.final_score);
|
let score_pct = article.final_score.map(|s| (s * 100.0).round() as i32);
|
||||||
rsx! {
|
rsx! {
|
||||||
li { class: "article-row",
|
li { class: "article-row",
|
||||||
div { class: "article-row-header",
|
div { class: "article-row-header",
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
/// Converts a fractional relevance score into a whole-number percentage for
|
|
||||||
/// the row's badge, rounding rather than truncating so e.g. 0.995 shows 100%
|
|
||||||
/// instead of 99%.
|
|
||||||
pub fn score_percent(final_score: Option<f32>) -> Option<i32> {
|
|
||||||
final_score.map(|s| (s * 100.0).round() as i32)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn no_score_yields_no_percent() {
|
|
||||||
assert_eq!(score_percent(None), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn score_is_rounded_not_truncated() {
|
|
||||||
assert_eq!(score_percent(Some(0.995)), Some(100));
|
|
||||||
assert_eq!(score_percent(Some(0.554)), Some(55));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn score_is_scaled_to_a_percentage() {
|
|
||||||
assert_eq!(score_percent(Some(0.5)), Some(50));
|
|
||||||
assert_eq!(score_percent(Some(0.0)), Some(0));
|
|
||||||
assert_eq!(score_percent(Some(1.0)), Some(100));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
mod component;
|
|
||||||
mod handlers;
|
|
||||||
|
|
||||||
pub use component::*;
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
use crate::api::FeedView;
|
|
||||||
|
|
||||||
/// Heading text for the article list: the selected feed's title, "Feed" as a
|
|
||||||
/// placeholder while that feed's title hasn't loaded yet, or "All articles"
|
|
||||||
/// when no feed is selected.
|
|
||||||
pub fn feed_heading(selected_feed_id: Option<&str>, feeds: Option<&[FeedView]>) -> String {
|
|
||||||
match selected_feed_id {
|
|
||||||
None => "All articles".to_string(),
|
|
||||||
Some(id) => feeds
|
|
||||||
.and_then(|feeds| feeds.iter().find(|f| f.id == id))
|
|
||||||
.map(|f| f.title.clone())
|
|
||||||
.unwrap_or_else(|| "Feed".to_string()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn feed(id: &str, title: &str) -> FeedView {
|
|
||||||
FeedView {
|
|
||||||
id: id.to_string(),
|
|
||||||
title: title.to_string(),
|
|
||||||
url: format!("https://example.com/{id}.xml"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn no_selection_shows_all_articles() {
|
|
||||||
assert_eq!(feed_heading(None, None), "All articles");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn selected_feed_shows_its_title() {
|
|
||||||
let feeds = vec![feed("1", "Rust Blog"), feed("2", "Hacker News")];
|
|
||||||
assert_eq!(feed_heading(Some("2"), Some(&feeds)), "Hacker News");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The feed list may still be loading (or the id may be stale) when a
|
|
||||||
/// selection is made, so fall back to a placeholder rather than panic
|
|
||||||
/// or show a blank heading.
|
|
||||||
#[test]
|
|
||||||
fn unresolved_selection_falls_back_to_placeholder() {
|
|
||||||
assert_eq!(feed_heading(Some("missing"), None), "Feed");
|
|
||||||
let feeds = vec![feed("1", "Rust Blog")];
|
|
||||||
assert_eq!(feed_heading(Some("missing"), Some(&feeds)), "Feed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,15 +1,14 @@
|
||||||
mod article_row;
|
mod article_row;
|
||||||
mod handlers;
|
|
||||||
mod subscribe_form;
|
mod subscribe_form;
|
||||||
|
|
||||||
use crate::api;
|
use crate::api;
|
||||||
use article_row::ArticleRow;
|
use crate::components::scroll_area::ScrollArea;
|
||||||
use dioxus::prelude::*;
|
use crate::components::sidebar::{
|
||||||
use feedsignal_dioxus_components::scroll_area::ScrollArea;
|
|
||||||
use feedsignal_dioxus_components::sidebar::{
|
|
||||||
Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarHeader,
|
Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarHeader,
|
||||||
SidebarInset, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarProvider, SidebarTrigger,
|
SidebarInset, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarProvider, SidebarTrigger,
|
||||||
};
|
};
|
||||||
|
use article_row::ArticleRow;
|
||||||
|
use dioxus::prelude::*;
|
||||||
use subscribe_form::SubscribeForm;
|
use subscribe_form::SubscribeForm;
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
|
|
@ -90,10 +89,16 @@ pub fn App() -> Element {
|
||||||
SidebarTrigger {}
|
SidebarTrigger {}
|
||||||
h2 {
|
h2 {
|
||||||
{
|
{
|
||||||
let heading = handlers::feed_heading(
|
let heading = match selected_feed.read().as_ref() {
|
||||||
selected_feed.read().as_deref(),
|
None => "All articles".to_string(),
|
||||||
feeds.read().as_ref().and_then(|r| r.as_ref().ok()).map(|v| v.as_slice()),
|
Some(id) => feeds
|
||||||
);
|
.read()
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|r| r.as_ref().ok())
|
||||||
|
.and_then(|feeds| feeds.iter().find(|f| &f.id == id))
|
||||||
|
.map(|f| f.title.clone())
|
||||||
|
.unwrap_or_else(|| "Feed".to_string()),
|
||||||
|
};
|
||||||
rsx! { "{heading}" }
|
rsx! { "{heading}" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
use super::handlers::{normalize_feed_url, resolve_submit_outcome, SubmitOutcome};
|
|
||||||
use crate::api;
|
use crate::api;
|
||||||
|
use crate::components::button::{Button, ButtonVariant};
|
||||||
|
use crate::components::input::Input;
|
||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
use feedsignal_dioxus_components::button::{Button, ButtonVariant};
|
|
||||||
use feedsignal_dioxus_components::input::Input;
|
|
||||||
|
|
||||||
/// Form for manually subscribing to a feed by URL. Fetches the feed
|
/// Form for manually subscribing to a feed by URL. Fetches the feed
|
||||||
/// immediately on submit (rather than waiting for the next scheduled poll)
|
/// immediately on submit (rather than waiting for the next scheduled poll)
|
||||||
|
|
@ -14,20 +13,22 @@ pub fn SubscribeForm(on_subscribed: EventHandler<()>) -> Element {
|
||||||
let mut submitting = use_signal(|| false);
|
let mut submitting = use_signal(|| false);
|
||||||
|
|
||||||
let submit = move |_| {
|
let submit = move |_| {
|
||||||
let Some(feed_url) = normalize_feed_url(&url.read()) else {
|
let feed_url = url.read().clone();
|
||||||
|
if feed_url.trim().is_empty() {
|
||||||
return;
|
return;
|
||||||
};
|
}
|
||||||
spawn(async move {
|
spawn(async move {
|
||||||
submitting.set(true);
|
submitting.set(true);
|
||||||
status.set(None);
|
status.set(None);
|
||||||
let result = api::feeds::subscribe_feed(feed_url).await;
|
match api::feeds::subscribe_feed(feed_url).await {
|
||||||
match resolve_submit_outcome(result.map_err(|err| err.to_string())) {
|
Ok(count) => {
|
||||||
SubmitOutcome::Success { message } => {
|
status.set(Some(Ok(format!(
|
||||||
status.set(Some(Ok(message)));
|
"Subscribed — pulled in {count} article(s)."
|
||||||
|
))));
|
||||||
url.set(String::new());
|
url.set(String::new());
|
||||||
on_subscribed.call(());
|
on_subscribed.call(());
|
||||||
}
|
}
|
||||||
SubmitOutcome::Failure { message } => status.set(Some(Err(message))),
|
Err(err) => status.set(Some(Err(err.to_string()))),
|
||||||
}
|
}
|
||||||
submitting.set(false);
|
submitting.set(false);
|
||||||
});
|
});
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
/// Trims a feed URL entered by the user, returning `None` when it's blank
|
|
||||||
/// so the submit handler can bail out before making a request.
|
|
||||||
pub fn normalize_feed_url(input: &str) -> Option<String> {
|
|
||||||
let trimmed = input.trim();
|
|
||||||
if trimmed.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(trimmed.to_string())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Success message shown after a feed subscription pulls in new articles.
|
|
||||||
fn subscribed_message(article_count: usize) -> String {
|
|
||||||
format!("Subscribed — pulled in {article_count} article(s).")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// What the form should show, and whether the URL input should be cleared,
|
|
||||||
/// after a subscription attempt resolves.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub enum SubmitOutcome {
|
|
||||||
/// The subscription succeeded: show `message` and clear the input.
|
|
||||||
Success { message: String },
|
|
||||||
/// The subscription failed: show `message` and leave the input as-is,
|
|
||||||
/// so the reader can fix it up and retry.
|
|
||||||
Failure { message: String },
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Maps a `subscribe_feed` result to what the form should do next.
|
|
||||||
pub fn resolve_submit_outcome(result: Result<usize, String>) -> SubmitOutcome {
|
|
||||||
match result {
|
|
||||||
Ok(count) => SubmitOutcome::Success {
|
|
||||||
message: subscribed_message(count),
|
|
||||||
},
|
|
||||||
Err(message) => SubmitOutcome::Failure { message },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn blank_input_normalizes_to_none() {
|
|
||||||
assert_eq!(normalize_feed_url(""), None);
|
|
||||||
assert_eq!(normalize_feed_url(" "), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn surrounding_whitespace_is_trimmed() {
|
|
||||||
assert_eq!(
|
|
||||||
normalize_feed_url(" https://example.com/feed.xml "),
|
|
||||||
Some("https://example.com/feed.xml".to_string())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn success_reports_the_article_count_and_clears_the_input() {
|
|
||||||
assert_eq!(
|
|
||||||
resolve_submit_outcome(Ok(3)),
|
|
||||||
SubmitOutcome::Success {
|
|
||||||
message: "Subscribed — pulled in 3 article(s).".to_string()
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A failed attempt should surface the error but leave the input alone
|
|
||||||
/// so the reader doesn't have to retype the URL to fix it.
|
|
||||||
#[test]
|
|
||||||
fn failure_surfaces_the_error_message() {
|
|
||||||
assert_eq!(
|
|
||||||
resolve_submit_outcome(Err("feed unreachable".to_string())),
|
|
||||||
SubmitOutcome::Failure {
|
|
||||||
message: "feed unreachable".to_string()
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
mod component;
|
|
||||||
mod handlers;
|
|
||||||
|
|
||||||
pub use component::*;
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
mod api;
|
mod api;
|
||||||
mod app;
|
mod app;
|
||||||
|
mod components;
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
mod server;
|
mod server;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue