Compare commits
No commits in common. "de6b4578d405d4cc9f2fedd74617d507843fc565" and "a37a93150b695563b045b6230f31c0f7bb55df27" have entirely different histories.
de6b4578d4
...
a37a93150b
42 changed files with 91 additions and 2949 deletions
12
Cargo.lock
generated
12
Cargo.lock
generated
|
|
@ -1136,17 +1136,6 @@ dependencies = [
|
||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "dioxus-icons"
|
|
||||||
version = "0.1.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "6ae929a4cdde2e51fca98ccb8a8fe2ea11640e5026c57486b15dbd5618ba7e56"
|
|
||||||
dependencies = [
|
|
||||||
"dioxus",
|
|
||||||
"dioxus-signals",
|
|
||||||
"lazy-js-bundle",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dioxus-interpreter-js"
|
name = "dioxus-interpreter-js"
|
||||||
version = "0.7.10"
|
version = "0.7.10"
|
||||||
|
|
@ -1633,7 +1622,6 @@ dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
"dioxus",
|
"dioxus",
|
||||||
"dioxus-icons",
|
|
||||||
"dioxus-primitives",
|
"dioxus-primitives",
|
||||||
"feedsignal-core",
|
"feedsignal-core",
|
||||||
"feedsignal-db",
|
"feedsignal-db",
|
||||||
|
|
|
||||||
|
|
@ -14,13 +14,6 @@ A change is done when all of the following hold, not just when it compiles.
|
||||||
- Within `db`, one file per domain concern (`feeds.rs`, `articles.rs`,
|
- Within `db`, one file per domain concern (`feeds.rs`, `articles.rs`,
|
||||||
`reading_events.rs`, `affinities.rs`) — a new table gets its own file,
|
`reading_events.rs`, `affinities.rs`) — a new table gets its own file,
|
||||||
not a growing `queries.rs`.
|
not a growing `queries.rs`.
|
||||||
- Data-model structs (domain models, query row types, DTOs/views) live in
|
|
||||||
a crate's `models.rs` rather than the file that produces or consumes
|
|
||||||
them, whenever a struct is used outside the function that builds it —
|
|
||||||
matching the existing `crates/core/src/models.rs` pattern. This doesn't
|
|
||||||
apply to structs that are inherently local to one file/component (e.g.
|
|
||||||
a Dioxus component's `Props`/`Styles`/context struct, or a helper
|
|
||||||
struct scoped to a single function's internals).
|
|
||||||
|
|
||||||
## DRY, but not premature
|
## DRY, but not premature
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,5 +3,5 @@ pub mod models;
|
||||||
pub mod scoring;
|
pub mod scoring;
|
||||||
|
|
||||||
pub use affinity::TopicAffinities;
|
pub use affinity::TopicAffinities;
|
||||||
pub use models::{Article, Feed, ReadingEvent, ReadingOutcome, RelevanceInputs};
|
pub use models::{Article, Feed, ReadingEvent, ReadingOutcome};
|
||||||
pub use scoring::score_article;
|
pub use scoring::{score_article, RelevanceInputs};
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
use crate::affinity::TopicAffinities;
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
@ -59,16 +58,3 @@ pub enum ReadingOutcome {
|
||||||
/// Explicitly marked not relevant, independent of whether it was opened.
|
/// Explicitly marked not relevant, independent of whether it was opened.
|
||||||
Dismissed,
|
Dismissed,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Inputs to the final blended relevance score for one article, see
|
|
||||||
/// `scoring::score_article`.
|
|
||||||
pub struct RelevanceInputs<'a> {
|
|
||||||
/// Cosine similarity (0.0-1.0, already renormalized from [-1,1] if
|
|
||||||
/// needed) between article and preference-profile embeddings.
|
|
||||||
pub embedding_score: f32,
|
|
||||||
/// LLM judgment (0.0-1.0), `None` if the article didn't clear the
|
|
||||||
/// embedding shortlist threshold and so was never sent to the LLM.
|
|
||||||
pub llm_score: Option<f32>,
|
|
||||||
pub topics: &'a [String],
|
|
||||||
pub affinities: &'a TopicAffinities,
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,16 @@
|
||||||
use crate::models::RelevanceInputs;
|
use crate::affinity::TopicAffinities;
|
||||||
|
|
||||||
|
/// Inputs to the final blended relevance score for one article.
|
||||||
|
pub struct RelevanceInputs<'a> {
|
||||||
|
/// Cosine similarity (0.0-1.0, already renormalized from [-1,1] if
|
||||||
|
/// needed) between article and preference-profile embeddings.
|
||||||
|
pub embedding_score: f32,
|
||||||
|
/// LLM judgment (0.0-1.0), `None` if the article didn't clear the
|
||||||
|
/// embedding shortlist threshold and so was never sent to the LLM.
|
||||||
|
pub llm_score: Option<f32>,
|
||||||
|
pub topics: &'a [String],
|
||||||
|
pub affinities: &'a TopicAffinities,
|
||||||
|
}
|
||||||
|
|
||||||
/// Weights are deliberately conservative: the LLM judgment dominates when
|
/// Weights are deliberately conservative: the LLM judgment dominates when
|
||||||
/// present (it has read the actual content), the embedding score is a
|
/// present (it has read the actual content), the embedding score is a
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
use crate::models::RankedArticleRow;
|
|
||||||
use crate::schema;
|
use crate::schema;
|
||||||
use crate::Db;
|
use crate::Db;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
|
@ -6,11 +5,11 @@ use chrono::Utc;
|
||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
use diesel_async::RunQueryDsl;
|
use diesel_async::RunQueryDsl;
|
||||||
use feedsignal_core::Article;
|
use feedsignal_core::Article;
|
||||||
use schema::articles::dsl;
|
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
impl Db {
|
impl Db {
|
||||||
pub async fn insert_article(&self, article: &Article) -> Result<()> {
|
pub async fn insert_article(&self, article: &Article) -> Result<()> {
|
||||||
|
use schema::articles::dsl;
|
||||||
let mut conn = self.pool.get().await?;
|
let mut conn = self.pool.get().await?;
|
||||||
let topics = serde_json::to_string(&article.topics)?;
|
let topics = serde_json::to_string(&article.topics)?;
|
||||||
diesel::insert_into(dsl::articles)
|
diesel::insert_into(dsl::articles)
|
||||||
|
|
@ -36,6 +35,7 @@ impl Db {
|
||||||
/// Articles above the embedding-similarity threshold that haven't been
|
/// Articles above the embedding-similarity threshold that haven't been
|
||||||
/// through the (slower) LLM scoring stage yet.
|
/// through the (slower) LLM scoring stage yet.
|
||||||
pub async fn shortlist_for_llm_scoring(&self, threshold: f32, limit: i64) -> Result<Vec<Uuid>> {
|
pub async fn shortlist_for_llm_scoring(&self, threshold: f32, limit: i64) -> Result<Vec<Uuid>> {
|
||||||
|
use schema::articles::dsl;
|
||||||
let mut conn = self.pool.get().await?;
|
let mut conn = self.pool.get().await?;
|
||||||
let ids: Vec<String> = dsl::articles
|
let ids: Vec<String> = dsl::articles
|
||||||
.filter(dsl::embedding_score.ge(threshold))
|
.filter(dsl::embedding_score.ge(threshold))
|
||||||
|
|
@ -54,6 +54,7 @@ impl Db {
|
||||||
&self,
|
&self,
|
||||||
article_id: Uuid,
|
article_id: Uuid,
|
||||||
) -> Result<Option<(String, String, Vec<String>, f32)>> {
|
) -> Result<Option<(String, String, Vec<String>, f32)>> {
|
||||||
|
use schema::articles::dsl;
|
||||||
let mut conn = self.pool.get().await?;
|
let mut conn = self.pool.get().await?;
|
||||||
let row: Option<(String, String, String, Option<f32>)> = dsl::articles
|
let row: Option<(String, String, String, Option<f32>)> = dsl::articles
|
||||||
.filter(dsl::id.eq(article_id.to_string()))
|
.filter(dsl::id.eq(article_id.to_string()))
|
||||||
|
|
@ -61,16 +62,13 @@ impl Db {
|
||||||
.first(&mut conn)
|
.first(&mut conn)
|
||||||
.await
|
.await
|
||||||
.optional()?;
|
.optional()?;
|
||||||
|
Ok(match row {
|
||||||
let result = match row {
|
|
||||||
Some((title, summary, topics_json, score)) => {
|
Some((title, summary, topics_json, score)) => {
|
||||||
let topics: Vec<String> = serde_json::from_str(&topics_json).unwrap_or_default();
|
let topics: Vec<String> = serde_json::from_str(&topics_json).unwrap_or_default();
|
||||||
Some((title, summary, topics, score.unwrap_or(0.0)))
|
Some((title, summary, topics, score.unwrap_or(0.0)))
|
||||||
}
|
}
|
||||||
None => None,
|
None => None,
|
||||||
};
|
})
|
||||||
|
|
||||||
Ok(result)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn store_llm_result(
|
pub async fn store_llm_result(
|
||||||
|
|
@ -93,27 +91,20 @@ impl Db {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Highest-ranked articles for display, most relevant first. When
|
/// Highest-ranked articles for display, most relevant first.
|
||||||
/// `feed_id` is given, only that feed's articles are returned —
|
|
||||||
/// otherwise every subscribed feed is joined into one ranked list.
|
|
||||||
pub async fn list_ranked_articles(
|
pub async fn list_ranked_articles(
|
||||||
&self,
|
&self,
|
||||||
feed_id: Option<Uuid>,
|
|
||||||
limit: i64,
|
limit: i64,
|
||||||
) -> Result<Vec<RankedArticleRow>> {
|
) -> Result<Vec<(String, String, String, String, Vec<String>, Option<f32>)>> {
|
||||||
|
use schema::articles::dsl;
|
||||||
let mut conn = self.pool.get().await?;
|
let mut conn = self.pool.get().await?;
|
||||||
// SQLite sorts NULL before any value, so `DESC` already puts NULL
|
// SQLite sorts NULL before any value, so `DESC` already puts NULL
|
||||||
// `final_score`s last — no separate NULLS LAST clause needed here.
|
// `final_score`s last — no separate NULLS LAST clause needed here.
|
||||||
let mut query = dsl::articles.into_boxed();
|
let rows: Vec<(String, String, String, String, String, Option<f32>)> = dsl::articles
|
||||||
if let Some(feed_id) = feed_id {
|
|
||||||
query = query.filter(dsl::feed_id.eq(feed_id.to_string()));
|
|
||||||
}
|
|
||||||
let rows: Vec<Row> = query
|
|
||||||
.order(dsl::final_score.desc())
|
.order(dsl::final_score.desc())
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.select((
|
.select((
|
||||||
dsl::id,
|
dsl::id,
|
||||||
dsl::feed_id,
|
|
||||||
dsl::title,
|
dsl::title,
|
||||||
dsl::url,
|
dsl::url,
|
||||||
dsl::summary,
|
dsl::summary,
|
||||||
|
|
@ -122,139 +113,18 @@ impl Db {
|
||||||
))
|
))
|
||||||
.load(&mut conn)
|
.load(&mut conn)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(rows.into_iter().map(RankedArticleRow::from).collect())
|
Ok(rows
|
||||||
}
|
.into_iter()
|
||||||
}
|
.map(|(id, title, url, summary, topics_json, final_score)| {
|
||||||
|
(
|
||||||
/// Raw shape of one `list_ranked_articles` row as loaded from SQLite —
|
id,
|
||||||
/// `topics` is still the JSON string column, not yet decoded.
|
title,
|
||||||
type Row = (String, String, String, String, String, String, Option<f32>);
|
url,
|
||||||
|
summary,
|
||||||
impl From<Row> for RankedArticleRow {
|
serde_json::from_str(&topics_json).unwrap_or_default(),
|
||||||
fn from((id, feed_id, title, url, summary, topics_json, final_score): Row) -> Self {
|
final_score,
|
||||||
Self {
|
)
|
||||||
id,
|
})
|
||||||
feed_id,
|
.collect())
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,18 +26,16 @@ impl Db {
|
||||||
Ok(Uuid::parse_str(&id)?)
|
Ok(Uuid::parse_str(&id)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// All subscribed feeds, ordered by title. Used both by the polling job
|
/// All subscribed feeds, for the polling job to iterate over.
|
||||||
/// (iterating to fetch each feed) and the sidebar's feed-navigation list.
|
pub async fn list_feeds(&self) -> Result<Vec<(Uuid, String)>> {
|
||||||
pub async fn list_feeds(&self) -> Result<Vec<(Uuid, String, String)>> {
|
|
||||||
use schema::feeds::dsl;
|
use schema::feeds::dsl;
|
||||||
let mut conn = self.pool.get().await?;
|
let mut conn = self.pool.get().await?;
|
||||||
let rows: Vec<(String, String, String)> = dsl::feeds
|
let rows: Vec<(String, String)> = dsl::feeds
|
||||||
.order(dsl::title.asc())
|
.select((dsl::id, dsl::url))
|
||||||
.select((dsl::id, dsl::title, dsl::url))
|
|
||||||
.load(&mut conn)
|
.load(&mut conn)
|
||||||
.await?;
|
.await?;
|
||||||
rows.into_iter()
|
rows.into_iter()
|
||||||
.map(|(id, title, url)| Ok((Uuid::parse_str(&id)?, title, url)))
|
.map(|(id, url)| Ok((Uuid::parse_str(&id)?, url)))
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,7 @@
|
||||||
mod affinities;
|
mod affinities;
|
||||||
mod articles;
|
mod articles;
|
||||||
mod feeds;
|
mod feeds;
|
||||||
mod models;
|
|
||||||
mod reading_events;
|
mod reading_events;
|
||||||
|
|
||||||
pub use models::RankedArticleRow;
|
|
||||||
pub(crate) mod schema;
|
pub(crate) mod schema;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
/// One row of [`crate::Db::list_ranked_articles`].
|
|
||||||
pub struct RankedArticleRow {
|
|
||||||
pub id: String,
|
|
||||||
pub feed_id: String,
|
|
||||||
pub title: String,
|
|
||||||
pub url: String,
|
|
||||||
pub summary: String,
|
|
||||||
pub topics: Vec<String>,
|
|
||||||
pub final_score: Option<f32>,
|
|
||||||
}
|
|
||||||
|
|
@ -24,7 +24,6 @@ chrono = { workspace = true, optional = true }
|
||||||
serde_json = { workspace = true, optional = true }
|
serde_json = { workspace = true, optional = true }
|
||||||
tokio-cron-scheduler = { version = "0.13", optional = true }
|
tokio-cron-scheduler = { version = "0.13", optional = true }
|
||||||
dioxus-primitives = { git = "https://github.com/DioxusLabs/components", version = "0.0.1", default-features = false }
|
dioxus-primitives = { git = "https://github.com/DioxusLabs/components", version = "0.0.1", default-features = false }
|
||||||
dioxus-icons = { version = "0.1.0", default-features = false }
|
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["web"]
|
default = ["web"]
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,13 @@
|
||||||
body { font-family: system-ui, sans-serif; margin: 0; color: #1a1a1a; height: 100vh; }
|
body { font-family: system-ui, sans-serif; max-width: 720px; margin: 2rem auto; color: #1a1a1a; }
|
||||||
.app-shell { display: flex; height: 100vh; }
|
.article-list { list-style: none; padding: 0; }
|
||||||
.content-header { display: flex; align-items: center; gap: 0.75rem; padding: 1.5rem 1.5rem 0.5rem; }
|
.article-row { border-bottom: 1px solid #ddd; padding: 1rem 0; }
|
||||||
.content-header h2 { margin: 0; }
|
|
||||||
/* Target the primitive's own stable data attribute, not a class we pass in:
|
|
||||||
ScrollArea sets its own `class` after spreading our attributes, so a
|
|
||||||
caller-supplied class never reaches the DOM (confirmed by inspecting the
|
|
||||||
live element — it only ever carries "dx-scroll-area-auto-hide"). Without
|
|
||||||
this, the scroll div has no bounded height, so it just grows to fit every
|
|
||||||
article (nothing to scroll) while its ancestor's overflow: hidden clips
|
|
||||||
everything past the viewport instead. */
|
|
||||||
main > [data-scroll-direction] { flex: 1; min-height: 0; padding: 0 1.5rem 1.5rem; }
|
|
||||||
.article-list { list-style: none; padding: 0; margin: 0; }
|
|
||||||
.article-row { border-bottom: 1px solid #ddd; padding: 1rem 0; display: flex; flex-direction: column; align-items: flex-start; gap: 0.5rem; }
|
|
||||||
.article-row-header { display: flex; align-items: baseline; flex-wrap: wrap; gap: 0.5rem; }
|
|
||||||
.article-row a { font-weight: 600; text-decoration: none; color: #0b5fff; }
|
.article-row a { font-weight: 600; text-decoration: none; color: #0b5fff; }
|
||||||
.summary { color: #444; margin: 0; }
|
.score { float: right; font-variant-numeric: tabular-nums; color: #555; }
|
||||||
.topics { display: flex; flex-wrap: wrap; gap: 0.3rem; }
|
.summary { color: #444; margin: 0.4rem 0; }
|
||||||
|
.topic-tag { display: inline-block; font-size: 0.75rem; background: #eee; border-radius: 4px; padding: 0.1rem 0.4rem; margin-right: 0.3rem; }
|
||||||
.error { color: #b00020; }
|
.error { color: #b00020; }
|
||||||
.subscribe-form { display: flex; gap: 0.5rem; margin: 1rem 0; min-width: 0; }
|
.subscribe-form { display: flex; gap: 0.5rem; margin: 1rem 0; }
|
||||||
/* Target the raw tag, not the component's own class: dioxus's #[css_module]
|
.subscribe-form .dx-input { flex: 1; }
|
||||||
content-hashes Input's class per build (e.g. "dx-input-83f82cbc"), so a
|
|
||||||
selector on the unhashed name never matches anything. */
|
|
||||||
.subscribe-form input { flex: 1 1 0; min-width: 0; }
|
|
||||||
.subscribe-status { margin: 0.25rem 0 1rem; font-size: 0.9rem; }
|
.subscribe-status { margin: 0.25rem 0 1rem; font-size: 0.9rem; }
|
||||||
.subscribe-status.success { color: #1a7f37; }
|
.subscribe-status.success { color: #1a7f37; }
|
||||||
.subscribe-status.error { color: #b00020; }
|
.subscribe-status.error { color: #b00020; }
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,12 @@ use super::ArticleView;
|
||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
/// Returns articles ranked by `final_score` descending, highest-relevance
|
/// Returns articles ranked by `final_score` descending, highest-relevance
|
||||||
/// first. `feed_id` restricts the list to one feed; `None` joins every
|
/// first. Runs on the server (native, has DB access); the `#[server]`
|
||||||
/// subscribed feed into one ranked list. Runs on the server (native, has
|
/// macro generates the HTTP call the browser/WASM build uses instead.
|
||||||
/// DB access); the `#[server]` macro generates the HTTP call the
|
|
||||||
/// browser/WASM build uses instead.
|
|
||||||
#[server]
|
#[server]
|
||||||
pub async fn list_ranked_articles(
|
pub async fn list_ranked_articles() -> Result<Vec<ArticleView>, ServerFnError> {
|
||||||
feed_id: Option<String>,
|
|
||||||
) -> Result<Vec<ArticleView>, ServerFnError> {
|
|
||||||
let db = crate::server::db().await?;
|
let db = crate::server::db().await?;
|
||||||
crate::server::services::articles::list_ranked(db, feed_id)
|
crate::server::services::articles::list_ranked(db)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ServerFnError::new(e.to_string()))
|
.map_err(|e| ServerFnError::new(e.to_string()))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,18 +6,9 @@ use serde::{Deserialize, Serialize};
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct ArticleView {
|
pub struct ArticleView {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub feed_id: String,
|
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub url: String,
|
pub url: String,
|
||||||
pub summary: String,
|
pub summary: String,
|
||||||
pub topics: Vec<String>,
|
pub topics: Vec<String>,
|
||||||
pub final_score: Option<f32>,
|
pub final_score: Option<f32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Subscribed feed, for the sidebar's feed-navigation list.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub struct FeedView {
|
|
||||||
pub id: String,
|
|
||||||
pub title: String,
|
|
||||||
pub url: String,
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
use super::FeedView;
|
|
||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
/// Registers a feed and fetches it immediately. Runs on the server (native,
|
/// Registers a feed and fetches it immediately. Runs on the server (native,
|
||||||
|
|
@ -11,12 +10,3 @@ pub async fn subscribe_feed(url: String) -> Result<usize, ServerFnError> {
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ServerFnError::new(e.to_string()))
|
.map_err(|e| ServerFnError::new(e.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lists every subscribed feed, for the sidebar's feed-navigation list.
|
|
||||||
#[server]
|
|
||||||
pub async fn list_feeds() -> Result<Vec<FeedView>, ServerFnError> {
|
|
||||||
let db = crate::server::db().await?;
|
|
||||||
crate::server::services::feeds::list(db)
|
|
||||||
.await
|
|
||||||
.map_err(|e| ServerFnError::new(e.to_string()))
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
pub mod articles;
|
pub mod articles;
|
||||||
|
mod dto;
|
||||||
pub mod feeds;
|
pub mod feeds;
|
||||||
mod models;
|
|
||||||
|
|
||||||
pub use models::{ArticleView, FeedView};
|
pub use dto::ArticleView;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
use crate::api;
|
use crate::api;
|
||||||
use crate::api::ArticleView;
|
use crate::api::ArticleView;
|
||||||
use crate::components::badge::{Badge, BadgeVariant};
|
|
||||||
use crate::components::button::{Button, ButtonSize, ButtonVariant};
|
|
||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
|
|
@ -9,25 +7,17 @@ pub fn ArticleRow(article: ArticleView) -> Element {
|
||||||
let score_pct = article.final_score.map(|s| (s * 100.0).round() as i32);
|
let score_pct = article.final_score.map(|s| (s * 100.0).round() as i32);
|
||||||
rsx! {
|
rsx! {
|
||||||
li { class: "article-row",
|
li { class: "article-row",
|
||||||
div { class: "article-row-header",
|
a { href: "{article.url}", target: "_blank", "{article.title}" }
|
||||||
a { href: "{article.url}", target: "_blank", "{article.title}" }
|
if let Some(pct) = score_pct {
|
||||||
if let Some(pct) = score_pct {
|
span { class: "score", "{pct}%" }
|
||||||
Badge { variant: BadgeVariant::Secondary, "{pct}%" }
|
}
|
||||||
|
p { class: "summary", "{article.summary}" }
|
||||||
|
div { class: "topics",
|
||||||
|
for topic in article.topics.iter() {
|
||||||
|
span { class: "topic-tag", "{topic}" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !article.summary.trim().is_empty() {
|
button {
|
||||||
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 |_| {
|
onclick: move |_| {
|
||||||
let id = article.id.clone();
|
let id = article.id.clone();
|
||||||
async move {
|
async move {
|
||||||
|
|
|
||||||
|
|
@ -2,122 +2,30 @@ mod article_row;
|
||||||
mod subscribe_form;
|
mod subscribe_form;
|
||||||
|
|
||||||
use crate::api;
|
use crate::api;
|
||||||
use crate::components::scroll_area::ScrollArea;
|
|
||||||
use crate::components::sidebar::{
|
|
||||||
Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarHeader,
|
|
||||||
SidebarInset, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarProvider, SidebarTrigger,
|
|
||||||
};
|
|
||||||
use article_row::ArticleRow;
|
use article_row::ArticleRow;
|
||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
use subscribe_form::SubscribeForm;
|
use subscribe_form::SubscribeForm;
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn App() -> Element {
|
pub fn App() -> Element {
|
||||||
let mut selected_feed = use_signal(|| Option::<String>::None);
|
let mut articles = use_server_future(api::articles::list_ranked_articles)?;
|
||||||
let mut feeds = use_server_future(api::feeds::list_feeds)?;
|
|
||||||
let mut articles =
|
|
||||||
use_server_future(move || api::articles::list_ranked_articles(selected_feed()))?;
|
|
||||||
|
|
||||||
let on_subscribed = move |_| {
|
|
||||||
feeds.restart();
|
|
||||||
articles.restart();
|
|
||||||
};
|
|
||||||
|
|
||||||
rsx! {
|
rsx! {
|
||||||
Stylesheet { href: asset!("/assets/app.css") }
|
Stylesheet { href: asset!("/assets/app.css") }
|
||||||
Stylesheet { href: asset!("/assets/dx-components-theme.css") }
|
Stylesheet { href: asset!("/assets/dx-components-theme.css") }
|
||||||
SidebarProvider {
|
main {
|
||||||
div { class: "app-shell",
|
h1 { "feedsignal" }
|
||||||
Sidebar {
|
SubscribeForm { on_subscribed: move |_| { articles.restart(); } }
|
||||||
SidebarHeader {
|
match articles.read().as_ref() {
|
||||||
h1 { "feedsignal" }
|
Some(Ok(articles)) => rsx! {
|
||||||
SubscribeForm { on_subscribed }
|
ul { class: "article-list",
|
||||||
}
|
for article in articles.iter() {
|
||||||
SidebarContent {
|
ArticleRow { article: article.clone() }
|
||||||
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 {
|
Some(Err(err)) => rsx! { p { class: "error", "Failed to load articles: {err}" } },
|
||||||
div { class: "content-header",
|
None => rsx! { p { "Loading..." } },
|
||||||
SidebarTrigger {}
|
|
||||||
h2 {
|
|
||||||
{
|
|
||||||
let heading = match selected_feed.read().as_ref() {
|
|
||||||
None => "All articles".to_string(),
|
|
||||||
Some(id) => feeds
|
|
||||||
.read()
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|r| r.as_ref().ok())
|
|
||||||
.and_then(|feeds| feeds.iter().find(|f| &f.id == id))
|
|
||||||
.map(|f| f.title.clone())
|
|
||||||
.unwrap_or_else(|| "Feed".to_string()),
|
|
||||||
};
|
|
||||||
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..." } },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,80 +0,0 @@
|
||||||
use dioxus::prelude::*;
|
|
||||||
use dioxus_icons::lucide::BadgeCheck;
|
|
||||||
|
|
||||||
#[css_module("/src/components/badge/style.css")]
|
|
||||||
struct Styles;
|
|
||||||
|
|
||||||
// Variants beyond the ones this app currently uses are part of the
|
|
||||||
// component library's public API, not unused code — CI runs clippy with
|
|
||||||
// `-D warnings`, which would otherwise turn this dead_code lint into a
|
|
||||||
// build failure.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
#[derive(Copy, Clone, PartialEq, Default)]
|
|
||||||
#[non_exhaustive]
|
|
||||||
pub enum BadgeVariant {
|
|
||||||
#[default]
|
|
||||||
Primary,
|
|
||||||
Secondary,
|
|
||||||
Destructive,
|
|
||||||
Outline,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl BadgeVariant {
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn class(&self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
BadgeVariant::Primary => "primary",
|
|
||||||
BadgeVariant::Secondary => "secondary",
|
|
||||||
BadgeVariant::Destructive => "destructive",
|
|
||||||
BadgeVariant::Outline => "outline",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The props for the [`Badge`] component.
|
|
||||||
#[derive(Props, Clone, PartialEq)]
|
|
||||||
pub struct BadgeProps {
|
|
||||||
#[props(default)]
|
|
||||||
pub variant: BadgeVariant,
|
|
||||||
|
|
||||||
/// Additional attributes to extend the badge element
|
|
||||||
#[props(extends = GlobalAttributes)]
|
|
||||||
pub attributes: Vec<Attribute>,
|
|
||||||
|
|
||||||
/// The children of the badge element
|
|
||||||
pub children: Element,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn Badge(props: BadgeProps) -> Element {
|
|
||||||
rsx! {
|
|
||||||
BadgeElement {
|
|
||||||
"padding": true,
|
|
||||||
variant: props.variant,
|
|
||||||
attributes: props.attributes,
|
|
||||||
{props.children}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
fn BadgeElement(props: BadgeProps) -> Element {
|
|
||||||
rsx! {
|
|
||||||
span {
|
|
||||||
class: Styles::dx_badge,
|
|
||||||
"data-style": props.variant.class(),
|
|
||||||
..props.attributes,
|
|
||||||
{props.children}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn VerifiedIcon() -> Element {
|
|
||||||
rsx! {
|
|
||||||
BadgeCheck {
|
|
||||||
size: "12px",
|
|
||||||
stroke: "var(--secondary-color-4)",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
mod component;
|
|
||||||
pub use component::*;
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
.dx-badge-example {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-badge {
|
|
||||||
display: inline-flex;
|
|
||||||
min-width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border-radius: 10px;
|
|
||||||
box-shadow: 0 0 0 1px var(--primary-color-2);
|
|
||||||
font-size: 12px;
|
|
||||||
gap: 4px
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-badge[padding="true"] {
|
|
||||||
padding: 0 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-badge[data-style="primary"] {
|
|
||||||
background-color: var(--secondary-color-2);
|
|
||||||
color: var(--primary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-badge[data-style="secondary"] {
|
|
||||||
background-color: var(--primary-color-5);
|
|
||||||
color: var(--secondary-color-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-badge[data-style="outline"] {
|
|
||||||
border: 1px solid var(--primary-color-6);
|
|
||||||
background-color: var(--light, var(--primary-color)) var(--dark, var(--primary-color-3));
|
|
||||||
color: var(--secondary-color-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-badge[data-style="destructive"] {
|
|
||||||
background-color: var(--primary-error-color);
|
|
||||||
color: var(--contrast-error-color);
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +1,3 @@
|
||||||
// AUTOGENERATED Components module
|
// AUTOGENERATED Components module
|
||||||
pub mod badge;
|
|
||||||
pub mod button;
|
pub mod button;
|
||||||
pub mod input;
|
pub mod input;
|
||||||
pub mod scroll_area;
|
|
||||||
pub mod separator;
|
|
||||||
pub mod sheet;
|
|
||||||
pub mod sidebar;
|
|
||||||
pub mod skeleton;
|
|
||||||
pub mod tooltip;
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
use dioxus::prelude::*;
|
|
||||||
use dioxus_primitives::scroll_area::{self, ScrollAreaProps};
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn ScrollArea(props: ScrollAreaProps) -> Element {
|
|
||||||
scroll_area::ScrollArea(props)
|
|
||||||
}
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
mod component;
|
|
||||||
pub use component::*;
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
/* Scroll area doesn't require any additional styles */
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
use dioxus::prelude::*;
|
|
||||||
use dioxus_primitives::separator::{self, SeparatorProps};
|
|
||||||
use dioxus_primitives::{dioxus_attributes::attributes, merge_attributes};
|
|
||||||
|
|
||||||
#[css_module("/src/components/separator/style.css")]
|
|
||||||
struct Styles;
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn Separator(props: SeparatorProps) -> Element {
|
|
||||||
let base = attributes!(div {
|
|
||||||
class: Styles::dx_separator,
|
|
||||||
});
|
|
||||||
let merged = merge_attributes(vec![base, props.attributes]);
|
|
||||||
|
|
||||||
rsx! {
|
|
||||||
separator::Separator {
|
|
||||||
horizontal: props.horizontal,
|
|
||||||
decorative: props.decorative,
|
|
||||||
attributes: merged,
|
|
||||||
{props.children}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
mod component;
|
|
||||||
pub use component::*;
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
.dx-separator {
|
|
||||||
background-color: var(--light, var(--primary-color-6)) var(--dark, var(--primary-color-7));
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-separator[data-orientation="horizontal"] {
|
|
||||||
width: 100%;
|
|
||||||
height: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-separator[data-orientation="vertical"] {
|
|
||||||
width: 1px;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
@ -1,150 +0,0 @@
|
||||||
use dioxus::prelude::*;
|
|
||||||
use dioxus_icons::lucide::X;
|
|
||||||
use dioxus_primitives::dialog::{
|
|
||||||
self, DialogCtx, DialogDescriptionProps, DialogRootProps, DialogTitleProps,
|
|
||||||
};
|
|
||||||
use dioxus_primitives::dioxus_attributes::attributes;
|
|
||||||
use dioxus_primitives::merge_attributes;
|
|
||||||
|
|
||||||
#[css_module("/src/components/sheet/style.css")]
|
|
||||||
struct Styles;
|
|
||||||
|
|
||||||
// Variants beyond the ones this app currently uses are part of the
|
|
||||||
// component library's public API, not unused code — CI runs clippy with
|
|
||||||
// `-D warnings`, which would otherwise turn this dead_code lint into a
|
|
||||||
// build failure.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
#[derive(Debug, Clone, Copy, Default, PartialEq)]
|
|
||||||
pub enum SheetSide {
|
|
||||||
Top,
|
|
||||||
#[default]
|
|
||||||
Right,
|
|
||||||
Bottom,
|
|
||||||
Left,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SheetSide {
|
|
||||||
pub fn as_str(&self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
SheetSide::Top => "top",
|
|
||||||
SheetSide::Right => "right",
|
|
||||||
SheetSide::Bottom => "bottom",
|
|
||||||
SheetSide::Left => "left",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn Sheet(props: DialogRootProps) -> Element {
|
|
||||||
let content_base = attributes!(div {
|
|
||||||
class: Styles::dx_sheet,
|
|
||||||
"data-slot": "sheet-content",
|
|
||||||
"data-side": SheetSide::Right.as_str(),
|
|
||||||
});
|
|
||||||
let content_attributes = merge_attributes(vec![content_base, props.attributes]);
|
|
||||||
|
|
||||||
rsx! {
|
|
||||||
dialog::DialogRoot {
|
|
||||||
class: Styles::dx_sheet_root,
|
|
||||||
"data-slot": "sheet-root",
|
|
||||||
id: props.id,
|
|
||||||
is_modal: props.is_modal,
|
|
||||||
open: props.open,
|
|
||||||
default_open: props.default_open,
|
|
||||||
on_open_change: props.on_open_change,
|
|
||||||
dialog::DialogContent {
|
|
||||||
class: None,
|
|
||||||
attributes: content_attributes,
|
|
||||||
{props.children}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn SheetContentClose(
|
|
||||||
#[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
|
|
||||||
) -> Element {
|
|
||||||
let base = attributes!(button {
|
|
||||||
class: Styles::dx_sheet_close,
|
|
||||||
});
|
|
||||||
let attributes = merge_attributes(vec![base, attributes]);
|
|
||||||
|
|
||||||
rsx! {
|
|
||||||
SheetClose { attributes,
|
|
||||||
X { size: "20px" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn SheetHeader(
|
|
||||||
#[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
|
|
||||||
children: Element,
|
|
||||||
) -> Element {
|
|
||||||
rsx! {
|
|
||||||
div { class: Styles::dx_sheet_header, "data-slot": "sheet-header", ..attributes, {children} }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn SheetFooter(
|
|
||||||
#[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
|
|
||||||
children: Element,
|
|
||||||
) -> Element {
|
|
||||||
rsx! {
|
|
||||||
div { class: Styles::dx_sheet_footer, "data-slot": "sheet-footer", ..attributes, {children} }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn SheetTitle(props: DialogTitleProps) -> Element {
|
|
||||||
rsx! {
|
|
||||||
dialog::DialogTitle {
|
|
||||||
id: props.id,
|
|
||||||
class: Styles::dx_sheet_title,
|
|
||||||
"data-slot": "sheet-title",
|
|
||||||
attributes: props.attributes,
|
|
||||||
{props.children}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn SheetDescription(props: DialogDescriptionProps) -> Element {
|
|
||||||
rsx! {
|
|
||||||
dialog::DialogDescription {
|
|
||||||
id: props.id,
|
|
||||||
class: Styles::dx_sheet_description,
|
|
||||||
"data-slot": "sheet-description",
|
|
||||||
attributes: props.attributes,
|
|
||||||
{props.children}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn SheetClose(
|
|
||||||
#[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
|
|
||||||
r#as: Option<Callback<Vec<Attribute>, Element>>,
|
|
||||||
children: Element,
|
|
||||||
) -> Element {
|
|
||||||
let ctx: DialogCtx = use_context();
|
|
||||||
|
|
||||||
let base = attributes! {
|
|
||||||
button {
|
|
||||||
onclick: move |_| {
|
|
||||||
ctx.set_open(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let merged = merge_attributes(vec![base, attributes]);
|
|
||||||
|
|
||||||
if let Some(dynamic) = r#as {
|
|
||||||
dynamic.call(merged)
|
|
||||||
} else {
|
|
||||||
rsx! {
|
|
||||||
button { ..merged, {children} }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
mod component;
|
|
||||||
pub use component::*;
|
|
||||||
|
|
@ -1,253 +0,0 @@
|
||||||
/* Sheet Root */
|
|
||||||
.dx-sheet-root {
|
|
||||||
position: fixed;
|
|
||||||
z-index: 1000;
|
|
||||||
background: rgb(0 0 0 / 50%);
|
|
||||||
inset: 0;
|
|
||||||
opacity: 0;
|
|
||||||
will-change: opacity;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sheet-root[data-state="closed"] {
|
|
||||||
animation: dx-sheet-root-out 150ms ease-in forwards;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sheet-root[data-state="open"] {
|
|
||||||
animation: dx-sheet-root-in 200ms ease-out forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes dx-sheet-root-in {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes dx-sheet-root-out {
|
|
||||||
from {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sheet {
|
|
||||||
position: fixed;
|
|
||||||
z-index: 1001;
|
|
||||||
display: flex;
|
|
||||||
box-sizing: border-box;
|
|
||||||
flex-direction: column;
|
|
||||||
border: none;
|
|
||||||
background: var(--primary-color-2);
|
|
||||||
box-shadow: 0 4px 20px rgb(0 0 0 / 20%);
|
|
||||||
color: var(--secondary-color-4);
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
||||||
gap: 1rem;
|
|
||||||
will-change: transform;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sheet[data-side="right"],
|
|
||||||
.dx-sheet[data-side="left"] {
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
width: 75%;
|
|
||||||
max-width: 24rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sheet[data-side="right"] {
|
|
||||||
right: 0;
|
|
||||||
border-left: 1px solid var(--primary-color-6);
|
|
||||||
transform: translateX(100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sheet[data-side="left"] {
|
|
||||||
left: 0;
|
|
||||||
border-right: 1px solid var(--primary-color-6);
|
|
||||||
transform: translateX(-100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sheet[data-side="top"],
|
|
||||||
.dx-sheet[data-side="bottom"] {
|
|
||||||
right: 0;
|
|
||||||
left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sheet[data-side="top"] {
|
|
||||||
top: 0;
|
|
||||||
border-bottom: 1px solid var(--primary-color-6);
|
|
||||||
transform: translateY(-100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sheet[data-side="bottom"] {
|
|
||||||
bottom: 0;
|
|
||||||
border-top: 1px solid var(--primary-color-6);
|
|
||||||
transform: translateY(100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sheet-root[data-state="open"] .dx-sheet[data-side="right"] {
|
|
||||||
animation: dx-slide-in-right 200ms ease-out forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sheet-root[data-state="open"] .dx-sheet[data-side="left"] {
|
|
||||||
animation: dx-slide-in-left 200ms ease-out forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sheet-root[data-state="open"] .dx-sheet[data-side="top"] {
|
|
||||||
animation: dx-slide-in-top 200ms ease-out forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sheet-root[data-state="open"] .dx-sheet[data-side="bottom"] {
|
|
||||||
animation: dx-slide-in-bottom 200ms ease-out forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sheet-root[data-state="closed"] .dx-sheet[data-side="right"] {
|
|
||||||
animation: dx-slide-out-right 150ms ease-in forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sheet-root[data-state="closed"] .dx-sheet[data-side="left"] {
|
|
||||||
animation: dx-slide-out-left 150ms ease-in forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sheet-root[data-state="closed"] .dx-sheet[data-side="top"] {
|
|
||||||
animation: dx-slide-out-top 150ms ease-in forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sheet-root[data-state="closed"] .dx-sheet[data-side="bottom"] {
|
|
||||||
animation: dx-slide-out-bottom 150ms ease-in forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes dx-slide-in-right {
|
|
||||||
from {
|
|
||||||
transform: translateX(100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
transform: translateX(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes dx-slide-out-right {
|
|
||||||
from {
|
|
||||||
transform: translateX(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
transform: translateX(100%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes dx-slide-in-left {
|
|
||||||
from {
|
|
||||||
transform: translateX(-100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
transform: translateX(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes dx-slide-out-left {
|
|
||||||
from {
|
|
||||||
transform: translateX(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
transform: translateX(-100%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes dx-slide-in-top {
|
|
||||||
from {
|
|
||||||
transform: translateY(-100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes dx-slide-out-top {
|
|
||||||
from {
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
transform: translateY(-100%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes dx-slide-in-bottom {
|
|
||||||
from {
|
|
||||||
transform: translateY(100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes dx-slide-out-bottom {
|
|
||||||
from {
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
transform: translateY(100%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sheet-header {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
padding: 1rem;
|
|
||||||
gap: 0.375rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sheet-footer {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
padding: 1rem;
|
|
||||||
margin-top: auto;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sheet-title {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--secondary-color-4);
|
|
||||||
font-size: 1.125rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sheet-description {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--secondary-color-5);
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sheet-close {
|
|
||||||
position: absolute;
|
|
||||||
top: 1rem;
|
|
||||||
right: 1rem;
|
|
||||||
display: flex;
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 0;
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: none;
|
|
||||||
color: var(--primary-color-7);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: color 150ms ease, background-color 150ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sheet-close:hover {
|
|
||||||
color: var(--secondary-color-4);
|
|
||||||
}
|
|
||||||
|
|
@ -1,845 +0,0 @@
|
||||||
use crate::components::button::{Button, ButtonVariant};
|
|
||||||
use crate::components::separator::Separator;
|
|
||||||
use crate::components::sheet::{
|
|
||||||
Sheet, SheetContentClose, SheetDescription, SheetHeader, SheetSide, SheetTitle,
|
|
||||||
};
|
|
||||||
use crate::components::skeleton::Skeleton;
|
|
||||||
use crate::components::tooltip::{Tooltip, TooltipContent, TooltipTrigger};
|
|
||||||
use dioxus::core::use_drop;
|
|
||||||
use dioxus::prelude::*;
|
|
||||||
use dioxus_icons::lucide::PanelLeft;
|
|
||||||
use dioxus_primitives::dioxus_attributes::attributes;
|
|
||||||
use dioxus_primitives::merge_attributes;
|
|
||||||
use dioxus_primitives::use_controlled;
|
|
||||||
|
|
||||||
#[css_module("/src/components/sidebar/style.css")]
|
|
||||||
struct Styles;
|
|
||||||
|
|
||||||
// constants
|
|
||||||
const SIDEBAR_WIDTH: &str = "16rem";
|
|
||||||
const SIDEBAR_WIDTH_MOBILE: &str = "18rem";
|
|
||||||
const SIDEBAR_WIDTH_ICON: &str = "3rem";
|
|
||||||
const SIDEBAR_KEYBOARD_SHORTCUT: &str = "b";
|
|
||||||
const MOBILE_BREAKPOINT: u32 = 768;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Default, PartialEq)]
|
|
||||||
pub enum SidebarState {
|
|
||||||
#[default]
|
|
||||||
Expanded,
|
|
||||||
Collapsed,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SidebarState {
|
|
||||||
pub fn as_str(&self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
SidebarState::Expanded => "expanded",
|
|
||||||
SidebarState::Collapsed => "collapsed",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Variants/methods beyond what this app currently uses are part of the
|
|
||||||
// component library's public API, not unused code — CI runs clippy with
|
|
||||||
// `-D warnings`, which would otherwise turn this dead_code lint into a
|
|
||||||
// build failure.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
#[derive(Debug, Clone, Copy, Default, PartialEq)]
|
|
||||||
pub enum SidebarSide {
|
|
||||||
#[default]
|
|
||||||
Left,
|
|
||||||
Right,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SidebarSide {
|
|
||||||
pub fn as_str(&self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
SidebarSide::Left => "left",
|
|
||||||
SidebarSide::Right => "right",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
#[derive(Debug, Clone, Copy, Default, PartialEq)]
|
|
||||||
pub enum SidebarVariant {
|
|
||||||
#[default]
|
|
||||||
Sidebar,
|
|
||||||
Floating,
|
|
||||||
Inset,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SidebarVariant {
|
|
||||||
pub fn as_str(&self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
SidebarVariant::Sidebar => "sidebar",
|
|
||||||
SidebarVariant::Floating => "floating",
|
|
||||||
SidebarVariant::Inset => "inset",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
#[derive(Debug, Clone, Copy, Default, PartialEq)]
|
|
||||||
pub enum SidebarCollapsible {
|
|
||||||
#[default]
|
|
||||||
Offcanvas,
|
|
||||||
Icon,
|
|
||||||
None,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SidebarCollapsible {
|
|
||||||
pub fn as_str(&self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
SidebarCollapsible::Offcanvas => "offcanvas",
|
|
||||||
SidebarCollapsible::Icon => "icon",
|
|
||||||
SidebarCollapsible::None => "none",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub struct SidebarCtx {
|
|
||||||
pub state: Memo<SidebarState>,
|
|
||||||
pub side: Signal<SidebarSide>,
|
|
||||||
pub is_mobile: Signal<bool>,
|
|
||||||
// From use_controlled:
|
|
||||||
open: Memo<bool>,
|
|
||||||
set_open: Callback<bool>,
|
|
||||||
// Mobile state:
|
|
||||||
open_mobile: Signal<bool>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SidebarCtx {
|
|
||||||
/// Toggle the sidebar open/closed state
|
|
||||||
pub fn toggle(&self) {
|
|
||||||
if (self.is_mobile)() {
|
|
||||||
let current = (self.open_mobile)();
|
|
||||||
let mut open_mobile = self.open_mobile;
|
|
||||||
open_mobile.set(!current);
|
|
||||||
} else {
|
|
||||||
self.set_open.call(!self.open());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set the mobile sidebar open state
|
|
||||||
pub fn set_open_mobile(&self, value: bool) {
|
|
||||||
let mut open_mobile = self.open_mobile;
|
|
||||||
open_mobile.set(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the current open state (desktop)
|
|
||||||
pub fn open(&self) -> bool {
|
|
||||||
self.open.cloned()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn use_sidebar() -> SidebarCtx {
|
|
||||||
use_context::<SidebarCtx>()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn use_is_mobile() -> Signal<bool> {
|
|
||||||
let mut is_mobile = use_signal(|| false);
|
|
||||||
|
|
||||||
use_effect(move || {
|
|
||||||
spawn(async move {
|
|
||||||
let js_code = format!(
|
|
||||||
r#"
|
|
||||||
function checkMobile() {{
|
|
||||||
return window.innerWidth < {MOBILE_BREAKPOINT};
|
|
||||||
}}
|
|
||||||
function handleResize() {{
|
|
||||||
dioxus.send(checkMobile());
|
|
||||||
}}
|
|
||||||
window.__sidebarResizeHandler = handleResize;
|
|
||||||
window.addEventListener('resize', window.__sidebarResizeHandler);
|
|
||||||
dioxus.send(checkMobile());
|
|
||||||
"#
|
|
||||||
);
|
|
||||||
let mut eval = document::eval(&js_code);
|
|
||||||
|
|
||||||
while let Ok(result) = eval.recv::<bool>().await {
|
|
||||||
is_mobile.set(result);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
use_drop(|| {
|
|
||||||
_ = document::eval(
|
|
||||||
r#"
|
|
||||||
window.removeEventListener('resize', window.__sidebarResizeHandler);
|
|
||||||
delete window.__sidebarResizeHandler;
|
|
||||||
"#,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
is_mobile
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn SidebarProvider(
|
|
||||||
#[props(default = true)] default_open: bool,
|
|
||||||
#[props(default)] open: ReadSignal<Option<bool>>,
|
|
||||||
#[props(default)] on_open_change: Callback<bool>,
|
|
||||||
#[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
|
|
||||||
children: Element,
|
|
||||||
) -> Element {
|
|
||||||
let is_mobile = use_is_mobile();
|
|
||||||
let side = use_signal(|| SidebarSide::Left);
|
|
||||||
let open_mobile = use_signal(|| false);
|
|
||||||
|
|
||||||
let (open, set_open) = use_controlled(open, default_open, on_open_change);
|
|
||||||
|
|
||||||
let state = use_memo(move || {
|
|
||||||
if open() {
|
|
||||||
SidebarState::Expanded
|
|
||||||
} else {
|
|
||||||
SidebarState::Collapsed
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let ctx = SidebarCtx {
|
|
||||||
state,
|
|
||||||
side,
|
|
||||||
is_mobile,
|
|
||||||
open,
|
|
||||||
set_open,
|
|
||||||
open_mobile,
|
|
||||||
};
|
|
||||||
|
|
||||||
use_context_provider(|| ctx);
|
|
||||||
|
|
||||||
use_effect(move || {
|
|
||||||
spawn(async move {
|
|
||||||
let js_code = format!(
|
|
||||||
r#"
|
|
||||||
function sidebarKeyHandler(event) {{
|
|
||||||
if (event.key === '{SIDEBAR_KEYBOARD_SHORTCUT}' && (event.metaKey || event.ctrlKey)) {{
|
|
||||||
event.preventDefault();
|
|
||||||
dioxus.send(true);
|
|
||||||
}}
|
|
||||||
}}
|
|
||||||
window.__sidebarKeyHandler = sidebarKeyHandler;
|
|
||||||
window.addEventListener('keydown', window.__sidebarKeyHandler);
|
|
||||||
"#
|
|
||||||
);
|
|
||||||
let mut eval = document::eval(&js_code);
|
|
||||||
|
|
||||||
loop {
|
|
||||||
if eval.recv::<bool>().await.is_ok() {
|
|
||||||
ctx.toggle();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
use_drop(|| {
|
|
||||||
_ = document::eval(
|
|
||||||
r#"
|
|
||||||
window.removeEventListener('keydown', window.__sidebarKeyHandler);
|
|
||||||
delete window.__sidebarKeyHandler;
|
|
||||||
"#,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
let sidebar_style = format!(
|
|
||||||
r#"
|
|
||||||
--dx-sidebar-width: {SIDEBAR_WIDTH};
|
|
||||||
--dx-sidebar-width-mobile: {SIDEBAR_WIDTH_MOBILE};
|
|
||||||
--dx-sidebar-width-icon: {SIDEBAR_WIDTH_ICON}
|
|
||||||
"#
|
|
||||||
);
|
|
||||||
|
|
||||||
let base = attributes!(div {
|
|
||||||
class: Styles::dx_sidebar_wrapper,
|
|
||||||
"data-slot": "sidebar-wrapper",
|
|
||||||
style: sidebar_style,
|
|
||||||
});
|
|
||||||
let merged = merge_attributes(vec![base, attributes]);
|
|
||||||
|
|
||||||
rsx! {
|
|
||||||
div { ..merged, {children} }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn Sidebar(
|
|
||||||
#[props(default)] side: SidebarSide,
|
|
||||||
#[props(default)] variant: SidebarVariant,
|
|
||||||
#[props(default)] collapsible: SidebarCollapsible,
|
|
||||||
#[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
|
|
||||||
children: Element,
|
|
||||||
) -> Element {
|
|
||||||
let ctx = use_sidebar();
|
|
||||||
let mut ctx_side = ctx.side;
|
|
||||||
if *ctx_side.peek() != side {
|
|
||||||
ctx_side.set(side);
|
|
||||||
}
|
|
||||||
|
|
||||||
let is_mobile = ctx.is_mobile;
|
|
||||||
let state = ctx.state;
|
|
||||||
let open_mobile = ctx.open_mobile;
|
|
||||||
|
|
||||||
if collapsible == SidebarCollapsible::None {
|
|
||||||
let base = attributes!(div {
|
|
||||||
class: Styles::dx_sidebar_static,
|
|
||||||
"data-slot": "sidebar",
|
|
||||||
});
|
|
||||||
let merged = merge_attributes(vec![base, attributes]);
|
|
||||||
|
|
||||||
return rsx! {
|
|
||||||
div { ..merged, {children} }
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if is_mobile() {
|
|
||||||
let sheet_side = match side {
|
|
||||||
SidebarSide::Left => SheetSide::Left,
|
|
||||||
SidebarSide::Right => SheetSide::Right,
|
|
||||||
};
|
|
||||||
|
|
||||||
return rsx! {
|
|
||||||
Sheet {
|
|
||||||
open: open_mobile(),
|
|
||||||
on_open_change: move |v| ctx.set_open_mobile(v),
|
|
||||||
"data-side": sheet_side.as_str(),
|
|
||||||
class: Styles::dx_sidebar_sheet.to_string(),
|
|
||||||
"data-sidebar": "sidebar",
|
|
||||||
"data-slot": "sidebar",
|
|
||||||
"data-mobile": "true",
|
|
||||||
SheetContentClose { class: Styles::dx_sidebar_sheet_close }
|
|
||||||
SheetHeader { class: Styles::dx_sr_only,
|
|
||||||
SheetTitle { "Sidebar" }
|
|
||||||
SheetDescription { "Displays the mobile sidebar." }
|
|
||||||
}
|
|
||||||
div { class: Styles::dx_sidebar_mobile_inner, {children} }
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let collapsible_str = if state() == SidebarState::Collapsed {
|
|
||||||
collapsible.as_str()
|
|
||||||
} else {
|
|
||||||
""
|
|
||||||
};
|
|
||||||
|
|
||||||
let container_base = attributes!(div {
|
|
||||||
class: Styles::dx_sidebar_container,
|
|
||||||
"data-slot": "sidebar-container",
|
|
||||||
});
|
|
||||||
let container_attrs = merge_attributes(vec![container_base, attributes]);
|
|
||||||
|
|
||||||
rsx! {
|
|
||||||
div {
|
|
||||||
class: Styles::dx_sidebar_desktop,
|
|
||||||
"data-state": state().as_str(),
|
|
||||||
"data-collapsible": collapsible_str,
|
|
||||||
"data-variant": variant.as_str(),
|
|
||||||
"data-side": side.as_str(),
|
|
||||||
"data-slot": "sidebar",
|
|
||||||
div { class: Styles::dx_sidebar_gap, "data-slot": "sidebar-gap" }
|
|
||||||
div {
|
|
||||||
..container_attrs,
|
|
||||||
div {
|
|
||||||
class: Styles::dx_sidebar_inner,
|
|
||||||
"data-sidebar": "sidebar",
|
|
||||||
"data-slot": "sidebar-inner",
|
|
||||||
{children}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn SidebarTrigger(
|
|
||||||
#[props(default)] onclick: Option<EventHandler<MouseEvent>>,
|
|
||||||
#[props(extends = GlobalAttributes)]
|
|
||||||
#[props(extends = button)]
|
|
||||||
attributes: Vec<Attribute>,
|
|
||||||
) -> Element {
|
|
||||||
let ctx = use_sidebar();
|
|
||||||
|
|
||||||
let base = attributes!(button {
|
|
||||||
class: Styles::dx_sidebar_trigger,
|
|
||||||
"data-sidebar": "trigger",
|
|
||||||
"data-slot": "sidebar-trigger",
|
|
||||||
});
|
|
||||||
let merged = merge_attributes(vec![base, attributes]);
|
|
||||||
|
|
||||||
rsx! {
|
|
||||||
Button {
|
|
||||||
variant: ButtonVariant::Ghost,
|
|
||||||
onclick: move |e| {
|
|
||||||
if let Some(handler) = &onclick {
|
|
||||||
handler.call(e);
|
|
||||||
}
|
|
||||||
ctx.toggle();
|
|
||||||
},
|
|
||||||
attributes: merged,
|
|
||||||
PanelLeft {
|
|
||||||
class: Styles::dx_sidebar_trigger_icon,
|
|
||||||
size: "1rem",
|
|
||||||
}
|
|
||||||
span { class: Styles::dx_sr_only, "Toggle Sidebar" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn SidebarRail(#[props(extends = GlobalAttributes)] attributes: Vec<Attribute>) -> Element {
|
|
||||||
let ctx = use_sidebar();
|
|
||||||
|
|
||||||
let base = attributes!(button {
|
|
||||||
class: Styles::dx_sidebar_rail,
|
|
||||||
"data-sidebar": "rail",
|
|
||||||
"data-slot": "sidebar-rail",
|
|
||||||
});
|
|
||||||
let merged = merge_attributes(vec![base, attributes]);
|
|
||||||
|
|
||||||
rsx! {
|
|
||||||
button {
|
|
||||||
aria_label: "Toggle Sidebar",
|
|
||||||
tabindex: -1,
|
|
||||||
onclick: move |_| ctx.toggle(),
|
|
||||||
title: "Toggle Sidebar",
|
|
||||||
..merged,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn SidebarInset(
|
|
||||||
#[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
|
|
||||||
children: Element,
|
|
||||||
) -> Element {
|
|
||||||
let base = attributes!(main {
|
|
||||||
class: Styles::dx_sidebar_inset,
|
|
||||||
"data-slot": "sidebar-inset",
|
|
||||||
});
|
|
||||||
let merged = merge_attributes(vec![base, attributes]);
|
|
||||||
|
|
||||||
rsx! {
|
|
||||||
main { ..merged, {children} }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn SidebarHeader(
|
|
||||||
#[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
|
|
||||||
children: Element,
|
|
||||||
) -> Element {
|
|
||||||
let base = attributes!(div {
|
|
||||||
class: Styles::dx_sidebar_header,
|
|
||||||
"data-slot": "sidebar-header",
|
|
||||||
"data-sidebar": "header",
|
|
||||||
});
|
|
||||||
let merged = merge_attributes(vec![base, attributes]);
|
|
||||||
|
|
||||||
rsx! {
|
|
||||||
div { ..merged, {children} }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn SidebarContent(
|
|
||||||
#[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
|
|
||||||
children: Element,
|
|
||||||
) -> Element {
|
|
||||||
let base = attributes!(div {
|
|
||||||
class: Styles::dx_sidebar_content,
|
|
||||||
"data-slot": "sidebar-content",
|
|
||||||
"data-sidebar": "content",
|
|
||||||
});
|
|
||||||
let merged = merge_attributes(vec![base, attributes]);
|
|
||||||
|
|
||||||
rsx! {
|
|
||||||
div { ..merged, {children} }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn SidebarFooter(
|
|
||||||
#[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
|
|
||||||
children: Element,
|
|
||||||
) -> Element {
|
|
||||||
let base = attributes!(div {
|
|
||||||
class: Styles::dx_sidebar_footer,
|
|
||||||
"data-slot": "sidebar-footer",
|
|
||||||
"data-sidebar": "footer",
|
|
||||||
});
|
|
||||||
let merged = merge_attributes(vec![base, attributes]);
|
|
||||||
|
|
||||||
rsx! {
|
|
||||||
div { ..merged, {children} }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn SidebarSeparator(
|
|
||||||
#[props(default = true)] horizontal: bool,
|
|
||||||
#[props(default = true)] decorative: bool,
|
|
||||||
#[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
|
|
||||||
) -> Element {
|
|
||||||
let base = attributes!(div {
|
|
||||||
class: Styles::dx_sidebar_separator,
|
|
||||||
"data-slot": "sidebar-separator",
|
|
||||||
"data-sidebar": "separator",
|
|
||||||
});
|
|
||||||
let merged = merge_attributes(vec![base, attributes]);
|
|
||||||
|
|
||||||
rsx! {
|
|
||||||
Separator { horizontal, decorative, attributes: merged }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn SidebarGroup(
|
|
||||||
#[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
|
|
||||||
children: Element,
|
|
||||||
) -> Element {
|
|
||||||
let base = attributes!(div {
|
|
||||||
class: Styles::dx_sidebar_group,
|
|
||||||
"data-slot": "sidebar-group",
|
|
||||||
"data-sidebar": "group",
|
|
||||||
});
|
|
||||||
let merged = merge_attributes(vec![base, attributes]);
|
|
||||||
|
|
||||||
rsx! {
|
|
||||||
div { ..merged, {children} }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn SidebarGroupLabel(
|
|
||||||
r#as: Option<Callback<Vec<Attribute>, Element>>,
|
|
||||||
#[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
|
|
||||||
children: Element,
|
|
||||||
) -> Element {
|
|
||||||
let base = attributes!(div {
|
|
||||||
class: Styles::dx_sidebar_group_label,
|
|
||||||
"data-slot": "sidebar-group-label",
|
|
||||||
"data-sidebar": "group-label",
|
|
||||||
});
|
|
||||||
let merged = merge_attributes(vec![base, attributes]);
|
|
||||||
|
|
||||||
if let Some(dynamic) = r#as {
|
|
||||||
dynamic.call(merged)
|
|
||||||
} else {
|
|
||||||
rsx! {
|
|
||||||
div { ..merged,{children} }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn SidebarGroupAction(
|
|
||||||
r#as: Option<Callback<Vec<Attribute>, Element>>,
|
|
||||||
#[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
|
|
||||||
children: Element,
|
|
||||||
) -> Element {
|
|
||||||
let base = attributes!(button {
|
|
||||||
class: Styles::dx_sidebar_group_action,
|
|
||||||
"data-slot": "sidebar-group-action",
|
|
||||||
"data-sidebar": "group-action",
|
|
||||||
});
|
|
||||||
let merged = merge_attributes(vec![base, attributes]);
|
|
||||||
|
|
||||||
if let Some(dynamic) = r#as {
|
|
||||||
dynamic.call(merged)
|
|
||||||
} else {
|
|
||||||
rsx! {
|
|
||||||
button { ..merged,{children} }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn SidebarGroupContent(
|
|
||||||
#[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
|
|
||||||
children: Element,
|
|
||||||
) -> Element {
|
|
||||||
let base = attributes!(div {
|
|
||||||
class: Styles::dx_sidebar_group_content,
|
|
||||||
"data-slot": "sidebar-group-content",
|
|
||||||
"data-sidebar": "group-content",
|
|
||||||
});
|
|
||||||
let merged = merge_attributes(vec![base, attributes]);
|
|
||||||
|
|
||||||
rsx! {
|
|
||||||
div { ..merged, {children} }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn SidebarMenu(
|
|
||||||
#[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
|
|
||||||
children: Element,
|
|
||||||
) -> Element {
|
|
||||||
let base = attributes!(ul {
|
|
||||||
class: Styles::dx_sidebar_menu,
|
|
||||||
"data-slot": "sidebar-menu",
|
|
||||||
"data-sidebar": "menu",
|
|
||||||
});
|
|
||||||
let merged = merge_attributes(vec![base, attributes]);
|
|
||||||
|
|
||||||
rsx! {
|
|
||||||
ul { ..merged, {children} }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn SidebarMenuItem(
|
|
||||||
#[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
|
|
||||||
children: Element,
|
|
||||||
) -> Element {
|
|
||||||
let base = attributes!(li {
|
|
||||||
class: Styles::dx_sidebar_menu_item,
|
|
||||||
"data-slot": "sidebar-menu-item",
|
|
||||||
"data-sidebar": "menu-item",
|
|
||||||
});
|
|
||||||
let merged = merge_attributes(vec![base, attributes]);
|
|
||||||
|
|
||||||
rsx! {
|
|
||||||
li { ..merged, {children} }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Default, PartialEq)]
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub enum SidebarMenuButtonVariant {
|
|
||||||
#[default]
|
|
||||||
Default,
|
|
||||||
Outline,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SidebarMenuButtonVariant {
|
|
||||||
pub fn as_str(&self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
SidebarMenuButtonVariant::Default => "default",
|
|
||||||
SidebarMenuButtonVariant::Outline => "outline",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Default, PartialEq)]
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub enum SidebarMenuButtonSize {
|
|
||||||
#[default]
|
|
||||||
Default,
|
|
||||||
Sm,
|
|
||||||
Lg,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SidebarMenuButtonSize {
|
|
||||||
pub fn as_str(&self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
SidebarMenuButtonSize::Default => "default",
|
|
||||||
SidebarMenuButtonSize::Sm => "sm",
|
|
||||||
SidebarMenuButtonSize::Lg => "lg",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn SidebarMenuButton(
|
|
||||||
#[props(default = false)] is_active: bool,
|
|
||||||
#[props(default)] variant: SidebarMenuButtonVariant,
|
|
||||||
#[props(default)] size: SidebarMenuButtonSize,
|
|
||||||
#[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
|
|
||||||
#[props(default)] tooltip: Option<Element>,
|
|
||||||
r#as: Option<Callback<Vec<Attribute>, Element>>,
|
|
||||||
children: Element,
|
|
||||||
) -> Element {
|
|
||||||
let ctx = use_sidebar();
|
|
||||||
let is_mobile = ctx.is_mobile;
|
|
||||||
let state = ctx.state;
|
|
||||||
|
|
||||||
let base = attributes!(button {
|
|
||||||
class: Styles::dx_sidebar_menu_button,
|
|
||||||
"data-slot": "sidebar-menu-button",
|
|
||||||
"data-sidebar": "menu-button",
|
|
||||||
"data-size": size.as_str(),
|
|
||||||
"data-variant": variant.as_str(),
|
|
||||||
"data-active": if is_active { "true" } else { "false" },
|
|
||||||
});
|
|
||||||
let merged = merge_attributes(vec![base, attributes]);
|
|
||||||
|
|
||||||
let Some(tooltip_content) = tooltip else {
|
|
||||||
return if let Some(dynamic) = r#as {
|
|
||||||
dynamic.call(merged)
|
|
||||||
} else {
|
|
||||||
rsx! { button { ..merged, {children} } }
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
let hidden = state() != SidebarState::Collapsed || is_mobile();
|
|
||||||
let sidebar_side = ctx.side;
|
|
||||||
|
|
||||||
rsx! {
|
|
||||||
Tooltip {
|
|
||||||
class: Styles::dx_sidebar_tooltip,
|
|
||||||
disabled: hidden,
|
|
||||||
TooltipTrigger {
|
|
||||||
as: move |tooltip_attrs: Vec<Attribute>| {
|
|
||||||
let final_attrs = merge_attributes(vec![tooltip_attrs, merged.clone()]);
|
|
||||||
let children = children.clone();
|
|
||||||
if let Some(dynamic) = &r#as {
|
|
||||||
dynamic.call(final_attrs)
|
|
||||||
} else {
|
|
||||||
rsx! { button { ..final_attrs, {children} } }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
TooltipContent {
|
|
||||||
side: match sidebar_side() {
|
|
||||||
SidebarSide::Left => dioxus_primitives::ContentSide::Right,
|
|
||||||
SidebarSide::Right => dioxus_primitives::ContentSide::Left,
|
|
||||||
},
|
|
||||||
{tooltip_content}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn SidebarMenuAction(
|
|
||||||
#[props(default = false)] show_on_hover: bool,
|
|
||||||
r#as: Option<Callback<Vec<Attribute>, Element>>,
|
|
||||||
#[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
|
|
||||||
children: Element,
|
|
||||||
) -> Element {
|
|
||||||
let base = attributes!(button {
|
|
||||||
class: Styles::dx_sidebar_menu_action,
|
|
||||||
"data-slot": "sidebar-menu-action",
|
|
||||||
"data-sidebar": "menu-action",
|
|
||||||
"data-show-on-hover": if show_on_hover { "true" } else { "false" },
|
|
||||||
});
|
|
||||||
let merged = merge_attributes(vec![base, attributes]);
|
|
||||||
|
|
||||||
if let Some(dynamic) = r#as {
|
|
||||||
dynamic.call(merged)
|
|
||||||
} else {
|
|
||||||
rsx! {
|
|
||||||
button { ..merged,{children} }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn SidebarMenuBadge(
|
|
||||||
#[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
|
|
||||||
children: Element,
|
|
||||||
) -> Element {
|
|
||||||
let base = attributes!(div {
|
|
||||||
class: Styles::dx_sidebar_menu_badge,
|
|
||||||
"data-slot": "sidebar-menu-badge",
|
|
||||||
"data-sidebar": "menu-badge",
|
|
||||||
});
|
|
||||||
let merged = merge_attributes(vec![base, attributes]);
|
|
||||||
|
|
||||||
rsx! {
|
|
||||||
div { ..merged, {children} }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn SidebarMenuSkeleton(
|
|
||||||
#[props(default = false)] show_icon: bool,
|
|
||||||
#[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
|
|
||||||
) -> Element {
|
|
||||||
let base = attributes!(div {
|
|
||||||
class: Styles::dx_sidebar_menu_skeleton,
|
|
||||||
"data-slot": "sidebar-menu-skeleton",
|
|
||||||
"data-sidebar": "menu-skeleton",
|
|
||||||
});
|
|
||||||
let merged = merge_attributes(vec![base, attributes]);
|
|
||||||
|
|
||||||
rsx! {
|
|
||||||
div {
|
|
||||||
..merged,
|
|
||||||
if show_icon {
|
|
||||||
Skeleton { class: Styles::dx_sidebar_menu_skeleton_icon }
|
|
||||||
}
|
|
||||||
Skeleton { class: Styles::dx_sidebar_menu_skeleton_text, width: "70%" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn SidebarMenuSub(
|
|
||||||
#[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
|
|
||||||
children: Element,
|
|
||||||
) -> Element {
|
|
||||||
let base = attributes!(ul {
|
|
||||||
class: Styles::dx_sidebar_menu_sub,
|
|
||||||
"data-slot": "sidebar-menu-sub",
|
|
||||||
"data-sidebar": "menu-sub",
|
|
||||||
});
|
|
||||||
let merged = merge_attributes(vec![base, attributes]);
|
|
||||||
|
|
||||||
rsx! {
|
|
||||||
ul { ..merged, {children} }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn SidebarMenuSubItem(
|
|
||||||
#[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
|
|
||||||
children: Element,
|
|
||||||
) -> Element {
|
|
||||||
let base = attributes!(li {
|
|
||||||
class: Styles::dx_sidebar_menu_sub_item,
|
|
||||||
"data-slot": "sidebar-menu-sub-item",
|
|
||||||
"data-sidebar": "menu-sub-item",
|
|
||||||
});
|
|
||||||
let merged = merge_attributes(vec![base, attributes]);
|
|
||||||
|
|
||||||
rsx! {
|
|
||||||
li { ..merged, {children} }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Default, PartialEq)]
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub enum SidebarMenuSubButtonSize {
|
|
||||||
Sm,
|
|
||||||
#[default]
|
|
||||||
Md,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SidebarMenuSubButtonSize {
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn as_str(&self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
SidebarMenuSubButtonSize::Sm => "sm",
|
|
||||||
SidebarMenuSubButtonSize::Md => "md",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn SidebarMenuSubButton(
|
|
||||||
#[props(default = false)] is_active: bool,
|
|
||||||
#[props(default)] size: SidebarMenuSubButtonSize,
|
|
||||||
r#as: Option<Callback<Vec<Attribute>, Element>>,
|
|
||||||
#[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
|
|
||||||
children: Element,
|
|
||||||
) -> Element {
|
|
||||||
let base = attributes!(a {
|
|
||||||
class: Styles::dx_sidebar_menu_sub_button,
|
|
||||||
"data-slot": "sidebar-menu-sub-button",
|
|
||||||
"data-sidebar": "menu-sub-button",
|
|
||||||
"data-size": size.as_str(),
|
|
||||||
"data-active": if is_active { "true" } else { "false" },
|
|
||||||
});
|
|
||||||
let merged = merge_attributes(vec![base, attributes]);
|
|
||||||
|
|
||||||
if let Some(dynamic) = r#as {
|
|
||||||
dynamic.call(merged)
|
|
||||||
} else {
|
|
||||||
rsx! {
|
|
||||||
a { ..merged, {children} }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
mod component;
|
|
||||||
pub use component::*;
|
|
||||||
|
|
@ -1,855 +0,0 @@
|
||||||
/* TODO: abstract as Utilitiy class */
|
|
||||||
.dx-sr-only {
|
|
||||||
position: absolute;
|
|
||||||
overflow: hidden;
|
|
||||||
width: 1px;
|
|
||||||
height: 1px;
|
|
||||||
padding: 0;
|
|
||||||
border: 0;
|
|
||||||
margin: -1px;
|
|
||||||
clip-path: inset(50%);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-wrapper {
|
|
||||||
--dx-sidebar-background: var(--primary-color-2);
|
|
||||||
--dx-sidebar-foreground: var(--secondary-color-4);
|
|
||||||
--dx-sidebar-border: var(--primary-color-6);
|
|
||||||
--dx-sidebar-accent: var(--primary-color-4);
|
|
||||||
--dx-sidebar-accent-foreground: var(--secondary-color-4);
|
|
||||||
--dx-sidebar-ring: var(--primary-color-7);
|
|
||||||
|
|
||||||
display: flex;
|
|
||||||
overflow: hidden;
|
|
||||||
width: 100%;
|
|
||||||
height: 100svh;
|
|
||||||
min-height: 100svh;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (width >=768px) {
|
|
||||||
.dx-sidebar-wrapper:has(.dx-sidebar-desktop[data-side="right"]) {
|
|
||||||
flex-direction: row-reverse;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-wrapper:has(.dx-sidebar-desktop[data-variant="inset"]) {
|
|
||||||
background: var(--dx-sidebar-background);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-desktop {
|
|
||||||
display: none;
|
|
||||||
color: var(--dx-sidebar-foreground);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (width >=768px) {
|
|
||||||
.dx-sidebar-desktop {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-gap {
|
|
||||||
position: relative;
|
|
||||||
width: var(--dx-sidebar-width);
|
|
||||||
background: transparent;
|
|
||||||
transition: width 200ms ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-collapsible="icon"] .dx-sidebar-gap {
|
|
||||||
width: var(--dx-sidebar-width-icon);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-variant="floating"] .dx-sidebar-gap,
|
|
||||||
[data-variant="inset"] .dx-sidebar-gap {
|
|
||||||
width: var(--dx-sidebar-width);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-variant="floating"][data-collapsible="icon"] .dx-sidebar-gap,
|
|
||||||
[data-variant="inset"][data-collapsible="icon"] .dx-sidebar-gap {
|
|
||||||
width: calc(var(--dx-sidebar-width-icon) + 1rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-collapsible="offcanvas"] .dx-sidebar-gap {
|
|
||||||
width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-container {
|
|
||||||
position: fixed;
|
|
||||||
z-index: 10;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
display: none;
|
|
||||||
width: var(--dx-sidebar-width);
|
|
||||||
height: 100svh;
|
|
||||||
box-sizing: border-box;
|
|
||||||
transition: left 200ms ease-out, right 200ms ease-out, width 200ms ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (width >=768px) {
|
|
||||||
.dx-sidebar-container {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-side="left"] .dx-sidebar-container {
|
|
||||||
left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-side="left"][data-collapsible="offcanvas"] .dx-sidebar-container {
|
|
||||||
left: calc(var(--dx-sidebar-width) * -1);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-side="right"] .dx-sidebar-container {
|
|
||||||
right: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-side="right"][data-collapsible="offcanvas"] .dx-sidebar-container {
|
|
||||||
right: calc(var(--dx-sidebar-width) * -1);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-collapsible="icon"] .dx-sidebar-container {
|
|
||||||
overflow: visible;
|
|
||||||
width: var(--dx-sidebar-width-icon);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-collapsible="icon"] .dx-sidebar-inner {
|
|
||||||
overflow: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-variant="sidebar"][data-side="left"] .dx-sidebar-container {
|
|
||||||
border-right: 1px solid var(--dx-sidebar-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-variant="sidebar"][data-side="right"] .dx-sidebar-container {
|
|
||||||
border-left: 1px solid var(--dx-sidebar-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-variant="floating"] .dx-sidebar-container,
|
|
||||||
[data-variant="inset"] .dx-sidebar-container {
|
|
||||||
padding: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-variant="floating"][data-collapsible="icon"] .dx-sidebar-container,
|
|
||||||
[data-variant="inset"][data-collapsible="icon"] .dx-sidebar-container {
|
|
||||||
width: calc(var(--dx-sidebar-width-icon) + 1rem + 2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-inner {
|
|
||||||
display: flex;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
flex-direction: column;
|
|
||||||
background: var(--dx-sidebar-background);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-variant="floating"] .dx-sidebar-inner {
|
|
||||||
border: 1px solid var(--dx-sidebar-border);
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
box-shadow: 0 1px 3px rgb(0 0 0 / 10%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-static {
|
|
||||||
display: flex;
|
|
||||||
width: var(--dx-sidebar-width);
|
|
||||||
height: 100%;
|
|
||||||
flex-direction: column;
|
|
||||||
background: var(--dx-sidebar-background);
|
|
||||||
color: var(--dx-sidebar-foreground);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-sheet {
|
|
||||||
width: var(--dx-sidebar-width-mobile) !important;
|
|
||||||
padding: 0 !important;
|
|
||||||
background: var(--dx-sidebar-background);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-sheet-close {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-mobile-inner {
|
|
||||||
display: flex;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-trigger {
|
|
||||||
display: inline-flex;
|
|
||||||
width: 1.75rem;
|
|
||||||
height: 1.75rem;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 0 !important;
|
|
||||||
line-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-trigger-icon {
|
|
||||||
width: 1rem;
|
|
||||||
height: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-rail {
|
|
||||||
position: absolute;
|
|
||||||
z-index: 20;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
display: none;
|
|
||||||
width: 1rem;
|
|
||||||
padding: 0;
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
cursor: ew-resize;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
transition: all 200ms ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (width >=640px) {
|
|
||||||
.dx-sidebar-rail {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-rail::after {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
left: 50%;
|
|
||||||
width: 2px;
|
|
||||||
content: "";
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-rail:hover::after {
|
|
||||||
background: var(--dx-sidebar-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-side="left"] .dx-sidebar-rail {
|
|
||||||
right: -1rem;
|
|
||||||
cursor: w-resize;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-side="right"] .dx-sidebar-rail {
|
|
||||||
left: 0;
|
|
||||||
cursor: e-resize;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-side="left"][data-state="collapsed"] .dx-sidebar-rail {
|
|
||||||
cursor: e-resize;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-side="right"][data-state="collapsed"] .dx-sidebar-rail {
|
|
||||||
cursor: w-resize;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-collapsible="offcanvas"] .dx-sidebar-rail {
|
|
||||||
transform: translateX(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-collapsible="offcanvas"] .dx-sidebar-rail::after {
|
|
||||||
left: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-collapsible="offcanvas"] .dx-sidebar-rail:hover {
|
|
||||||
background: var(--dx-sidebar-background);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-side="left"][data-collapsible="offcanvas"] .dx-sidebar-rail {
|
|
||||||
right: -0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-side="right"][data-collapsible="offcanvas"] .dx-sidebar-rail {
|
|
||||||
left: -0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-inset {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
width: 100%;
|
|
||||||
flex: 1 1 0%;
|
|
||||||
flex-direction: column;
|
|
||||||
background: var(--primary-color-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-variant="inset"]~.dx-sidebar-inset {
|
|
||||||
border-radius: 0.75rem;
|
|
||||||
margin: 0.5rem;
|
|
||||||
margin-left: 0;
|
|
||||||
box-shadow: 0 1px 3px rgb(0 0 0 / 10%);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-variant="inset"][data-state="collapsed"]~.dx-sidebar-inset {
|
|
||||||
margin-left: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-variant="inset"][data-side="right"]~.dx-sidebar-inset {
|
|
||||||
margin-right: 0;
|
|
||||||
margin-left: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-variant="inset"][data-side="right"][data-state="collapsed"]~.dx-sidebar-inset {
|
|
||||||
margin-right: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-header {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
padding: 0.5rem;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-content {
|
|
||||||
display: flex;
|
|
||||||
overflow: hidden auto;
|
|
||||||
min-height: 0;
|
|
||||||
flex: 1 1 0%;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-collapsible="icon"] .dx-sidebar-content {
|
|
||||||
overflow: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-footer {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
padding: 0.5rem;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-separator {
|
|
||||||
width: auto;
|
|
||||||
margin: 0 0.5rem;
|
|
||||||
background: var(--dx-sidebar-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-group {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
min-width: 0;
|
|
||||||
flex-direction: column;
|
|
||||||
padding: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-group-label {
|
|
||||||
display: flex;
|
|
||||||
height: 2rem;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0 0.5rem;
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
color: var(--dx-sidebar-foreground);
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 500;
|
|
||||||
opacity: 0.7;
|
|
||||||
outline: none;
|
|
||||||
transition: margin 200ms ease-out, opacity 200ms ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-group-label svg {
|
|
||||||
width: 1rem;
|
|
||||||
height: 1rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-collapsible="icon"] .dx-sidebar-group-label {
|
|
||||||
margin-top: -2rem;
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-group-action {
|
|
||||||
position: absolute;
|
|
||||||
top: 0.875rem;
|
|
||||||
right: 0.75rem;
|
|
||||||
display: flex;
|
|
||||||
width: 1.25rem;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 0;
|
|
||||||
border: none;
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
aspect-ratio: 1;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--dx-sidebar-foreground);
|
|
||||||
cursor: pointer;
|
|
||||||
outline: none;
|
|
||||||
transition: transform 150ms ease-out, opacity 200ms ease-out, visibility 0ms 0ms;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-group-action:hover {
|
|
||||||
background: var(--dx-sidebar-accent);
|
|
||||||
color: var(--dx-sidebar-accent-foreground);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-group-action svg {
|
|
||||||
width: 1rem;
|
|
||||||
height: 1rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Increase hit area on mobile */
|
|
||||||
.dx-sidebar-group-action::after {
|
|
||||||
position: absolute;
|
|
||||||
content: "";
|
|
||||||
inset: -0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (width >=768px) {
|
|
||||||
.dx-sidebar-group-action::after {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-collapsible="icon"] .dx-sidebar-group-action {
|
|
||||||
opacity: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
transition: transform 150ms ease-out, opacity 200ms ease-out, visibility 0ms 200ms;
|
|
||||||
visibility: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-group-content {
|
|
||||||
width: 100%;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-menu {
|
|
||||||
display: flex;
|
|
||||||
width: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
flex-direction: column;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
gap: 0.25rem;
|
|
||||||
list-style: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-menu .dx-sidebar-dropdown-menu,
|
|
||||||
.dx-sidebar-menu .dx-sidebar-tooltip {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-dropdown-menu-content .dx-sidebar-dropdown-separator[data-orientation="horizontal"] {
|
|
||||||
margin: 0.25rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-menu-item {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-menu-item > .dx-sidebar-dropdown-menu:has(.dx-sidebar-menu-action) {
|
|
||||||
position: static;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-header .dx-sidebar-menu-button.dx-sidebar-dropdown-menu-trigger:not(:focus-visible),
|
|
||||||
.dx-sidebar-footer .dx-sidebar-menu-button.dx-sidebar-dropdown-menu-trigger:not(:focus-visible) {
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-sheet .dx-sidebar-header .dx-sidebar-dropdown-menu-content {
|
|
||||||
margin-top: 4px;
|
|
||||||
margin-bottom: 0;
|
|
||||||
inset: 100% auto auto 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-sheet .dx-sidebar-footer .dx-sidebar-dropdown-menu-content {
|
|
||||||
margin-top: 0;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
inset: auto auto 100% 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-menu-button.dx-sidebar-collapsible-trigger:hover {
|
|
||||||
text-decoration: none;
|
|
||||||
text-decoration-line: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (width >= 768px) {
|
|
||||||
.dx-sidebar-desktop[data-side="left"] :is(.dx-sidebar-header, .dx-sidebar-menu-item:has(.dx-sidebar-menu-action)) .dx-sidebar-dropdown-menu-content {
|
|
||||||
top: 0;
|
|
||||||
left: 100%;
|
|
||||||
margin-top: 0;
|
|
||||||
margin-left: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-desktop[data-side="left"] .dx-sidebar-footer .dx-sidebar-dropdown-menu-content {
|
|
||||||
top: auto;
|
|
||||||
bottom: 0;
|
|
||||||
left: 100%;
|
|
||||||
margin-bottom: 0;
|
|
||||||
margin-left: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-desktop[data-side="right"] :is(.dx-sidebar-header, .dx-sidebar-menu-item:has(.dx-sidebar-menu-action)) .dx-sidebar-dropdown-menu-content {
|
|
||||||
top: 0;
|
|
||||||
right: 100%;
|
|
||||||
left: auto;
|
|
||||||
margin-top: 0;
|
|
||||||
margin-right: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-desktop[data-side="right"] .dx-sidebar-footer .dx-sidebar-dropdown-menu-content {
|
|
||||||
margin-right: 0.5rem;
|
|
||||||
margin-bottom: 0;
|
|
||||||
inset: auto 100% 0 auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-menu-button[data-sidebar="menu-button"] {
|
|
||||||
display: flex;
|
|
||||||
width: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0.5rem;
|
|
||||||
border: none;
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--dx-sidebar-foreground);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
gap: 0.5rem;
|
|
||||||
outline: none;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
text-align: left;
|
|
||||||
text-decoration: none;
|
|
||||||
transition: width 200ms ease-out, padding 200ms ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-menu-button[data-sidebar="menu-button"]:hover {
|
|
||||||
background: var(--dx-sidebar-accent);
|
|
||||||
color: var(--dx-sidebar-accent-foreground);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-menu-button[data-sidebar="menu-button"]:focus-visible {
|
|
||||||
box-shadow: 0 0 0 2px var(--dx-sidebar-ring);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-menu-button[data-sidebar="menu-button"]:active {
|
|
||||||
background: var(--dx-sidebar-accent);
|
|
||||||
color: var(--dx-sidebar-accent-foreground);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-menu-button[data-sidebar="menu-button"]:disabled,
|
|
||||||
.dx-sidebar-menu-button[data-sidebar="menu-button"][aria-disabled="true"] {
|
|
||||||
opacity: 0.5;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-menu-button[data-sidebar="menu-button"][data-active="true"] {
|
|
||||||
background: var(--dx-sidebar-accent);
|
|
||||||
color: var(--dx-sidebar-accent-foreground);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-menu-button[data-sidebar="menu-button"] svg {
|
|
||||||
width: 1rem;
|
|
||||||
height: 1rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-menu-button[data-sidebar="menu-button"]>span:last-child {
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
transition: opacity 200ms ease-out;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-desktop[data-collapsible="icon"] .dx-sidebar-menu-button[data-sidebar="menu-button"]>span:last-child {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Size variants */
|
|
||||||
.dx-sidebar-menu-button[data-sidebar="menu-button"][data-size="default"] {
|
|
||||||
min-height: 2rem;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-menu-button[data-sidebar="menu-button"][data-size="sm"] {
|
|
||||||
min-height: 1.75rem;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-menu-button[data-sidebar="menu-button"][data-size="lg"] {
|
|
||||||
min-height: 3rem;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-menu-button[data-sidebar="menu-button"][data-variant="outline"] {
|
|
||||||
background: var(--primary-color-1);
|
|
||||||
box-shadow: 0 0 0 1px var(--dx-sidebar-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-menu-button[data-sidebar="menu-button"][data-variant="outline"]:hover {
|
|
||||||
background: var(--dx-sidebar-accent);
|
|
||||||
box-shadow: 0 0 0 1px var(--dx-sidebar-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-desktop[data-collapsible="icon"] .dx-sidebar-menu-button[data-sidebar="menu-button"] {
|
|
||||||
width: 2rem;
|
|
||||||
height: 2rem;
|
|
||||||
padding: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-desktop[data-collapsible="icon"] .dx-sidebar-menu-button[data-sidebar="menu-button"][data-size="lg"] {
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-desktop[data-collapsible="icon"] .dx-sidebar-menu-button[data-sidebar="menu-button"]:has(> :first-child:is(svg, img)),
|
|
||||||
.dx-sidebar-desktop[data-collapsible="icon"] .dx-sidebar-menu-button[data-sidebar="menu-button"]:has(> :first-child:has(svg, img)) {
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-desktop[data-collapsible="icon"] .dx-sidebar-menu-button[data-sidebar="menu-button"]:has(> :first-child:is(svg, img))> :not(:first-child),
|
|
||||||
.dx-sidebar-desktop[data-collapsible="icon"] .dx-sidebar-menu-button[data-sidebar="menu-button"]:has(> :first-child:has(svg, img))> :not(:first-child) {
|
|
||||||
position: absolute;
|
|
||||||
overflow: hidden;
|
|
||||||
width: 1px;
|
|
||||||
height: 1px;
|
|
||||||
padding: 0;
|
|
||||||
border: 0;
|
|
||||||
margin: -1px;
|
|
||||||
clip-path: inset(50%);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-desktop[data-collapsible="icon"] .dx-sidebar-menu-button[data-sidebar="menu-button"] svg {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-menu-item:has(.dx-sidebar-menu-action[data-sidebar="menu-action"]) .dx-sidebar-menu-button[data-sidebar="menu-button"] {
|
|
||||||
padding-right: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-menu-action[data-sidebar="menu-action"] {
|
|
||||||
position: absolute;
|
|
||||||
top: 0.375rem;
|
|
||||||
right: 0.25rem;
|
|
||||||
display: flex;
|
|
||||||
width: 1.25rem;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 0;
|
|
||||||
border: none;
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
aspect-ratio: 1;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--dx-sidebar-foreground);
|
|
||||||
cursor: pointer;
|
|
||||||
outline: none;
|
|
||||||
transition: transform 150ms ease-out, opacity 200ms ease-out, visibility 0ms 0ms;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-menu-action[data-sidebar="menu-action"]:hover {
|
|
||||||
background: var(--dx-sidebar-accent);
|
|
||||||
color: var(--dx-sidebar-accent-foreground);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-menu-action[data-sidebar="menu-action"] svg {
|
|
||||||
width: 1rem;
|
|
||||||
height: 1rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-menu-action[data-sidebar="menu-action"]::after {
|
|
||||||
position: absolute;
|
|
||||||
content: "";
|
|
||||||
inset: -0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (width >=768px) {
|
|
||||||
.dx-sidebar-menu-action[data-sidebar="menu-action"]::after {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-menu-button[data-sidebar="menu-button"][data-size="sm"]~.dx-sidebar-menu-action[data-sidebar="menu-action"] {
|
|
||||||
top: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-menu-button[data-sidebar="menu-button"][data-size="default"]~.dx-sidebar-menu-action[data-sidebar="menu-action"] {
|
|
||||||
top: 0.375rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-menu-button[data-sidebar="menu-button"][data-size="lg"]~.dx-sidebar-menu-action[data-sidebar="menu-action"] {
|
|
||||||
top: 0.625rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-collapsible="icon"] .dx-sidebar-menu-action[data-sidebar="menu-action"] {
|
|
||||||
opacity: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
transition: transform 150ms ease-out, opacity 200ms ease-out, visibility 0ms 200ms;
|
|
||||||
visibility: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-menu-action[data-sidebar="menu-action"][data-show-on-hover="true"] {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (width >=768px) {
|
|
||||||
.dx-sidebar-menu-item:hover .dx-sidebar-menu-action[data-sidebar="menu-action"][data-show-on-hover="true"],
|
|
||||||
.dx-sidebar-menu-item:focus-within .dx-sidebar-menu-action[data-sidebar="menu-action"][data-show-on-hover="true"],
|
|
||||||
.dx-sidebar-menu-action[data-sidebar="menu-action"][data-show-on-hover="true"][data-state="open"] {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-menu-button[data-sidebar="menu-button"][data-active="true"]~.dx-sidebar-menu-action[data-sidebar="menu-action"][data-show-on-hover="true"] {
|
|
||||||
color: var(--dx-sidebar-accent-foreground);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-menu-badge {
|
|
||||||
position: absolute;
|
|
||||||
top: 0.375rem;
|
|
||||||
right: 0.25rem;
|
|
||||||
display: flex;
|
|
||||||
min-width: 1.25rem;
|
|
||||||
height: 1.25rem;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 0 0.25rem;
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
color: var(--dx-sidebar-foreground);
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
font-weight: 500;
|
|
||||||
pointer-events: none;
|
|
||||||
transition: opacity 200ms ease-out;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-menu-item:hover .dx-sidebar-menu-badge,
|
|
||||||
.dx-sidebar-menu-item:has(.dx-sidebar-menu-button[data-active="true"]) .dx-sidebar-menu-badge {
|
|
||||||
color: var(--dx-sidebar-accent-foreground);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-menu-item:has(.dx-sidebar-menu-button[data-size="sm"]) .dx-sidebar-menu-badge {
|
|
||||||
top: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-menu-item:has(.dx-sidebar-menu-button[data-size="lg"]) .dx-sidebar-menu-badge {
|
|
||||||
top: 0.625rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-collapsible="icon"] .dx-sidebar-menu-badge {
|
|
||||||
opacity: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-menu-skeleton {
|
|
||||||
display: flex;
|
|
||||||
height: 2rem;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0 0.5rem;
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-menu-skeleton-icon {
|
|
||||||
width: 1rem;
|
|
||||||
height: 1rem;
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-menu-skeleton-text {
|
|
||||||
height: 1rem;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-menu-sub {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
padding: 0.125rem 0.625rem;
|
|
||||||
border-left: 1px solid var(--dx-sidebar-border);
|
|
||||||
margin: 0 0.875rem;
|
|
||||||
gap: 0.25rem;
|
|
||||||
list-style: none;
|
|
||||||
transform: translateX(1px);
|
|
||||||
transition: opacity 200ms ease-out, max-height 200ms ease-out, padding 200ms ease-out, margin 200ms ease-out, visibility 0ms 0ms;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-collapsible="icon"] .dx-sidebar-menu-sub {
|
|
||||||
overflow: hidden;
|
|
||||||
max-height: 0;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
opacity: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
transition: opacity 200ms ease-out, max-height 200ms ease-out, padding 200ms ease-out, margin 200ms ease-out, visibility 0ms 200ms;
|
|
||||||
visibility: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-menu-sub-item {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-menu-sub-button {
|
|
||||||
display: flex;
|
|
||||||
overflow: hidden;
|
|
||||||
width: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
height: 1.75rem;
|
|
||||||
box-sizing: border-box;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0 0.5rem;
|
|
||||||
border: none;
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--dx-sidebar-foreground);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
gap: 0.5rem;
|
|
||||||
outline: none;
|
|
||||||
text-decoration: none;
|
|
||||||
transform: translateX(-1px);
|
|
||||||
transition: opacity 200ms ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-menu-sub-button:hover {
|
|
||||||
background: var(--dx-sidebar-accent);
|
|
||||||
color: var(--dx-sidebar-accent-foreground);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-menu-sub-button:focus-visible {
|
|
||||||
box-shadow: 0 0 0 2px var(--dx-sidebar-ring);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-menu-sub-button:active {
|
|
||||||
background: var(--dx-sidebar-accent);
|
|
||||||
color: var(--dx-sidebar-accent-foreground);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-menu-sub-button:disabled,
|
|
||||||
.dx-sidebar-menu-sub-button[aria-disabled="true"] {
|
|
||||||
opacity: 0.5;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-menu-sub-button[data-active="true"] {
|
|
||||||
background: var(--dx-sidebar-accent);
|
|
||||||
color: var(--dx-sidebar-accent-foreground);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-menu-sub-button svg {
|
|
||||||
width: 1rem;
|
|
||||||
height: 1rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
color: var(--dx-sidebar-accent-foreground);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-menu-sub-button>span:last-child {
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-menu-sub-button[data-size="sm"] {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-sidebar-menu-sub-button[data-size="md"] {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-collapsible="icon"] .dx-sidebar-menu-sub-button {
|
|
||||||
opacity: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
use dioxus::prelude::*;
|
|
||||||
use dioxus_primitives::{dioxus_attributes::attributes, merge_attributes};
|
|
||||||
|
|
||||||
#[css_module("/src/components/skeleton/style.css")]
|
|
||||||
struct Styles;
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn Skeleton(#[props(extends=GlobalAttributes)] attributes: Vec<Attribute>) -> Element {
|
|
||||||
let base = attributes!(div {
|
|
||||||
class: Styles::dx_skeleton,
|
|
||||||
});
|
|
||||||
let merged = merge_attributes(vec![base, attributes]);
|
|
||||||
|
|
||||||
rsx! {
|
|
||||||
div { ..merged }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
mod component;
|
|
||||||
pub use component::*;
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
.dx-skeleton {
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
animation: dx-skeleton-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
|
||||||
background-color: var(--primary-color-5);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes dx-skeleton-pulse {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
61.8% {
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
use dioxus::prelude::*;
|
|
||||||
use dioxus_primitives::dioxus_attributes::attributes;
|
|
||||||
use dioxus_primitives::merge_attributes;
|
|
||||||
use dioxus_primitives::tooltip::{self, TooltipContentProps, TooltipProps, TooltipTriggerProps};
|
|
||||||
|
|
||||||
#[css_module("/src/components/tooltip/style.css")]
|
|
||||||
struct Styles;
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn Tooltip(props: TooltipProps) -> Element {
|
|
||||||
let base = attributes!(div {
|
|
||||||
class: Styles::dx_tooltip,
|
|
||||||
});
|
|
||||||
let merged = merge_attributes(vec![base, props.attributes]);
|
|
||||||
|
|
||||||
rsx! {
|
|
||||||
tooltip::Tooltip {
|
|
||||||
disabled: props.disabled,
|
|
||||||
open: props.open,
|
|
||||||
default_open: props.default_open,
|
|
||||||
on_open_change: props.on_open_change,
|
|
||||||
attributes: merged,
|
|
||||||
{props.children}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn TooltipTrigger(props: TooltipTriggerProps) -> Element {
|
|
||||||
let base = attributes!(button {
|
|
||||||
class: Styles::dx_tooltip_trigger,
|
|
||||||
});
|
|
||||||
let merged = merge_attributes(vec![base, props.attributes]);
|
|
||||||
|
|
||||||
rsx! {
|
|
||||||
tooltip::TooltipTrigger {
|
|
||||||
id: props.id,
|
|
||||||
as: props.r#as,
|
|
||||||
attributes: merged,
|
|
||||||
{props.children}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn TooltipContent(props: TooltipContentProps) -> Element {
|
|
||||||
let base = attributes!(div {
|
|
||||||
class: Styles::dx_tooltip_content,
|
|
||||||
});
|
|
||||||
let merged = merge_attributes(vec![base, props.attributes]);
|
|
||||||
|
|
||||||
rsx! {
|
|
||||||
tooltip::TooltipContent {
|
|
||||||
id: props.id,
|
|
||||||
side: props.side,
|
|
||||||
align: props.align,
|
|
||||||
attributes: merged,
|
|
||||||
{props.children}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
mod component;
|
|
||||||
pub use component::*;
|
|
||||||
|
|
@ -1,150 +0,0 @@
|
||||||
/* Tooltip Styles */
|
|
||||||
.dx-tooltip {
|
|
||||||
position: relative;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-tooltip-trigger {
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-tooltip-content {
|
|
||||||
position: absolute;
|
|
||||||
z-index: 1000;
|
|
||||||
max-width: 250px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
animation: dx-tooltip-fade-in 0.2s ease-in-out;
|
|
||||||
background-color: var(--secondary-color-4);
|
|
||||||
color: var(--primary-color);
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-tooltip-content::after {
|
|
||||||
position: absolute;
|
|
||||||
border-width: 0.25rem;
|
|
||||||
border-style: solid;
|
|
||||||
margin-left: -0.25rem;
|
|
||||||
content: " ";
|
|
||||||
rotate: 45deg;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Positioning based on side */
|
|
||||||
.dx-tooltip-content[data-side="top"] {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 100%;
|
|
||||||
left: 50%;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-tooltip-content[data-side="top"]::after {
|
|
||||||
top: calc(100% - 0.25rem);
|
|
||||||
left: 50%;
|
|
||||||
border-color: var(--secondary-color-4);
|
|
||||||
border-radius: 0 0 0.1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-tooltip-content[data-side="right"] {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
left: 100%;
|
|
||||||
margin-left: 8px;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-tooltip-content[data-side="right"]::after {
|
|
||||||
top: calc(50% - 0.25rem);
|
|
||||||
left: 0;
|
|
||||||
border-color: var(--secondary-color-4);
|
|
||||||
border-radius: 0 0 0 0.1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-tooltip-content[data-side="bottom"] {
|
|
||||||
position: absolute;
|
|
||||||
top: 100%;
|
|
||||||
left: 50%;
|
|
||||||
margin-top: 8px;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-tooltip-content[data-side="bottom"]::after {
|
|
||||||
bottom: calc(100% - 0.25rem);
|
|
||||||
left: 50%;
|
|
||||||
border-color: var(--secondary-color-4);
|
|
||||||
border-radius: 0.1rem 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-tooltip-content[data-side="left"] {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
right: 100%;
|
|
||||||
margin-right: 8px;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-tooltip-content[data-side="left"]::after {
|
|
||||||
top: calc(50% - 0.25rem);
|
|
||||||
right: -0.25rem;
|
|
||||||
border-color: var(--secondary-color-4);
|
|
||||||
border-radius: 0 0.1rem 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Alignment styles for top and bottom */
|
|
||||||
.dx-tooltip-content[data-side="top"][data-align="start"],
|
|
||||||
.dx-tooltip-content[data-side="bottom"][data-align="start"] {
|
|
||||||
left: 0;
|
|
||||||
transform: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-tooltip-content[data-side="top"][data-align="end"],
|
|
||||||
.dx-tooltip-content[data-side="bottom"][data-align="end"] {
|
|
||||||
right: 0;
|
|
||||||
left: auto;
|
|
||||||
transform: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Alignment styles for left and right */
|
|
||||||
.dx-tooltip-content[data-side="left"][data-align="start"],
|
|
||||||
.dx-tooltip-content[data-side="right"][data-align="start"] {
|
|
||||||
top: 0;
|
|
||||||
transform: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-tooltip-content[data-side="left"][data-align="center"],
|
|
||||||
.dx-tooltip-content[data-side="right"][data-align="center"] {
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-tooltip-content[data-side="left"][data-align="end"],
|
|
||||||
.dx-tooltip-content[data-side="right"][data-align="end"] {
|
|
||||||
top: auto;
|
|
||||||
bottom: 0;
|
|
||||||
transform: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Animation */
|
|
||||||
@keyframes dx-tooltip-fade-in {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* State styles */
|
|
||||||
.dx-tooltip[data-disabled="true"] .dx-tooltip-trigger {
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-tooltip-content[data-state="closed"] {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dx-tooltip-content[data-state="open"] {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
@ -10,7 +10,7 @@ pub async fn run(db: &Db) -> Result<()> {
|
||||||
let feeds = db.list_feeds().await?;
|
let feeds = db.list_feeds().await?;
|
||||||
tracing::info!(count = feeds.len(), "polling subscribed feeds");
|
tracing::info!(count = feeds.len(), "polling subscribed feeds");
|
||||||
|
|
||||||
for (feed_id, _title, url) in feeds {
|
for (feed_id, url) in feeds {
|
||||||
let (_title, articles) = match feedsignal_feeds::fetch_feed(&url, feed_id).await {
|
let (_title, articles) = match feedsignal_feeds::fetch_feed(&url, feed_id).await {
|
||||||
Ok(result) => result,
|
Ok(result) => result,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,22 @@
|
||||||
use crate::api::ArticleView;
|
use crate::api::ArticleView;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use feedsignal_db::Db;
|
use feedsignal_db::Db;
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
/// Returns articles ranked by `final_score` descending, highest-relevance
|
/// Returns articles ranked by `final_score` descending, highest-relevance first.
|
||||||
/// first. `feed_id` restricts the list to one feed; `None` joins every
|
pub async fn list_ranked(db: Db) -> Result<Vec<ArticleView>> {
|
||||||
/// subscribed feed into one ranked list.
|
|
||||||
pub async fn list_ranked(db: Db, feed_id: Option<String>) -> Result<Vec<ArticleView>> {
|
|
||||||
let feed_id = feed_id.map(|id| Uuid::parse_str(&id)).transpose()?;
|
|
||||||
Ok(db
|
Ok(db
|
||||||
.list_ranked_articles(feed_id, 100)
|
.list_ranked_articles(100)
|
||||||
.await?
|
.await?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|row| ArticleView {
|
.map(
|
||||||
id: row.id,
|
|(id, title, url, summary, topics, final_score)| ArticleView {
|
||||||
feed_id: row.feed_id,
|
id,
|
||||||
title: row.title,
|
title,
|
||||||
url: row.url,
|
url,
|
||||||
summary: row.summary,
|
summary,
|
||||||
topics: row.topics,
|
topics,
|
||||||
final_score: row.final_score,
|
final_score,
|
||||||
})
|
},
|
||||||
|
)
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
use crate::api::FeedView;
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use feedsignal_db::Db;
|
use feedsignal_db::Db;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
@ -32,17 +31,3 @@ pub async fn subscribe(db: Db, url: String) -> Result<usize> {
|
||||||
}
|
}
|
||||||
Ok(count)
|
Ok(count)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lists every subscribed feed, for the sidebar's feed-navigation list.
|
|
||||||
pub async fn list(db: Db) -> Result<Vec<FeedView>> {
|
|
||||||
Ok(db
|
|
||||||
.list_feeds()
|
|
||||||
.await?
|
|
||||||
.into_iter()
|
|
||||||
.map(|(id, title, url)| FeedView {
|
|
||||||
id: id.to_string(),
|
|
||||||
title,
|
|
||||||
url,
|
|
||||||
})
|
|
||||||
.collect())
|
|
||||||
}
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue