Compare commits

..

No commits in common. "main" and "worktree-srp-refactor-db" have entirely different histories.

62 changed files with 134 additions and 3340 deletions

29
Cargo.lock generated
View file

@ -1136,17 +1136,6 @@ dependencies = [
"syn 2.0.119", "syn 2.0.119",
] ]
[[package]]
name = "dioxus-icons"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ae929a4cdde2e51fca98ccb8a8fe2ea11640e5026c57486b15dbd5618ba7e56"
dependencies = [
"dioxus",
"dioxus-signals",
"lazy-js-bundle",
]
[[package]] [[package]]
name = "dioxus-interpreter-js" name = "dioxus-interpreter-js"
version = "0.7.10" version = "0.7.10"
@ -1603,15 +1592,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"
@ -1645,7 +1625,6 @@ dependencies = [
"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 +2412,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 +3388,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",

View file

@ -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" }

View file

@ -1,90 +0,0 @@
# Definition of Done — feedsignal
A change is done when all of the following hold, not just when it compiles.
## Single Responsibility
- Each module/function does one thing. If a PR description needs "and" to
describe what a function does, split it.
- Crate boundaries stay honest: `core` has no I/O (see `crates/core`
domain models + scoring/affinity math only); `db` only talks to SQLite;
`feeds` only fetches/parses; `llm` only talks to Ollama. A change that
makes `core` reach for a DB handle or `db` parse RSS is a sign the code
landed in the wrong crate.
- Within `db`, one file per domain concern (`feeds.rs`, `articles.rs`,
`reading_events.rs`, `affinities.rs`) — a new table gets its own file,
not a growing `queries.rs`.
- Data-model structs (domain models, query row types, DTOs/views) live in
a crate's `models.rs` rather than the file that produces or consumes
them, whenever a struct is used outside the function that builds it —
matching the existing `crates/core/src/models.rs` pattern. This doesn't
apply to structs that are inherently local to one file/component (e.g.
a Dioxus component's `Props`/`Styles`/context struct, or a helper
struct scoped to a single function's internals).
## DRY, but not premature
- No copy-pasted logic across call sites — extract a shared function once
a second real call site exists, not in anticipation of one.
- No new abstraction (trait, generic helper, config knob) unless at least
two concrete cases need it today.
## Testing
- All non-trivial logic ships with unit tests wherever practical — not
just `core`'s pure functions (scoring, affinity math — the existing
pattern in `crates/core/src/affinity.rs`), but query/filter logic in
`db` and non-passthrough logic in `web`'s `server::services`. "Non-trivial"
excludes thin wrappers (a `#[server]` fn or service function that's just
`db.some_query(args).await?` with no branching of its own) and glue code
whose only failure mode is a compiler error.
- When a DB/network dependency makes a case genuinely impractical to unit
test (e.g. a Diesel query's SQL correctness), say so in the PR rather
than silently skipping it — that's a real gap to track, not a pass.
- Test rationale goes in a `///` doc comment above `#[test] fn`, not
inline in the body.
- A bug fix includes a regression test that fails on the old code.
- `cargo test --workspace --exclude feedsignal-web` passes locally before
pushing (CI runs this; `feedsignal-web` itself currently has no test
target).
## Static checks (all of CI, not a subset)
- `cargo fmt --check` clean.
- `cargo clippy --workspace --exclude feedsignal-web --all-targets -- -D warnings` clean.
- `cargo clippy -p feedsignal-web --no-default-features --features server --all-targets -- -D warnings` clean.
- `cargo clippy -p feedsignal-web --no-default-features --features web --target wasm32-unknown-unknown -- -D warnings` clean.
- `cargo audit` clean, or a documented reason if it can't be (e.g. an
unfixed transitive advisory).
## No half-finished work
- No `TODO`s standing in for a decision the PR itself was supposed to
make (a `TODO` marking genuinely deferred, ticketed follow-up work is
fine — see `LlmConfig`'s config-story TODO).
- No dead code kept "just in case" — if it's unused, delete it. Library-
surface exceptions (e.g. `dioxus-primitives`-derived components) get an
explicit `#[allow(dead_code)]` with a one-line reason, matching the
existing pattern in `components/button/component.rs`.
- No commented-out code left in.
## Correctness at the boundary
- New `#[server]` fns validate/trim input the way `services::feeds::subscribe`
does (`anyhow::ensure!` on empty URL) — don't trust the browser.
- DB writes that must be atomic with respect to a failure path (e.g.
"fetch before writing so a bad URL leaves no half-registered row") get
a comment explaining why, like `subscribe`'s doc comment does.
- Migrations are additive/reversible — no editing a shipped migration file.
## Docs
- A non-obvious "why" (a workaround, a chosen threshold, a design
tradeoff) gets a comment; "what" is left to the code.
- `README.md`'s architecture section gets updated if the change adds a
crate, a pipeline stage, or a scoring input.
## PR hygiene
- Diff is scoped to the stated task — no drive-by refactors bundled in.
- `Cargo.lock` changes match what the dependency change actually requires.

View file

@ -3,5 +3,5 @@ pub mod models;
pub mod scoring; pub mod scoring;
pub use affinity::TopicAffinities; pub use affinity::TopicAffinities;
pub use models::{Article, Feed, ReadingEvent, ReadingOutcome, RelevanceInputs}; pub use models::{Article, Feed, ReadingEvent, ReadingOutcome};
pub use scoring::score_article; pub use scoring::{score_article, RelevanceInputs};

View file

@ -1,4 +1,3 @@
use crate::affinity::TopicAffinities;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
@ -59,16 +58,3 @@ pub enum ReadingOutcome {
/// Explicitly marked not relevant, independent of whether it was opened. /// Explicitly marked not relevant, independent of whether it was opened.
Dismissed, Dismissed,
} }
/// Inputs to the final blended relevance score for one article, see
/// `scoring::score_article`.
pub struct RelevanceInputs<'a> {
/// Cosine similarity (0.0-1.0, already renormalized from [-1,1] if
/// needed) between article and preference-profile embeddings.
pub embedding_score: f32,
/// LLM judgment (0.0-1.0), `None` if the article didn't clear the
/// embedding shortlist threshold and so was never sent to the LLM.
pub llm_score: Option<f32>,
pub topics: &'a [String],
pub affinities: &'a TopicAffinities,
}

View file

@ -1,4 +1,16 @@
use crate::models::RelevanceInputs; use crate::affinity::TopicAffinities;
/// Inputs to the final blended relevance score for one article.
pub struct RelevanceInputs<'a> {
/// Cosine similarity (0.0-1.0, already renormalized from [-1,1] if
/// needed) between article and preference-profile embeddings.
pub embedding_score: f32,
/// LLM judgment (0.0-1.0), `None` if the article didn't clear the
/// embedding shortlist threshold and so was never sent to the LLM.
pub llm_score: Option<f32>,
pub topics: &'a [String],
pub affinities: &'a TopicAffinities,
}
/// Weights are deliberately conservative: the LLM judgment dominates when /// Weights are deliberately conservative: the LLM judgment dominates when
/// present (it has read the actual content), the embedding score is a /// present (it has read the actual content), the embedding score is a

View file

