feedsignal/crates/web/src/app.rs
Austin Schaefer 6fc33f4854
Some checks failed
CI / check (pull_request) Failing after 32s
CI / test (pull_request) Has been skipped
CI / audit (pull_request) Has been skipped
chore: Change license, re-position test comments, add gitignore entries.
2026-08-21 13:53:49 +02:00

87 lines
2.9 KiB
Rust

use dioxus::prelude::*;
use serde::{Deserialize, Serialize};
/// Article shape sent to the browser — a trimmed view of
/// `feedsignal_core::Article`, kept separate so the wire format can evolve
/// independently of the storage model.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ArticleView {
pub id: String,
pub title: String,
pub url: String,
pub summary: String,
pub topics: Vec<String>,
pub final_score: Option<f32>,
}
#[component]
pub fn App() -> Element {
let articles = use_server_future(list_ranked_articles)?;
rsx! {
Stylesheet { href: asset!("/assets/app.css") }
main {
h1 { "feedsignal" }
match articles.read().as_ref() {
Some(Ok(articles)) => rsx! {
ul { class: "article-list",
for article in articles.iter() {
ArticleRow { article: article.clone() }
}
}
},
Some(Err(err)) => rsx! { p { class: "error", "Failed to load articles: {err}" } },
None => rsx! { p { "Loading..." } },
}
}
}
}
#[component]
fn ArticleRow(article: ArticleView) -> Element {
let score_pct = article.final_score.map(|s| (s * 100.0).round() as i32);
rsx! {
li { class: "article-row",
a { href: "{article.url}", target: "_blank", "{article.title}" }
if let Some(pct) = score_pct {
span { class: "score", "{pct}%" }
}
p { class: "summary", "{article.summary}" }
div { class: "topics",
for topic in article.topics.iter() {
span { class: "topic-tag", "{topic}" }
}
}
button {
onclick: move |_| {
let id = article.id.clone();
async move {
let _ = mark_dismissed(id).await;
}
},
"Not relevant"
}
}
}
}
/// Returns articles ranked by `final_score` descending, highest-relevance
/// first. Runs on the server (native, has DB access); the `#[server]`
/// macro generates the HTTP call the browser/WASM build uses instead.
#[server]
async fn list_ranked_articles() -> Result<Vec<ArticleView>, ServerFnError> {
let db = crate::server::db().await?;
crate::server::list_ranked_articles_impl(db)
.await
.map_err(|e| ServerFnError::new(e.to_string()))
}
/// Records an explicit "not relevant" signal, which feeds directly into the
/// topic-affinity update (see `feedsignal_core::affinity::apply_feedback`).
#[server]
async fn mark_dismissed(article_id: String) -> Result<(), ServerFnError> {
let db = crate::server::db().await?;
crate::server::mark_dismissed_impl(db, article_id)
.await
.map_err(|e| ServerFnError::new(e.to_string()))
}