Compare commits
45 commits
worktree-f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| baf1037306 | |||
|
|
803ade78eb | ||
| bc07a8808f | |||
|
|
3dcf404b26 | ||
| b50b7e61cb | |||
|
|
49ce3eaad8 | ||
|
|
bd086b0288 | ||
|
|
2ae3293c9d | ||
|
|
de6256a812 | ||
| de6b4578d4 | |||
|
|
03a3c1577d | ||
|
|
893e0ed251 | ||
|
|
7ccc36955a | ||
|
|
cc79f59a7d | ||
|
|
e70909af47 | ||
|
|
f4b7e95d15 | ||
|
|
73c80491be | ||
|
|
33d2dfe011 | ||
| a37a93150b | |||
|
|
f0e42e2ad5 | ||
|
|
44e2fb4d4e | ||
|
|
86863d809d | ||
|
|
21103b1f25 | ||
|
|
8edffe8973 | ||
|
|
4846ab3bd6 | ||
|
|
22d0e4f537 | ||
|
|
6a2bf8c9a8 | ||
| 088821f660 | |||
|
|
64c46655d7 | ||
| 75148e5119 | |||
|
|
0612d642c9 | ||
|
|
6fb470084d | ||
| 9f65ff33b2 | |||
|
|
44b9724645 | ||
| fa9876a1b4 | |||
|
|
f28ed7b758 | ||
|
|
e621720e47 | ||
|
|
8d316408b8 | ||
|
|
d40c90552c | ||
| afe3df1401 | |||
|
|
6445f32f8a | ||
|
|
6fc33f4854 | ||
|
|
279125abb5 | ||
| 4f70bfc960 | |||
|
|
2a41544546 |
74 changed files with 4856 additions and 519 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -3,3 +3,6 @@
|
|||
*.db-shm
|
||||
*.db-wal
|
||||
.env
|
||||
|
||||
.idea/**
|
||||
.claude/worktrees/**
|
||||
152
Cargo.lock
generated
152
Cargo.lock
generated
|
|
@ -32,6 +32,15 @@ version = "1.0.104"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "approx"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "as-any"
|
||||
version = "0.3.2"
|
||||
|
|
@ -267,6 +276,12 @@ version = "3.20.3"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||
|
||||
[[package]]
|
||||
name = "by_address"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder"
|
||||
version = "1.5.0"
|
||||
|
|
@ -822,6 +837,17 @@ dependencies = [
|
|||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dioxus-attributes"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/DioxusLabs/components#bf007c15d0cf4d04d3181cc46cf12325aa773955"
|
||||
dependencies = [
|
||||
"dioxus-rsx",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dioxus-cli-config"
|
||||
version = "0.7.10"
|
||||
|
|
@ -1110,6 +1136,17 @@ dependencies = [
|
|||
"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]]
|
||||
name = "dioxus-interpreter-js"
|
||||
version = "0.7.10"
|
||||
|
|
@ -1169,6 +1206,22 @@ dependencies = [
|
|||
"tracing-wasm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dioxus-primitives"
|
||||
version = "0.0.1"
|
||||
source = "git+https://github.com/DioxusLabs/components#bf007c15d0cf4d04d3181cc46cf12325aa773955"
|
||||
dependencies = [
|
||||
"dioxus",
|
||||
"dioxus-attributes",
|
||||
"dioxus-sdk-time",
|
||||
"lazy-js-bundle",
|
||||
"num-integer",
|
||||
"palette",
|
||||
"serde",
|
||||
"time",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dioxus-router"
|
||||
version = "0.7.10"
|
||||
|
|
@ -1218,6 +1271,18 @@ dependencies = [
|
|||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dioxus-sdk-time"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "80c25ae93a3f72e734873b97fbd09d9b1b6adff97205fb0ffd8543e3564fb78e"
|
||||
dependencies = [
|
||||
"dioxus",
|
||||
"futures",
|
||||
"gloo-timers 0.3.0",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dioxus-server"
|
||||
version = "0.7.10"
|
||||
|
|
@ -1538,6 +1603,15 @@ dependencies = [
|
|||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "feedsignal-dioxus-components"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"dioxus",
|
||||
"dioxus-icons",
|
||||
"dioxus-primitives",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "feedsignal-feeds"
|
||||
version = "0.1.0"
|
||||
|
|
@ -1568,8 +1642,10 @@ dependencies = [
|
|||
"anyhow",
|
||||
"chrono",
|
||||
"dioxus",
|
||||
"dioxus-primitives",
|
||||
"feedsignal-core",
|
||||
"feedsignal-db",
|
||||
"feedsignal-dioxus-components",
|
||||
"feedsignal-feeds",
|
||||
"feedsignal-llm",
|
||||
"serde",
|
||||
|
|
@ -2357,9 +2433,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "libsqlite3-sys"
|
||||
version = "0.30.1"
|
||||
version = "0.38.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
|
||||
checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
|
|
@ -2660,6 +2736,15 @@ dependencies = [
|
|||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-integer"
|
||||
version = "0.1.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
|
|
@ -2691,6 +2776,15 @@ dependencies = [
|
|||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num_threads"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2"
|
||||
version = "0.6.4"
|
||||
|
|
@ -2727,6 +2821,36 @@ dependencies = [
|
|||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "palette"
|
||||
version = "0.7.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ddeed8580d347d2abf3dcf06a5f0b3dc020258338526b277847cd4248a70fc64"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"palette_derive",
|
||||
"palette_math",
|
||||
"phf",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "palette_derive"
|
||||
version = "0.7.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "88537020289b719d81be994ccf1bbf4990f477e2f69ee52fe3e45f43a02e56be"
|
||||
dependencies = [
|
||||
"by_address",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "palette_math"
|
||||
version = "0.7.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e6eb142958d64335fb0e345c5b9ead2ecd6fc438c307e9d7d3c4fd428dbaf12"
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
version = "0.12.5"
|
||||
|
|
@ -2756,6 +2880,24 @@ version = "2.3.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
|
||||
dependencies = [
|
||||
"phf_shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_shared"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
|
||||
dependencies = [
|
||||
"siphasher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project"
|
||||
version = "1.1.13"
|
||||
|
|
@ -3267,9 +3409,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.43"
|
||||
version = "0.23.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
|
||||
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"once_cell",
|
||||
|
|
@ -3872,7 +4014,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
|
||||
dependencies = [
|
||||
"deranged",
|
||||
"libc",
|
||||
"num-conv",
|
||||
"num_threads",
|
||||
"powerfmt",
|
||||
"serde_core",
|
||||
"time-core",
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ resolver = "2"
|
|||
members = [
|
||||
"crates/core",
|
||||
"crates/db",
|
||||
"crates/dioxus-components",
|
||||
"crates/feeds",
|
||||
"crates/llm",
|
||||
"crates/web",
|
||||
|
|
@ -11,7 +12,7 @@ members = [
|
|||
[workspace.package]
|
||||
edition = "2021"
|
||||
version = "0.1.0"
|
||||
license = "MIT"
|
||||
license = "AGPL-3.0-or-later"
|
||||
|
||||
[workspace.dependencies]
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
|
|
@ -27,9 +28,10 @@ diesel = { version = "2.3", features = ["sqlite", "chrono"] }
|
|||
diesel-async = { version = "0.9", features = ["sqlite", "bb8"] }
|
||||
diesel_migrations = { version = "2.3", features = ["sqlite"] }
|
||||
bb8 = "0.9"
|
||||
libsqlite3-sys = { version = "0.30", features = ["bundled"] }
|
||||
libsqlite3-sys = { version = "0.38.2", features = ["bundled"] }
|
||||
|
||||
feedsignal-core = { path = "crates/core" }
|
||||
feedsignal-db = { path = "crates/db" }
|
||||
feedsignal-dioxus-components = { path = "crates/dioxus-components" }
|
||||
feedsignal-feeds = { path = "crates/feeds" }
|
||||
feedsignal-llm = { path = "crates/llm" }
|
||||
|
|
|
|||
90
DEFINITION_OF_DONE.md
Normal file
90
DEFINITION_OF_DONE.md
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
# Definition of Done — feedsignal
|
||||
|
||||
A change is done when all of the following hold, not just when it compiles.
|
||||
|
||||
## Single Responsibility
|
||||
|
||||
- Each module/function does one thing. If a PR description needs "and" to
|
||||
describe what a function does, split it.
|
||||
- Crate boundaries stay honest: `core` has no I/O (see `crates/core` —
|
||||
domain models + scoring/affinity math only); `db` only talks to SQLite;
|
||||
`feeds` only fetches/parses; `llm` only talks to Ollama. A change that
|
||||
makes `core` reach for a DB handle or `db` parse RSS is a sign the code
|
||||
landed in the wrong crate.
|
||||
- Within `db`, one file per domain concern (`feeds.rs`, `articles.rs`,
|
||||
`reading_events.rs`, `affinities.rs`) — a new table gets its own file,
|
||||
not a growing `queries.rs`.
|
||||
- Data-model structs (domain models, query row types, DTOs/views) live in
|
||||
a crate's `models.rs` rather than the file that produces or consumes
|
||||
them, whenever a struct is used outside the function that builds it —
|
||||
matching the existing `crates/core/src/models.rs` pattern. This doesn't
|
||||
apply to structs that are inherently local to one file/component (e.g.
|
||||
a Dioxus component's `Props`/`Styles`/context struct, or a helper
|
||||
struct scoped to a single function's internals).
|
||||
|
||||
## DRY, but not premature
|
||||
|
||||
- No copy-pasted logic across call sites — extract a shared function once
|
||||
a second real call site exists, not in anticipation of one.
|
||||
- No new abstraction (trait, generic helper, config knob) unless at least
|
||||
two concrete cases need it today.
|
||||
|
||||
## Testing
|
||||
|
||||
- All non-trivial logic ships with unit tests wherever practical — not
|
||||
just `core`'s pure functions (scoring, affinity math — the existing
|
||||
pattern in `crates/core/src/affinity.rs`), but query/filter logic in
|
||||
`db` and non-passthrough logic in `web`'s `server::services`. "Non-trivial"
|
||||
excludes thin wrappers (a `#[server]` fn or service function that's just
|
||||
`db.some_query(args).await?` with no branching of its own) and glue code
|
||||
whose only failure mode is a compiler error.
|
||||
- When a DB/network dependency makes a case genuinely impractical to unit
|
||||
test (e.g. a Diesel query's SQL correctness), say so in the PR rather
|
||||
than silently skipping it — that's a real gap to track, not a pass.
|
||||
- Test rationale goes in a `///` doc comment above `#[test] fn`, not
|
||||
inline in the body.
|
||||
- A bug fix includes a regression test that fails on the old code.
|
||||
- `cargo test --workspace --exclude feedsignal-web` passes locally before
|
||||
pushing (CI runs this; `feedsignal-web` itself currently has no test
|
||||
target).
|
||||
|
||||
## Static checks (all of CI, not a subset)
|
||||
|
||||
- `cargo fmt --check` clean.
|
||||
- `cargo clippy --workspace --exclude feedsignal-web --all-targets -- -D warnings` clean.
|
||||
- `cargo clippy -p feedsignal-web --no-default-features --features server --all-targets -- -D warnings` clean.
|
||||
- `cargo clippy -p feedsignal-web --no-default-features --features web --target wasm32-unknown-unknown -- -D warnings` clean.
|
||||
- `cargo audit` clean, or a documented reason if it can't be (e.g. an
|
||||
unfixed transitive advisory).
|
||||
|
||||
## No half-finished work
|
||||
|
||||
- No `TODO`s standing in for a decision the PR itself was supposed to
|
||||
make (a `TODO` marking genuinely deferred, ticketed follow-up work is
|
||||
fine — see `LlmConfig`'s config-story TODO).
|
||||
- No dead code kept "just in case" — if it's unused, delete it. Library-
|
||||
surface exceptions (e.g. `dioxus-primitives`-derived components) get an
|
||||
explicit `#[allow(dead_code)]` with a one-line reason, matching the
|
||||
existing pattern in `components/button/component.rs`.
|
||||
- No commented-out code left in.
|
||||
|
||||
## Correctness at the boundary
|
||||
|
||||
- New `#[server]` fns validate/trim input the way `services::feeds::subscribe`
|
||||
does (`anyhow::ensure!` on empty URL) — don't trust the browser.
|
||||
- DB writes that must be atomic with respect to a failure path (e.g.
|
||||
"fetch before writing so a bad URL leaves no half-registered row") get
|
||||
a comment explaining why, like `subscribe`'s doc comment does.
|
||||
- Migrations are additive/reversible — no editing a shipped migration file.
|
||||
|
||||
## Docs
|
||||
|
||||
- A non-obvious "why" (a workaround, a chosen threshold, a design
|
||||
tradeoff) gets a comment; "what" is left to the code.
|
||||
- `README.md`'s architecture section gets updated if the change adds a
|
||||
crate, a pipeline stage, or a scoring input.
|
||||
|
||||
## PR hygiene
|
||||
|
||||
- Diff is scoped to the stated task — no drive-by refactors bundled in.
|
||||
- `Cargo.lock` changes match what the dependency change actually requires.
|
||||
|
|
@ -19,13 +19,14 @@ const LEARNING_RATE: f64 = 0.15;
|
|||
const DAILY_DECAY: f64 = 0.02;
|
||||
|
||||
impl TopicAffinities {
|
||||
/// Get score for given topic
|
||||
pub fn score(&self, topic: &str) -> f64 {
|
||||
self.scores.get(topic).copied().unwrap_or(0.0)
|
||||
}
|
||||
|
||||
/// Mean affinity across an article's topics; 0.0 for an untagged
|
||||
/// article (neutral, defers entirely to the embedding/LLM stages).
|
||||
pub fn score_topics(&self, topics: &[String]) -> f64 {
|
||||
pub fn get_mean_affinity(&self, topics: &[String]) -> f64 {
|
||||
if topics.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
|
|
@ -37,7 +38,7 @@ impl TopicAffinities {
|
|||
/// `[0.0, 1.0]` (see `scoring::engagement_score`). Positive surprise
|
||||
/// (the user engaged more than the pipeline predicted) nudges those
|
||||
/// topics up; negative surprise nudges them down. This is what lets
|
||||
/// "the model thought this was irrelevant but I read the whole thing"
|
||||
/// "the model thought this was irrelevant, but I read the whole thing"
|
||||
/// actually change future behavior.
|
||||
pub fn apply_feedback(&mut self, topics: &[String], surprise: f64) {
|
||||
for topic in topics {
|
||||
|
|
@ -52,11 +53,15 @@ impl TopicAffinities {
|
|||
/// neutral rather than staying permanently pinned from a few old
|
||||
/// signals.
|
||||
pub fn decay(&mut self) {
|
||||
self.scores.retain(|_, v| v.abs() > 1e-4);
|
||||
for v in self.scores.values_mut() {
|
||||
if v.abs() <= DAILY_DECAY {
|
||||
*v = 0.0;
|
||||
} else {
|
||||
*v -= v.signum() * DAILY_DECAY;
|
||||
}
|
||||
}
|
||||
self.scores.retain(|_, v| v.abs() > 1e-4);
|
||||
}
|
||||
|
||||
pub fn top_n(&self, n: usize) -> Vec<(&str, f64)> {
|
||||
let mut items: Vec<_> = self.scores.iter().map(|(k, v)| (k.as_str(), *v)).collect();
|
||||
|
|
@ -87,6 +92,7 @@ pub fn engagement_score(
|
|||
if dismissed && !opened {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let mut score = if !opened {
|
||||
0.0
|
||||
} else {
|
||||
|
|
@ -97,6 +103,7 @@ pub fn engagement_score(
|
|||
_ => 0.5,
|
||||
}
|
||||
};
|
||||
|
||||
if starred {
|
||||
score = (score + 0.3).min(1.0);
|
||||
}
|
||||
|
|
@ -107,6 +114,10 @@ pub fn engagement_score(
|
|||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// --- apply_feedback: new = clamp(current + LEARNING_RATE(0.15) * surprise, -1, 1) ---
|
||||
|
||||
/// Positive surprise (engaged more than predicted) should move the
|
||||
/// score up, never down or unchanged.
|
||||
#[test]
|
||||
fn under_predicted_relevance_boosts_topic() {
|
||||
let mut aff = TopicAffinities::default();
|
||||
|
|
@ -116,6 +127,8 @@ mod tests {
|
|||
assert!(aff.score("rust") > 0.0);
|
||||
}
|
||||
|
||||
/// Negative surprise (engaged less than predicted) should move the
|
||||
/// score down, the mirror image of the boost case above.
|
||||
#[test]
|
||||
fn over_predicted_relevance_lowers_topic() {
|
||||
let mut aff = TopicAffinities::default();
|
||||
|
|
@ -125,6 +138,60 @@ mod tests {
|
|||
assert!(aff.score("crypto") < 0.0);
|
||||
}
|
||||
|
||||
/// Scores are documented to live in [-1.0, 1.0]. Repeated max-surprise
|
||||
/// feedback would overshoot 1.0 without the clamp, so this guards the
|
||||
/// invariant directly rather than trusting a single update.
|
||||
#[test]
|
||||
fn apply_feedback_clamps_at_positive_one() {
|
||||
let mut aff = TopicAffinities::default();
|
||||
let topics = vec!["rust".to_string()];
|
||||
for _ in 0..20 {
|
||||
aff.apply_feedback(&topics, 1.0);
|
||||
}
|
||||
assert_eq!(aff.score("rust"), 1.0);
|
||||
}
|
||||
|
||||
/// Verifies a decay from a large negative value doesn't overshoot and go beyond -1.0
|
||||
#[test]
|
||||
fn apply_feedback_clamps_at_negative_one() {
|
||||
let mut aff = TopicAffinities::default();
|
||||
let topics = vec!["crypto".to_string()];
|
||||
for _ in 0..20 {
|
||||
aff.apply_feedback(&topics, -1.0);
|
||||
}
|
||||
assert_eq!(aff.score("crypto"), -1.0);
|
||||
}
|
||||
|
||||
/// apply_feedback loops over every topic on the article and applies
|
||||
/// the same surprise to each independently; it must not skip topics
|
||||
/// or bleed the update into topics the article wasn't tagged with.
|
||||
#[test]
|
||||
fn apply_feedback_updates_every_topic_on_the_article() {
|
||||
let mut aff = TopicAffinities::default();
|
||||
let topics = vec!["rust".to_string(), "async".to_string()];
|
||||
aff.apply_feedback(&topics, 0.4);
|
||||
assert_eq!(aff.score("rust"), 0.15 * 0.4);
|
||||
assert_eq!(aff.score("async"), 0.15 * 0.4);
|
||||
// Untouched topics are unaffected.
|
||||
assert_eq!(aff.score("crypto"), 0.0);
|
||||
}
|
||||
|
||||
/// surprise = 0.0 means engagement exactly matched the prediction, so
|
||||
/// the score shouldn't move at all (current + 0.15 * 0.0 == current).
|
||||
#[test]
|
||||
fn apply_feedback_zero_surprise_is_a_noop() {
|
||||
let mut aff = TopicAffinities::default();
|
||||
let topics = vec!["rust".to_string()];
|
||||
aff.apply_feedback(&topics, 0.5);
|
||||
let before = aff.score("rust");
|
||||
aff.apply_feedback(&topics, 0.0);
|
||||
assert_eq!(aff.score("rust"), before);
|
||||
}
|
||||
|
||||
// --- decay: v -= sign(v) * DAILY_DECAY(0.02), settling at 0 instead of overshooting ---
|
||||
|
||||
/// Core decay behavior: a positive score should shrink toward zero
|
||||
/// after one nightly pass, without crossing it.
|
||||
#[test]
|
||||
fn decay_pulls_toward_zero() {
|
||||
let mut aff = TopicAffinities::default();
|
||||
|
|
@ -134,4 +201,177 @@ mod tests {
|
|||
assert!(aff.score("rust") < before);
|
||||
assert!(aff.score("rust") > 0.0);
|
||||
}
|
||||
|
||||
/// Pins the exact arithmetic (not just the direction) so a future
|
||||
/// change to the decay formula is caught immediately.
|
||||
/// surprise 1.0 -> 0.15, then one decay pass subtracts DAILY_DECAY (0.02).
|
||||
#[test]
|
||||
fn decay_gives_expected_value() {
|
||||
let mut aff = TopicAffinities::default();
|
||||
aff.apply_feedback(&["java".to_string()], 1.0);
|
||||
aff.decay();
|
||||
|
||||
assert_eq!(aff.score("java"), 0.13);
|
||||
}
|
||||
|
||||
/// Regression test for a real bug: subtracting a fixed 0.02 from a
|
||||
/// smaller score (e.g. 0.015) used to flip its sign to -0.005 instead
|
||||
/// of landing on 0.0, which would make the score oscillate around
|
||||
/// zero on every subsequent decay pass rather than settling.
|
||||
#[test]
|
||||
fn decay_settles_at_zero_instead_of_overshooting() {
|
||||
let mut aff = TopicAffinities::default();
|
||||
aff.apply_feedback(&["rust".to_string()], 0.1); // score = 0.015
|
||||
aff.decay();
|
||||
assert_eq!(aff.score("rust"), 0.0);
|
||||
}
|
||||
|
||||
/// Same fix as above, verified on the negative side, and also checks
|
||||
/// that the post-decay prune (dropping |v| <= 1e-4) actually removes
|
||||
/// the entry rather than leaving a stray 0.0 in the map.
|
||||
#[test]
|
||||
fn decay_prunes_negative_scores_that_settle_at_zero() {
|
||||
let mut aff = TopicAffinities::default();
|
||||
aff.apply_feedback(&["crypto".to_string()], -0.1); // score = -0.015
|
||||
aff.decay();
|
||||
assert_eq!(aff.score("crypto"), 0.0);
|
||||
}
|
||||
|
||||
/// decay_pulls_toward_zero's mirror image: negative scores should
|
||||
/// shrink in magnitude too, not just positive ones.
|
||||
#[test]
|
||||
fn decay_is_symmetric_for_negative_scores() {
|
||||
let mut aff = TopicAffinities::default();
|
||||
aff.apply_feedback(&["crypto".to_string()], -1.0);
|
||||
let before = aff.score("crypto");
|
||||
aff.decay();
|
||||
assert!(aff.score("crypto") > before);
|
||||
assert!(aff.score("crypto") < 0.0);
|
||||
}
|
||||
|
||||
// --- score / get_mean_affinity ---
|
||||
|
||||
/// A topic with no feedback yet must read as neutral (0.0), not
|
||||
/// panic or return some other sentinel.
|
||||
#[test]
|
||||
fn score_defaults_to_zero_for_unknown_topic() {
|
||||
let aff = TopicAffinities::default();
|
||||
assert_eq!(aff.score("never-seen"), 0.0);
|
||||
}
|
||||
|
||||
/// Documented behavior for untagged articles: defer entirely to the
|
||||
/// embedding/LLM stages by returning a neutral 0.0 rather than
|
||||
/// dividing by zero.
|
||||
#[test]
|
||||
fn get_mean_affinity_given_empty_topics_returns_zero() {
|
||||
let aff = TopicAffinities::default();
|
||||
assert_eq!(aff.get_mean_affinity(&[]), 0.0);
|
||||
}
|
||||
|
||||
/// Confirms it's a plain arithmetic mean: an equally strong positive
|
||||
/// and negative topic on the same article should cancel out to 0.0.
|
||||
#[test]
|
||||
fn get_mean_affinity_averages_across_topics() {
|
||||
let mut aff = TopicAffinities::default();
|
||||
aff.apply_feedback(&["rust".to_string()], 1.0); // 0.15
|
||||
aff.apply_feedback(&["crypto".to_string()], -1.0); // -0.15
|
||||
let topics = vec!["rust".to_string(), "crypto".to_string()];
|
||||
assert_eq!(aff.get_mean_affinity(&topics), 0.0);
|
||||
}
|
||||
|
||||
/// A topic mix of "known" and "never seen" shouldn't shrink the
|
||||
/// denominator or get skipped — the unscored topic counts as 0.0 in
|
||||
/// the average, per score()'s default.
|
||||
#[test]
|
||||
fn get_mean_affinity_treats_unscored_topics_as_zero() {
|
||||
let mut aff = TopicAffinities::default();
|
||||
aff.apply_feedback(&["rust".to_string()], 1.0); // 0.15
|
||||
let topics = vec!["rust".to_string(), "never-seen".to_string()];
|
||||
assert_eq!(aff.get_mean_affinity(&topics), 0.075);
|
||||
}
|
||||
|
||||
// --- top_n ---
|
||||
|
||||
/// top_n is used to surface a user's strongest interests, so it must
|
||||
/// sort highest-first (not insertion order) and respect the limit.
|
||||
#[test]
|
||||
fn top_n_sorts_descending_and_truncates() {
|
||||
let mut aff = TopicAffinities::default();
|
||||
aff.apply_feedback(&["low".to_string()], 0.2);
|
||||
aff.apply_feedback(&["high".to_string()], 1.0);
|
||||
aff.apply_feedback(&["mid".to_string()], 0.5);
|
||||
|
||||
let top = aff.top_n(2);
|
||||
assert_eq!(top.len(), 2);
|
||||
assert_eq!(top[0].0, "high");
|
||||
assert_eq!(top[1].0, "mid");
|
||||
}
|
||||
|
||||
// --- engagement_score ---
|
||||
|
||||
/// No signal at all (not opened, not dismissed) is neutral, not
|
||||
/// penalized.
|
||||
#[test]
|
||||
fn engagement_score_never_opened_is_zero() {
|
||||
assert_eq!(engagement_score(false, None, None, false, false), 0.0);
|
||||
}
|
||||
|
||||
/// Dismissing without opening is an explicit negative signal, but
|
||||
/// engagement_score itself is floored at 0.0 (the doc comment notes
|
||||
/// the negative direction is expressed later via `surprise`, not
|
||||
/// here) — this pins that the dismissed+!opened branch returns 0.0,
|
||||
/// not a negative number.
|
||||
#[test]
|
||||
fn engagement_score_dismissed_without_opening_is_zero() {
|
||||
assert_eq!(engagement_score(false, None, None, false, true), 0.0);
|
||||
}
|
||||
|
||||
/// Reading half the estimated time should score as half-engaged.
|
||||
#[test]
|
||||
fn engagement_score_opened_uses_dwell_over_estimate_ratio() {
|
||||
assert_eq!(
|
||||
engagement_score(true, Some(30), Some(60), false, false),
|
||||
0.5
|
||||
);
|
||||
}
|
||||
|
||||
/// Dwelling far longer than the estimate (e.g. left the tab open)
|
||||
/// must not push the score above the documented [0.0, 1.0] range.
|
||||
#[test]
|
||||
fn engagement_score_opened_caps_ratio_at_one() {
|
||||
assert_eq!(
|
||||
engagement_score(true, Some(600), Some(60), false, false),
|
||||
1.0
|
||||
);
|
||||
}
|
||||
|
||||
/// When we simply don't have dwell/estimate data yet, the code
|
||||
/// credits partial engagement (0.5) rather than assuming 0 (unfairly
|
||||
/// penalizing) or 1 (unfairly rewarding).
|
||||
#[test]
|
||||
fn engagement_score_opened_without_dwell_or_estimate_defaults_to_half() {
|
||||
assert_eq!(engagement_score(true, None, None, false, false), 0.5);
|
||||
}
|
||||
|
||||
/// est == 0 would divide by zero, so the `est > 0` guard routes this
|
||||
/// case to the same "unknown read time" default (0.5) instead of
|
||||
/// panicking or producing NaN/infinity.
|
||||
#[test]
|
||||
fn engagement_score_opened_with_zero_estimate_defaults_to_half() {
|
||||
assert_eq!(engagement_score(true, Some(10), Some(0), false, false), 0.5);
|
||||
}
|
||||
|
||||
/// Starring is an explicit "yes" beyond dwell time: it should add
|
||||
/// 0.3 on top of the dwell-ratio score.
|
||||
#[test]
|
||||
fn engagement_score_starred_adds_bonus() {
|
||||
assert_eq!(engagement_score(true, Some(30), Some(60), true, false), 0.8);
|
||||
}
|
||||
|
||||
/// The +0.3 star bonus must also respect the 1.0 ceiling, even when
|
||||
/// the dwell ratio alone is already at the max.
|
||||
#[test]
|
||||
fn engagement_score_starred_bonus_caps_at_one() {
|
||||
assert_eq!(engagement_score(true, Some(60), Some(60), true, false), 1.0);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,5 +3,5 @@ pub mod models;
|
|||
pub mod scoring;
|
||||
|
||||
pub use affinity::TopicAffinities;
|
||||
pub use models::{Article, Feed, ReadingEvent, ReadingOutcome};
|
||||
pub use scoring::{score_article, RelevanceInputs};
|
||||
pub use models::{Article, Feed, ReadingEvent, ReadingOutcome, RelevanceInputs};
|
||||
pub use scoring::score_article;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
use crate::affinity::TopicAffinities;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
|
@ -58,3 +59,16 @@ pub enum ReadingOutcome {
|
|||
/// Explicitly marked not relevant, independent of whether it was opened.
|
||||
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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,4 @@
|
|||
use crate::affinity::TopicAffinities;
|
||||
|
||||
/// Inputs to the final blended relevance score for one article.
|
||||
pub struct RelevanceInputs<'a> {
|
||||
/// Cosine similarity (0.0-1.0, already renormalized from [-1,1] if
|
||||
/// needed) between article and preference-profile embeddings.
|
||||
pub embedding_score: f32,
|
||||
/// LLM judgment (0.0-1.0), `None` if the article didn't clear the
|
||||
/// embedding shortlist threshold and so was never sent to the LLM.
|
||||
pub llm_score: Option<f32>,
|
||||
pub topics: &'a [String],
|
||||
pub affinities: &'a TopicAffinities,
|
||||
}
|
||||
use crate::models::RelevanceInputs;
|
||||
|
||||
/// Weights are deliberately conservative: the LLM judgment dominates when
|
||||
/// present (it has read the actual content), the embedding score is a
|
||||
|
|
@ -22,7 +10,7 @@ const W_EMBEDDING: f32 = 0.25;
|
|||
const W_AFFINITY: f32 = 0.15;
|
||||
|
||||
pub fn score_article(inputs: RelevanceInputs) -> f32 {
|
||||
let affinity = inputs.affinities.score_topics(inputs.topics) as f32; // [-1, 1]
|
||||
let affinity = inputs.affinities.get_mean_affinity(inputs.topics) as f32; // [-1, 1]
|
||||
let affinity_component = (affinity + 1.0) / 2.0; // renormalize to [0, 1]
|
||||
|
||||
match inputs.llm_score {
|
||||
|
|
|
|||
43
crates/db/src/affinities.rs
Normal file
43
crates/db/src/affinities.rs
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
use crate::schema;
|
||||
use crate::Db;
|
||||
use anyhow::Result;
|
||||
use chrono::Utc;
|
||||
use diesel::prelude::*;
|
||||
use diesel_async::RunQueryDsl;
|
||||
use feedsignal_core::TopicAffinities;
|
||||
|
||||
impl Db {
|
||||
pub async fn load_affinities(&self) -> Result<TopicAffinities> {
|
||||
use schema::topic_affinities::dsl;
|
||||
let mut conn = self.pool.get().await?;
|
||||
let row: Option<String> = dsl::topic_affinities
|
||||
.filter(dsl::user_id.eq("default"))
|
||||
.select(dsl::affinities)
|
||||
.first(&mut conn)
|
||||
.await
|
||||
.optional()?;
|
||||
Ok(match row {
|
||||
Some(json) => serde_json::from_str(&json)?,
|
||||
None => TopicAffinities::default(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn save_affinities(&self, affinities: &TopicAffinities) -> Result<()> {
|
||||
use schema::topic_affinities::dsl;
|
||||
let mut conn = self.pool.get().await?;
|
||||
let json = serde_json::to_string(affinities)?;
|
||||
let updated_at = Utc::now().to_rfc3339();
|
||||
diesel::insert_into(dsl::topic_affinities)
|
||||
.values((
|
||||
dsl::user_id.eq("default"),
|
||||
dsl::affinities.eq(&json),
|
||||
dsl::updated_at.eq(&updated_at),
|
||||
))
|
||||
.on_conflict(dsl::user_id)
|
||||
.do_update()
|
||||
.set((dsl::affinities.eq(&json), dsl::updated_at.eq(&updated_at)))
|
||||
.execute(&mut conn)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
260
crates/db/src/articles.rs
Normal file
260
crates/db/src/articles.rs
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
use crate::models::RankedArticleRow;
|
||||
use crate::schema;
|
||||
use crate::Db;
|
||||
use anyhow::Result;
|
||||
use chrono::Utc;
|
||||
use diesel::prelude::*;
|
||||
use diesel_async::RunQueryDsl;
|
||||
use feedsignal_core::Article;
|
||||
use schema::articles::dsl;
|
||||
use uuid::Uuid;
|
||||
|
||||
impl Db {
|
||||
pub async fn insert_article(&self, article: &Article) -> Result<()> {
|
||||
let mut conn = self.pool.get().await?;
|
||||
let topics = serde_json::to_string(&article.topics)?;
|
||||
diesel::insert_into(dsl::articles)
|
||||
.values((
|
||||
dsl::id.eq(article.id.to_string()),
|
||||
dsl::feed_id.eq(article.feed_id.to_string()),
|
||||
dsl::url.eq(&article.url),
|
||||
dsl::title.eq(&article.title),
|
||||
dsl::summary.eq(&article.summary),
|
||||
dsl::fetched_at.eq(Utc::now().to_rfc3339()),
|
||||
dsl::topics.eq(topics),
|
||||
dsl::embedding_score.eq(article.embedding_score),
|
||||
dsl::llm_score.eq(article.llm_score),
|
||||
dsl::final_score.eq(article.final_score),
|
||||
dsl::estimated_read_seconds.eq(article.estimated_read_seconds.map(|v| v as i32)),
|
||||
))
|
||||
.on_conflict_do_nothing()
|
||||
.execute(&mut conn)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Articles above the embedding-similarity threshold that haven't been
|
||||
/// through the (slower) LLM scoring stage yet.
|
||||
pub async fn shortlist_for_llm_scoring(&self, threshold: f32, limit: i64) -> Result<Vec<Uuid>> {
|
||||
let mut conn = self.pool.get().await?;
|
||||
let ids: Vec<String> = dsl::articles
|
||||
.filter(dsl::embedding_score.ge(threshold))
|
||||
.filter(dsl::llm_score.is_null())
|
||||
.order(dsl::embedding_score.desc())
|
||||
.limit(limit)
|
||||
.select(dsl::id)
|
||||
.load(&mut conn)
|
||||
.await?;
|
||||
ids.into_iter().map(|s| Ok(Uuid::parse_str(&s)?)).collect()
|
||||
}
|
||||
|
||||
/// Title/summary/topics/embedding_score for one article, used to build
|
||||
/// the LLM-judging prompt for it.
|
||||
pub async fn article_scoring_fields(
|
||||
&self,
|
||||
article_id: Uuid,
|
||||
) -> Result<Option<(String, String, Vec<String>, f32)>> {
|
||||
let mut conn = self.pool.get().await?;
|
||||
let row: Option<(String, String, String, Option<f32>)> = dsl::articles
|
||||
.filter(dsl::id.eq(article_id.to_string()))
|
||||
.select((dsl::title, dsl::summary, dsl::topics, dsl::embedding_score))
|
||||
.first(&mut conn)
|
||||
.await
|
||||
.optional()?;
|
||||
|
||||
let result = match row {
|
||||
Some((title, summary, topics_json, score)) => {
|
||||
let topics: Vec<String> = serde_json::from_str(&topics_json).unwrap_or_default();
|
||||
Some((title, summary, topics, score.unwrap_or(0.0)))
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn store_llm_result(
|
||||
&self,
|
||||
article_id: Uuid,
|
||||
llm_score: f32,
|
||||
rationale: &str,
|
||||
final_score: f32,
|
||||
) -> Result<()> {
|
||||
use schema::articles::dsl;
|
||||
let mut conn = self.pool.get().await?;
|
||||
diesel::update(dsl::articles.filter(dsl::id.eq(article_id.to_string())))
|
||||
.set((
|
||||
dsl::llm_score.eq(llm_score),
|
||||
dsl::llm_rationale.eq(rationale),
|
||||
dsl::final_score.eq(final_score),
|
||||
))
|
||||
.execute(&mut conn)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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(
|
||||
&self,
|
||||
feed_id: Option<Uuid>,
|
||||
limit: i64,
|
||||
) -> Result<Vec<RankedArticleRow>> {
|
||||
let mut conn = self.pool.get().await?;
|
||||
// SQLite sorts NULL before any value, so `DESC` already puts NULL
|
||||
// `final_score`s last — no separate NULLS LAST clause needed here.
|
||||
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())
|
||||
.limit(limit)
|
||||
.select((
|
||||
dsl::id,
|
||||
dsl::feed_id,
|
||||
dsl::title,
|
||||
dsl::url,
|
||||
dsl::summary,
|
||||
dsl::topics,
|
||||
dsl::final_score,
|
||||
))
|
||||
.load(&mut conn)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(RankedArticleRow::from).collect())
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
feed_id,
|
||||
title,
|
||||
url,
|
||||
summary,
|
||||
topics: serde_json::from_str(&topics_json).unwrap_or_default(),
|
||||
final_score,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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));
|
||||
}
|
||||
}
|
||||
55
crates/db/src/feeds.rs
Normal file
55
crates/db/src/feeds.rs
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
use crate::schema;
|
||||
use crate::Db;
|
||||
use anyhow::Result;
|
||||
use chrono::Utc;
|
||||
use diesel::prelude::*;
|
||||
use diesel_async::RunQueryDsl;
|
||||
use uuid::Uuid;
|
||||
|
||||
impl Db {
|
||||
pub async fn upsert_feed(&self, url: &str, title: &str) -> Result<Uuid> {
|
||||
use schema::feeds::dsl;
|
||||
let mut conn = self.pool.get().await?;
|
||||
let new_id = Uuid::new_v4().to_string();
|
||||
diesel::insert_into(dsl::feeds)
|
||||
.values((dsl::id.eq(&new_id), dsl::url.eq(url), dsl::title.eq(title)))
|
||||
.on_conflict(dsl::url)
|
||||
.do_update()
|
||||
.set(dsl::title.eq(title))
|
||||
.execute(&mut conn)
|
||||
.await?;
|
||||
let id: String = dsl::feeds
|
||||
.filter(dsl::url.eq(url))
|
||||
.select(dsl::id)
|
||||
.first(&mut conn)
|
||||
.await?;
|
||||
Ok(Uuid::parse_str(&id)?)
|
||||
}
|
||||
|
||||
/// All subscribed feeds, ordered by title. Used both by the polling job
|
||||
/// (iterating to fetch each feed) and the sidebar's feed-navigation list.
|
||||
pub async fn list_feeds(&self) -> Result<Vec<(Uuid, String, String)>> {
|
||||
use schema::feeds::dsl;
|
||||
let mut conn = self.pool.get().await?;
|
||||
let rows: Vec<(String, String, String)> = dsl::feeds
|
||||
.order(dsl::title.asc())
|
||||
.select((dsl::id, dsl::title, dsl::url))
|
||||
.load(&mut conn)
|
||||
.await?;
|
||||
rows.into_iter()
|
||||
.map(|(id, title, url)| Ok((Uuid::parse_str(&id)?, title, url)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Records that a feed was just polled, so the next run can be judged
|
||||
/// against it (e.g. surfaced in the UI as "last checked").
|
||||
pub async fn mark_feed_fetched(&self, feed_id: Uuid) -> Result<()> {
|
||||
use schema::feeds::dsl;
|
||||
let mut conn = self.pool.get().await?;
|
||||
diesel::update(dsl::feeds.filter(dsl::id.eq(feed_id.to_string())))
|
||||
.set(dsl::last_fetched_at.eq(Utc::now().to_rfc3339()))
|
||||
.execute(&mut conn)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
@ -1,16 +1,19 @@
|
|||
mod schema;
|
||||
mod affinities;
|
||||
mod articles;
|
||||
mod feeds;
|
||||
mod models;
|
||||
mod reading_events;
|
||||
|
||||
pub use models::RankedArticleRow;
|
||||
pub(crate) mod schema;
|
||||
|
||||
use anyhow::Result;
|
||||
use chrono::Utc;
|
||||
use diesel::prelude::*;
|
||||
use diesel::sqlite::SqliteConnection;
|
||||
use diesel_async::pooled_connection::bb8::Pool;
|
||||
use diesel_async::pooled_connection::AsyncDieselConnectionManager;
|
||||
use diesel_async::sync_connection_wrapper::SyncConnectionWrapper;
|
||||
use diesel_async::RunQueryDsl;
|
||||
use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness};
|
||||
use feedsignal_core::{Article, ReadingEvent, ReadingOutcome, TopicAffinities};
|
||||
use uuid::Uuid;
|
||||
|
||||
const MIGRATIONS: EmbeddedMigrations = embed_migrations!("./migrations");
|
||||
|
||||
|
|
@ -47,199 +50,4 @@ impl Db {
|
|||
let pool = Pool::builder().max_size(5).build(manager).await?;
|
||||
Ok(Self { pool })
|
||||
}
|
||||
|
||||
pub async fn upsert_feed(&self, url: &str, title: &str) -> Result<Uuid> {
|
||||
use schema::feeds::dsl;
|
||||
let mut conn = self.pool.get().await?;
|
||||
let new_id = Uuid::new_v4().to_string();
|
||||
diesel::insert_into(dsl::feeds)
|
||||
.values((dsl::id.eq(&new_id), dsl::url.eq(url), dsl::title.eq(title)))
|
||||
.on_conflict(dsl::url)
|
||||
.do_update()
|
||||
.set(dsl::title.eq(title))
|
||||
.execute(&mut conn)
|
||||
.await?;
|
||||
let id: String = dsl::feeds
|
||||
.filter(dsl::url.eq(url))
|
||||
.select(dsl::id)
|
||||
.first(&mut conn)
|
||||
.await?;
|
||||
Ok(Uuid::parse_str(&id)?)
|
||||
}
|
||||
|
||||
pub async fn insert_article(&self, article: &Article) -> Result<()> {
|
||||
use schema::articles::dsl;
|
||||
let mut conn = self.pool.get().await?;
|
||||
let topics = serde_json::to_string(&article.topics)?;
|
||||
diesel::insert_into(dsl::articles)
|
||||
.values((
|
||||
dsl::id.eq(article.id.to_string()),
|
||||
dsl::feed_id.eq(article.feed_id.to_string()),
|
||||
dsl::url.eq(&article.url),
|
||||
dsl::title.eq(&article.title),
|
||||
dsl::summary.eq(&article.summary),
|
||||
dsl::fetched_at.eq(Utc::now().to_rfc3339()),
|
||||
dsl::topics.eq(topics),
|
||||
dsl::embedding_score.eq(article.embedding_score),
|
||||
dsl::llm_score.eq(article.llm_score),
|
||||
dsl::final_score.eq(article.final_score),
|
||||
dsl::estimated_read_seconds.eq(article.estimated_read_seconds.map(|v| v as i32)),
|
||||
))
|
||||
.on_conflict_do_nothing()
|
||||
.execute(&mut conn)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Articles above the embedding-similarity threshold that haven't been
|
||||
/// through the (slower) LLM scoring stage yet.
|
||||
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 ids: Vec<String> = dsl::articles
|
||||
.filter(dsl::embedding_score.ge(threshold))
|
||||
.filter(dsl::llm_score.is_null())
|
||||
.order(dsl::embedding_score.desc())
|
||||
.limit(limit)
|
||||
.select(dsl::id)
|
||||
.load(&mut conn)
|
||||
.await?;
|
||||
ids.into_iter().map(|s| Ok(Uuid::parse_str(&s)?)).collect()
|
||||
}
|
||||
|
||||
/// Title/summary/topics/embedding_score for one article, used to build
|
||||
/// the LLM-judging prompt for it.
|
||||
pub async fn article_scoring_fields(
|
||||
&self,
|
||||
article_id: Uuid,
|
||||
) -> Result<Option<(String, String, Vec<String>, f32)>> {
|
||||
use schema::articles::dsl;
|
||||
let mut conn = self.pool.get().await?;
|
||||
let row: Option<(String, String, String, Option<f32>)> = dsl::articles
|
||||
.filter(dsl::id.eq(article_id.to_string()))
|
||||
.select((dsl::title, dsl::summary, dsl::topics, dsl::embedding_score))
|
||||
.first(&mut conn)
|
||||
.await
|
||||
.optional()?;
|
||||
Ok(match row {
|
||||
Some((title, summary, topics_json, score)) => {
|
||||
let topics: Vec<String> = serde_json::from_str(&topics_json).unwrap_or_default();
|
||||
Some((title, summary, topics, score.unwrap_or(0.0)))
|
||||
}
|
||||
None => None,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn store_llm_result(
|
||||
&self,
|
||||
article_id: Uuid,
|
||||
llm_score: f32,
|
||||
rationale: &str,
|
||||
final_score: f32,
|
||||
) -> Result<()> {
|
||||
use schema::articles::dsl;
|
||||
let mut conn = self.pool.get().await?;
|
||||
diesel::update(dsl::articles.filter(dsl::id.eq(article_id.to_string())))
|
||||
.set((
|
||||
dsl::llm_score.eq(llm_score),
|
||||
dsl::llm_rationale.eq(rationale),
|
||||
dsl::final_score.eq(final_score),
|
||||
))
|
||||
.execute(&mut conn)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Highest-ranked articles for display, most relevant first.
|
||||
pub async fn list_ranked_articles(
|
||||
&self,
|
||||
limit: i64,
|
||||
) -> Result<Vec<(String, String, String, String, Vec<String>, Option<f32>)>> {
|
||||
use schema::articles::dsl;
|
||||
let mut conn = self.pool.get().await?;
|
||||
// SQLite sorts NULL before any value, so `DESC` already puts NULL
|
||||
// `final_score`s last — no separate NULLS LAST clause needed here.
|
||||
let rows: Vec<(String, String, String, String, String, Option<f32>)> = dsl::articles
|
||||
.order(dsl::final_score.desc())
|
||||
.limit(limit)
|
||||
.select((
|
||||
dsl::id,
|
||||
dsl::title,
|
||||
dsl::url,
|
||||
dsl::summary,
|
||||
dsl::topics,
|
||||
dsl::final_score,
|
||||
))
|
||||
.load(&mut conn)
|
||||
.await?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|(id, title, url, summary, topics_json, final_score)| {
|
||||
(
|
||||
id,
|
||||
title,
|
||||
url,
|
||||
summary,
|
||||
serde_json::from_str(&topics_json).unwrap_or_default(),
|
||||
final_score,
|
||||
)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn record_event(&self, event: &ReadingEvent) -> Result<()> {
|
||||
use schema::reading_events::dsl;
|
||||
let mut conn = self.pool.get().await?;
|
||||
let (outcome, dwell) = match &event.outcome {
|
||||
ReadingOutcome::Impression => ("impression", None),
|
||||
ReadingOutcome::Opened { dwell_seconds } => ("opened", dwell_seconds.map(|v| v as i32)),
|
||||
ReadingOutcome::Starred => ("starred", None),
|
||||
ReadingOutcome::Dismissed => ("dismissed", None),
|
||||
};
|
||||
diesel::insert_into(dsl::reading_events)
|
||||
.values((
|
||||
dsl::id.eq(event.id.to_string()),
|
||||
dsl::article_id.eq(event.article_id.to_string()),
|
||||
dsl::occurred_at.eq(event.occurred_at.to_rfc3339()),
|
||||
dsl::outcome.eq(outcome),
|
||||
dsl::dwell_seconds.eq(dwell),
|
||||
))
|
||||
.execute(&mut conn)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn load_affinities(&self) -> Result<TopicAffinities> {
|
||||
use schema::topic_affinities::dsl;
|
||||
let mut conn = self.pool.get().await?;
|
||||
let row: Option<String> = dsl::topic_affinities
|
||||
.filter(dsl::user_id.eq("default"))
|
||||
.select(dsl::affinities)
|
||||
.first(&mut conn)
|
||||
.await
|
||||
.optional()?;
|
||||
Ok(match row {
|
||||
Some(json) => serde_json::from_str(&json)?,
|
||||
None => TopicAffinities::default(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn save_affinities(&self, affinities: &TopicAffinities) -> Result<()> {
|
||||
use schema::topic_affinities::dsl;
|
||||
let mut conn = self.pool.get().await?;
|
||||
let json = serde_json::to_string(affinities)?;
|
||||
let updated_at = Utc::now().to_rfc3339();
|
||||
diesel::insert_into(dsl::topic_affinities)
|
||||
.values((
|
||||
dsl::user_id.eq("default"),
|
||||
dsl::affinities.eq(&json),
|
||||
dsl::updated_at.eq(&updated_at),
|
||||
))
|
||||
.on_conflict(dsl::user_id)
|
||||
.do_update()
|
||||
.set((dsl::affinities.eq(&json), dsl::updated_at.eq(&updated_at)))
|
||||
.execute(&mut conn)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
10
crates/db/src/models.rs
Normal file
10
crates/db/src/models.rs
Normal 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>,
|
||||
}
|
||||
30
crates/db/src/reading_events.rs
Normal file
30
crates/db/src/reading_events.rs
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
use crate::schema;
|
||||
use crate::Db;
|
||||
use anyhow::Result;
|
||||
use diesel::prelude::*;
|
||||
use diesel_async::RunQueryDsl;
|
||||
use feedsignal_core::{ReadingEvent, ReadingOutcome};
|
||||
|
||||
impl Db {
|
||||
pub async fn record_event(&self, event: &ReadingEvent) -> Result<()> {
|
||||
use schema::reading_events::dsl;
|
||||
let mut conn = self.pool.get().await?;
|
||||
let (outcome, dwell) = match &event.outcome {
|
||||
ReadingOutcome::Impression => ("impression", None),
|
||||
ReadingOutcome::Opened { dwell_seconds } => ("opened", dwell_seconds.map(|v| v as i32)),
|
||||
ReadingOutcome::Starred => ("starred", None),
|
||||
ReadingOutcome::Dismissed => ("dismissed", None),
|
||||
};
|
||||
diesel::insert_into(dsl::reading_events)
|
||||
.values((
|
||||
dsl::id.eq(event.id.to_string()),
|
||||
dsl::article_id.eq(event.article_id.to_string()),
|
||||
dsl::occurred_at.eq(event.occurred_at.to_rfc3339()),
|
||||
dsl::outcome.eq(outcome),
|
||||
dsl::dwell_seconds.eq(dwell),
|
||||
))
|
||||
.execute(&mut conn)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
10
crates/dioxus-components/Cargo.toml
Normal file
10
crates/dioxus-components/Cargo.toml
Normal 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 }
|
||||
80
crates/dioxus-components/src/components/badge/component.rs
Normal file
80
crates/dioxus-components/src/components/badge/component.rs
Normal 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)",
|
||||
}
|
||||
}
|
||||
}
|
||||
2
crates/dioxus-components/src/components/badge/mod.rs
Normal file
2
crates/dioxus-components/src/components/badge/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
mod component;
|
||||
pub use component::*;
|
||||
42
crates/dioxus-components/src/components/badge/style.css
Normal file
42
crates/dioxus-components/src/components/badge/style.css
Normal 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);
|
||||
}
|
||||
114
crates/dioxus-components/src/components/button/component.rs
Normal file
114
crates/dioxus-components/src/components/button/component.rs
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
use dioxus::prelude::*;
|
||||
use dioxus_primitives::dioxus_attributes::attributes;
|
||||
use dioxus_primitives::merge_attributes;
|
||||
|
||||
#[css_module("/src/components/button/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 ButtonVariant {
|
||||
#[default]
|
||||
Primary,
|
||||
Secondary,
|
||||
Destructive,
|
||||
Outline,
|
||||
Ghost,
|
||||
Link,
|
||||
}
|
||||
|
||||
impl ButtonVariant {
|
||||
pub fn class(&self) -> &'static str {
|
||||
match self {
|
||||
ButtonVariant::Primary => "primary",
|
||||
ButtonVariant::Secondary => "secondary",
|
||||
ButtonVariant::Destructive => "destructive",
|
||||
ButtonVariant::Outline => "outline",
|
||||
ButtonVariant::Ghost => "ghost",
|
||||
ButtonVariant::Link => "link",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Copy, Clone, PartialEq, Default)]
|
||||
#[non_exhaustive]
|
||||
pub enum ButtonSize {
|
||||
Xs,
|
||||
Sm,
|
||||
#[default]
|
||||
Default,
|
||||
Lg,
|
||||
Icon,
|
||||
IconXs,
|
||||
IconSm,
|
||||
IconLg,
|
||||
}
|
||||
|
||||
impl ButtonSize {
|
||||
pub fn class(&self) -> &'static str {
|
||||
match self {
|
||||
ButtonSize::Xs => "xs",
|
||||
ButtonSize::Sm => "sm",
|
||||
ButtonSize::Default => "default",
|
||||
ButtonSize::Lg => "lg",
|
||||
ButtonSize::Icon => "icon",
|
||||
ButtonSize::IconXs => "icon-xs",
|
||||
ButtonSize::IconSm => "icon-sm",
|
||||
ButtonSize::IconLg => "icon-lg",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Button(
|
||||
#[props(default)] variant: ButtonVariant,
|
||||
#[props(default)] size: ButtonSize,
|
||||
#[props(extends=GlobalAttributes)]
|
||||
#[props(extends=button)]
|
||||
attributes: Vec<Attribute>,
|
||||
onclick: Option<EventHandler<MouseEvent>>,
|
||||
onmousedown: Option<EventHandler<MouseEvent>>,
|
||||
onmouseup: Option<EventHandler<MouseEvent>>,
|
||||
onkeydown: Option<EventHandler<KeyboardEvent>>,
|
||||
children: Element,
|
||||
) -> Element {
|
||||
let base = attributes!(button {
|
||||
class: Styles::dx_button,
|
||||
"data-style": variant.class(),
|
||||
"data-size": size.class(),
|
||||
});
|
||||
let merged = merge_attributes(vec![base, attributes]);
|
||||
|
||||
rsx! {
|
||||
button {
|
||||
onclick: move |event| {
|
||||
if let Some(f) = &onclick {
|
||||
f.call(event);
|
||||
}
|
||||
},
|
||||
onmousedown: move |event| {
|
||||
if let Some(f) = &onmousedown {
|
||||
f.call(event);
|
||||
}
|
||||
},
|
||||
onmouseup: move |event| {
|
||||
if let Some(f) = &onmouseup {
|
||||
f.call(event);
|
||||
}
|
||||
},
|
||||
onkeydown: move |event| {
|
||||
if let Some(f) = &onkeydown {
|
||||
f.call(event);
|
||||
}
|
||||
},
|
||||
..merged,
|
||||
{children}
|
||||
}
|
||||
}
|
||||
}
|
||||
2
crates/dioxus-components/src/components/button/mod.rs
Normal file
2
crates/dioxus-components/src/components/button/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
mod component;
|
||||
pub use component::*;
|
||||
220
crates/dioxus-components/src/components/button/style.css
Normal file
220
crates/dioxus-components/src/components/button/style.css
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
.dx-button {
|
||||
display: inline-flex;
|
||||
box-sizing: border-box;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
cursor: default;
|
||||
font-family: inherit;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
gap: 0.5rem;
|
||||
line-height: 1.25rem;
|
||||
outline: none;
|
||||
transition-duration: 150ms;
|
||||
transition-property: all;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dx-button svg {
|
||||
flex-shrink: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.dx-button svg:not([class*="size-"]) {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.dx-button[data-size="xs"] {
|
||||
height: 1.5rem;
|
||||
padding: 0 0.5rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
gap: 0.25rem;
|
||||
line-height: 1rem;
|
||||
}
|
||||
|
||||
.dx-button[data-size="xs"]:has(> svg) {
|
||||
padding-inline: 0.375rem;
|
||||
}
|
||||
|
||||
.dx-button[data-size="xs"] svg:not([class*="size-"]) {
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
}
|
||||
|
||||
.dx-button[data-size="sm"] {
|
||||
height: 2rem;
|
||||
padding: 0 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.dx-button[data-size="sm"]:has(> svg) {
|
||||
padding-inline: 0.625rem;
|
||||
}
|
||||
|
||||
.dx-button[data-size="default"] {
|
||||
height: 2.25rem;
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.dx-button[data-size="default"]:has(> svg) {
|
||||
padding-inline: 0.75rem;
|
||||
}
|
||||
|
||||
.dx-button[data-size="lg"] {
|
||||
height: 2.5rem;
|
||||
padding: 0 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dx-button[data-size="lg"]:has(> svg) {
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
|
||||
.dx-button[data-size="icon"] {
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
}
|
||||
|
||||
.dx-button[data-size="icon"] svg:not([class*="size-"]) {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.dx-button[data-size="icon-xs"] {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dx-button[data-size="icon-xs"] svg:not([class*="size-"]) {
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
}
|
||||
|
||||
.dx-button[data-size="icon-sm"] {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
}
|
||||
|
||||
.dx-button[data-size="icon-sm"] svg:not([class*="size-"]) {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.dx-button[data-size="icon-lg"] {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
}
|
||||
|
||||
.dx-button[data-size="icon-lg"] svg:not([class*="size-"]) {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.dx-button:disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.dx-button[data-style="primary"] {
|
||||
background-color: var(--secondary-color-2);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.dx-button[data-style="primary"]:not(:disabled):hover {
|
||||
background-color: color-mix(
|
||||
in oklab,
|
||||
var(--secondary-color-2) 90%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.dx-button[data-style="secondary"] {
|
||||
background-color: var(--primary-color-5);
|
||||
color: var(--secondary-color-1);
|
||||
}
|
||||
|
||||
.dx-button[data-style="secondary"]:not(:disabled):hover {
|
||||
background-color: color-mix(in oklab, var(--primary-color-5) 80%, transparent);
|
||||
}
|
||||
|
||||
.dx-button[data-style="ghost"] {
|
||||
background-color: transparent;
|
||||
color: var(--secondary-color-4);
|
||||
}
|
||||
|
||||
.dx-button[data-style="ghost"]:not(:disabled):hover {
|
||||
background-color: var(--light, var(--primary-color-5))
|
||||
var(--dark, color-mix(in oklab, var(--primary-color-5) 50%, transparent));
|
||||
color: var(--secondary-color-1);
|
||||
}
|
||||
|
||||
.dx-button[data-style="outline"] {
|
||||
border: 1px solid var(--light, var(--primary-color-6))
|
||||
var(--dark, var(--primary-color-7));
|
||||
background-color: var(--light, var(--primary-color))
|
||||
var(--dark, color-mix(in oklab, var(--primary-color-7) 30%, transparent));
|
||||
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 5%);
|
||||
color: var(--secondary-color-4);
|
||||
}
|
||||
|
||||
.dx-button[data-style="outline"]:not(:disabled):hover {
|
||||
background-color: var(--light, var(--primary-color-5))
|
||||
var(--dark, color-mix(in oklab, var(--primary-color-7) 50%, transparent));
|
||||
color: var(--secondary-color-1);
|
||||
}
|
||||
|
||||
.dx-button[data-style="destructive"] {
|
||||
background-color: var(--light, var(--primary-error-color))
|
||||
var(--dark, color-mix(in oklab, var(--primary-error-color) 60%, transparent));
|
||||
color: var(--contrast-error-color);
|
||||
}
|
||||
|
||||
.dx-button[data-style="destructive"]:not(:disabled):hover {
|
||||
background-color: color-mix(
|
||||
in oklab,
|
||||
var(--primary-error-color) 90%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.dx-button[data-style="destructive"]:focus-visible {
|
||||
box-shadow: 0 0 0 3px
|
||||
var(--light, color-mix(in oklab, var(--primary-error-color) 20%, transparent))
|
||||
var(--dark, color-mix(in oklab, var(--primary-error-color) 40%, transparent));
|
||||
}
|
||||
|
||||
.dx-button[data-style="link"] {
|
||||
background-color: transparent;
|
||||
color: var(--secondary-color-2);
|
||||
text-decoration: none;
|
||||
text-underline-offset: 0.25rem;
|
||||
}
|
||||
|
||||
.dx-button[data-style="link"]:not(:disabled):hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.dx-button:focus-visible {
|
||||
border-color: var(--focused-border-color);
|
||||
box-shadow: 0 0 0 3px
|
||||
color-mix(in oklab, var(--focused-border-color) 50%, transparent);
|
||||
}
|
||||
|
||||
.dx-button[aria-invalid="true"] {
|
||||
border-color: var(--primary-error-color);
|
||||
}
|
||||
|
||||
.dx-button[aria-invalid="true"]:focus-visible {
|
||||
box-shadow: 0 0 0 3px
|
||||
var(--light, color-mix(in oklab, var(--primary-error-color) 20%, transparent))
|
||||
var(--dark, color-mix(in oklab, var(--primary-error-color) 40%, transparent));
|
||||
}
|
||||
57
crates/dioxus-components/src/components/input/component.rs
Normal file
57
crates/dioxus-components/src/components/input/component.rs
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
use dioxus::prelude::*;
|
||||
#[css_module("/src/components/input/style.css")]
|
||||
struct Styles;
|
||||
|
||||
#[component]
|
||||
pub fn Input(
|
||||
oninput: Option<EventHandler<FormEvent>>,
|
||||
onchange: Option<EventHandler<FormEvent>>,
|
||||
oninvalid: Option<EventHandler<FormEvent>>,
|
||||
onselect: Option<EventHandler<SelectionEvent>>,
|
||||
onselectionchange: Option<EventHandler<SelectionEvent>>,
|
||||
onfocus: Option<EventHandler<FocusEvent>>,
|
||||
onblur: Option<EventHandler<FocusEvent>>,
|
||||
onfocusin: Option<EventHandler<FocusEvent>>,
|
||||
onfocusout: Option<EventHandler<FocusEvent>>,
|
||||
onkeydown: Option<EventHandler<KeyboardEvent>>,
|
||||
onkeypress: Option<EventHandler<KeyboardEvent>>,
|
||||
onkeyup: Option<EventHandler<KeyboardEvent>>,
|
||||
onwheel: Option<EventHandler<WheelEvent>>,
|
||||
oncompositionstart: Option<EventHandler<CompositionEvent>>,
|
||||
oncompositionupdate: Option<EventHandler<CompositionEvent>>,
|
||||
oncompositionend: Option<EventHandler<CompositionEvent>>,
|
||||
oncopy: Option<EventHandler<ClipboardEvent>>,
|
||||
oncut: Option<EventHandler<ClipboardEvent>>,
|
||||
onpaste: Option<EventHandler<ClipboardEvent>>,
|
||||
#[props(extends=GlobalAttributes)]
|
||||
#[props(extends=input)]
|
||||
attributes: Vec<Attribute>,
|
||||
children: Element,
|
||||
) -> Element {
|
||||
rsx! {
|
||||
input {
|
||||
class: Styles::dx_input,
|
||||
oninput: move |e| _ = oninput.map(|callback| callback(e)),
|
||||
onchange: move |e| _ = onchange.map(|callback| callback(e)),
|
||||
oninvalid: move |e| _ = oninvalid.map(|callback| callback(e)),
|
||||
onselect: move |e| _ = onselect.map(|callback| callback(e)),
|
||||
onselectionchange: move |e| _ = onselectionchange.map(|callback| callback(e)),
|
||||
onfocus: move |e| _ = onfocus.map(|callback| callback(e)),
|
||||
onblur: move |e| _ = onblur.map(|callback| callback(e)),
|
||||
onfocusin: move |e| _ = onfocusin.map(|callback| callback(e)),
|
||||
onfocusout: move |e| _ = onfocusout.map(|callback| callback(e)),
|
||||
onkeydown: move |e| _ = onkeydown.map(|callback| callback(e)),
|
||||
onkeypress: move |e| _ = onkeypress.map(|callback| callback(e)),
|
||||
onkeyup: move |e| _ = onkeyup.map(|callback| callback(e)),
|
||||
onwheel: move |e| _ = onwheel.map(|callback| callback(e)),
|
||||
oncompositionstart: move |e| _ = oncompositionstart.map(|callback| callback(e)),
|
||||
oncompositionupdate: move |e| _ = oncompositionupdate.map(|callback| callback(e)),
|
||||
oncompositionend: move |e| _ = oncompositionend.map(|callback| callback(e)),
|
||||
oncopy: move |e| _ = oncopy.map(|callback| callback(e)),
|
||||
oncut: move |e| _ = oncut.map(|callback| callback(e)),
|
||||
onpaste: move |e| _ = onpaste.map(|callback| callback(e)),
|
||||
..attributes,
|
||||
{children}
|
||||
}
|
||||
}
|
||||
}
|
||||
2
crates/dioxus-components/src/components/input/mod.rs
Normal file
2
crates/dioxus-components/src/components/input/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
mod component;
|
||||
pub use component::*;
|
||||
38
crates/dioxus-components/src/components/input/style.css
Normal file
38
crates/dioxus-components/src/components/input/style.css
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
/* Input Styles */
|
||||
.dx-input {
|
||||
position: relative;
|
||||
display: flex;
|
||||
box-sizing: border-box;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.25rem;
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
border-radius: calc(0.5rem);
|
||||
background: none;
|
||||
background-color: var(--light, var(--primary-color)) var(--dark, color-mix(in oklab, #FFFFFF26 30%, transparent));
|
||||
box-shadow: inset 0 0 0 1px var(--light, var(--primary-color-6))
|
||||
var(--dark, var(--primary-color-7));
|
||||
color: var(--secondary-color-4);
|
||||
gap: 0.25rem;
|
||||
transition: background-color 100ms ease-out;
|
||||
}
|
||||
|
||||
.dx-input::placeholder {
|
||||
color: var(--secondary-color-5);
|
||||
}
|
||||
|
||||
.dx-input:disabled {
|
||||
color: var(--secondary-color-5);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.dx-input:hover:not(:disabled),
|
||||
.dx-input:focus-visible {
|
||||
background: var(--light, var(--primary-color-4))
|
||||
var(--dark, color-mix(in oklab, #FFFFFF26 50%, transparent));
|
||||
color: var(--secondary-color-1);
|
||||
outline: none;
|
||||
}
|
||||
10
crates/dioxus-components/src/components/mod.rs
Normal file
10
crates/dioxus-components/src/components/mod.rs
Normal 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;
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
mod component;
|
||||
pub use component::*;
|
||||
|
|
@ -0,0 +1 @@
|
|||
/* Scroll area doesn't require any additional styles */
|
||||
|
|
@ -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}
|
||||
}
|
||||
}
|
||||
}
|
||||
2
crates/dioxus-components/src/components/separator/mod.rs
Normal file
2
crates/dioxus-components/src/components/separator/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
mod component;
|
||||
pub use component::*;
|
||||
13
crates/dioxus-components/src/components/separator/style.css
Normal file
13
crates/dioxus-components/src/components/separator/style.css
Normal 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%;
|
||||
}
|
||||
150
crates/dioxus-components/src/components/sheet/component.rs
Normal file
150
crates/dioxus-components/src/components/sheet/component.rs
Normal 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} }
|
||||
}
|
||||
}
|
||||
}
|
||||
2
crates/dioxus-components/src/components/sheet/mod.rs
Normal file
2
crates/dioxus-components/src/components/sheet/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
mod component;
|
||||
pub use component::*;
|
||||
253
crates/dioxus-components/src/components/sheet/style.css
Normal file
253
crates/dioxus-components/src/components/sheet/style.css
Normal 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);
|
||||
}
|
||||
845
crates/dioxus-components/src/components/sidebar/component.rs
Normal file
845
crates/dioxus-components/src/components/sidebar/component.rs
Normal 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} }
|
||||
}
|
||||
}
|
||||
}
|
||||
2
crates/dioxus-components/src/components/sidebar/mod.rs
Normal file
2
crates/dioxus-components/src/components/sidebar/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
mod component;
|
||||
pub use component::*;
|
||||
855
crates/dioxus-components/src/components/sidebar/style.css
Normal file
855
crates/dioxus-components/src/components/sidebar/style.css
Normal 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;
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
2
crates/dioxus-components/src/components/skeleton/mod.rs
Normal file
2
crates/dioxus-components/src/components/skeleton/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
mod component;
|
||||
pub use component::*;
|
||||
16
crates/dioxus-components/src/components/skeleton/style.css
Normal file
16
crates/dioxus-components/src/components/skeleton/style.css
Normal 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;
|
||||
}
|
||||
}
|
||||
61
crates/dioxus-components/src/components/tooltip/component.rs
Normal file
61
crates/dioxus-components/src/components/tooltip/component.rs
Normal 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}
|
||||
}
|
||||
}
|
||||
}
|
||||
2
crates/dioxus-components/src/components/tooltip/mod.rs
Normal file
2
crates/dioxus-components/src/components/tooltip/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
mod component;
|
||||
pub use component::*;
|
||||
150
crates/dioxus-components/src/components/tooltip/style.css
Normal file
150
crates/dioxus-components/src/components/tooltip/style.css
Normal 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;
|
||||
}
|
||||
9
crates/dioxus-components/src/lib.rs
Normal file
9
crates/dioxus-components/src/lib.rs
Normal 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::*;
|
||||
|
|
@ -3,12 +3,24 @@ use chrono::{DateTime, Utc};
|
|||
use feedsignal_core::Article;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Fetches and parses one RSS/Atom feed. `feed-rs` normalizes both formats
|
||||
/// into a common model so callers don't need to branch on feed type.
|
||||
pub async fn fetch_feed(feed_url: &str, feed_id: Uuid) -> Result<Vec<Article>> {
|
||||
let bytes = reqwest::get(feed_url).await?.bytes().await?;
|
||||
/// Fetches and parses one RSS/Atom feed, returning its title alongside the
|
||||
/// entries. `feed-rs` normalizes both RSS and Atom into a common model so
|
||||
/// callers don't need to branch on feed type.
|
||||
pub async fn fetch_feed(feed_url: &str, feed_id: Uuid) -> Result<(String, Vec<Article>)> {
|
||||
// `.error_for_status()` turns a 404/5xx into a clear `Err` here rather
|
||||
// than letting a non-feed error page fall through to a confusing
|
||||
// feed-rs parse failure below.
|
||||
let response = reqwest::get(feed_url).await?.error_for_status()?;
|
||||
let bytes = response.bytes().await?;
|
||||
let parsed = feed_rs::parser::parse(&bytes[..])?;
|
||||
|
||||
let feed_title = parsed
|
||||
.title
|
||||
.as_ref()
|
||||
.map(|t| t.content.clone())
|
||||
.filter(|t| has_content(t))
|
||||
.unwrap_or_else(|| feed_url.to_string());
|
||||
|
||||
let articles = parsed
|
||||
.entries
|
||||
.into_iter()
|
||||
|
|
@ -39,7 +51,13 @@ pub async fn fetch_feed(feed_url: &str, feed_id: Uuid) -> Result<Vec<Article>> {
|
|||
})
|
||||
.collect();
|
||||
|
||||
Ok(articles)
|
||||
Ok((feed_title, articles))
|
||||
}
|
||||
|
||||
/// True if `s` has any non-whitespace content — used to reject a
|
||||
/// present-but-blank feed `<title>` rather than surface it as-is.
|
||||
fn has_content(s: &str) -> bool {
|
||||
!s.trim().is_empty()
|
||||
}
|
||||
|
||||
/// Rough estimate from word count at ~200 wpm, used as the denominator for
|
||||
|
|
@ -51,3 +69,27 @@ fn estimate_read_seconds(text: &str) -> u32 {
|
|||
let words = text.split_whitespace().count() as u32;
|
||||
((words.max(1) as f32 / 200.0) * 60.0) as u32
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// An empty feed title should be treated the same as a missing one.
|
||||
#[test]
|
||||
fn has_content_rejects_empty_string() {
|
||||
assert!(!has_content(""));
|
||||
}
|
||||
|
||||
/// A whitespace-only feed title should be treated the same as a
|
||||
/// missing one, not surfaced as a blank-looking name in the UI.
|
||||
#[test]
|
||||
fn has_content_rejects_whitespace_only_string() {
|
||||
assert!(!has_content(" \n\t"));
|
||||
}
|
||||
|
||||
/// A real title is accepted as-is.
|
||||
#[test]
|
||||
fn has_content_accepts_non_blank_string() {
|
||||
assert!(has_content("Rust Blog"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ uuid = { workspace = true, optional = true }
|
|||
chrono = { workspace = true, optional = true }
|
||||
serde_json = { workspace = true, 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 }
|
||||
feedsignal-dioxus-components.workspace = true
|
||||
|
||||
[features]
|
||||
default = ["web"]
|
||||
|
|
|
|||
|
|
@ -1,8 +1,27 @@
|
|||
body { font-family: system-ui, sans-serif; max-width: 720px; margin: 2rem auto; color: #1a1a1a; }
|
||||
.article-list { list-style: none; padding: 0; }
|
||||
.article-row { border-bottom: 1px solid #ddd; padding: 1rem 0; }
|
||||
body { font-family: system-ui, sans-serif; margin: 0; color: #1a1a1a; height: 100vh; }
|
||||
.app-shell { display: flex; height: 100vh; }
|
||||
.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; }
|
||||
.score { float: right; font-variant-numeric: tabular-nums; color: #555; }
|
||||
.summary { color: #444; margin: 0.4rem 0; }
|
||||
.topic-tag { display: inline-block; font-size: 0.75rem; background: #eee; border-radius: 4px; padding: 0.1rem 0.4rem; margin-right: 0.3rem; }
|
||||
.summary { color: #444; margin: 0; }
|
||||
.topics { display: flex; flex-wrap: wrap; gap: 0.3rem; }
|
||||
.error { color: #b00020; }
|
||||
.subscribe-form { display: flex; gap: 0.5rem; margin: 1rem 0; min-width: 0; }
|
||||
/* 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.success { color: #1a7f37; }
|
||||
.subscribe-status.error { color: #b00020; }
|
||||
|
|
|
|||
70
crates/web/assets/dx-components-theme.css
Normal file
70
crates/web/assets/dx-components-theme.css
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
/* This file contains the theme variables for the styled dioxus-components components. You only
|
||||
* need to import this file once in your project root.
|
||||
*/
|
||||
|
||||
html[data-theme="dark"] {
|
||||
--dark: initial;
|
||||
--light: ;
|
||||
}
|
||||
|
||||
html[data-theme="light"] {
|
||||
--dark: ;
|
||||
--light: initial;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] [class~="dxc-system"] {
|
||||
--dxc-light-on: ;
|
||||
--dxc-dark-on: initial;
|
||||
}
|
||||
|
||||
html[data-theme="light"] [class~="dxc-system"] {
|
||||
--dxc-light-on: initial;
|
||||
--dxc-dark-on: ;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--dark: initial;
|
||||
--light: ;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
--dark: ;
|
||||
--light: initial;
|
||||
}
|
||||
}
|
||||
|
||||
:root {
|
||||
/* Primary colors */
|
||||
--primary-color: var(--dark, #000) var(--light, #fff);
|
||||
--primary-color-1: var(--dark, #0e0e0e) var(--light, #fbfbfb);
|
||||
--primary-color-2: var(--dark, #0a0a0a) var(--light, #fff);
|
||||
--primary-color-3: var(--dark, #141313) var(--light, #f8f8f8);
|
||||
--primary-color-4: var(--dark, #1a1a1a) var(--light, #f8f8f8);
|
||||
--primary-color-5: var(--dark, #262626) var(--light, #f5f5f5);
|
||||
--primary-color-6: var(--dark, #232323) var(--light, #e5e5e5);
|
||||
--primary-color-7: var(--dark, #3e3e3e) var(--light, #b0b0b0);
|
||||
|
||||
/* Secondary colors */
|
||||
--secondary-color: var(--dark, #fff) var(--light, #000);
|
||||
--secondary-color-1: var(--dark, #fafafa) var(--light, #000);
|
||||
--secondary-color-2: var(--dark, #e6e6e6) var(--light, #0d0d0d);
|
||||
--secondary-color-3: var(--dark, #dcdcdc) var(--light, #2b2b2b);
|
||||
--secondary-color-4: var(--dark, #d4d4d4) var(--light, #111);
|
||||
--secondary-color-5: var(--dark, #a1a1a1) var(--light, #848484);
|
||||
--secondary-color-6: var(--dark, #5d5d5d) var(--light, #d0d0d0);
|
||||
|
||||
/* Highlight colors */
|
||||
--focused-border-color: var(--dark, #2b7fff) var(--light, #2b7fff);
|
||||
--primary-success-color: var(--dark, #02271c) var(--light, #ecfdf5);
|
||||
--secondary-success-color: var(--dark, #b6fae3) var(--light, #10b981);
|
||||
--primary-warning-color: var(--dark, #342203) var(--light, #fffbeb);
|
||||
--secondary-warning-color: var(--dark, #feeac7) var(--light, #f59e0b);
|
||||
--primary-error-color: var(--dark, #a22e2e) var(--light, #dc2626);
|
||||
--secondary-error-color: var(--dark, #9b1c1c) var(--light, #ef4444);
|
||||
--contrast-error-color: var(--dark, var(--secondary-color-3)) var(--light, var(--primary-color));
|
||||
--primary-info-color: var(--dark, var(--primary-color-5)) var(--light, var(--primary-color));
|
||||
--secondary-info-color: var(--dark, var(--primary-color-7)) var(--light, var(--secondary-color-3));
|
||||
}
|
||||
27
crates/web/src/api/articles.rs
Normal file
27
crates/web/src/api/articles.rs
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
use super::ArticleView;
|
||||
use dioxus::prelude::*;
|
||||
|
||||
/// Returns articles ranked by `final_score` descending, highest-relevance
|
||||
/// first. `feed_id` restricts the list to one feed; `None` joins every
|
||||
/// 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]
|
||||
pub async fn list_ranked_articles(
|
||||
feed_id: Option<String>,
|
||||
) -> Result<Vec<ArticleView>, ServerFnError> {
|
||||
let db = crate::server::db().await?;
|
||||
crate::server::services::articles::list_ranked(db, feed_id)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))
|
||||
}
|
||||
|
||||
/// Records an explicit "not relevant" signal, which feeds directly into the
|
||||
/// topic-affinity update (see `feedsignal_core::affinity::apply_feedback`).
|
||||
#[server]
|
||||
pub async fn mark_dismissed(article_id: String) -> Result<(), ServerFnError> {
|
||||
let db = crate::server::db().await?;
|
||||
crate::server::services::reading_events::mark_dismissed(db, article_id)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))
|
||||
}
|
||||
22
crates/web/src/api/feeds.rs
Normal file
22
crates/web/src/api/feeds.rs
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
use super::FeedView;
|
||||
use dioxus::prelude::*;
|
||||
|
||||
/// Registers a feed and fetches it immediately. Runs on the server (native,
|
||||
/// has DB + network access); the `#[server]` macro generates the HTTP call
|
||||
/// the browser/WASM build uses instead.
|
||||
#[server]
|
||||
pub async fn subscribe_feed(url: String) -> Result<usize, ServerFnError> {
|
||||
let db = crate::server::db().await?;
|
||||
crate::server::services::feeds::subscribe(db, url)
|
||||
.await
|
||||
.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()))
|
||||
}
|
||||
5
crates/web/src/api/mod.rs
Normal file
5
crates/web/src/api/mod.rs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
pub mod articles;
|
||||
pub mod feeds;
|
||||
mod models;
|
||||
|
||||
pub use models::{ArticleView, FeedView};
|
||||
23
crates/web/src/api/models.rs
Normal file
23
crates/web/src/api/models.rs
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Article shape sent to the browser — a trimmed view of
|
||||
/// `feedsignal_core::Article`, kept separate so the wire format can evolve
|
||||
/// independently of the storage model.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ArticleView {
|
||||
pub id: String,
|
||||
pub feed_id: String,
|
||||
pub title: String,
|
||||
pub url: String,
|
||||
pub summary: String,
|
||||
pub topics: Vec<String>,
|
||||
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,
|
||||
}
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
use dioxus::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Article shape sent to the browser — a trimmed view of
|
||||
/// `feedsignal_core::Article`, kept separate so the wire format can evolve
|
||||
/// independently of the storage model.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ArticleView {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub url: String,
|
||||
pub summary: String,
|
||||
pub topics: Vec<String>,
|
||||
pub final_score: Option<f32>,
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn App() -> Element {
|
||||
let articles = use_server_future(list_ranked_articles)?;
|
||||
|
||||
rsx! {
|
||||
style { {include_str!("../assets/app.css")} }
|
||||
main {
|
||||
h1 { "feedsignal" }
|
||||
match articles.read().as_ref() {
|
||||
Some(Ok(articles)) => rsx! {
|
||||
ul { class: "article-list",
|
||||
for article in articles.iter() {
|
||||
ArticleRow { article: article.clone() }
|
||||
}
|
||||
}
|
||||
},
|
||||
Some(Err(err)) => rsx! { p { class: "error", "Failed to load articles: {err}" } },
|
||||
None => rsx! { p { "Loading..." } },
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn ArticleRow(article: ArticleView) -> Element {
|
||||
let score_pct = article.final_score.map(|s| (s * 100.0).round() as i32);
|
||||
rsx! {
|
||||
li { class: "article-row",
|
||||
a { href: "{article.url}", target: "_blank", "{article.title}" }
|
||||
if let Some(pct) = score_pct {
|
||||
span { class: "score", "{pct}%" }
|
||||
}
|
||||
p { class: "summary", "{article.summary}" }
|
||||
div { class: "topics",
|
||||
for topic in article.topics.iter() {
|
||||
span { class: "topic-tag", "{topic}" }
|
||||
}
|
||||
}
|
||||
button {
|
||||
onclick: move |_| {
|
||||
let id = article.id.clone();
|
||||
async move {
|
||||
let _ = mark_dismissed(id).await;
|
||||
}
|
||||
},
|
||||
"Not relevant"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns articles ranked by `final_score` descending, highest-relevance
|
||||
/// first. Runs on the server (native, has DB access); the `#[server]`
|
||||
/// macro generates the HTTP call the browser/WASM build uses instead.
|
||||
#[server]
|
||||
async fn list_ranked_articles() -> Result<Vec<ArticleView>, ServerFnError> {
|
||||
let db = crate::server::db().await?;
|
||||
crate::server::list_ranked_articles_impl(db)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))
|
||||
}
|
||||
|
||||
/// Records an explicit "not relevant" signal, which feeds directly into the
|
||||
/// topic-affinity update (see `feedsignal_core::affinity::apply_feedback`).
|
||||
#[server]
|
||||
async fn mark_dismissed(article_id: String) -> Result<(), ServerFnError> {
|
||||
let db = crate::server::db().await?;
|
||||
crate::server::mark_dismissed_impl(db, article_id)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))
|
||||
}
|
||||
42
crates/web/src/app/article_row/component.rs
Normal file
42
crates/web/src/app/article_row/component.rs
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
29
crates/web/src/app/article_row/handlers.rs
Normal file
29
crates/web/src/app/article_row/handlers.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
4
crates/web/src/app/article_row/mod.rs
Normal file
4
crates/web/src/app/article_row/mod.rs
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
mod component;
|
||||
mod handlers;
|
||||
|
||||
pub use component::*;
|
||||
48
crates/web/src/app/handlers.rs
Normal file
48
crates/web/src/app/handlers.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
119
crates/web/src/app/mod.rs
Normal file
119
crates/web/src/app/mod.rs
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
mod article_row;
|
||||
mod handlers;
|
||||
mod subscribe_form;
|
||||
|
||||
use crate::api;
|
||||
use article_row::ArticleRow;
|
||||
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;
|
||||
|
||||
#[component]
|
||||
pub fn App() -> Element {
|
||||
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! {
|
||||
Stylesheet { href: asset!("/assets/app.css") }
|
||||
Stylesheet { href: asset!("/assets/dx-components-theme.css") }
|
||||
SidebarProvider {
|
||||
div { class: "app-shell",
|
||||
Sidebar {
|
||||
SidebarHeader {
|
||||
h1 { "feedsignal" }
|
||||
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() {
|
||||
Some(Ok(articles)) => rsx! {
|
||||
ul { class: "article-list",
|
||||
for article in articles.iter() {
|
||||
ArticleRow { article: article.clone() }
|
||||
}
|
||||
}
|
||||
},
|
||||
Some(Err(err)) => rsx! { p { class: "error", "Failed to load articles: {err}" } },
|
||||
None => rsx! { p { "Loading..." } },
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
68
crates/web/src/app/subscribe_form/component.rs
Normal file
68
crates/web/src/app/subscribe_form/component.rs
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
use super::handlers::{normalize_feed_url, resolve_submit_outcome, SubmitOutcome};
|
||||
use crate::api;
|
||||
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
|
||||
/// immediately on submit (rather than waiting for the next scheduled poll)
|
||||
/// so the reader isn't empty right after subscribing.
|
||||
#[component]
|
||||
pub fn SubscribeForm(on_subscribed: EventHandler<()>) -> Element {
|
||||
let mut url = use_signal(String::new);
|
||||
let mut status = use_signal(|| Option::<Result<String, String>>::None);
|
||||
let mut submitting = use_signal(|| false);
|
||||
|
||||
let submit = move |_| {
|
||||
let Some(feed_url) = normalize_feed_url(&url.read()) else {
|
||||
return;
|
||||
};
|
||||
spawn(async move {
|
||||
submitting.set(true);
|
||||
status.set(None);
|
||||
let result = api::feeds::subscribe_feed(feed_url).await;
|
||||
match resolve_submit_outcome(result.map_err(|err| err.to_string())) {
|
||||
SubmitOutcome::Success { message } => {
|
||||
status.set(Some(Ok(message)));
|
||||
url.set(String::new());
|
||||
on_subscribed.call(());
|
||||
}
|
||||
SubmitOutcome::Failure { message } => status.set(Some(Err(message))),
|
||||
}
|
||||
submitting.set(false);
|
||||
});
|
||||
};
|
||||
|
||||
rsx! {
|
||||
form {
|
||||
class: "subscribe-form",
|
||||
onsubmit: move |ev: FormEvent| {
|
||||
ev.prevent_default();
|
||||
submit(());
|
||||
},
|
||||
Input {
|
||||
r#type: "url",
|
||||
placeholder: "https://example.com/feed.xml",
|
||||
value: "{url}",
|
||||
required: true,
|
||||
disabled: submitting(),
|
||||
oninput: move |ev: FormEvent| url.set(ev.value()),
|
||||
}
|
||||
Button {
|
||||
r#type: "submit",
|
||||
variant: ButtonVariant::Primary,
|
||||
disabled: submitting() || url.read().trim().is_empty(),
|
||||
if submitting() {
|
||||
"Subscribing..."
|
||||
} else {
|
||||
"Subscribe"
|
||||
}
|
||||
}
|
||||
}
|
||||
match status.read().as_ref() {
|
||||
Some(Ok(msg)) => rsx! { p { class: "subscribe-status success", "{msg}" } },
|
||||
Some(Err(err)) => rsx! { p { class: "subscribe-status error", "Failed to subscribe: {err}" } },
|
||||
None => rsx! {},
|
||||
}
|
||||
}
|
||||
}
|
||||
77
crates/web/src/app/subscribe_form/handlers.rs
Normal file
77
crates/web/src/app/subscribe_form/handlers.rs
Normal 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()
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
4
crates/web/src/app/subscribe_form/mod.rs
Normal file
4
crates/web/src/app/subscribe_form/mod.rs
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
mod component;
|
||||
mod handlers;
|
||||
|
||||
pub use component::*;
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
mod api;
|
||||
mod app;
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
|
|
|
|||
|
|
@ -1,84 +0,0 @@
|
|||
use crate::app::ArticleView;
|
||||
use anyhow::Result;
|
||||
use feedsignal_core::{ReadingEvent, ReadingOutcome};
|
||||
use feedsignal_db::Db;
|
||||
use feedsignal_llm::Llm;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::OnceCell;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub mod pipeline;
|
||||
|
||||
pub fn init_tracing() {
|
||||
tracing_subscriber::fmt::init();
|
||||
}
|
||||
|
||||
static BACKGROUND_JOBS: OnceCell<()> = OnceCell::const_new();
|
||||
|
||||
/// Lazily starts the feed-polling/scoring scheduler on first use. Deferred
|
||||
/// rather than started at process boot because `dioxus_server::launch_cfg`
|
||||
/// owns the tokio runtime construction — this runs the first time a server
|
||||
/// function executes, which is guaranteed to already be inside that runtime.
|
||||
async fn ensure_background_jobs_started(db: Arc<Db>) {
|
||||
BACKGROUND_JOBS
|
||||
.get_or_init(|| async {
|
||||
// TODO: move base_url/model names to config/env once there's a
|
||||
// settings story; hardcoded to models already pulled locally.
|
||||
let llm = Arc::new(
|
||||
Llm::new(
|
||||
"http://localhost:11434",
|
||||
"nomic-embed-text",
|
||||
768,
|
||||
"gemma4-e4b",
|
||||
)
|
||||
.expect("failed to construct ollama client"),
|
||||
);
|
||||
if let Err(err) = pipeline::start_scheduler(db, llm).await {
|
||||
tracing::error!(?err, "failed to start background job scheduler");
|
||||
}
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
pub async fn db() -> Result<Db, dioxus::prelude::ServerFnError> {
|
||||
// TODO: hold this in a `OnceCell`/app-wide state instead of reconnecting
|
||||
// per request once the server-state story is wired up.
|
||||
let db = Db::connect("feedsignal.db")
|
||||
.await
|
||||
.map_err(|e| dioxus::prelude::ServerFnError::new(e.to_string()))?;
|
||||
ensure_background_jobs_started(Arc::new(db.clone())).await;
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
pub async fn list_ranked_articles_impl(db: Db) -> Result<Vec<ArticleView>> {
|
||||
Ok(db
|
||||
.list_ranked_articles(100)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(
|
||||
|(id, title, url, summary, topics, final_score)| ArticleView {
|
||||
id,
|
||||
title,
|
||||
url,
|
||||
summary,
|
||||
topics,
|
||||
final_score,
|
||||
},
|
||||
)
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn mark_dismissed_impl(db: Db, article_id: String) -> Result<()> {
|
||||
let event = ReadingEvent {
|
||||
id: Uuid::new_v4(),
|
||||
article_id: Uuid::parse_str(&article_id)?,
|
||||
occurred_at: chrono::Utc::now(),
|
||||
outcome: ReadingOutcome::Dismissed,
|
||||
};
|
||||
db.record_event(&event).await?;
|
||||
// Affinity re-weighting from this event happens in the batched pipeline
|
||||
// job (see `pipeline::apply_pending_feedback`) rather than inline here,
|
||||
// so a burst of dismissals doesn't serialize on read-modify-write of
|
||||
// the single affinities row.
|
||||
Ok(())
|
||||
}
|
||||
21
crates/web/src/server/config.rs
Normal file
21
crates/web/src/server/config.rs
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
/// Ollama connection settings for the embedding + judgment models.
|
||||
///
|
||||
/// TODO: move to config/env once there's a settings story; hardcoded to
|
||||
/// models already pulled locally.
|
||||
pub struct LlmConfig {
|
||||
pub base_url: &'static str,
|
||||
pub embedding_model: &'static str,
|
||||
pub embedding_dims: usize,
|
||||
pub judge_model: &'static str,
|
||||
}
|
||||
|
||||
impl Default for LlmConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
base_url: "http://localhost:11434",
|
||||
embedding_model: "nomic-embed-text",
|
||||
embedding_dims: 768,
|
||||
judge_model: "gemma4-e4b",
|
||||
}
|
||||
}
|
||||
}
|
||||
9
crates/web/src/server/jobs/affinity_decay.rs
Normal file
9
crates/web/src/server/jobs/affinity_decay.rs
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
use anyhow::Result;
|
||||
use feedsignal_db::Db;
|
||||
|
||||
pub async fn run(db: &Db) -> Result<()> {
|
||||
let mut affinities = db.load_affinities().await?;
|
||||
affinities.decay();
|
||||
db.save_affinities(&affinities).await?;
|
||||
Ok(())
|
||||
}
|
||||
29
crates/web/src/server/jobs/feed_polling.rs
Normal file
29
crates/web/src/server/jobs/feed_polling.rs
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
use anyhow::Result;
|
||||
use feedsignal_db::Db;
|
||||
|
||||
/// Re-fetches every subscribed feed and inserts any articles published since
|
||||
/// the last run. `articles.url` is unique, so re-fetching the same feed and
|
||||
/// inserting its (mostly already-seen) entries is safe — `insert_article`
|
||||
/// silently skips ones already in the database, leaving only genuinely new
|
||||
/// articles behind.
|
||||
pub async fn run(db: &Db) -> Result<()> {
|
||||
let feeds = db.list_feeds().await?;
|
||||
tracing::info!(count = feeds.len(), "polling subscribed feeds");
|
||||
|
||||
for (feed_id, _title, url) in feeds {
|
||||
let (_title, articles) = match feedsignal_feeds::fetch_feed(&url, feed_id).await {
|
||||
Ok(result) => result,
|
||||
Err(err) => {
|
||||
tracing::warn!(?err, %url, "failed to poll feed, skipping");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
for article in &articles {
|
||||
db.insert_article(article).await?;
|
||||
}
|
||||
db.mark_feed_fetched(feed_id).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
74
crates/web/src/server/jobs/mod.rs
Normal file
74
crates/web/src/server/jobs/mod.rs
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
mod affinity_decay;
|
||||
mod feed_polling;
|
||||
mod scoring;
|
||||
|
||||
use anyhow::Result;
|
||||
use feedsignal_db::Db;
|
||||
use feedsignal_llm::Llm;
|
||||
use std::sync::Arc;
|
||||
use tokio_cron_scheduler::{Job, JobScheduler};
|
||||
|
||||
/// Wires up the recurring jobs described in the design discussion: polling
|
||||
/// subscribed feeds for new articles, scoring them, and decaying topic
|
||||
/// affinities once a day so stale signals fade. Call once at server
|
||||
/// startup, and also runs once immediately so a freshly (re)opened app
|
||||
/// doesn't wait 15 minutes for its first check.
|
||||
pub async fn start_scheduler(db: Arc<Db>, llm: Arc<Llm>) -> Result<JobScheduler> {
|
||||
let scheduler = JobScheduler::new().await?;
|
||||
|
||||
{
|
||||
let db = db.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(err) = feed_polling::run(&db).await {
|
||||
tracing::error!(?err, "initial feed poll failed");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let db = db.clone();
|
||||
scheduler
|
||||
.add(Job::new_async("0 */15 * * * *", move |_uuid, _lock| {
|
||||
let db = db.clone();
|
||||
Box::pin(async move {
|
||||
if let Err(err) = feed_polling::run(&db).await {
|
||||
tracing::error!(?err, "feed polling run failed");
|
||||
}
|
||||
})
|
||||
})?)
|
||||
.await?;
|
||||
}
|
||||
|
||||
{
|
||||
let db = db.clone();
|
||||
let llm = llm.clone();
|
||||
scheduler
|
||||
.add(Job::new_async("0 */15 * * * *", move |_uuid, _lock| {
|
||||
let db = db.clone();
|
||||
let llm = llm.clone();
|
||||
Box::pin(async move {
|
||||
if let Err(err) = scoring::run(&db, &llm).await {
|
||||
tracing::error!(?err, "scoring pipeline run failed");
|
||||
}
|
||||
})
|
||||
})?)
|
||||
.await?;
|
||||
}
|
||||
|
||||
{
|
||||
let db = db.clone();
|
||||
scheduler
|
||||
.add(Job::new_async("0 0 4 * * *", move |_uuid, _lock| {
|
||||
let db = db.clone();
|
||||
Box::pin(async move {
|
||||
if let Err(err) = affinity_decay::run(&db).await {
|
||||
tracing::error!(?err, "affinity decay run failed");
|
||||
}
|
||||
})
|
||||
})?)
|
||||
.await?;
|
||||
}
|
||||
|
||||
scheduler.start().await?;
|
||||
Ok(scheduler)
|
||||
}
|
||||
60
crates/web/src/server/jobs/scoring.rs
Normal file
60
crates/web/src/server/jobs/scoring.rs
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
use anyhow::Result;
|
||||
use feedsignal_core::{score_article, RelevanceInputs};
|
||||
use feedsignal_db::Db;
|
||||
use feedsignal_llm::Llm;
|
||||
|
||||
/// Below this embedding-similarity score, an article is never sent to the
|
||||
/// LLM at all — it's assumed irrelevant cheaply. Tune once real usage data
|
||||
/// exists; too low wastes LLM time, too high risks silently dropping
|
||||
/// things the LLM would have caught (e.g. a term-mismatch the embedding
|
||||
/// space doesn't capture well).
|
||||
const EMBEDDING_SHORTLIST_THRESHOLD: f32 = 0.55;
|
||||
const LLM_BATCH_SIZE: i64 = 25;
|
||||
|
||||
/// One pass of: embed the shortlist, run the LLM on it, blend into
|
||||
/// `final_score`. Assumes `feed_polling::run` has already pulled in any new
|
||||
/// articles for this cycle.
|
||||
pub async fn run(db: &Db, llm: &Llm) -> Result<()> {
|
||||
let affinities = db.load_affinities().await?;
|
||||
let shortlist = db
|
||||
.shortlist_for_llm_scoring(EMBEDDING_SHORTLIST_THRESHOLD, LLM_BATCH_SIZE)
|
||||
.await?;
|
||||
|
||||
tracing::info!(count = shortlist.len(), "scoring shortlist with LLM");
|
||||
|
||||
let affinity_summary = {
|
||||
let top = affinities.top_n(10);
|
||||
serde_json::to_string(&top)?
|
||||
};
|
||||
|
||||
for article_id in shortlist {
|
||||
// Fetch just the fields needed for the prompt; a real implementation
|
||||
// would batch this rather than one query per article.
|
||||
let Some((title, summary, topics, embedding_score)) =
|
||||
db.article_scoring_fields(article_id).await?
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let judgment = llm
|
||||
.judge_relevance(
|
||||
"TODO: load from `preferences` table",
|
||||
&affinity_summary,
|
||||
&title,
|
||||
&summary,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let final_score = score_article(RelevanceInputs {
|
||||
embedding_score,
|
||||
llm_score: Some(judgment.score),
|
||||
topics: &topics,
|
||||
affinities: &affinities,
|
||||
});
|
||||
|
||||
db.store_llm_result(article_id, judgment.score, &judgment.rationale, final_score)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
51
crates/web/src/server/mod.rs
Normal file
51
crates/web/src/server/mod.rs
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
use anyhow::Result;
|
||||
use feedsignal_db::Db;
|
||||
use feedsignal_llm::Llm;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::OnceCell;
|
||||
|
||||
mod config;
|
||||
pub mod jobs;
|
||||
pub mod services;
|
||||
|
||||
use config::LlmConfig;
|
||||
|
||||
pub fn init_tracing() {
|
||||
tracing_subscriber::fmt::init();
|
||||
}
|
||||
|
||||
static BACKGROUND_JOBS: OnceCell<()> = OnceCell::const_new();
|
||||
|
||||
/// Lazily starts the feed-polling/scoring scheduler on first use. Deferred
|
||||
/// rather than started at process boot because `dioxus_server::launch_cfg`
|
||||
/// owns the tokio runtime construction — this runs the first time a server
|
||||
/// function executes, which is guaranteed to already be inside that runtime.
|
||||
async fn ensure_background_jobs_started(db: Arc<Db>) {
|
||||
BACKGROUND_JOBS
|
||||
.get_or_init(|| async {
|
||||
let cfg = LlmConfig::default();
|
||||
let llm = Arc::new(
|
||||
Llm::new(
|
||||
cfg.base_url,
|
||||
cfg.embedding_model,
|
||||
cfg.embedding_dims,
|
||||
cfg.judge_model,
|
||||
)
|
||||
.expect("failed to construct ollama client"),
|
||||
);
|
||||
if let Err(err) = jobs::start_scheduler(db, llm).await {
|
||||
tracing::error!(?err, "failed to start background job scheduler");
|
||||
}
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
pub async fn db() -> Result<Db, dioxus::prelude::ServerFnError> {
|
||||
// TODO: hold this in a `OnceCell`/app-wide state instead of reconnecting
|
||||
// per request once the server-state story is wired up.
|
||||
let db = Db::connect("feedsignal.db")
|
||||
.await
|
||||
.map_err(|e| dioxus::prelude::ServerFnError::new(e.to_string()))?;
|
||||
ensure_background_jobs_started(Arc::new(db.clone())).await;
|
||||
Ok(db)
|
||||
}
|
||||
|
|
@ -1,111 +0,0 @@
|
|||
use anyhow::Result;
|
||||
use feedsignal_core::{score_article, RelevanceInputs};
|
||||
use feedsignal_db::Db;
|
||||
use feedsignal_llm::Llm;
|
||||
use std::sync::Arc;
|
||||
use tokio_cron_scheduler::{Job, JobScheduler};
|
||||
|
||||
/// Below this embedding-similarity score, an article is never sent to the
|
||||
/// LLM at all — it's assumed irrelevant cheaply. Tune once real usage data
|
||||
/// exists; too low wastes LLM time, too high risks silently dropping
|
||||
/// things the LLM would have caught (e.g. a term-mismatch the embedding
|
||||
/// space doesn't capture well).
|
||||
const EMBEDDING_SHORTLIST_THRESHOLD: f32 = 0.55;
|
||||
const LLM_BATCH_SIZE: i64 = 25;
|
||||
|
||||
/// Wires up the two recurring jobs described in the design discussion:
|
||||
/// polling feeds + scoring new articles on a short interval, and decaying
|
||||
/// topic affinities once a day so stale signals fade. Call once at server
|
||||
/// startup.
|
||||
pub async fn start_scheduler(db: Arc<Db>, llm: Arc<Llm>) -> Result<JobScheduler> {
|
||||
let scheduler = JobScheduler::new().await?;
|
||||
|
||||
{
|
||||
let db = db.clone();
|
||||
let llm = llm.clone();
|
||||
scheduler
|
||||
.add(Job::new_async("0 */15 * * * *", move |_uuid, _lock| {
|
||||
let db = db.clone();
|
||||
let llm = llm.clone();
|
||||
Box::pin(async move {
|
||||
if let Err(err) = run_scoring_pipeline(&db, &llm).await {
|
||||
tracing::error!(?err, "scoring pipeline run failed");
|
||||
}
|
||||
})
|
||||
})?)
|
||||
.await?;
|
||||
}
|
||||
|
||||
{
|
||||
let db = db.clone();
|
||||
scheduler
|
||||
.add(Job::new_async("0 0 4 * * *", move |_uuid, _lock| {
|
||||
let db = db.clone();
|
||||
Box::pin(async move {
|
||||
if let Err(err) = decay_affinities(&db).await {
|
||||
tracing::error!(?err, "affinity decay run failed");
|
||||
}
|
||||
})
|
||||
})?)
|
||||
.await?;
|
||||
}
|
||||
|
||||
scheduler.start().await?;
|
||||
Ok(scheduler)
|
||||
}
|
||||
|
||||
/// One pass of: embed the shortlist, run the LLM on it, blend into
|
||||
/// `final_score`. Feed polling itself (calling `feedsignal_feeds::fetch_feed`
|
||||
/// per subscribed feed and inserting new articles) is intentionally left as
|
||||
/// a TODO here — wire it in once feed subscription management exists.
|
||||
async fn run_scoring_pipeline(db: &Db, llm: &Llm) -> Result<()> {
|
||||
let affinities = db.load_affinities().await?;
|
||||
let shortlist = db
|
||||
.shortlist_for_llm_scoring(EMBEDDING_SHORTLIST_THRESHOLD, LLM_BATCH_SIZE)
|
||||
.await?;
|
||||
|
||||
tracing::info!(count = shortlist.len(), "scoring shortlist with LLM");
|
||||
|
||||
let affinity_summary = {
|
||||
let top = affinities.top_n(10);
|
||||
serde_json::to_string(&top)?
|
||||
};
|
||||
|
||||
for article_id in shortlist {
|
||||
// Fetch just the fields needed for the prompt; a real implementation
|
||||
// would batch this rather than one query per article.
|
||||
let Some((title, summary, topics, embedding_score)) =
|
||||
db.article_scoring_fields(article_id).await?
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let judgment = llm
|
||||
.judge_relevance(
|
||||
"TODO: load from `preferences` table",
|
||||
&affinity_summary,
|
||||
&title,
|
||||
&summary,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let final_score = score_article(RelevanceInputs {
|
||||
embedding_score,
|
||||
llm_score: Some(judgment.score),
|
||||
topics: &topics,
|
||||
affinities: &affinities,
|
||||
});
|
||||
|
||||
db.store_llm_result(article_id, judgment.score, &judgment.rationale, final_score)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn decay_affinities(db: &Db) -> Result<()> {
|
||||
let mut affinities = db.load_affinities().await?;
|
||||
affinities.decay();
|
||||
db.save_affinities(&affinities).await?;
|
||||
Ok(())
|
||||
}
|
||||
25
crates/web/src/server/services/articles.rs
Normal file
25
crates/web/src/server/services/articles.rs
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
use crate::api::ArticleView;
|
||||
use anyhow::Result;
|
||||
use feedsignal_db::Db;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Returns articles ranked by `final_score` descending, highest-relevance
|
||||
/// 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
|
||||
.list_ranked_articles(feed_id, 100)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|row| ArticleView {
|
||||
id: row.id,
|
||||
feed_id: row.feed_id,
|
||||
title: row.title,
|
||||
url: row.url,
|
||||
summary: row.summary,
|
||||
topics: row.topics,
|
||||
final_score: row.final_score,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
48
crates/web/src/server/services/feeds.rs
Normal file
48
crates/web/src/server/services/feeds.rs
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
use crate::api::FeedView;
|
||||
use anyhow::Result;
|
||||
use feedsignal_db::Db;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Subscribes to a feed by URL: registers it (or updates its title if
|
||||
/// already subscribed) and does an immediate first fetch so the reader
|
||||
/// isn't empty until the next scheduled poll. Returns the number of
|
||||
/// articles pulled in on this fetch.
|
||||
///
|
||||
/// Fetches under a throwaway id before writing anything, so there's exactly
|
||||
/// one write to the `feeds` table (`upsert_feed` is the single source of
|
||||
/// truth for the real id — the existing one on a re-subscribe, a fresh one
|
||||
/// otherwise) and no half-registered row left behind if the fetch fails
|
||||
/// (e.g. the URL is well-formed but points at nothing, or the feed no
|
||||
/// longer exists — `fetch_feed` surfaces both as an `Err` via
|
||||
/// `error_for_status`/feed-rs parse failure, which becomes the error
|
||||
/// message shown in the subscribe form).
|
||||
pub async fn subscribe(db: Db, url: String) -> Result<usize> {
|
||||
anyhow::ensure!(!url.trim().is_empty(), "feed URL is required");
|
||||
let url = url.trim();
|
||||
|
||||
let (title, mut articles) = feedsignal_feeds::fetch_feed(url, Uuid::new_v4()).await?;
|
||||
let feed_id = db.upsert_feed(url, &title).await?;
|
||||
for article in &mut articles {
|
||||
article.feed_id = feed_id;
|
||||
}
|
||||
|
||||
let count = articles.len();
|
||||
for article in &articles {
|
||||
db.insert_article(article).await?;
|
||||
}
|
||||
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())
|
||||
}
|
||||
3
crates/web/src/server/services/mod.rs
Normal file
3
crates/web/src/server/services/mod.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
pub mod articles;
|
||||
pub mod feeds;
|
||||
pub mod reading_events;
|
||||
21
crates/web/src/server/services/reading_events.rs
Normal file
21
crates/web/src/server/services/reading_events.rs
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
use anyhow::Result;
|
||||
use feedsignal_core::{ReadingEvent, ReadingOutcome};
|
||||
use feedsignal_db::Db;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Records an explicit "not relevant" signal, which feeds directly into the
|
||||
/// topic-affinity update (see `feedsignal_core::affinity::apply_feedback`).
|
||||
pub async fn mark_dismissed(db: Db, article_id: String) -> Result<()> {
|
||||
let event = ReadingEvent {
|
||||
id: Uuid::new_v4(),
|
||||
article_id: Uuid::parse_str(&article_id)?,
|
||||
occurred_at: chrono::Utc::now(),
|
||||
outcome: ReadingOutcome::Dismissed,
|
||||
};
|
||||
db.record_event(&event).await?;
|
||||
// Affinity re-weighting from this event happens in the batched pipeline
|
||||
// job (see `jobs::scoring::run`) rather than inline here, so a burst of
|
||||
// dismissals doesn't serialize on read-modify-write of the single
|
||||
// affinities row.
|
||||
Ok(())
|
||||
}
|
||||
Loading…
Reference in a new issue