88 lines
2.9 KiB
Rust
88 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! {
|
||
|
|
style { {include_str!("../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()))
|
||
|
|
}
|