@ -1,4 +1,3 @@
use crate::models::RankedArticleRow;
use crate::schema; use crate::schema;
use crate::Db; use crate::Db;
use anyhow::Result; use anyhow::Result;
@ -6,11 +5,11 @@ use chrono::Utc;
use diesel::prelude::*; use diesel::prelude::*;
use diesel_async::RunQueryDsl; use diesel_async::RunQueryDsl;
use feedsignal_core::Article; use feedsignal_core::Article;
use schema::articles::dsl;
use uuid::Uuid; use uuid::Uuid;
impl Db { impl Db {
pub async fn insert_article(&self, article: &Article) -> Result<()> { pub async fn insert_article(&self, article: &Article) -> Result<()> {
use schema::articles::dsl;
let mut conn = self.pool.get().await?; let mut conn = self.pool.get().await?;
let topics = serde_json::to_string(&article.topics)?; let topics = serde_json::to_string(&article.topics)?;
diesel::insert_into(dsl::articles) diesel::insert_into(dsl::articles)
@ -36,6 +35,7 @@ impl Db {
/// Articles above the embedding-similarity threshold that haven't been /// Articles above the embedding-similarity threshold that haven't been
/// through the (slower) LLM scoring stage yet. /// through the (slower) LLM scoring stage yet.
pub async fn shortlist_for_llm_scoring(&self, threshold: f32, limit: i64) -> Result<Vec<Uuid>> { pub async fn shortlist_for_llm_scoring(&self, threshold: f32, limit: i64) -> Result<Vec<Uuid>> {
use schema::articles::dsl;
let mut conn = self.pool.get().await?; let mut conn = self.pool.get().await?;
let ids: Vec<String> = dsl::articles let ids: Vec<String> = dsl::articles
.filter(dsl::embedding_score.ge(threshold)) .filter(dsl::embedding_score.ge(threshold))
@ -54,6 +54,7 @@ impl Db {
&self, &self,
article_id: Uuid, article_id: Uuid,
) -> Result<Option<(String, String, Vec<String>, f32)>> { ) -> Result<Option<(String, String, Vec<String>, f32)>> {
use schema::articles::dsl;
let mut conn = self.pool.get().await?; let mut conn = self.pool.get().await?;
let row: Option<(String, String, String, Option<f32>)> = dsl::articles let row: Option<(String, String, String, Option<f32>)> = dsl::articles
.filter(dsl::id.eq(article_id.to_string())) .filter(dsl::id.eq(article_id.to_string()))
@ -61,16 +62,13 @@ impl Db {
.first(&mut conn) .first(&mut conn)
.await .await
.optional()?; .optional()?;
Ok(match row {
let result = match row {
Some((title, summary, topics_json, score)) => { Some((title, summary, topics_json, score)) => {
let topics: Vec<String> = serde_json::from_str(&topics_json).unwrap_or_default(); let topics: Vec<String> = serde_json::from_str(&topics_json).unwrap_or_default();
Some((title, summary, topics, score.unwrap_or(0.0))) Some((title, summary, topics, score.unwrap_or(0.0)))
} }
None => None, None => None,
}; })
Ok(result)
} }
pub async fn store_llm_result( pub async fn store_llm_result(
@ -93,27 +91,20 @@ impl Db {
Ok(()) Ok(())
} }
/// Highest-ranked articles for display, most relevant first. When /// Highest-ranked articles for display, most relevant first.
/// `feed_id` is given, only that feed's articles are returned —
/// otherwise every subscribed feed is joined into one ranked list.
pub async fn list_ranked_articles( pub async fn list_ranked_articles(
&self, &self,
feed_id: Option<Uuid>,
limit: i64, limit: i64,
) -> Result<Vec<RankedArticleRow>> { ) -> Result<Vec<(String, String, String, String, Vec<String>, Option<f32>)>> {
use schema::articles::dsl;
let mut conn = self.pool.get().await?; let mut conn = self.pool.get().await?;
// SQLite sorts NULL before any value, so `DESC` already puts NULL // SQLite sorts NULL before any value, so `DESC` already puts NULL
// `final_score`s last — no separate NULLS LAST clause needed here. // `final_score`s last — no separate NULLS LAST clause needed here.
let mut query = dsl::articles.into_boxed(); let rows: Vec<(String, String, String, String, String, Option<f32>)> = dsl::articles
if let Some(feed_id) = feed_id {
query = query.filter(dsl::feed_id.eq(feed_id.to_string()));
}
let rows: Vec<Row> = query
.order(dsl::final_score.desc()) .order(dsl::final_score.desc())
.limit(limit) .limit(limit)
.select(( .select((
dsl::id, dsl::id,
dsl::feed_id,
dsl::title, dsl::title,
dsl::url, dsl::url,
dsl::summary, dsl::summary,
@ -122,139 +113,18 @@ impl Db {
)) ))
.load(&mut conn) .load(&mut conn)
.await?; .await?;
Ok(rows.into_iter().map(RankedArticleRow::from).collect()) Ok(rows
} .into_iter()
} .map(|(id, title, url, summary, topics_json, final_score)| {
(
/// Raw shape of one `list_ranked_articles` row as loaded from SQLite —
/// `topics` is still the JSON string column, not yet decoded.
type Row = (String, String, String, String, String, String, Option<f32>);
impl From<Row> for RankedArticleRow {
fn from((id, feed_id, title, url, summary, topics_json, final_score): Row) -> Self {
Self {
id, id,
feed_id,
title, title,
url, url,
summary, summary,
topics: serde_json::from_str(&topics_json).unwrap_or_default(), serde_json::from_str(&topics_json).unwrap_or_default(),
final_score, final_score,
} )
} })
} .collect())
#[cfg(test)]
mod tests {
use super::*;
/// A `Db` backed by a throwaway SQLite file under the OS temp dir,
/// migrated fresh per test and deleted (including its `-wal`/`-shm`
/// siblings) when the test finishes, so tests can't see each other's
/// data or leak files across runs.
struct TestDb {
db: Db,
path: std::path::PathBuf,
}
impl std::ops::Deref for TestDb {
type Target = Db;
fn deref(&self) -> &Db {
&self.db
}
}
impl Drop for TestDb {
fn drop(&mut self) {
for suffix in ["", "-wal", "-shm"] {
let _ = std::fs::remove_file(format!("{}{suffix}", self.path.display()));
}
}
}
async fn test_db() -> TestDb {
let path = std::env::temp_dir().join(format!("feedsignal-test-{}.db", Uuid::new_v4()));
let db = Db::connect(path.to_str().unwrap())
.await
.expect("connect test db");
TestDb { db, path }
}
fn article(feed_id: Uuid, final_score: f32) -> Article {
Article {
id: Uuid::new_v4(),
feed_id,
url: format!("https://example.com/{}", Uuid::new_v4()),
title: "Title".to_string(),
summary: "Summary".to_string(),
published_at: None,
topics: vec![],
embedding_score: None,
llm_score: None,
final_score: Some(final_score),
estimated_read_seconds: None,
}
}
/// `feed_id: None` joins every subscribed feed's articles into one
/// ranked list — what the sidebar's "All" view depends on.
#[tokio::test]
async fn list_ranked_articles_with_no_feed_filter_returns_every_feed() {
let db = test_db().await;
let feed_a = db
.upsert_feed("https://a.example.com/feed", "Feed A")
.await
.unwrap();
let feed_b = db
.upsert_feed("https://b.example.com/feed", "Feed B")
.await
.unwrap();
db.insert_article(&article(feed_a, 0.9)).await.unwrap();
db.insert_article(&article(feed_b, 0.5)).await.unwrap();
let rows = db.list_ranked_articles(None, 10).await.unwrap();
assert_eq!(rows.len(), 2);
}
/// `feed_id: Some(id)` scopes the list to that one feed and excludes
/// every other subscribed feed's articles — what the sidebar's
/// per-feed nav depends on.
#[tokio::test]
async fn list_ranked_articles_with_feed_filter_excludes_other_feeds() {
let db = test_db().await;
let feed_a = db
.upsert_feed("https://a.example.com/feed", "Feed A")
.await
.unwrap();
let feed_b = db
.upsert_feed("https://b.example.com/feed", "Feed B")
.await
.unwrap();
db.insert_article(&article(feed_a, 0.9)).await.unwrap();
db.insert_article(&article(feed_b, 0.5)).await.unwrap();
let rows = db.list_ranked_articles(Some(feed_a), 10).await.unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].feed_id, feed_a.to_string());
}
/// Results stay ordered by `final_score` descending regardless of
/// insertion order, matching how the UI ranks the article list.
#[tokio::test]
async fn list_ranked_articles_orders_by_final_score_descending() {
let db = test_db().await;
let feed = db
.upsert_feed("https://a.example.com/feed", "Feed A")
.await
.unwrap();
db.insert_article(&article(feed, 0.2)).await.unwrap();
db.insert_article(&article(feed, 0.8)).await.unwrap();
let rows = db.list_ranked_articles(None, 10).await.unwrap();
assert_eq!(rows[0].final_score, Some(0.8));
assert_eq!(rows[1].final_score, Some(0.2));
} }
} }

View file

@ -1,7 +1,6 @@
use crate::schema; use crate::schema;
use crate::Db; use crate::Db;
use anyhow::Result; use anyhow::Result;
use chrono::Utc;
use diesel::prelude::*; use diesel::prelude::*;
use diesel_async::RunQueryDsl; use diesel_async::RunQueryDsl;
use uuid::Uuid; use uuid::Uuid;
@ -25,31 +24,4 @@ impl Db {
.await?; .await?;
Ok(Uuid::parse_str(&id)?) Ok(Uuid::parse_str(&id)?)
} }
/// All subscribed feeds, ordered by title. Used both by the polling job
/// (iterating to fetch each feed) and the sidebar's feed-navigation list.
pub async fn list_feeds(&self) -> Result<Vec<(Uuid, String, String)>> {
use schema::feeds::dsl;
let mut conn = self.pool.get().await?;
let rows: Vec<(String, String, String)> = dsl::feeds
.order(dsl::title.asc())
.select((dsl::id, dsl::title, dsl::url))
.load(&mut conn)
.await?;
rows.into_iter()
.map(|(id, title, url)| Ok((Uuid::parse_str(&id)?, title, url)))
.collect()
}
/// Records that a feed was just polled, so the next run can be judged
/// against it (e.g. surfaced in the UI as "last checked").
pub async fn mark_feed_fetched(&self, feed_id: Uuid) -> Result<()> {
use schema::feeds::dsl;
let mut conn = self.pool.get().await?;
diesel::update(dsl::feeds.filter(dsl::id.eq(feed_id.to_string())))
.set(dsl::last_fetched_at.eq(Utc::now().to_rfc3339()))
.execute(&mut conn)
.await?;
Ok(())
}
} }

View file

@ -1,10 +1,7 @@
mod affinities; mod affinities;
mod articles; mod articles;
mod feeds; mod feeds;
mod models;
mod reading_events; mod reading_events;
pub use models::RankedArticleRow;
pub(crate) mod schema; pub(crate) mod schema;
use anyhow::Result; use anyhow::Result;

View file

@ -1,10 +0,0 @@
/// One row of [`crate::Db::list_ranked_articles`].
pub struct RankedArticleRow {
pub id: String,
pub feed_id: String,
pub title: String,
pub url: String,
pub summary: String,
pub topics: Vec<String>,
pub final_score: Option<f32>,
}

View file

@ -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 }

View file

@ -1,80 +0,0 @@
use dioxus::prelude::*;
use dioxus_icons::lucide::BadgeCheck;
#[css_module("/src/components/badge/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 BadgeVariant {
#[default]
Primary,
Secondary,
Destructive,
Outline,
}
impl BadgeVariant {
#[allow(dead_code)]
pub fn class(&self) -> &'static str {
match self {
BadgeVariant::Primary => "primary",
BadgeVariant::Secondary => "secondary",
BadgeVariant::Destructive => "destructive",
BadgeVariant::Outline => "outline",
}
}
}
/// The props for the [`Badge`] component.
#[derive(Props, Clone, PartialEq)]
pub struct BadgeProps {
#[props(default)]
pub variant: BadgeVariant,
/// Additional attributes to extend the badge element
#[props(extends = GlobalAttributes)]
pub attributes: Vec<Attribute>,
/// The children of the badge element
pub children: Element,
}
#[component]
pub fn Badge(props: BadgeProps) -> Element {
rsx! {
BadgeElement {
"padding": true,
variant: props.variant,
attributes: props.attributes,
{props.children}
}
}
}
#[component]
fn BadgeElement(props: BadgeProps) -> Element {
rsx! {
span {
class: Styles::dx_badge,
"data-style": props.variant.class(),
..props.attributes,
{props.children}
}
}
}
#[component]
pub fn VerifiedIcon() -> Element {
rsx! {
BadgeCheck {
size: "12px",
stroke: "var(--secondary-color-4)",
}
}
}

View file

@ -1,42 +0,0 @@
.dx-badge-example {
display: flex;
align-items: center;
gap: 1rem;
}
.dx-badge {
display: inline-flex;
min-width: 20px;
height: 20px;
align-items: center;
justify-content: center;
border-radius: 10px;
box-shadow: 0 0 0 1px var(--primary-color-2);
font-size: 12px;
gap: 4px
}
.dx-badge[padding="true"] {
padding: 0 8px;
}
.dx-badge[data-style="primary"] {
background-color: var(--secondary-color-2);
color: var(--primary-color);
}
.dx-badge[data-style="secondary"] {
background-color: var(--primary-color-5);
color: var(--secondary-color-1);
}
.dx-badge[data-style="outline"] {
border: 1px solid var(--primary-color-6);
background-color: var(--light, var(--primary-color)) var(--dark, var(--primary-color-3));
color: var(--secondary-color-4);
}
.dx-badge[data-style="destructive"] {
background-color: var(--primary-error-color);
color: var(--contrast-error-color);
}

View file

@ -1,2 +0,0 @@
mod component;
pub use component::*;

View file

@ -1,10 +0,0 @@
// AUTOGENERATED Components module
pub mod badge;
pub mod button;
pub mod input;
pub mod scroll_area;
pub mod separator;
pub mod sheet;
pub mod sidebar;
pub mod skeleton;
pub mod tooltip;

View file

@ -1,7 +0,0 @@
use dioxus::prelude::*;
use dioxus_primitives::scroll_area::{self, ScrollAreaProps};
#[component]
pub fn ScrollArea(props: ScrollAreaProps) -> Element {
scroll_area::ScrollArea(props)
}

View file

@ -1,2 +0,0 @@
mod component;
pub use component::*;

View file

@ -1 +0,0 @@
/* Scroll area doesn't require any additional styles */

View file

@ -1,23 +0,0 @@
use dioxus::prelude::*;
use dioxus_primitives::separator::{self, SeparatorProps};
use dioxus_primitives::{dioxus_attributes::attributes, merge_attributes};
#[css_module("/src/components/separator/style.css")]
struct Styles;
#[component]
pub fn Separator(props: SeparatorProps) -> Element {
let base = attributes!(div {
class: Styles::dx_separator,
});
let merged = merge_attributes(vec![base, props.attributes]);
rsx! {
separator::Separator {
horizontal: props.horizontal,
decorative: props.decorative,
attributes: merged,
{props.children}
}
}
}

View file

@ -1,2 +0,0 @@
mod component;
pub use component::*;

View file

@ -1,13 +0,0 @@
.dx-separator {
background-color: var(--light, var(--primary-color-6)) var(--dark, var(--primary-color-7));
}
.dx-separator[data-orientation="horizontal"] {
width: 100%;
height: 1px;
}
.dx-separator[data-orientation="vertical"] {
width: 1px;
height: 100%;
}

View file

@ -1,150 +0,0 @@
use dioxus::prelude::*;
use dioxus_icons::lucide::X;
use dioxus_primitives::dialog::{
self, DialogCtx, DialogDescriptionProps, DialogRootProps, DialogTitleProps,
};
use dioxus_primitives::dioxus_attributes::attributes;
use dioxus_primitives::merge_attributes;
#[css_module("/src/components/sheet/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(Debug, Clone, Copy, Default, PartialEq)]
pub enum SheetSide {
Top,
#[default]
Right,
Bottom,
Left,
}
impl SheetSide {
pub fn as_str(&self) -> &'static str {
match self {
SheetSide::Top => "top",
SheetSide::Right => "right",
SheetSide::Bottom => "bottom",
SheetSide::Left => "left",
}
}
}
#[component]
pub fn Sheet(props: DialogRootProps) -> Element {
let content_base = attributes!(div {
class: Styles::dx_sheet,
"data-slot": "sheet-content",
"data-side": SheetSide::Right.as_str(),
});
let content_attributes = merge_attributes(vec![content_base, props.attributes]);
rsx! {
dialog::DialogRoot {
class: Styles::dx_sheet_root,
"data-slot": "sheet-root",
id: props.id,
is_modal: props.is_modal,
open: props.open,
default_open: props.default_open,
on_open_change: props.on_open_change,
dialog::DialogContent {
class: None,
attributes: content_attributes,
{props.children}
}
}
}
}
#[component]
pub fn SheetContentClose(
#[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
) -> Element {
let base = attributes!(button {
class: Styles::dx_sheet_close,
});
let attributes = merge_attributes(vec![base, attributes]);
rsx! {
SheetClose { attributes,
X { size: "20px" }
}
}
}
#[component]
pub fn SheetHeader(
#[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
children: Element,
) -> Element {
rsx! {
div { class: Styles::dx_sheet_header, "data-slot": "sheet-header", ..attributes, {children} }
}
}
#[component]
pub fn SheetFooter(
#[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
children: Element,
) -> Element {
rsx! {
div { class: Styles::dx_sheet_footer, "data-slot": "sheet-footer", ..attributes, {children} }
}
}
#[component]
pub fn SheetTitle(props: DialogTitleProps) -> Element {
rsx! {
dialog::DialogTitle {
id: props.id,
class: Styles::dx_sheet_title,
"data-slot": "sheet-title",
attributes: props.attributes,
{props.children}
}
}
}
#[component]
pub fn SheetDescription(props: DialogDescriptionProps) -> Element {
rsx! {
dialog::DialogDescription {
id: props.id,
class: Styles::dx_sheet_description,
"data-slot": "sheet-description",
attributes: props.attributes,
{props.children}
}
}
}
#[component]
pub fn SheetClose(
#[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
r#as: Option<Callback<Vec<Attribute>, Element>>,
children: Element,
) -> Element {
let ctx: DialogCtx = use_context();
let base = attributes! {
button {
onclick: move |_| {
ctx.set_open(false);
}
}
};
let merged = merge_attributes(vec![base, attributes]);
if let Some(dynamic) = r#as {
dynamic.call(merged)
} else {
rsx! {
button { ..merged, {children} }
}
}
}

