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