Compare commits

..

25 commits

Author SHA1 Message Date
baf1037306 Merge pull request 'Upgrade SQLite' (#12) from chore/update-sqlite into main
All checks were successful
CI / check (push) Successful in 7m47s
CI / test (push) Successful in 5m31s
CI / audit (push) Successful in 19s
Reviewed-on: #12
2026-09-16 09:59:02 +00:00
Austin Schaefer
803ade78eb Upgrade SQLite
All checks were successful
CI / check (pull_request) Successful in 6m41s
CI / test (pull_request) Successful in 5m19s
CI / audit (pull_request) Successful in 18s
2026-09-16 10:46:05 +02:00
bc07a8808f Merge pull request 'Extract vendored dioxus-primitives components into their own crate' (#10) from worktree-extract-dx-components into main
All checks were successful
CI / check (push) Successful in 4m59s
CI / test (push) Successful in 3m23s
CI / audit (push) Successful in 13s
Reviewed-on: #10
Reviewed-by: Austin Schaefer <austin.schaefer@mailo.eu>
2026-09-15 10:23:28 +00:00
Austin Schaefer
3dcf404b26 Extract vendored dioxus-primitives components into their own crate
All checks were successful
CI / check (pull_request) Successful in 15m54s
CI / test (pull_request) Successful in 10m5s
CI / audit (pull_request) Successful in 27s
crates/web/src/components/ was a byte-for-byte copy of the
DioxusLabs/components "preview" registry (a shadcn-style copy-into-
your-project source, not something published as a dependency). Move it
wholesale into a new feedsignal-dioxus-components crate so it reads as
vendored code we depend on rather than application source, and future
app-specific styling should layer on via the class/attributes props
each component already accepts instead of editing the vendored files.

No component logic changed — this is a pure move plus import-path
updates in the four app files that consumed it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 11:31:27 +02:00
b50b7e61cb Merge pull request 'Extract pure logic out of RSX components into unit-testable handlers modules' (#9) from worktree-extract-rsx-logic into main
All checks were successful
CI / check (push) Successful in 12m3s
CI / test (push) Successful in 10m43s
CI / audit (push) Successful in 48s
Reviewed-on: #9
2026-09-15 09:11:50 +00:00
Austin Schaefer
49ce3eaad8 Bump rustls to 0.23.45 to clear RUSTSEC-2026-0285
All checks were successful
CI / check (pull_request) Successful in 10m18s
CI / test (pull_request) Successful in 6m44s
CI / audit (pull_request) Successful in 25s
cargo audit was failing on main independent of this branch's changes: a
new advisory (published 2026-09-14) flags rustls 0.23.43, pulled in
transitively via dioxus-server/reqwest/rig-core, for accepting TLS 1.3
handshake messages across encryption level boundaries. The fix is
upstream in 0.23.45; no code changes needed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 10:11:00 +02:00
Austin Schaefer
bd086b0288 Extract subscribe_form's submit-result mapping into a testable outcome
Some checks failed
CI / check (pull_request) Successful in 1m47s
CI / test (pull_request) Successful in 3m30s
CI / audit (pull_request) Failing after 14s
The submit closure mixed pure decision logic (what should the form show
after subscribe_feed resolves) with Dioxus signal orchestration. Splits
out resolve_submit_outcome(Result<usize, String>) -> SubmitOutcome, so
the success/failure mapping is unit-tested without a Dioxus runtime; the
component now just applies the resulting outcome to its signals.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 09:50:06 +02:00
Austin Schaefer
2ae3293c9d Fix cargo fmt formatting in subscribe_form handlers tests
Some checks failed
CI / check (pull_request) Successful in 1m52s
CI / test (pull_request) Successful in 3m29s
CI / audit (pull_request) Failing after 13s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 09:42:08 +02:00
Austin Schaefer
de6256a812 Extract pure logic out of RSX components into unit-testable handlers modules
Some checks failed
CI / check (pull_request) Failing after 21s
CI / test (pull_request) Has been skipped
CI / audit (pull_request) Has been skipped
Splits article_row.rs and subscribe_form.rs into mod.rs + component.rs +
handlers.rs (mirroring the components/*/ convention), and adds a sibling
app/handlers.rs for App's heading computation. RSX now calls into plain
functions (score_percent, normalize_feed_url, subscribed_message,
feed_heading) that are covered by unit tests, instead of computing the
same logic inline where it can't be tested without a Dioxus runtime.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 09:26:00 +02:00
de6b4578d4 Merge pull request 'Add feed sidebar navigation' (#8) from worktree-feed-sidebar-nav into main
Some checks failed
CI / check (push) Successful in 17m42s
CI / test (push) Successful in 3m45s
CI / audit (push) Failing after 18s
Reviewed-on: #8
2026-09-15 07:20:40 +00:00
Austin Schaefer
03a3c1577d Let sidebar menu buttons grow with long feed titles
Some checks failed
CI / check (pull_request) Successful in 16m45s
CI / test (pull_request) Successful in 4m46s
CI / audit (pull_request) Failing after 16s
Fixed height + overflow:hidden on .dx-sidebar-menu-button caused long
feed titles (real RSS <title> text, often longer than the custom
labels shown in other readers) to clip and visually collide with the
next row. Switch to min-height per size variant and drop the
overflow/height clipping so rows grow to fit wrapped text; the
existing scrollable sidebar-content container handles the rest.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 09:16:29 +02:00
Austin Schaefer
893e0ed251 Move RelevanceInputs into core's models.rs, wire up api/models.rs rename
All checks were successful
CI / check (pull_request) Successful in 2m5s
CI / test (pull_request) Successful in 2m43s
CI / audit (pull_request) Successful in 10s
The previous commit only carried the dto.rs -> models.rs file rename;
this carries the rest: mod.rs's import/re-export update, and
RelevanceInputs moving out of scoring.rs into models.rs (built by
score_article's callers, not score_article itself), plus an unrelated
import-order fmt fix in db/src/articles.rs.
2026-09-03 23:24:48 +02:00
Austin Schaefer
7ccc36955a Sweep dto.rs and RelevanceInputs into the models.rs convention
Some checks failed
CI / check (pull_request) Failing after 1m8s
CI / test (pull_request) Has been skipped
CI / audit (pull_request) Has been skipped
- crates/web/src/api/dto.rs -> api/models.rs (ArticleView/FeedView were
  already models-shaped content, just not named models.rs)
- crates/core/src/scoring.rs's RelevanceInputs moves into
  crates/core/src/models.rs alongside the other core domain types,
  since it's built by score_article's callers, not score_article
  itself

Also picks up an unrelated fmt fix in db/src/articles.rs (import
ordering) that landed on disk from elsewhere.
2026-09-03 23:24:35 +02:00
Austin Schaefer
cc79f59a7d Decode list_ranked_articles rows via a From<Row> impl
All checks were successful
CI / check (pull_request) Successful in 1m58s
CI / test (pull_request) Successful in 2m28s
CI / audit (pull_request) Successful in 10s
Replaces the inline .map() closure with From<Row> for
RankedArticleRow, separating "what a raw SQLite row looks like" from
the query itself.
2026-09-03 21:26:56 +02:00
Austin Schaefer
e70909af47 Add and apply a models.rs convention for data-model structs
All checks were successful
CI / check (pull_request) Successful in 2m2s
CI / test (pull_request) Successful in 3m15s
CI / audit (pull_request) Successful in 10s
Document in the DoD that data-model structs (domain models, query row
types, DTOs/views) belong in a crate's models.rs rather than the file
that produces/consumes them, once used outside that function -
matching the existing crates/core/src/models.rs pattern. Component-
local structs (Props, Styles, context) are exempt.

Apply it to db: move RankedArticleRow out of articles.rs into a new
crates/db/src/models.rs.
2026-09-03 21:23:34 +02:00
Austin Schaefer
f4b7e95d15 Merge main to pick up Definition of Done 2026-09-03 21:22:29 +02:00
Austin Schaefer
73c80491be Turn RankedArticleRow into a struct instead of a 7-field tuple
All checks were successful
CI / check (pull_request) Successful in 1m34s
CI / test (pull_request) Successful in 3m19s
CI / audit (pull_request) Successful in 12s
Tuples this large stop being readable at the call site (positional
indices like rows[0].6 give no hint what they mean); a named struct
documents each field and lets rustc catch reordering mistakes.
2026-09-03 21:20:41 +02:00
Austin Schaefer
33d2dfe011 Merge list_feeds_with_titles into list_feeds
All checks were successful
CI / check (pull_request) Successful in 5m2s
CI / test (pull_request) Successful in 3m18s
CI / audit (pull_request) Successful in 15s
Both queried the same table with near-identical loads, differing only
by an extra title column and ordering. Fold title into list_feeds'
tuple (its one prior caller, the polling job, ignores it) so there's
a single feed-listing query instead of two that can drift apart.
2026-09-03 19:49:39 +02:00
a37a93150b Merge pull request 'worktree-definition-of-done' (#7) from worktree-definition-of-done into main
Some checks failed
CI / check (push) Successful in 2m42s
CI / test (push) Failing after 18s
CI / audit (push) Has been skipped
Reviewed-on: #7
2026-09-03 17:20:38 +00:00
Austin Schaefer
f0e42e2ad5 Fix article list not scrolling: duplicate <main> broke the scroll area
All checks were successful
CI / check (pull_request) Successful in 4m55s
CI / test (pull_request) Successful in 4m2s
CI / audit (pull_request) Successful in 14s
Articles past the first screenful were unreachable — not clipped by
accident, genuinely inaccessible, since the page itself couldn't
scroll either (the sidebar wrapper is overflow: hidden by design, so
the intended scroll boundary is internal to the main content pane).

Root cause: SidebarInset already renders a <main> (class
dx-sidebar-inset, a properly height-bound flex column — main{
height:900px in a 900px viewport, flex-direction:column}), but I'd
also written an explicit `main { ... }` as ITS child, producing a
`<main><main>...</main></main>` (confirmed via a headless Playwright
probe against the live dev server, not just DevTools guesswork). The
inner <main> is just a plain flex item with the default flex: 0 1 auto,
so it sized to its own content (6500+px) instead of being constrained
by the outer one's box, and the ScrollArea inside it had nothing
bounded to scroll within.

Removed the redundant inner <main> — SidebarInset's children (the
content-header div and ScrollArea) now sit directly in its own <main>,
which is the actual flex column that needs to size them. Also pinned
ScrollArea's direction to Vertical (it defaults to Both, which was
adding an unnecessary horizontal scrollbar) and moved the sizing rule
in app.css off a class ScrollArea silently drops (confirmed via the
same probe — a caller-supplied `class` never reaches ScrollArea's
rendered DOM, only its own internal one does) onto its stable
data-scroll-direction attribute instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PqTJmazHBQK878vjQ4JnnF
2026-09-03 19:17:57 +02:00
Austin Schaefer
44e2fb4d4e Actually fix the Subscribe button overflow (previous fix was inert)
The prior commit's .subscribe-form .dx-input selector never matched
anything: dioxus's #[css_module] macro content-hashes Input's class at
build time (e.g. rendered as class="dx-input-83f82cbc", confirmed by
inspecting the live DOM), so a selector on the unhashed "dx-input"
name was dead CSS from the start — hence the overflow persisting
across rebuilds and hard refreshes.

Select on the raw <input> tag within .subscribe-form instead, which
doesn't depend on the module's per-build hash. Verified against the
running dev server (not just visually): the Subscribe button now
renders flush with the form's own right edge (both at x=247) instead
of spilling to x=314, well inside the sidebar's x=256 boundary.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PqTJmazHBQK878vjQ4JnnF
2026-09-03 19:07:51 +02:00
Austin Schaefer
86863d809d Fix Subscribe button overflowing the sidebar's fixed width
Not an article-row or hot-reload issue after all — the Subscribe
button in the sidebar header was genuinely spilling past the sidebar's
16rem width into the main content column. The sidebar panel is
position: fixed; z-index: 10 (dx-sidebar-container in the vendored
sidebar CSS), so that overflow floated on top of whatever main content
happened to sit at the same height, which was the first article row's
button — hence it only ever looked broken there.

Root cause: .subscribe-form .dx-input had flex: 1 but no min-width: 0,
so flexbox's default min-width: auto stopped it shrinking below its
content-based minimum once the Subscribe button took its share of the
row — a classic flexbox overflow gotcha. Added min-width: 0 to both
the input and the form row.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PqTJmazHBQK878vjQ4JnnF
2026-09-03 18:18:49 +02:00
Austin Schaefer
21103b1f25 Fix article row layout: button overlapping title, uneven summary gaps
article-row had no layout structure at all — title link, score badge,
and the dismiss button were plain inline siblings in a block <li>, so a
long title pushed the button flush against (or past) the row's edge.
Group title+score into a header row and make the row itself a flex
column so summary/topics/button always stack on their own line below,
regardless of title length.

Also stopped rendering the summary <p> and topics div at all when
there's no content (many RSS entries have no description), instead of
emitting empty elements — spacing was uneven because those still
reserved a line's worth of height. Left this as an explicit Rust-side
branch rather than CSS :empty: an empty <p> still contains a
zero-length text node, and whether :empty matches that is
inconsistent across engines, whereas the topics div (an empty `for`
loop) has zero children and would be a safe :empty candidate — but
kept both on the same explicit mechanism for consistency.

Swapped the dismiss button from a raw <button> to the dx Button
component, matching the rest of the row and the project's preference
for the component library over raw elements.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PqTJmazHBQK878vjQ4JnnF
2026-09-03 18:08:14 +02:00
Austin Schaefer
8edffe8973 Add unit tests for list_ranked_articles' feed_id filtering
The sidebar's per-feed nav depends on this query correctly scoping to
one feed (or joining all of them when feed_id is None), and it's real
branching logic rather than a passthrough — exactly what the updated
Definition of Done's testing rule calls for.

Uses a throwaway SQLite file per test (migrated fresh, cleaned up via
Drop) rather than a shared fixture, since diesel-async's bb8 pool would
otherwise hand out per-connection ":memory:" databases that don't share
state.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PqTJmazHBQK878vjQ4JnnF
2026-09-03 17:04:01 +02:00
Austin Schaefer
6a2bf8c9a8 Add feed sidebar navigation, built from Dioxus's component library
Default view stays the all-feeds joined article list, now with a sidebar
listing every subscribed feed so a reader can pin down to one feed's
articles. Filtering happens server-side (list_ranked_articles now takes
an optional feed_id).

Pulled in the sidebar/badge/scroll_area (plus their sheet/skeleton/
tooltip/separator dependencies) components via `dx components add`
instead of hand-rolling nav/tag/scroll markup, matching this project's
existing pattern of using the Dioxus component library over raw
elements (see button/input).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PqTJmazHBQK878vjQ4JnnF
2026-09-03 16:28:22 +02:00
60 changed files with 3177 additions and 133 deletions

29
Cargo.lock generated
View file

@ -1136,6 +1136,17 @@ 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"
@ -1592,6 +1603,15 @@ 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"
@ -1625,6 +1645,7 @@ 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",
@ -2412,9 +2433,9 @@ dependencies = [
[[package]] [[package]]
name = "libsqlite3-sys" name = "libsqlite3-sys"
version = "0.30.1" version = "0.38.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8"
dependencies = [ dependencies = [
"cc", "cc",
"pkg-config", "pkg-config",
@ -3388,9 +3409,9 @@ dependencies = [
[[package]] [[package]]
name = "rustls" name = "rustls"
version = "0.23.43" version = "0.23.45"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
dependencies = [ dependencies = [
"aws-lc-rs", "aws-lc-rs",
"once_cell", "once_cell",

View file

@ -3,6 +3,7 @@ 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",
@ -27,9 +28,10 @@ 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.30", features = ["bundled"] } libsqlite3-sys = { version = "0.38.2", 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

@ -14,6 +14,13 @@ A change is done when all of the following hold, not just when it compiles.
- Within `db`, one file per domain concern (`feeds.rs`, `articles.rs`, - Within `db`, one file per domain concern (`feeds.rs`, `articles.rs`,
`reading_events.rs`, `affinities.rs`) — a new table gets its own file, `reading_events.rs`, `affinities.rs`) — a new table gets its own file,
not a growing `queries.rs`. 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 ## DRY, but not premature

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}; pub use models::{Article, Feed, ReadingEvent, ReadingOutcome, RelevanceInputs};
pub use scoring::{score_article, RelevanceInputs}; pub use scoring::score_article;

View file

@ -1,3 +1,4 @@
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;
@ -58,3 +59,16 @@ 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,16 +1,4 @@
use crate::affinity::TopicAffinities; use crate::models::RelevanceInputs;
/// 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,3 +1,4 @@
use crate::models::RankedArticleRow;
use crate::schema; use crate::schema;
use crate::Db; use crate::Db;
use anyhow::Result; use anyhow::Result;
@ -5,11 +6,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)
@ -35,7 +36,6 @@ 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,7 +54,6 @@ 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()))
@ -62,13 +61,16 @@ 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(
@ -91,20 +93,27 @@ impl Db {
Ok(()) Ok(())
} }
/// Highest-ranked articles for display, most relevant first. /// Highest-ranked articles for display, most relevant first. When
/// `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<(String, String, String, String, Vec<String>, Option<f32>)>> { ) -> Result<Vec<RankedArticleRow>> {
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 rows: Vec<(String, String, String, String, String, Option<f32>)> = dsl::articles let mut query = dsl::articles.into_boxed();
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,
@ -113,18 +122,139 @@ impl Db {
)) ))
.load(&mut conn) .load(&mut conn)
.await?; .await?;
Ok(rows Ok(rows.into_iter().map(RankedArticleRow::from).collect())
.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,
serde_json::from_str(&topics_json).unwrap_or_default(), topics: 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

@ -26,16 +26,18 @@ impl Db {
Ok(Uuid::parse_str(&id)?) Ok(Uuid::parse_str(&id)?)
} }
/// All subscribed feeds, for the polling job to iterate over. /// All subscribed feeds, ordered by title. Used both by the polling job
pub async fn list_feeds(&self) -> Result<Vec<(Uuid, String)>> { /// (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; use schema::feeds::dsl;
let mut conn = self.pool.get().await?; let mut conn = self.pool.get().await?;
let rows: Vec<(String, String)> = dsl::feeds let rows: Vec<(String, String, String)> = dsl::feeds
.select((dsl::id, dsl::url)) .order(dsl::title.asc())
.select((dsl::id, dsl::title, dsl::url))
.load(&mut conn) .load(&mut conn)
.await?; .await?;
rows.into_iter() rows.into_iter()
.map(|(id, url)| Ok((Uuid::parse_str(&id)?, url))) .map(|(id, title, url)| Ok((Uuid::parse_str(&id)?, title, url)))
.collect() .collect()
} }

View file

@ -1,7 +1,10 @@
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;

10
crates/db/src/models.rs Normal file
View file

@ -0,0 +1,10 @@
/// 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

@ -0,0 +1,10 @@
[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

@ -0,0 +1,80 @@
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

@ -0,0 +1,42 @@
.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

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

View file

@ -0,0 +1,10 @@
// 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

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

View file

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

View file

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

View file

@ -0,0 +1,23 @@
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

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

View file

@ -0,0 +1,13 @@
.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

@ -0,0 +1,150 @@
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

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

View file

@ -0,0 +1,253 @@
/* 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

@ -0,0 +1,845 @@
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

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

View file

@ -0,0 +1,855 @@
/* 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

@ -0,0 +1,17 @@
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

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

View file

@ -0,0 +1,16 @@
.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

@ -0,0 +1,61 @@
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

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

View file

@ -0,0 +1,150 @@
/* 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

@ -0,0 +1,9 @@
// 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,6 +24,7 @@ chrono = { workspace = true, optional = true }
serde_json = { workspace = true, optional = true } serde_json = { workspace = true, optional = true }
tokio-cron-scheduler = { version = "0.13", optional = true } tokio-cron-scheduler = { version = "0.13", optional = true }
dioxus-primitives = { git = "https://github.com/DioxusLabs/components", version = "0.0.1", default-features = false } dioxus-primitives = { git = "https://github.com/DioxusLabs/components", version = "0.0.1", default-features = false }
feedsignal-dioxus-components.workspace = true
[features] [features]
default = ["web"] default = ["web"]

View file

@ -1,13 +1,27 @@
body { font-family: system-ui, sans-serif; max-width: 720px; margin: 2rem auto; color: #1a1a1a; } body { font-family: system-ui, sans-serif; margin: 0; color: #1a1a1a; height: 100vh; }
.article-list { list-style: none; padding: 0; } .app-shell { display: flex; height: 100vh; }
.article-row { border-bottom: 1px solid #ddd; padding: 1rem 0; } .content-header { display: flex; align-items: center; gap: 0.75rem; padding: 1.5rem 1.5rem 0.5rem; }
.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; }
.score { float: right; font-variant-numeric: tabular-nums; color: #555; } .summary { color: #444; margin: 0; }
.summary { color: #444; margin: 0.4rem 0; } .topics { display: flex; flex-wrap: wrap; gap: 0.3rem; }
.topic-tag { display: inline-block; font-size: 0.75rem; background: #eee; border-radius: 4px; padding: 0.1rem 0.4rem; margin-right: 0.3rem; }
.error { color: #b00020; } .error { color: #b00020; }
.subscribe-form { display: flex; gap: 0.5rem; margin: 1rem 0; } .subscribe-form { display: flex; gap: 0.5rem; margin: 1rem 0; min-width: 0; }
.subscribe-form .dx-input { flex: 1; } /* Target the raw tag, not the component's own class: dioxus's #[css_module]
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,12 +2,16 @@ 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. Runs on the server (native, has DB access); the `#[server]` /// first. `feed_id` restricts the list to one feed; `None` joins every
/// macro generates the HTTP call the browser/WASM build uses instead. /// subscribed feed into one ranked list. Runs on the server (native, has
/// DB access); the `#[server]` macro generates the HTTP call the
/// browser/WASM build uses instead.
#[server] #[server]
pub async fn list_ranked_articles() -> Result<Vec<ArticleView>, ServerFnError> { pub async fn list_ranked_articles(
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) crate::server::services::articles::list_ranked(db, feed_id)
.await .await
.map_err(|e| ServerFnError::new(e.to_string())) .map_err(|e| ServerFnError::new(e.to_string()))
} }

View file

@ -1,3 +1,4 @@
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,
@ -10,3 +11,12 @@ 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 dto::ArticleView; pub use models::{ArticleView, FeedView};

View file

@ -6,9 +6,18 @@ 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,31 +0,0 @@
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

@ -0,0 +1,42 @@
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

@ -0,0 +1,29 @@
/// 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

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

View file

@ -0,0 +1,48 @@
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,21 +1,105 @@
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 articles = use_server_future(api::articles::list_ranked_articles)?; let mut selected_feed = use_signal(|| Option::<String>::None);
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") }
main { SidebarProvider {
div { class: "app-shell",
Sidebar {
SidebarHeader {
h1 { "feedsignal" } h1 { "feedsignal" }
SubscribeForm { on_subscribed: move |_| { articles.restart(); } } SubscribeForm { on_subscribed }
}
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",
@ -30,3 +114,6 @@ pub fn App() -> Element {
} }
} }
} }
}
}
}

View file

@ -1,7 +1,8 @@
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)
@ -13,22 +14,20 @@ 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 feed_url = url.read().clone(); let Some(feed_url) = normalize_feed_url(&url.read()) else {
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);
match api::feeds::subscribe_feed(feed_url).await { let result = api::feeds::subscribe_feed(feed_url).await;
Ok(count) => { match resolve_submit_outcome(result.map_err(|err| err.to_string())) {
status.set(Some(Ok(format!( SubmitOutcome::Success { message } => {
"Subscribed — pulled in {count} article(s)." status.set(Some(Ok(message)));
))));
url.set(String::new()); url.set(String::new());
on_subscribed.call(()); on_subscribed.call(());
} }
Err(err) => status.set(Some(Err(err.to_string()))), SubmitOutcome::Failure { message } => status.set(Some(Err(message))),
} }
submitting.set(false); submitting.set(false);
}); });

View file

@ -0,0 +1,77 @@
/// 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

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

View file

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

View file

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

View file

@ -10,7 +10,7 @@ pub async fn run(db: &Db) -> Result<()> {
let feeds = db.list_feeds().await?; let feeds = db.list_feeds().await?;
tracing::info!(count = feeds.len(), "polling subscribed feeds"); tracing::info!(count = feeds.len(), "polling subscribed feeds");
for (feed_id, url) in feeds { for (feed_id, _title, url) in feeds {
let (_title, articles) = match feedsignal_feeds::fetch_feed(&url, feed_id).await { let (_title, articles) = match feedsignal_feeds::fetch_feed(&url, feed_id).await {
Ok(result) => result, Ok(result) => result,
Err(err) => { Err(err) => {

View file

@ -1,22 +1,25 @@
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 first. /// Returns articles ranked by `final_score` descending, highest-relevance
pub async fn list_ranked(db: Db) -> Result<Vec<ArticleView>> { /// first. `feed_id` restricts the list to one feed; `None` joins every
/// 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(100) .list_ranked_articles(feed_id, 100)
.await? .await?
.into_iter() .into_iter()
.map( .map(|row| ArticleView {
|(id, title, url, summary, topics, final_score)| ArticleView { id: row.id,
id, feed_id: row.feed_id,
title, title: row.title,
url, url: row.url,
summary, summary: row.summary,
topics, topics: row.topics,
final_score, final_score: row.final_score,
}, })
)
.collect()) .collect())
} }

View file

@ -1,3 +1,4 @@
use crate::api::FeedView;
use anyhow::Result; use anyhow::Result;
use feedsignal_db::Db; use feedsignal_db::Db;
use uuid::Uuid; use uuid::Uuid;
@ -31,3 +32,17 @@ 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())
}