View file

@ -1,2 +0,0 @@
mod component;
pub use component::*;

View file

@ -1,253 +0,0 @@
/* Sheet Root */
.dx-sheet-root {
position: fixed;
z-index: 1000;
background: rgb(0 0 0 / 50%);
inset: 0;
opacity: 0;
will-change: opacity;
}
.dx-sheet-root[data-state="closed"] {
animation: dx-sheet-root-out 150ms ease-in forwards;
pointer-events: none;
}
.dx-sheet-root[data-state="open"] {
animation: dx-sheet-root-in 200ms ease-out forwards;
}
@keyframes dx-sheet-root-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes dx-sheet-root-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
.dx-sheet {
position: fixed;
z-index: 1001;
display: flex;
box-sizing: border-box;
flex-direction: column;
border: none;
background: var(--primary-color-2);
box-shadow: 0 4px 20px rgb(0 0 0 / 20%);
color: var(--secondary-color-4);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
gap: 1rem;
will-change: transform;
}
.dx-sheet[data-side="right"],
.dx-sheet[data-side="left"] {
top: 0;
bottom: 0;
width: 75%;
max-width: 24rem;
}
.dx-sheet[data-side="right"] {
right: 0;
border-left: 1px solid var(--primary-color-6);
transform: translateX(100%);
}
.dx-sheet[data-side="left"] {
left: 0;
border-right: 1px solid var(--primary-color-6);
transform: translateX(-100%);
}
.dx-sheet[data-side="top"],
.dx-sheet[data-side="bottom"] {
right: 0;
left: 0;
}
.dx-sheet[data-side="top"] {
top: 0;
border-bottom: 1px solid var(--primary-color-6);
transform: translateY(-100%);
}
.dx-sheet[data-side="bottom"] {
bottom: 0;
border-top: 1px solid var(--primary-color-6);
transform: translateY(100%);
}
.dx-sheet-root[data-state="open"] .dx-sheet[data-side="right"] {
animation: dx-slide-in-right 200ms ease-out forwards;
}
.dx-sheet-root[data-state="open"] .dx-sheet[data-side="left"] {
animation: dx-slide-in-left 200ms ease-out forwards;
}
.dx-sheet-root[data-state="open"] .dx-sheet[data-side="top"] {
animation: dx-slide-in-top 200ms ease-out forwards;
}
.dx-sheet-root[data-state="open"] .dx-sheet[data-side="bottom"] {
animation: dx-slide-in-bottom 200ms ease-out forwards;
}
.dx-sheet-root[data-state="closed"] .dx-sheet[data-side="right"] {
animation: dx-slide-out-right 150ms ease-in forwards;
}
.dx-sheet-root[data-state="closed"] .dx-sheet[data-side="left"] {
animation: dx-slide-out-left 150ms ease-in forwards;
}
.dx-sheet-root[data-state="closed"] .dx-sheet[data-side="top"] {
animation: dx-slide-out-top 150ms ease-in forwards;
}
.dx-sheet-root[data-state="closed"] .dx-sheet[data-side="bottom"] {
animation: dx-slide-out-bottom 150ms ease-in forwards;
}
@keyframes dx-slide-in-right {
from {
transform: translateX(100%);
}
to {
transform: translateX(0);
}
}
@keyframes dx-slide-out-right {
from {
transform: translateX(0);
}
to {
transform: translateX(100%);
}
}
@keyframes dx-slide-in-left {
from {
transform: translateX(-100%);
}
to {
transform: translateX(0);
}
}
@keyframes dx-slide-out-left {
from {
transform: translateX(0);
}
to {
transform: translateX(-100%);
}
}
@keyframes dx-slide-in-top {
from {
transform: translateY(-100%);
}
to {
transform: translateY(0);
}
}
@keyframes dx-slide-out-top {
from {
transform: translateY(0);
}
to {
transform: translateY(-100%);
}
}
@keyframes dx-slide-in-bottom {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
@keyframes dx-slide-out-bottom {
from {
transform: translateY(0);
}
to {
transform: translateY(100%);
}
}
.dx-sheet-header {
display: flex;
flex-direction: column;
padding: 1rem;
gap: 0.375rem;
}
.dx-sheet-footer {
display: flex;
flex-direction: column;
padding: 1rem;
margin-top: auto;
gap: 0.5rem;
}
.dx-sheet-title {
margin: 0;
color: var(--secondary-color-4);
font-size: 1.125rem;
font-weight: 600;
}
.dx-sheet-description {
margin: 0;
color: var(--secondary-color-5);
font-size: 0.875rem;
}
.dx-sheet-close {
position: absolute;
top: 1rem;
right: 1rem;
display: flex;
width: 24px;
height: 24px;
align-items: center;
justify-content: center;
padding: 0;
border: none;
border-radius: 4px;
background: none;
color: var(--primary-color-7);
cursor: pointer;
transition: color 150ms ease, background-color 150ms ease;
}
.dx-sheet-close:hover {
color: var(--secondary-color-4);
}

View file

@ -1,845 +0,0 @@
use crate::components::button::{Button, ButtonVariant};
use crate::components::separator::Separator;
use crate::components::sheet::{
Sheet, SheetContentClose, SheetDescription, SheetHeader, SheetSide, SheetTitle,
};
use crate::components::skeleton::Skeleton;
use crate::components::tooltip::{Tooltip, TooltipContent, TooltipTrigger};
use dioxus::core::use_drop;
use dioxus::prelude::*;
use dioxus_icons::lucide::PanelLeft;
use dioxus_primitives::dioxus_attributes::attributes;
use dioxus_primitives::merge_attributes;
use dioxus_primitives::use_controlled;
#[css_module("/src/components/sidebar/style.css")]
struct Styles;
// constants
const SIDEBAR_WIDTH: &str = "16rem";
const SIDEBAR_WIDTH_MOBILE: &str = "18rem";
const SIDEBAR_WIDTH_ICON: &str = "3rem";
const SIDEBAR_KEYBOARD_SHORTCUT: &str = "b";
const MOBILE_BREAKPOINT: u32 = 768;
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub enum SidebarState {
#[default]
Expanded,
Collapsed,
}
impl SidebarState {
pub fn as_str(&self) -> &'static str {
match self {
SidebarState::Expanded => "expanded",
SidebarState::Collapsed => "collapsed",
}
}
}
// Variants/methods beyond what 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(Debug, Clone, Copy, Default, PartialEq)]
pub enum SidebarSide {
#[default]
Left,
Right,
}
impl SidebarSide {
pub fn as_str(&self) -> &'static str {
match self {
SidebarSide::Left => "left",
SidebarSide::Right => "right",
}
}
}
#[allow(dead_code)]
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub enum SidebarVariant {
#[default]
Sidebar,
Floating,
Inset,
}
impl SidebarVariant {
pub fn as_str(&self) -> &'static str {
match self {
SidebarVariant::Sidebar => "sidebar",
SidebarVariant::Floating => "floating",
SidebarVariant::Inset => "inset",
}
}
}
#[allow(dead_code)]
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub enum SidebarCollapsible {
#[default]
Offcanvas,
Icon,
None,
}
impl SidebarCollapsible {
pub fn as_str(&self) -> &'static str {
match self {
SidebarCollapsible::Offcanvas => "offcanvas",
SidebarCollapsible::Icon => "icon",
SidebarCollapsible::None => "none",
}
}
}
#[derive(Clone, Copy)]
#[allow(dead_code)]
pub struct SidebarCtx {
pub state: Memo<SidebarState>,
pub side: Signal<SidebarSide>,
pub is_mobile: Signal<bool>,
// From use_controlled:
open: Memo<bool>,
set_open: Callback<bool>,
// Mobile state:
open_mobile: Signal<bool>,
}
impl SidebarCtx {
/// Toggle the sidebar open/closed state
pub fn toggle(&self) {
if (self.is_mobile)() {
let current = (self.open_mobile)();
let mut open_mobile = self.open_mobile;
open_mobile.set(!current);
} else {
self.set_open.call(!self.open());
}
}
/// Set the mobile sidebar open state
pub fn set_open_mobile(&self, value: bool) {
let mut open_mobile = self.open_mobile;
open_mobile.set(value);
}
/// Get the current open state (desktop)
pub fn open(&self) -> bool {
self.open.cloned()
}
}
pub fn use_sidebar() -> SidebarCtx {
use_context::<SidebarCtx>()
}
pub fn use_is_mobile() -> Signal<bool> {
let mut is_mobile = use_signal(|| false);
use_effect(move || {
spawn(async move {
let js_code = format!(
r#"
function checkMobile() {{
return window.innerWidth < {MOBILE_BREAKPOINT};
}}
function handleResize() {{
dioxus.send(checkMobile());
}}
window.__sidebarResizeHandler = handleResize;
window.addEventListener('resize', window.__sidebarResizeHandler);
dioxus.send(checkMobile());
"#
);
let mut eval = document::eval(&js_code);
while let Ok(result) = eval.recv::<bool>().await {
is_mobile.set(result);
}
});
});
use_drop(|| {
_ = document::eval(
r#"
window.removeEventListener('resize', window.__sidebarResizeHandler);
delete window.__sidebarResizeHandler;
"#,
);
});
is_mobile
}
#[component]
pub fn SidebarProvider(
#[props(default = true)] default_open: bool,
#[props(default)] open: ReadSignal<Option<bool>>,
#[props(default)] on_open_change: Callback<bool>,
#[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
children: Element,
) -> Element {
let is_mobile = use_is_mobile();
let side = use_signal(|| SidebarSide::Left);
let open_mobile = use_signal(|| false);
let (open, set_open) = use_controlled(open, default_open, on_open_change);
let state = use_memo(move || {
if open() {
SidebarState::Expanded
} else {
SidebarState::Collapsed
}
});
let ctx = SidebarCtx {
state,
side,
is_mobile,
open,
set_open,
open_mobile,
};
use_context_provider(|| ctx);
use_effect(move || {
spawn(async move {
let js_code = format!(
r#"
function sidebarKeyHandler(event) {{
if (event.key === '{SIDEBAR_KEYBOARD_SHORTCUT}' && (event.metaKey || event.ctrlKey)) {{
event.preventDefault();
dioxus.send(true);
}}
}}
window.__sidebarKeyHandler = sidebarKeyHandler;
window.addEventListener('keydown', window.__sidebarKeyHandler);
"#
);
let mut eval = document::eval(&js_code);
loop {
if eval.recv::<bool>().await.is_ok() {
ctx.toggle();
}
}
});
});
use_drop(|| {
_ = document::eval(
r#"
window.removeEventListener('keydown', window.__sidebarKeyHandler);
delete window.__sidebarKeyHandler;
"#,
);
});
let sidebar_style = format!(
r#"
--dx-sidebar-width: {SIDEBAR_WIDTH};
--dx-sidebar-width-mobile: {SIDEBAR_WIDTH_MOBILE};
--dx-sidebar-width-icon: {SIDEBAR_WIDTH_ICON}
"#
);
let base = attributes!(div {
class: Styles::dx_sidebar_wrapper,
"data-slot": "sidebar-wrapper",
style: sidebar_style,
});
let merged = merge_attributes(vec![base, attributes]);
rsx! {
div { ..merged, {children} }
}
}
#[component]
pub fn Sidebar(
#[props(default)] side: SidebarSide,
#[props(default)] variant: SidebarVariant,
#[props(default)] collapsible: SidebarCollapsible,
#[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
children: Element,
) -> Element {
let ctx = use_sidebar();
let mut ctx_side = ctx.side;
if *ctx_side.peek() != side {
ctx_side.set(side);
}
let is_mobile = ctx.is_mobile;
let state = ctx.state;
let open_mobile = ctx.open_mobile;
if collapsible == SidebarCollapsible::None {
let base = attributes!(div {
class: Styles::dx_sidebar_static,
"data-slot": "sidebar",
});
let merged = merge_attributes(vec![base, attributes]);
return rsx! {
div { ..merged, {children} }
};
}
if is_mobile() {
let sheet_side = match side {
SidebarSide::Left => SheetSide::Left,
SidebarSide::Right => SheetSide::Right,
};
return rsx! {
Sheet {
open: open_mobile(),
on_open_change: move |v| ctx.set_open_mobile(v),
"data-side": sheet_side.as_str(),
class: Styles::dx_sidebar_sheet.to_string(),
"data-sidebar": "sidebar",
"data-slot": "sidebar",
"data-mobile": "true",
SheetContentClose { class: Styles::dx_sidebar_sheet_close }
SheetHeader { class: Styles::dx_sr_only,
SheetTitle { "Sidebar" }
SheetDescription { "Displays the mobile sidebar." }
}
div { class: Styles::dx_sidebar_mobile_inner, {children} }
}
};
}
let collapsible_str = if state() == SidebarState::Collapsed {
collapsible.as_str()
} else {
""
};
let container_base = attributes!(div {
class: Styles::dx_sidebar_container,
"data-slot": "sidebar-container",
});
let container_attrs = merge_attributes(vec![container_base, attributes]);
rsx! {
div {
class: Styles::dx_sidebar_desktop,
"data-state": state().as_str(),
"data-collapsible": collapsible_str,
"data-variant": variant.as_str(),
"data-side": side.as_str(),
"data-slot": "sidebar",
div { class: Styles::dx_sidebar_gap, "data-slot": "sidebar-gap" }
div {
..container_attrs,
div {
class: Styles::dx_sidebar_inner,
"data-sidebar": "sidebar",
"data-slot": "sidebar-inner",
{children}
}
}
}
}
}
#[component]
pub fn SidebarTrigger(
#[props(default)] onclick: Option<EventHandler<MouseEvent>>,
#[props(extends = GlobalAttributes)]
#[props(extends = button)]
attributes: Vec<Attribute>,
) -> Element {
let ctx = use_sidebar();
let base = attributes!(button {
class: Styles::dx_sidebar_trigger,
"data-sidebar": "trigger",
"data-slot": "sidebar-trigger",
});
let merged = merge_attributes(vec![base, attributes]);
rsx! {
Button {
variant: ButtonVariant::Ghost,
onclick: move |e| {
if let Some(handler) = &onclick {
handler.call(e);
}
ctx.toggle();
},
attributes: merged,
PanelLeft {
class: Styles::dx_sidebar_trigger_icon,
size: "1rem",
}
span { class: Styles::dx_sr_only, "Toggle Sidebar" }
}
}
}
#[component]
pub fn SidebarRail(#[props(extends = GlobalAttributes)] attributes: Vec<Attribute>) -> Element {
let ctx = use_sidebar();
let base = attributes!(button {
class: Styles::dx_sidebar_rail,
"data-sidebar": "rail",
"data-slot": "sidebar-rail",
});
let merged = merge_attributes(vec![base, attributes]);
rsx! {
button {
aria_label: "Toggle Sidebar",
tabindex: -1,
onclick: move |_| ctx.toggle(),
title: "Toggle Sidebar",
..merged,
}
}
}
#[component]
pub fn SidebarInset(
#[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
children: Element,
) -> Element {
let base = attributes!(main {
class: Styles::dx_sidebar_inset,
"data-slot": "sidebar-inset",
});
let merged = merge_attributes(vec![base, attributes]);
rsx! {
main { ..merged, {children} }
}
}
#[component]
pub fn SidebarHeader(
#[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
children: Element,
) -> Element {
let base = attributes!(div {
class: Styles::dx_sidebar_header,
"data-slot": "sidebar-header",
"data-sidebar": "header",
});
let merged = merge_attributes(vec![base, attributes]);
rsx! {
div { ..merged, {children} }
}
}
#[component]
pub fn SidebarContent(
#[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
children: Element,
) -> Element {
let base = attributes!(div {
class: Styles::dx_sidebar_content,
"data-slot": "sidebar-content",
"data-sidebar": "content",
});
let merged = merge_attributes(vec![base, attributes]);
rsx! {
div { ..merged, {children} }
}
}
#[component]
pub fn SidebarFooter(
#[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
children: Element,
) -> Element {
let base = attributes!(div {
class: Styles::dx_sidebar_footer,
"data-slot": "sidebar-footer",
"data-sidebar": "footer",
});
let merged = merge_attributes(vec![base, attributes]);
rsx! {
div { ..merged, {children} }
}
}
#[component]
pub fn SidebarSeparator(
#[props(default = true)] horizontal: bool,
#[props(default = true)] decorative: bool,
#[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
) -> Element {
let base = attributes!(div {
class: Styles::dx_sidebar_separator,
"data-slot": "sidebar-separator",
"data-sidebar": "separator",
});
let merged = merge_attributes(vec![base, attributes]);
rsx! {
Separator { horizontal, decorative, attributes: merged }
}
}
#[component]
pub fn SidebarGroup(
#[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
children: Element,
) -> Element {
let base = attributes!(div {
class: Styles::dx_sidebar_group,
"data-slot": "sidebar-group",
"data-sidebar": "group",
});
let merged = merge_attributes(vec![base, attributes]);
rsx! {
div { ..merged, {children} }
}
}
#[component]
pub fn SidebarGroupLabel(
r#as: Option<Callback<Vec<Attribute>, Element>>,
#[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
children: Element,
) -> Element {
let base = attributes!(div {
class: Styles::dx_sidebar_group_label,
"data-slot": "sidebar-group-label",
"data-sidebar": "group-label",
});
let merged = merge_attributes(vec![base, attributes]);
if let Some(dynamic) = r#as {
dynamic.call(merged)
} else {
rsx! {
div { ..merged,{children} }
}
}
}
#[component]
pub fn SidebarGroupAction(
r#as: Option<Callback<Vec<Attribute>, Element>>,
#[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
children: Element,
) -> Element {
let base = attributes!(button {
class: Styles::dx_sidebar_group_action,
"data-slot": "sidebar-group-action",
"data-sidebar": "group-action",
});
let merged = merge_attributes(vec![base, attributes]);
if let Some(dynamic) = r#as {
dynamic.call(merged)
} else {
rsx! {
button { ..merged,{children} }
}
}
}
#[component]
pub fn SidebarGroupContent(
#[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
children: Element,
) -> Element {
let base = attributes!(div {
class: Styles::dx_sidebar_group_content,
"data-slot": "sidebar-group-content",
"data-sidebar": "group-content",
});
let merged = merge_attributes(vec![base, attributes]);
rsx! {
div { ..merged, {children} }
}
}
#[component]
pub fn SidebarMenu(
#[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
children: Element,
) -> Element {
let base = attributes!(ul {
class: Styles::dx_sidebar_menu,
"data-slot": "sidebar-menu",
"data-sidebar": "menu",
});
let merged = merge_attributes(vec![base, attributes]);
rsx! {
ul { ..merged, {children} }
}
}
#[component]
pub fn SidebarMenuItem(
#[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
children: Element,
) -> Element {
let base = attributes!(li {
class: Styles::dx_sidebar_menu_item,
"data-slot": "sidebar-menu-item",
"data-sidebar": "menu-item",
});
let merged = merge_attributes(vec![base, attributes]);
rsx! {
li { ..merged, {children} }
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq)]
#[allow(dead_code)]
pub enum SidebarMenuButtonVariant {
#[default]
Default,
Outline,
}
impl SidebarMenuButtonVariant {
pub fn as_str(&self) -> &'static str {
match self {
SidebarMenuButtonVariant::Default => "default",
SidebarMenuButtonVariant::Outline => "outline",
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq)]
#[allow(dead_code)]
pub enum SidebarMenuButtonSize {
#[default]
Default,
Sm,
Lg,
}
impl SidebarMenuButtonSize {
pub fn as_str(&self) -> &'static str {
match self {
SidebarMenuButtonSize::Default => "default",
SidebarMenuButtonSize::Sm => "sm",
SidebarMenuButtonSize::Lg => "lg",
}
}
}
#[component]
pub fn SidebarMenuButton(
#[props(default = false)] is_active: bool,
#[props(default)] variant: SidebarMenuButtonVariant,
#[props(default)] size: SidebarMenuButtonSize,
#[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
#[props(default)] tooltip: Option<Element>,
r#as: Option<Callback<Vec<Attribute>, Element>>,
children: Element,
) -> Element {
let ctx = use_sidebar();
let is_mobile = ctx.is_mobile;
let state = ctx.state;
let base = attributes!(button {
class: Styles::dx_sidebar_menu_button,
"data-slot": "sidebar-menu-button",
"data-sidebar": "menu-button",
"data-size": size.as_str(),
"data-variant": variant.as_str(),
"data-active": if is_active { "true" } else { "false" },
});
let merged = merge_attributes(vec![base, attributes]);
let Some(tooltip_content) = tooltip else {
return if let Some(dynamic) = r#as {
dynamic.call(merged)
} else {
rsx! { button { ..merged, {children} } }
};
};
let hidden = state() != SidebarState::Collapsed || is_mobile();
let sidebar_side = ctx.side;
rsx! {
Tooltip {
class: Styles::dx_sidebar_tooltip,
disabled: hidden,
TooltipTrigger {
as: move |tooltip_attrs: Vec<Attribute>| {
let final_attrs = merge_attributes(vec![tooltip_attrs, merged.clone()]);
let children = children.clone();
if let Some(dynamic) = &r#as {
dynamic.call(final_attrs)
} else {
rsx! { button { ..final_attrs, {children} } }
}
},
}
TooltipContent {
side: match sidebar_side() {
SidebarSide::Left => dioxus_primitives::ContentSide::Right,
SidebarSide::Right => dioxus_primitives::ContentSide::Left,
},
{tooltip_content}
}
}
}
}
#[component]
pub fn SidebarMenuAction(
#[props(default = false)] show_on_hover: bool,
r#as: Option<Callback<Vec<Attribute>, Element>>,
#[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
children: Element,
) -> Element {
let base = attributes!(button {
class: Styles::dx_sidebar_menu_action,
"data-slot": "sidebar-menu-action",
"data-sidebar": "menu-action",
"data-show-on-hover": if show_on_hover { "true" } else { "false" },
});
let merged = merge_attributes(vec![base, attributes]);
if let Some(dynamic) = r#as {
dynamic.call(merged)
} else {
rsx! {
button { ..merged,{children} }
}
}
}
#[component]
pub fn SidebarMenuBadge(
#[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
children: Element,
) -> Element {
let base = attributes!(div {
class: Styles::dx_sidebar_menu_badge,
"data-slot": "sidebar-menu-badge",
"data-sidebar": "menu-badge",
});
let merged = merge_attributes(vec![base, attributes]);
rsx! {
div { ..merged, {children} }
}
}
#[component]
pub fn SidebarMenuSkeleton(
#[props(default = false)] show_icon: bool,
#[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
) -> Element {
let base = attributes!(div {
class: Styles::dx_sidebar_menu_skeleton,
"data-slot": "sidebar-menu-skeleton",
"data-sidebar": "menu-skeleton",
});
let merged = merge_attributes(vec![base, attributes]);
rsx! {
div {
..merged,
if show_icon {
Skeleton { class: Styles::dx_sidebar_menu_skeleton_icon }
}
Skeleton { class: Styles::dx_sidebar_menu_skeleton_text, width: "70%" }
}
}
}
#[component]
pub fn SidebarMenuSub(
#[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
children: Element,
) -> Element {
let base = attributes!(ul {
class: Styles::dx_sidebar_menu_sub,
"data-slot": "sidebar-menu-sub",
"data-sidebar": "menu-sub",
});
let merged = merge_attributes(vec![base, attributes]);
rsx! {
ul { ..merged, {children} }
}
}
#[component]
pub fn SidebarMenuSubItem(
#[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
children: Element,
) -> Element {
let base = attributes!(li {
class: Styles::dx_sidebar_menu_sub_item,
"data-slot": "sidebar-menu-sub-item",
"data-sidebar": "menu-sub-item",
});
let merged = merge_attributes(vec![base, attributes]);
rsx! {
li { ..merged, {children} }
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq)]
#[allow(dead_code)]
pub enum SidebarMenuSubButtonSize {
Sm,
#[default]
Md,
}
impl SidebarMenuSubButtonSize {
#[allow(dead_code)]
pub fn as_str(&self) -> &'static str {
match self {
SidebarMenuSubButtonSize::Sm => "sm",
SidebarMenuSubButtonSize::Md => "md",
}
}
}
#[component]
pub fn SidebarMenuSubButton(
#[props(default = false)] is_active: bool,
#[props(default)] size: SidebarMenuSubButtonSize,
r#as: Option<Callback<Vec<Attribute>, Element>>,
#[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
children: Element,
) -> Element {
let base = attributes!(a {
class: Styles::dx_sidebar_menu_sub_button,
"data-slot": "sidebar-menu-sub-button",
"data-sidebar": "menu-sub-button",
"data-size": size.as_str(),
"data-active": if is_active { "true" } else { "false" },
});
let merged = merge_attributes(vec![base, attributes]);
if let Some(dynamic) = r#as {
dynamic.call(merged)
} else {
rsx! {
a { ..merged, {children} }
}
}
}

View file

@ -1,2 +0,0 @@
mod component;
pub use component::*;

View file

@ -1,855 +0,0 @@
/* TODO: abstract as Utilitiy class */
.dx-sr-only {
position: absolute;
overflow: hidden;
width: 1px;
height: 1px;
padding: 0;
border: 0;
margin: -1px;
clip-path: inset(50%);
white-space: nowrap;
}
.dx-sidebar-wrapper {
--dx-sidebar-background: var(--primary-color-2);
--dx-sidebar-foreground: var(--secondary-color-4);
--dx-sidebar-border: var(--primary-color-6);
--dx-sidebar-accent: var(--primary-color-4);
--dx-sidebar-accent-foreground: var(--secondary-color-4);
--dx-sidebar-ring: var(--primary-color-7);
display: flex;
overflow: hidden;
width: 100%;
height: 100svh;
min-height: 100svh;
}
@media (width >=768px) {
.dx-sidebar-wrapper:has(.dx-sidebar-desktop[data-side="right"]) {
flex-direction: row-reverse;
}
.dx-sidebar-wrapper:has(.dx-sidebar-desktop[data-variant="inset"]) {
background: var(--dx-sidebar-background);
}
}
.dx-sidebar-desktop {
display: none;
color: var(--dx-sidebar-foreground);
}
@media (width >=768px) {
.dx-sidebar-desktop {
display: block;
}
}
.dx-sidebar-gap {
position: relative;
width: var(--dx-sidebar-width);
background: transparent;
transition: width 200ms ease-out;
}
[data-collapsible="icon"] .dx-sidebar-gap {
width: var(--dx-sidebar-width-icon);
}
[data-variant="floating"] .dx-sidebar-gap,
[data-variant="inset"] .dx-sidebar-gap {
width: var(--dx-sidebar-width);
}
[data-variant="floating"][data-collapsible="icon"] .dx-sidebar-gap,
[data-variant="inset"][data-collapsible="icon"] .dx-sidebar-gap {
width: calc(var(--dx-sidebar-width-icon) + 1rem);
}
[data-collapsible="offcanvas"] .dx-sidebar-gap {
width: 0;
}
.dx-sidebar-container {
position: fixed;
z-index: 10;
top: 0;
bottom: 0;
display: none;
width: var(--dx-sidebar-width);
height: 100svh;
box-sizing: border-box;
transition: left 200ms ease-out, right 200ms ease-out, width 200ms ease-out;
}
@media (width >=768px) {
.dx-sidebar-container {
display: flex;
}
}
[data-side="left"] .dx-sidebar-container {
left: 0;
}
[data-side="left"][data-collapsible="offcanvas"] .dx-sidebar-container {
left: calc(var(--dx-sidebar-width) * -1);
}
[data-side="right"] .dx-sidebar-container {
right: 0;
}
[data-side="right"][data-collapsible="offcanvas"] .dx-sidebar-container {
right: calc(var(--dx-sidebar-width) * -1);
}
[data-collapsible="icon"] .dx-sidebar-container {
overflow: visible;
width: var(--dx-sidebar-width-icon);
}
[data-collapsible="icon"] .dx-sidebar-inner {
overflow: visible;
}
[data-variant="sidebar"][data-side="left"] .dx-sidebar-container {
border-right: 1px solid var(--dx-sidebar-border);
}
[data-variant="sidebar"][data-side="right"] .dx-sidebar-container {
border-left: 1px solid var(--dx-sidebar-border);
}
[data-variant="floating"] .dx-sidebar-container,
[data-variant="inset"] .dx-sidebar-container {
padding: 0.5rem;
}
[data-variant="floating"][data-collapsible="icon"] .dx-sidebar-container,
[data-variant="inset"][data-collapsible="icon"] .dx-sidebar-container {
width: calc(var(--dx-sidebar-width-icon) + 1rem + 2px);
}
.dx-sidebar-inner {
display: flex;
width: 100%;
height: 100%;
box-sizing: border-box;
flex-direction: column;
background: var(--dx-sidebar-background);
}
[data-variant="floating"] .dx-sidebar-inner {
border: 1px solid var(--dx-sidebar-border);
border-radius: 0.5rem;
box-shadow: 0 1px 3px rgb(0 0 0 / 10%);
}
.dx-sidebar-static {
display: flex;
width: var(--dx-sidebar-width);
height: 100%;
flex-direction: column;
background: var(--dx-sidebar-background);
color: var(--dx-sidebar-foreground);
}
.dx-sidebar-sheet {
width: var(--dx-sidebar-width-mobile) !important;
padding: 0 !important;
background: var(--dx-sidebar-background);
}
.dx-sidebar-sheet-close {
display: none;
}
.dx-sidebar-mobile-inner {
display: flex;
width: 100%;
height: 100%;
flex-direction: column;
}
.dx-sidebar-trigger {
display: inline-flex;
width: 1.75rem;
height: 1.75rem;
align-items: center;
justify-content: center;
padding: 0 !important;
line-height: 0;
}
.dx-sidebar-trigger-icon {
width: 1rem;
height: 1rem;
}
.dx-sidebar-rail {
position: absolute;
z-index: 20;
top: 0;
bottom: 0;
display: none;
width: 1rem;
padding: 0;
border: none;
background: transparent;
cursor: ew-resize;
transform: translateX(-50%);
transition: all 200ms ease-out;
}
@media (width >=640px) {
.dx-sidebar-rail {
display: flex;
}
}
.dx-sidebar-rail::after {
position: absolute;
top: 0;
bottom: 0;
left: 50%;
width: 2px;
content: "";
}
.dx-sidebar-rail:hover::after {
background: var(--dx-sidebar-border);
}
[data-side="left"] .dx-sidebar-rail {
right: -1rem;
cursor: w-resize;
}
[data-side="right"] .dx-sidebar-rail {
left: 0;
cursor: e-resize;
}
[data-side="left"][data-state="collapsed"] .dx-sidebar-rail {
cursor: e-resize;
}
[data-side="right"][data-state="collapsed"] .dx-sidebar-rail {
cursor: w-resize;
}
[data-collapsible="offcanvas"] .dx-sidebar-rail {
transform: translateX(0);
}
[data-collapsible="offcanvas"] .dx-sidebar-rail::after {
left: 100%;
}
[data-collapsible="offcanvas"] .dx-sidebar-rail:hover {
background: var(--dx-sidebar-background);
}
[data-side="left"][data-collapsible="offcanvas"] .dx-sidebar-rail {
right: -0.5rem;
}
[data-side="right"][data-collapsible="offcanvas"] .dx-sidebar-rail {
left: -0.5rem;
}
.dx-sidebar-inset {
position: relative;
display: flex;
width: 100%;
flex: 1 1 0%;
flex-direction: column;
background: var(--primary-color-1);
}
[data-variant="inset"]~.dx-sidebar-inset {
border-radius: 0.75rem;
margin: 0.5rem;
margin-left: 0;
box-shadow: 0 1px 3px rgb(0 0 0 / 10%);
}
[data-variant="inset"][data-state="collapsed"]~.dx-sidebar-inset {
margin-left: 0.5rem;
}
[data-variant="inset"][data-side="right"]~.dx-sidebar-inset {
margin-right: 0;
margin-left: 0.5rem;
}
[data-variant="inset"][data-side="right"][data-state="collapsed"]~.dx-sidebar-inset {
margin-right: 0.5rem;
}
.dx-sidebar-header {
display: flex;
flex-direction: column;
padding: 0.5rem;
gap: 0.5rem;
}
.dx-sidebar-content {
display: flex;
overflow: hidden auto;
min-height: 0;
flex: 1 1 0%;
flex-direction: column;
gap: 0.5rem;
}
[data-collapsible="icon"] .dx-sidebar-content {
overflow: visible;
}
.dx-sidebar-footer {
display: flex;
flex-direction: column;
padding: 0.5rem;
gap: 0.5rem;
}
.dx-sidebar-separator {
width: auto;
margin: 0 0.5rem;
background: var(--dx-sidebar-border);
}
.dx-sidebar-group {
position: relative;
display: flex;
min-width: 0;
flex-direction: column;
padding: 0.5rem;
}
.dx-sidebar-group-label {
display: flex;
height: 2rem;
align-items: center;
padding: 0 0.5rem;
border-radius: 0.375rem;
color: var(--dx-sidebar-foreground);
font-size: 0.75rem;
font-weight: 500;
opacity: 0.7;
outline: none;
transition: margin 200ms ease-out, opacity 200ms ease-out;
}
.dx-sidebar-group-label svg {
width: 1rem;
height: 1rem;
flex-shrink: 0;
}
[data-collapsible="icon"] .dx-sidebar-group-label {
margin-top: -2rem;
opacity: 0;
}
.dx-sidebar-group-action {
position: absolute;
top: 0.875rem;
right: 0.75rem;
display: flex;
width: 1.25rem;
align-items: center;
justify-content: center;
padding: 0;
border: none;
border-radius: 0.375rem;
aspect-ratio: 1;
background: transparent;
color: var(--dx-sidebar-foreground);
cursor: pointer;
outline: none;
transition: transform 150ms ease-out, opacity 200ms ease-out, visibility 0ms 0ms;
}
.dx-sidebar-group-action:hover {
background: var(--dx-sidebar-accent);
color: var(--dx-sidebar-accent-foreground);
}
.dx-sidebar-group-action svg {
width: 1rem;
height: 1rem;
flex-shrink: 0;
}
/* Increase hit area on mobile */
.dx-sidebar-group-action::after {
position: absolute;
content: "";
inset: -0.5rem;
}
@media (width >=768px) {
.dx-sidebar-group-action::after {
display: none;
}
}
[data-collapsible="icon"] .dx-sidebar-group-action {
opacity: 0;
pointer-events: none;
transition: transform 150ms ease-out, opacity 200ms ease-out, visibility 0ms 200ms;
visibility: hidden;
}
.dx-sidebar-group-content {
width: 100%;
font-size: 0.875rem;
}
.dx-sidebar-menu {
display: flex;
width: 100%;
min-width: 0;
flex-direction: column;
padding: 0;
margin: 0;
gap: 0.25rem;
list-style: none;
}
.dx-sidebar-menu .dx-sidebar-dropdown-menu,
.dx-sidebar-menu .dx-sidebar-tooltip {
display: block;
width: 100%;
}
.dx-sidebar-dropdown-menu-content .dx-sidebar-dropdown-separator[data-orientation="horizontal"] {
margin: 0.25rem 0;
}
.dx-sidebar-menu-item {
position: relative;
}
.dx-sidebar-menu-item > .dx-sidebar-dropdown-menu:has(.dx-sidebar-menu-action) {
position: static;
}
.dx-sidebar-header .dx-sidebar-menu-button.dx-sidebar-dropdown-menu-trigger:not(:focus-visible),
.dx-sidebar-footer .dx-sidebar-menu-button.dx-sidebar-dropdown-menu-trigger:not(:focus-visible) {
box-shadow: none;
}
.dx-sidebar-sheet .dx-sidebar-header .dx-sidebar-dropdown-menu-content {
margin-top: 4px;
margin-bottom: 0;
inset: 100% auto auto 0;
}
.dx-sidebar-sheet .dx-sidebar-footer .dx-sidebar-dropdown-menu-content {
margin-top: 0;
margin-bottom: 4px;
inset: auto auto 100% 0;
}
.dx-sidebar-menu-button.dx-sidebar-collapsible-trigger:hover {
text-decoration: none;
text-decoration-line: none;
}
@media (width >= 768px) {
.dx-sidebar-desktop[data-side="left"] :is(.dx-sidebar-header, .dx-sidebar-menu-item:has(.dx-sidebar-menu-action)) .dx-sidebar-dropdown-menu-content {
top: 0;
left: 100%;
margin-top: 0;
margin-left: 0.5rem;
}
.dx-sidebar-desktop[data-side="left"] .dx-sidebar-footer .dx-sidebar-dropdown-menu-content {
top: auto;
bottom: 0;
left: 100%;
margin-bottom: 0;
margin-left: 0.5rem;
}
.dx-sidebar-desktop[data-side="right"] :is(.dx-sidebar-header, .dx-sidebar-menu-item:has(.dx-sidebar-menu-action)) .dx-sidebar-dropdown-menu-content {
top: 0;
right: 100%;
left: auto;
margin-top: 0;
margin-right: 0.5rem;
}
.dx-sidebar-desktop[data-side="right"] .dx-sidebar-footer .dx-sidebar-dropdown-menu-content {
margin-right: 0.5rem;
margin-bottom: 0;
inset: auto 100% 0 auto;
}
}
.dx-sidebar-menu-button[data-sidebar="menu-button"] {
display: flex;
width: 100%;
box-sizing: border-box;
align-items: center;
padding: 0.5rem;
border: none;
border-radius: 0.375rem;
background: transparent;
color: var(--dx-sidebar-foreground);
cursor: pointer;
font-size: 0.875rem;
gap: 0.5rem;
outline: none;
overflow-wrap: anywhere;
text-align: left;
text-decoration: none;
transition: width 200ms ease-out, padding 200ms ease-out;
}
.dx-sidebar-menu-button[data-sidebar="menu-button"]:hover {
background: var(--dx-sidebar-accent);
color: var(--dx-sidebar-accent-foreground);
}
.dx-sidebar-menu-button[data-sidebar="menu-button"]:focus-visible {
box-shadow: 0 0 0 2px var(--dx-sidebar-ring);
}
.dx-sidebar-menu-button[data-sidebar="menu-button"]:active {
background: var(--dx-sidebar-accent);
color: var(--dx-sidebar-accent-foreground);
}
.dx-sidebar-menu-button[data-sidebar="menu-button"]:disabled,
.dx-sidebar-menu-button[data-sidebar="menu-button"][aria-disabled="true"] {
opacity: 0.5;
pointer-events: none;
}
.dx-sidebar-menu-button[data-sidebar="menu-button"][data-active="true"] {
background: var(--dx-sidebar-accent);
color: var(--dx-sidebar-accent-foreground);
font-weight: 500;
}
.dx-sidebar-menu-button[data-sidebar="menu-button"] svg {
width: 1rem;
height: 1rem;
flex-shrink: 0;
}
.dx-sidebar-menu-button[data-sidebar="menu-button"]>span:last-child {
overflow: hidden;
text-overflow: ellipsis;
transition: opacity 200ms ease-out;
white-space: nowrap;
}
.dx-sidebar-desktop[data-collapsible="icon"] .dx-sidebar-menu-button[data-sidebar="menu-button"]>span:last-child {
opacity: 0;
}
/* Size variants */
.dx-sidebar-menu-button[data-sidebar="menu-button"][data-size="default"] {
min-height: 2rem;
font-size: 0.875rem;
}
.dx-sidebar-menu-button[data-sidebar="menu-button"][data-size="sm"] {
min-height: 1.75rem;
font-size: 0.75rem;
}
.dx-sidebar-menu-button[data-sidebar="menu-button"][data-size="lg"] {
min-height: 3rem;
font-size: 0.875rem;
}
.dx-sidebar-menu-button[data-sidebar="menu-button"][data-variant="outline"] {
background: var(--primary-color-1);
box-shadow: 0 0 0 1px var(--dx-sidebar-border);
}
.dx-sidebar-menu-button[data-sidebar="menu-button"][data-variant="outline"]:hover {
background: var(--dx-sidebar-accent);
box-shadow: 0 0 0 1px var(--dx-sidebar-accent);
}
.dx-sidebar-desktop[data-collapsible="icon"] .dx-sidebar-menu-button[data-sidebar="menu-button"] {
width: 2rem;
height: 2rem;
padding: 0.5rem;
}
.dx-sidebar-desktop[data-collapsible="icon"] .dx-sidebar-menu-button[data-sidebar="menu-button"][data-size="lg"] {
padding: 0;
}
.dx-sidebar-desktop[data-collapsible="icon"] .dx-sidebar-menu-button[data-sidebar="menu-button"]:has(> :first-child:is(svg, img)),
.dx-sidebar-desktop[data-collapsible="icon"] .dx-sidebar-menu-button[data-sidebar="menu-button"]:has(> :first-child:has(svg, img)) {
justify-content: center;
gap: 0;
}
.dx-sidebar-desktop[data-collapsible="icon"] .dx-sidebar-menu-button[data-sidebar="menu-button"]:has(> :first-child:is(svg, img))> :not(:first-child),
.dx-sidebar-desktop[data-collapsible="icon"] .dx-sidebar-menu-button[data-sidebar="menu-button"]:has(> :first-child:has(svg, img))> :not(:first-child) {
position: absolute;
overflow: hidden;
width: 1px;
height: 1px;
padding: 0;
border: 0;
margin: -1px;
clip-path: inset(50%);
white-space: nowrap;
}
.dx-sidebar-desktop[data-collapsible="icon"] .dx-sidebar-menu-button[data-sidebar="menu-button"] svg {
display: block;
}
.dx-sidebar-menu-item:has(.dx-sidebar-menu-action[data-sidebar="menu-action"]) .dx-sidebar-menu-button[data-sidebar="menu-button"] {
padding-right: 2rem;
}
.dx-sidebar-menu-action[data-sidebar="menu-action"] {
position: absolute;
top: 0.375rem;
right: 0.25rem;
display: flex;
width: 1.25rem;
align-items: center;
justify-content: center;
padding: 0;
border: none;
border-radius: 0.375rem;
aspect-ratio: 1;
background: transparent;
color: var(--dx-sidebar-foreground);
cursor: pointer;
outline: none;
transition: transform 150ms ease-out, opacity 200ms ease-out, visibility 0ms 0ms;
}
.dx-sidebar-menu-action[data-sidebar="menu-action"]:hover {
background: var(--dx-sidebar-accent);
color: var(--dx-sidebar-accent-foreground);
}
.dx-sidebar-menu-action[data-sidebar="menu-action"] svg {
width: 1rem;
height: 1rem;
flex-shrink: 0;
}
.dx-sidebar-menu-action[data-sidebar="menu-action"]::after {
position: absolute;
content: "";
inset: -0.5rem;
}
@media (width >=768px) {
.dx-sidebar-menu-action[data-sidebar="menu-action"]::after {
display: none;
}
}
.dx-sidebar-menu-button[data-sidebar="menu-button"][data-size="sm"]~.dx-sidebar-menu-action[data-sidebar="menu-action"] {
top: 0.25rem;
}
.dx-sidebar-menu-button[data-sidebar="menu-button"][data-size="default"]~.dx-sidebar-menu-action[data-sidebar="menu-action"] {
top: 0.375rem;
}
.dx-sidebar-menu-button[data-sidebar="menu-button"][data-size="lg"]~.dx-sidebar-menu-action[data-sidebar="menu-action"] {
top: 0.625rem;
}
[data-collapsible="icon"] .dx-sidebar-menu-action[data-sidebar="menu-action"] {
opacity: 0;
pointer-events: none;
transition: transform 150ms ease-out, opacity 200ms ease-out, visibility 0ms 200ms;
visibility: hidden;
}
.dx-sidebar-menu-action[data-sidebar="menu-action"][data-show-on-hover="true"] {
opacity: 0;
}
@media (width >=768px) {
.dx-sidebar-menu-item:hover .dx-sidebar-menu-action[data-sidebar="menu-action"][data-show-on-hover="true"],
.dx-sidebar-menu-item:focus-within .dx-sidebar-menu-action[data-sidebar="menu-action"][data-show-on-hover="true"],
.dx-sidebar-menu-action[data-sidebar="menu-action"][data-show-on-hover="true"][data-state="open"] {
opacity: 1;
}
}
.dx-sidebar-menu-button[data-sidebar="menu-button"][data-active="true"]~.dx-sidebar-menu-action[data-sidebar="menu-action"][data-show-on-hover="true"] {
color: var(--dx-sidebar-accent-foreground);
}
.dx-sidebar-menu-badge {
position: absolute;
top: 0.375rem;
right: 0.25rem;
display: flex;
min-width: 1.25rem;
height: 1.25rem;
align-items: center;
justify-content: center;
padding: 0 0.25rem;
border-radius: 0.375rem;
color: var(--dx-sidebar-foreground);
font-size: 0.75rem;
font-variant-numeric: tabular-nums;
font-weight: 500;
pointer-events: none;
transition: opacity 200ms ease-out;
user-select: none;
}
.dx-sidebar-menu-item:hover .dx-sidebar-menu-badge,
.dx-sidebar-menu-item:has(.dx-sidebar-menu-button[data-active="true"]) .dx-sidebar-menu-badge {
color: var(--dx-sidebar-accent-foreground);
}
.dx-sidebar-menu-item:has(.dx-sidebar-menu-button[data-size="sm"]) .dx-sidebar-menu-badge {
top: 0.25rem;
}
.dx-sidebar-menu-item:has(.dx-sidebar-menu-button[data-size="lg"]) .dx-sidebar-menu-badge {
top: 0.625rem;
}
[data-collapsible="icon"] .dx-sidebar-menu-badge {
opacity: 0;
pointer-events: none;
}
.dx-sidebar-menu-skeleton {
display: flex;
height: 2rem;
align-items: center;
padding: 0 0.5rem;
border-radius: 0.375rem;
gap: 0.5rem;
}
.dx-sidebar-menu-skeleton-icon {
width: 1rem;
height: 1rem;
border-radius: 0.375rem;
}
.dx-sidebar-menu-skeleton-text {
height: 1rem;
flex: 1;
}
.dx-sidebar-menu-sub {
display: flex;
flex-direction: column;
padding: 0.125rem 0.625rem;
border-left: 1px solid var(--dx-sidebar-border);
margin: 0 0.875rem;
gap: 0.25rem;
list-style: none;
transform: translateX(1px);
transition: opacity 200ms ease-out, max-height 200ms ease-out, padding 200ms ease-out, margin 200ms ease-out, visibility 0ms 0ms;
}
[data-collapsible="icon"] .dx-sidebar-menu-sub {
overflow: hidden;
max-height: 0;
padding: 0;
margin: 0;
opacity: 0;
pointer-events: none;
transition: opacity 200ms ease-out, max-height 200ms ease-out, padding 200ms ease-out, margin 200ms ease-out, visibility 0ms 200ms;
visibility: hidden;
}
.dx-sidebar-menu-sub-item {
position: relative;
}
.dx-sidebar-menu-sub-button {
display: flex;
overflow: hidden;
width: 100%;
min-width: 0;
height: 1.75rem;
box-sizing: border-box;
align-items: center;
padding: 0 0.5rem;
border: none;
border-radius: 0.375rem;
background: transparent;
color: var(--dx-sidebar-foreground);
cursor: pointer;
font-size: 0.875rem;
gap: 0.5rem;
outline: none;
text-decoration: none;
transform: translateX(-1px);
transition: opacity 200ms ease-out;
}
.dx-sidebar-menu-sub-button:hover {
background: var(--dx-sidebar-accent);
color: var(--dx-sidebar-accent-foreground);
}
.dx-sidebar-menu-sub-button:focus-visible {
box-shadow: 0 0 0 2px var(--dx-sidebar-ring);
}
.dx-sidebar-menu-sub-button:active {
background: var(--dx-sidebar-accent);
color: var(--dx-sidebar-accent-foreground);
}
.dx-sidebar-menu-sub-button:disabled,
.dx-sidebar-menu-sub-button[aria-disabled="true"] {
opacity: 0.5;
pointer-events: none;
}
.dx-sidebar-menu-sub-button[data-active="true"] {
background: var(--dx-sidebar-accent);
color: var(--dx-sidebar-accent-foreground);
}
.dx-sidebar-menu-sub-button svg {
width: 1rem;
height: 1rem;
flex-shrink: 0;
color: var(--dx-sidebar-accent-foreground);
}
.dx-sidebar-menu-sub-button>span:last-child {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dx-sidebar-menu-sub-button[data-size="sm"] {
font-size: 0.75rem;
}
.dx-sidebar-menu-sub-button[data-size="md"] {
font-size: 0.875rem;
}
[data-collapsible="icon"] .dx-sidebar-menu-sub-button {
opacity: 0;
pointer-events: none;
}

View file

@ -1,17 +0,0 @@
use dioxus::prelude::*;
use dioxus_primitives::{dioxus_attributes::attributes, merge_attributes};
#[css_module("/src/components/skeleton/style.css")]
struct Styles;
#[component]
pub fn Skeleton(#[props(extends=GlobalAttributes)] attributes: Vec<Attribute>) -> Element {
let base = attributes!(div {
class: Styles::dx_skeleton,
});
let merged = merge_attributes(vec![base, attributes]);
rsx! {
div { ..merged }
}
}

View file

@ -1,2 +0,0 @@
mod component;
pub use component::*;

View file

@ -1,16 +0,0 @@
.dx-skeleton {
border-radius: 0.375rem;
animation: dx-skeleton-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
background-color: var(--primary-color-5);
}
@keyframes dx-skeleton-pulse {
0%,
100% {
opacity: 1;
}
61.8% {
opacity: 0.5;
}
}

View file

@ -1,61 +0,0 @@
use dioxus::prelude::*;
use dioxus_primitives::dioxus_attributes::attributes;
use dioxus_primitives::merge_attributes;
use dioxus_primitives::tooltip::{self, TooltipContentProps, TooltipProps, TooltipTriggerProps};
#[css_module("/src/components/tooltip/style.css")]
struct Styles;
#[component]
pub fn Tooltip(props: TooltipProps) -> Element {
let base = attributes!(div {
class: Styles::dx_tooltip,
});
let merged = merge_attributes(vec![base, props.attributes]);
rsx! {
tooltip::Tooltip {
disabled: props.disabled,
open: props.open,
default_open: props.default_open,
on_open_change: props.on_open_change,
attributes: merged,
{props.children}
}
}
}
#[component]
pub fn TooltipTrigger(props: TooltipTriggerProps) -> Element {
let base = attributes!(button {
class: Styles::dx_tooltip_trigger,
});
let merged = merge_attributes(vec![base, props.attributes]);
rsx! {
tooltip::TooltipTrigger {
id: props.id,
as: props.r#as,
attributes: merged,
{props.children}
}
}
}
#[component]
pub fn TooltipContent(props: TooltipContentProps) -> Element {
let base = attributes!(div {
class: Styles::dx_tooltip_content,
});
let merged = merge_attributes(vec![base, props.attributes]);
rsx! {
tooltip::TooltipContent {
id: props.id,
side: props.side,
align: props.align,
attributes: merged,
{props.children}
}
}
}

View file

@ -1,2 +0,0 @@
mod component;
pub use component::*;

View file

@ -1,150 +0,0 @@
/* Tooltip Styles */
.dx-tooltip {
position: relative;
display: inline-block;
}
.dx-tooltip-trigger {
display: inline-block;
}
.dx-tooltip-content {
position: absolute;
z-index: 1000;
max-width: 250px;
padding: 8px 12px;
border-radius: 0.5rem;
animation: dx-tooltip-fade-in 0.2s ease-in-out;
background-color: var(--secondary-color-4);
color: var(--primary-color);
font-size: 14px;
line-height: 1.4;
}
.dx-tooltip-content::after {
position: absolute;
border-width: 0.25rem;
border-style: solid;
margin-left: -0.25rem;
content: " ";
rotate: 45deg;
}
/* Positioning based on side */
.dx-tooltip-content[data-side="top"] {
position: absolute;
bottom: 100%;
left: 50%;
margin-bottom: 8px;
transform: translateX(-50%);
}
.dx-tooltip-content[data-side="top"]::after {
top: calc(100% - 0.25rem);
left: 50%;
border-color: var(--secondary-color-4);
border-radius: 0 0 0.1rem;
}
.dx-tooltip-content[data-side="right"] {
position: absolute;
top: 50%;
left: 100%;
margin-left: 8px;
transform: translateY(-50%);
}
.dx-tooltip-content[data-side="right"]::after {
top: calc(50% - 0.25rem);
left: 0;
border-color: var(--secondary-color-4);
border-radius: 0 0 0 0.1rem;
}
.dx-tooltip-content[data-side="bottom"] {
position: absolute;
top: 100%;
left: 50%;
margin-top: 8px;
transform: translateX(-50%);
}
.dx-tooltip-content[data-side="bottom"]::after {
bottom: calc(100% - 0.25rem);
left: 50%;
border-color: var(--secondary-color-4);
border-radius: 0.1rem 0 0;
}
.dx-tooltip-content[data-side="left"] {
position: absolute;
top: 50%;
right: 100%;
margin-right: 8px;
transform: translateY(-50%);
}
.dx-tooltip-content[data-side="left"]::after {
top: calc(50% - 0.25rem);
right: -0.25rem;
border-color: var(--secondary-color-4);
border-radius: 0 0.1rem 0 0;
}
/* Alignment styles for top and bottom */
.dx-tooltip-content[data-side="top"][data-align="start"],
.dx-tooltip-content[data-side="bottom"][data-align="start"] {
left: 0;
transform: none;
}
.dx-tooltip-content[data-side="top"][data-align="end"],
.dx-tooltip-content[data-side="bottom"][data-align="end"] {
right: 0;
left: auto;
transform: none;
}
/* Alignment styles for left and right */
.dx-tooltip-content[data-side="left"][data-align="start"],
.dx-tooltip-content[data-side="right"][data-align="start"] {
top: 0;
transform: none;
}
.dx-tooltip-content[data-side="left"][data-align="center"],
.dx-tooltip-content[data-side="right"][data-align="center"] {
top: 50%;
transform: translateY(-50%);
}
.dx-tooltip-content[data-side="left"][data-align="end"],
.dx-tooltip-content[data-side="right"][data-align="end"] {
top: auto;
bottom: 0;
transform: none;
}
/* Animation */
@keyframes dx-tooltip-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/* State styles */
.dx-tooltip[data-disabled="true"] .dx-tooltip-trigger {
cursor: default;
}
.dx-tooltip-content[data-state="closed"] {
display: none;
}
.dx-tooltip-content[data-state="open"] {
display: block;
}

View file

@ -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::*;

View file

@ -24,7 +24,6 @@ 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
[features] [features]
default = ["web"] default = ["web"]

View file

@ -1,27 +1,13 @@
body { font-family: system-ui, sans-serif; margin: 0; color: #1a1a1a; height: 100vh; } body { font-family: system-ui, sans-serif; max-width: 720px; margin: 2rem auto; color: #1a1a1a; }
.app-shell { display: flex; height: 100vh; } .article-list { list-style: none; padding: 0; }
.content-header { display: flex; align-items: center; gap: 0.75rem; padding: 1.5rem 1.5rem 0.5rem; } .article-row { border-bottom: 1px solid #ddd; padding: 1rem 0; }
.content-header h2 { margin: 0; }
/* Target the primitive's own stable data attribute, not a class we pass in:
ScrollArea sets its own `class` after spreading our attributes, so a
caller-supplied class never reaches the DOM (confirmed by inspecting the
live element it only ever carries "dx-scroll-area-auto-hide"). Without
this, the scroll div has no bounded height, so it just grows to fit every
article (nothing to scroll) while its ancestor's overflow: hidden clips
everything past the viewport instead. */
main > [data-scroll-direction] { flex: 1; min-height: 0; padding: 0 1.5rem 1.5rem; }
.article-list { list-style: none; padding: 0; margin: 0; }
.article-row { border-bottom: 1px solid #ddd; padding: 1rem 0; display: flex; flex-direction: column; align-items: flex-start; gap: 0.5rem; }
.article-row-header { display: flex; align-items: baseline; flex-wrap: wrap; gap: 0.5rem; }
.article-row a { font-weight: 600; text-decoration: none; color: #0b5fff; } .article-row a { font-weight: 600; text-decoration: none; color: #0b5fff; }
.summary { color: #444; margin: 0; } .score { float: right; font-variant-numeric: tabular-nums; color: #555; }
.topics { display: flex; flex-wrap: wrap; gap: 0.3rem; } .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; } .error { color: #b00020; }
.subscribe-form { display: flex; gap: 0.5rem; margin: 1rem 0; min-width: 0; } .subscribe-form { display: flex; gap: 0.5rem; margin: 1rem 0; }
/* Target the raw tag, not the component's own class: dioxus's #[css_module] .subscribe-form .dx-input { flex: 1; }
content-hashes Input's class per build (e.g. "dx-input-83f82cbc"), so a
selector on the unhashed name never matches anything. */
.subscribe-form input { flex: 1 1 0; min-width: 0; }
.subscribe-status { margin: 0.25rem 0 1rem; font-size: 0.9rem; } .subscribe-status { margin: 0.25rem 0 1rem; font-size: 0.9rem; }
.subscribe-status.success { color: #1a7f37; } .subscribe-status.success { color: #1a7f37; }
.subscribe-status.error { color: #b00020; } .subscribe-status.error { color: #b00020; }

View file

@ -2,16 +2,12 @@ use super::ArticleView;
use dioxus::prelude::*; use dioxus::prelude::*;
/// Returns articles ranked by `final_score` descending, highest-relevance /// Returns articles ranked by `final_score` descending, highest-relevance
/// first. `feed_id` restricts the list to one feed; `None` joins every /// first. Runs on the server (native, has DB access); the `#[server]`
/// subscribed feed into one ranked list. Runs on the server (native, has /// macro generates the HTTP call the browser/WASM build uses instead.
/// DB access); the `#[server]` macro generates the HTTP call the
/// browser/WASM build uses instead.
#[server] #[server]
pub async fn list_ranked_articles( pub async fn list_ranked_articles() -> Result<Vec<ArticleView>, ServerFnError> {
feed_id: Option<String>,
) -> Result<Vec<ArticleView>, ServerFnError> {
let db = crate::server::db().await?; let db = crate::server::db().await?;
crate::server::services::articles::list_ranked(db, feed_id) crate::server::services::articles::list_ranked(db)
.await .await
.map_err(|e| ServerFnError::new(e.to_string())) .map_err(|e| ServerFnError::new(e.to_string()))
} }

View file

@ -6,18 +6,9 @@ use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ArticleView { pub struct ArticleView {
pub id: String, pub id: String,
pub feed_id: String,
pub title: String, pub title: String,
pub url: String, pub url: String,
pub summary: String, pub summary: String,
pub topics: Vec<String>, pub topics: Vec<String>,
pub final_score: Option<f32>, pub final_score: Option<f32>,
} }
/// Subscribed feed, for the sidebar's feed-navigation list.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct FeedView {
pub id: String,
pub title: String,
pub url: String,
}

View file

@ -1,4 +1,3 @@
use super::FeedView;
use dioxus::prelude::*; use dioxus::prelude::*;
/// Registers a feed and fetches it immediately. Runs on the server (native, /// Registers a feed and fetches it immediately. Runs on the server (native,
@ -11,12 +10,3 @@ pub async fn subscribe_feed(url: String) -> Result<usize, ServerFnError> {
.await .await
.map_err(|e| ServerFnError::new(e.to_string())) .map_err(|e| ServerFnError::new(e.to_string()))
} }
/// Lists every subscribed feed, for the sidebar's feed-navigation list.
#[server]
pub async fn list_feeds() -> Result<Vec<FeedView>, ServerFnError> {
let db = crate::server::db().await?;
crate::server::services::feeds::list(db)
.await
.map_err(|e| ServerFnError::new(e.to_string()))
}

View file

@ -1,5 +1,5 @@
pub mod articles; pub mod articles;
mod dto;
pub mod feeds; pub mod feeds;
mod models;
pub use models::{ArticleView, FeedView}; pub use dto::ArticleView;

View file

@ -0,0 +1,31 @@
use crate::api;
use crate::api::ArticleView;
use dioxus::prelude::*;
#[component]
pub 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 _ = api::articles::mark_dismissed(id).await;
}
},
"Not relevant"
}
}
}
}

View file

@ -1,42 +0,0 @@
use super::handlers::score_percent;
use crate::api;
use crate::api::ArticleView;
use dioxus::prelude::*;
use feedsignal_dioxus_components::badge::{Badge, BadgeVariant};
use feedsignal_dioxus_components::button::{Button, ButtonSize, ButtonVariant};
#[component]
pub fn ArticleRow(article: ArticleView) -> Element {
let score_pct = score_percent(article.final_score);
rsx! {
li { class: "article-row",
div { class: "article-row-header",
a { href: "{article.url}", target: "_blank", "{article.title}" }
if let Some(pct) = score_pct {
Badge { variant: BadgeVariant::Secondary, "{pct}%" }
}
}
if !article.summary.trim().is_empty() {
p { class: "summary", "{article.summary}" }
}
if !article.topics.is_empty() {
div { class: "topics",
for topic in article.topics.iter() {
Badge { variant: BadgeVariant::Outline, "{topic}" }
}
}
}
Button {
variant: ButtonVariant::Outline,
size: ButtonSize::Sm,
onclick: move |_| {
let id = article.id.clone();
async move {
let _ = api::articles::mark_dismissed(id).await;
}
},
"Not relevant"
}
}
}
}

View file

@ -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));
}
}

View file

@ -1,4 +0,0 @@
mod component;
mod handlers;
pub use component::*;

View file

@ -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");
}
}

View file

@ -1,105 +1,21 @@
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 article_row::ArticleRow;
use dioxus::prelude::*; use dioxus::prelude::*;
use feedsignal_dioxus_components::scroll_area::ScrollArea;
use feedsignal_dioxus_components::sidebar::{
Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarHeader,
SidebarInset, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarProvider, SidebarTrigger,
};
use subscribe_form::SubscribeForm; use subscribe_form::SubscribeForm;
#[component] #[component]
pub fn App() -> Element { pub fn App() -> Element {
let mut selected_feed = use_signal(|| Option::<String>::None); let mut articles = use_server_future(api::articles::list_ranked_articles)?;
let mut feeds = use_server_future(api::feeds::list_feeds)?;
let mut articles =
use_server_future(move || api::articles::list_ranked_articles(selected_feed()))?;
let on_subscribed = move |_| {
feeds.restart();
articles.restart();
};
rsx! { rsx! {
Stylesheet { href: asset!("/assets/app.css") } Stylesheet { href: asset!("/assets/app.css") }
Stylesheet { href: asset!("/assets/dx-components-theme.css") } Stylesheet { href: asset!("/assets/dx-components-theme.css") }
SidebarProvider { main {
div { class: "app-shell",
Sidebar {
SidebarHeader {
h1 { "feedsignal" } h1 { "feedsignal" }
SubscribeForm { on_subscribed } SubscribeForm { on_subscribed: move |_| { articles.restart(); } }
}
SidebarContent {
SidebarGroup {
SidebarGroupLabel { "Feeds" }
SidebarGroupContent {
SidebarMenu {
SidebarMenuItem {
SidebarMenuButton {
is_active: selected_feed().is_none(),
r#as: move |attrs: Vec<Attribute>| rsx! {
button { onclick: move |_| selected_feed.set(None), ..attrs, "All" }
},
}
}
match feeds.read().as_ref() {
Some(Ok(feeds)) => rsx! {
for feed in feeds.iter() {
{
let feed_id = feed.id.clone();
let feed_title = feed.title.clone();
let is_active = selected_feed.read().as_deref() == Some(feed.id.as_str());
rsx! {
SidebarMenuItem {
SidebarMenuButton {
is_active,
r#as: move |attrs: Vec<Attribute>| {
let feed_id = feed_id.clone();
let feed_title = feed_title.clone();
rsx! {
button {
onclick: move |_| selected_feed.set(Some(feed_id.clone())),
..attrs,
"{feed_title}"
}
}
},
}
}
}
}
}
},
Some(Err(err)) => rsx! {
p { class: "error", "Failed to load feeds: {err}" }
},
None => rsx! { p { "Loading feeds..." } },
}
}
}
}
}
}
SidebarInset {
div { class: "content-header",
SidebarTrigger {}
h2 {
{
let heading = handlers::feed_heading(
selected_feed.read().as_deref(),
feeds.read().as_ref().and_then(|r| r.as_ref().ok()).map(|v| v.as_slice()),
);
rsx! { "{heading}" }
}
}
}
ScrollArea {
direction: dioxus_primitives::scroll_area::ScrollDirection::Vertical,
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",
@ -114,6 +30,3 @@ pub fn App() -> Element {
} }
} }
} }
}
}
}

View file

@ -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);
}); });

View file

@ -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()
}
);
}
}

View file

@ -1,4 +0,0 @@
mod component;
mod handlers;
pub use component::*;

View file

@ -0,0 +1,3 @@
// AUTOGENERATED Components module
pub mod button;
pub mod input;

View file

@ -1,5 +1,6 @@
mod api; mod api;
mod app; mod app;
mod components;
#[cfg(feature = "server")] #[cfg(feature = "server")]
mod server; mod server;

View file

@ -1,29 +0,0 @@
use anyhow::Result;
use feedsignal_db::Db;
/// Re-fetches every subscribed feed and inserts any articles published since
/// the last run. `articles.url` is unique, so re-fetching the same feed and
/// inserting its (mostly already-seen) entries is safe — `insert_article`
/// silently skips ones already in the database, leaving only genuinely new
/// articles behind.
pub async fn run(db: &Db) -> Result<()> {
let feeds = db.list_feeds().await?;
tracing::info!(count = feeds.len(), "polling subscribed feeds");
for (feed_id, _title, url) in feeds {
let (_title, articles) = match feedsignal_feeds::fetch_feed(&url, feed_id).await {
Ok(result) => result,
Err(err) => {
tracing::warn!(?err, %url, "failed to poll feed, skipping");
continue;
}
};
for article in &articles {
db.insert_article(article).await?;
}
db.mark_feed_fetched(feed_id).await?;
}
Ok(())
}

View file

@ -1,5 +1,4 @@
mod affinity_decay; mod affinity_decay;
mod feed_polling;
mod scoring; mod scoring;
use anyhow::Result; use anyhow::Result;
@ -8,37 +7,13 @@ use feedsignal_llm::Llm;
use std::sync::Arc; use std::sync::Arc;
use tokio_cron_scheduler::{Job, JobScheduler}; use tokio_cron_scheduler::{Job, JobScheduler};
/// Wires up the recurring jobs described in the design discussion: polling /// Wires up the two recurring jobs described in the design discussion:
/// subscribed feeds for new articles, scoring them, and decaying topic /// polling feeds + scoring new articles on a short interval, and decaying
/// affinities once a day so stale signals fade. Call once at server /// topic affinities once a day so stale signals fade. Call once at server
/// startup, and also runs once immediately so a freshly (re)opened app /// startup.
/// doesn't wait 15 minutes for its first check.
pub async fn start_scheduler(db: Arc<Db>, llm: Arc<Llm>) -> Result<JobScheduler> { pub async fn start_scheduler(db: Arc<Db>, llm: Arc<Llm>) -> Result<JobScheduler> {
let scheduler = JobScheduler::new().await?; let scheduler = JobScheduler::new().await?;
{
let db = db.clone();
tokio::spawn(async move {
if let Err(err) = feed_polling::run(&db).await {
tracing::error!(?err, "initial feed poll failed");
}
});
}
{
let db = db.clone();
scheduler
.add(Job::new_async("0 */15 * * * *", move |_uuid, _lock| {
let db = db.clone();
Box::pin(async move {
if let Err(err) = feed_polling::run(&db).await {
tracing::error!(?err, "feed polling run failed");
}
})
})?)
.await?;
}
{ {
let db = db.clone(); let db = db.clone();
let llm = llm.clone(); let llm = llm.clone();

View file

@ -12,8 +12,9 @@ const EMBEDDING_SHORTLIST_THRESHOLD: f32 = 0.55;
const LLM_BATCH_SIZE: i64 = 25; const LLM_BATCH_SIZE: i64 = 25;
/// One pass of: embed the shortlist, run the LLM on it, blend into /// One pass of: embed the shortlist, run the LLM on it, blend into
/// `final_score`. Assumes `feed_polling::run` has already pulled in any new /// `final_score`. Feed polling itself (calling `feedsignal_feeds::fetch_feed`
/// articles for this cycle. /// per subscribed feed and inserting new articles) is intentionally left as
/// a TODO here — wire it in once feed subscription management exists.
pub async fn run(db: &Db, llm: &Llm) -> Result<()> { pub async fn run(db: &Db, llm: &Llm) -> Result<()> {
let affinities = db.load_affinities().await?; let affinities = db.load_affinities().await?;
let shortlist = db let shortlist = db

View file

@ -1,25 +1,22 @@
use crate::api::ArticleView; use crate::api::ArticleView;
use anyhow::Result; use anyhow::Result;
use feedsignal_db::Db; use feedsignal_db::Db;
use uuid::Uuid;
/// Returns articles ranked by `final_score` descending, highest-relevance /// Returns articles ranked by `final_score` descending, highest-relevance first.
/// first. `feed_id` restricts the list to one feed; `None` joins every pub async fn list_ranked(db: Db) -> Result<Vec<ArticleView>> {
/// subscribed feed into one ranked list.
pub async fn list_ranked(db: Db, feed_id: Option<String>) -> Result<Vec<ArticleView>> {
let feed_id = feed_id.map(|id| Uuid::parse_str(&id)).transpose()?;
Ok(db Ok(db
.list_ranked_articles(feed_id, 100) .list_ranked_articles(100)
.await? .await?
.into_iter() .into_iter()
.map(|row| ArticleView { .map(
id: row.id, |(id, title, url, summary, topics, final_score)| ArticleView {
feed_id: row.feed_id, id,
title: row.title, title,
url: row.url, url,
summary: row.summary, summary,
topics: row.topics, topics,
final_score: row.final_score, final_score,
}) },
)
.collect()) .collect())
} }

View file

@ -1,4 +1,3 @@
use crate::api::FeedView;
use anyhow::Result; use anyhow::Result;
use feedsignal_db::Db; use feedsignal_db::Db;
use uuid::Uuid; use uuid::Uuid;
@ -32,17 +31,3 @@ pub async fn subscribe(db: Db, url: String) -> Result<usize> {
} }
Ok(count) Ok(count)
} }
/// Lists every subscribed feed, for the sidebar's feed-navigation list.
pub async fn list(db: Db) -> Result<Vec<FeedView>> {
Ok(db
.list_feeds()
.await?
.into_iter()
.map(|(id, title, url)| FeedView {
id: id.to_string(),
title,
url,
})
.collect())
}