diff --git a/Cargo.lock b/Cargo.lock index 41a0123..d5691c2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1136,6 +1136,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "dioxus-icons" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ae929a4cdde2e51fca98ccb8a8fe2ea11640e5026c57486b15dbd5618ba7e56" +dependencies = [ + "dioxus", + "dioxus-signals", + "lazy-js-bundle", +] + [[package]] name = "dioxus-interpreter-js" version = "0.7.10" @@ -1622,6 +1633,7 @@ dependencies = [ "anyhow", "chrono", "dioxus", + "dioxus-icons", "dioxus-primitives", "feedsignal-core", "feedsignal-db", diff --git a/crates/db/src/articles.rs b/crates/db/src/articles.rs index c5ba262..3135346 100644 --- a/crates/db/src/articles.rs +++ b/crates/db/src/articles.rs @@ -7,6 +7,18 @@ use diesel_async::RunQueryDsl; use feedsignal_core::Article; use uuid::Uuid; +/// `(id, feed_id, title, url, summary, topics, final_score)`, as returned +/// by [`Db::list_ranked_articles`]. +type RankedArticleRow = ( + String, + String, + String, + String, + String, + Vec, + Option, +); + impl Db { pub async fn insert_article(&self, article: &Article) -> Result<()> { use schema::articles::dsl; @@ -91,20 +103,29 @@ impl Db { 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( &self, + feed_id: Option, limit: i64, - ) -> Result, Option)>> { + ) -> Result> { use schema::articles::dsl; let mut conn = self.pool.get().await?; // SQLite sorts NULL before any value, so `DESC` already puts NULL // `final_score`s last — no separate NULLS LAST clause needed here. - let rows: Vec<(String, String, String, String, String, Option)> = 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())); + } + type Row = (String, String, String, String, String, String, Option); + let rows: Vec = query .order(dsl::final_score.desc()) .limit(limit) .select(( dsl::id, + dsl::feed_id, dsl::title, dsl::url, dsl::summary, @@ -115,16 +136,19 @@ impl Db { .await?; Ok(rows .into_iter() - .map(|(id, title, url, summary, topics_json, final_score)| { - ( - id, - title, - url, - summary, - serde_json::from_str(&topics_json).unwrap_or_default(), - final_score, - ) - }) + .map( + |(id, feed_id, title, url, summary, topics_json, final_score)| { + ( + id, + feed_id, + title, + url, + summary, + serde_json::from_str(&topics_json).unwrap_or_default(), + final_score, + ) + }, + ) .collect()) } } diff --git a/crates/db/src/feeds.rs b/crates/db/src/feeds.rs index 06fffb1..1be19b3 100644 --- a/crates/db/src/feeds.rs +++ b/crates/db/src/feeds.rs @@ -39,6 +39,20 @@ impl Db { .collect() } + /// All subscribed feeds with their titles, for the sidebar's feed list. + pub async fn list_feeds_with_titles(&self) -> Result> { + use schema::feeds::dsl; + let mut conn = self.pool.get().await?; + let rows: Vec<(String, String, String)> = dsl::feeds + .order(dsl::title.asc()) + .select((dsl::id, dsl::title, dsl::url)) + .load(&mut conn) + .await?; + rows.into_iter() + .map(|(id, title, url)| Ok((Uuid::parse_str(&id)?, title, url))) + .collect() + } + /// Records that a feed was just polled, so the next run can be judged /// against it (e.g. surfaced in the UI as "last checked"). pub async fn mark_feed_fetched(&self, feed_id: Uuid) -> Result<()> { diff --git a/crates/web/Cargo.toml b/crates/web/Cargo.toml index 0c319eb..b6102e0 100644 --- a/crates/web/Cargo.toml +++ b/crates/web/Cargo.toml @@ -24,6 +24,7 @@ chrono = { workspace = true, optional = true } serde_json = { workspace = true, optional = true } tokio-cron-scheduler = { version = "0.13", optional = true } dioxus-primitives = { git = "https://github.com/DioxusLabs/components", version = "0.0.1", default-features = false } +dioxus-icons = { version = "0.1.0", default-features = false } [features] default = ["web"] diff --git a/crates/web/assets/app.css b/crates/web/assets/app.css index ca7e7c7..3ca2928 100644 --- a/crates/web/assets/app.css +++ b/crates/web/assets/app.css @@ -1,10 +1,13 @@ -body { font-family: system-ui, sans-serif; max-width: 720px; margin: 2rem auto; color: #1a1a1a; } -.article-list { list-style: none; padding: 0; } +body { font-family: system-ui, sans-serif; margin: 0; color: #1a1a1a; height: 100vh; } +.app-shell { display: flex; height: 100vh; } +.content-header { display: flex; align-items: center; gap: 0.75rem; padding: 1.5rem 1.5rem 0.5rem; } +.content-header h2 { margin: 0; } +.article-scroll-area { 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; } .article-row a { font-weight: 600; text-decoration: none; color: #0b5fff; } -.score { float: right; font-variant-numeric: tabular-nums; color: #555; } .summary { color: #444; margin: 0.4rem 0; } -.topic-tag { display: inline-block; font-size: 0.75rem; background: #eee; border-radius: 4px; padding: 0.1rem 0.4rem; margin-right: 0.3rem; } +.topics { display: flex; flex-wrap: wrap; gap: 0.3rem; } .error { color: #b00020; } .subscribe-form { display: flex; gap: 0.5rem; margin: 1rem 0; } .subscribe-form .dx-input { flex: 1; } diff --git a/crates/web/src/api/articles.rs b/crates/web/src/api/articles.rs index d718775..8ea8107 100644 --- a/crates/web/src/api/articles.rs +++ b/crates/web/src/api/articles.rs @@ -2,12 +2,16 @@ use super::ArticleView; use dioxus::prelude::*; /// Returns articles ranked by `final_score` descending, highest-relevance -/// first. Runs on the server (native, has DB access); the `#[server]` -/// macro generates the HTTP call the browser/WASM build uses instead. +/// first. `feed_id` restricts the list to one feed; `None` joins every +/// subscribed feed into one ranked list. Runs on the server (native, has +/// DB access); the `#[server]` macro generates the HTTP call the +/// browser/WASM build uses instead. #[server] -pub async fn list_ranked_articles() -> Result, ServerFnError> { +pub async fn list_ranked_articles( + feed_id: Option, +) -> Result, ServerFnError> { let db = crate::server::db().await?; - crate::server::services::articles::list_ranked(db) + crate::server::services::articles::list_ranked(db, feed_id) .await .map_err(|e| ServerFnError::new(e.to_string())) } diff --git a/crates/web/src/api/dto.rs b/crates/web/src/api/dto.rs index 5d3f9d3..5f809b9 100644 --- a/crates/web/src/api/dto.rs +++ b/crates/web/src/api/dto.rs @@ -6,9 +6,18 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ArticleView { pub id: String, + pub feed_id: String, pub title: String, pub url: String, pub summary: String, pub topics: Vec, pub final_score: Option, } + +/// 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, +} diff --git a/crates/web/src/api/feeds.rs b/crates/web/src/api/feeds.rs index 928f19c..9616a60 100644 --- a/crates/web/src/api/feeds.rs +++ b/crates/web/src/api/feeds.rs @@ -1,3 +1,4 @@ +use super::FeedView; use dioxus::prelude::*; /// Registers a feed and fetches it immediately. Runs on the server (native, @@ -10,3 +11,12 @@ pub async fn subscribe_feed(url: String) -> Result { .await .map_err(|e| ServerFnError::new(e.to_string())) } + +/// Lists every subscribed feed, for the sidebar's feed-navigation list. +#[server] +pub async fn list_feeds() -> Result, ServerFnError> { + let db = crate::server::db().await?; + crate::server::services::feeds::list(db) + .await + .map_err(|e| ServerFnError::new(e.to_string())) +} diff --git a/crates/web/src/api/mod.rs b/crates/web/src/api/mod.rs index 0a12eb2..62211e5 100644 --- a/crates/web/src/api/mod.rs +++ b/crates/web/src/api/mod.rs @@ -2,4 +2,4 @@ pub mod articles; mod dto; pub mod feeds; -pub use dto::ArticleView; +pub use dto::{ArticleView, FeedView}; diff --git a/crates/web/src/app/article_row.rs b/crates/web/src/app/article_row.rs index f6e83fc..50fca43 100644 --- a/crates/web/src/app/article_row.rs +++ b/crates/web/src/app/article_row.rs @@ -1,5 +1,6 @@ use crate::api; use crate::api::ArticleView; +use crate::components::badge::{Badge, BadgeVariant}; use dioxus::prelude::*; #[component] @@ -9,12 +10,12 @@ pub fn ArticleRow(article: ArticleView) -> Element { li { class: "article-row", a { href: "{article.url}", target: "_blank", "{article.title}" } if let Some(pct) = score_pct { - span { class: "score", "{pct}%" } + Badge { variant: BadgeVariant::Secondary, "{pct}%" } } p { class: "summary", "{article.summary}" } div { class: "topics", for topic in article.topics.iter() { - span { class: "topic-tag", "{topic}" } + Badge { variant: BadgeVariant::Outline, "{topic}" } } } button { diff --git a/crates/web/src/app/mod.rs b/crates/web/src/app/mod.rs index 2baec65..ec6c9b0 100644 --- a/crates/web/src/app/mod.rs +++ b/crates/web/src/app/mod.rs @@ -2,30 +2,123 @@ mod article_row; mod subscribe_form; 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 dioxus::prelude::*; use subscribe_form::SubscribeForm; #[component] pub fn App() -> Element { - let mut articles = use_server_future(api::articles::list_ranked_articles)?; + let mut selected_feed = use_signal(|| Option::::None); + let mut feeds = use_server_future(api::feeds::list_feeds)?; + let mut articles = + use_server_future(move || api::articles::list_ranked_articles(selected_feed()))?; + + let on_subscribed = move |_| { + feeds.restart(); + articles.restart(); + }; rsx! { Stylesheet { href: asset!("/assets/app.css") } Stylesheet { href: asset!("/assets/dx-components-theme.css") } - main { - h1 { "feedsignal" } - SubscribeForm { on_subscribed: move |_| { articles.restart(); } } - match articles.read().as_ref() { - Some(Ok(articles)) => rsx! { - ul { class: "article-list", - for article in articles.iter() { - ArticleRow { article: article.clone() } + SidebarProvider { + div { class: "app-shell", + Sidebar { + SidebarHeader { + h1 { "feedsignal" } + SubscribeForm { on_subscribed } + } + SidebarContent { + SidebarGroup { + SidebarGroupLabel { "Feeds" } + SidebarGroupContent { + SidebarMenu { + SidebarMenuItem { + SidebarMenuButton { + is_active: selected_feed().is_none(), + r#as: move |attrs: Vec| 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| { + 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..." } }, + } + } + } } } - }, - Some(Err(err)) => rsx! { p { class: "error", "Failed to load articles: {err}" } }, - None => rsx! { p { "Loading..." } }, + } + SidebarInset { + main { + 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 { class: "article-scroll-area", + match articles.read().as_ref() { + Some(Ok(articles)) => rsx! { + ul { class: "article-list", + for article in articles.iter() { + ArticleRow { article: article.clone() } + } + } + }, + Some(Err(err)) => rsx! { p { class: "error", "Failed to load articles: {err}" } }, + None => rsx! { p { "Loading..." } }, + } + } + } + } } } } diff --git a/crates/web/src/components/badge/component.rs b/crates/web/src/components/badge/component.rs new file mode 100644 index 0000000..9435544 --- /dev/null +++ b/crates/web/src/components/badge/component.rs @@ -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, + + /// 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)", + } + } +} diff --git a/crates/web/src/components/badge/mod.rs b/crates/web/src/components/badge/mod.rs new file mode 100644 index 0000000..2590c01 --- /dev/null +++ b/crates/web/src/components/badge/mod.rs @@ -0,0 +1,2 @@ +mod component; +pub use component::*; diff --git a/crates/web/src/components/badge/style.css b/crates/web/src/components/badge/style.css new file mode 100644 index 0000000..0b18dc1 --- /dev/null +++ b/crates/web/src/components/badge/style.css @@ -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); +} diff --git a/crates/web/src/components/mod.rs b/crates/web/src/components/mod.rs index 546ad73..2b8135b 100644 --- a/crates/web/src/components/mod.rs +++ b/crates/web/src/components/mod.rs @@ -1,3 +1,10 @@ // AUTOGENERATED Components module +pub mod badge; pub mod button; pub mod input; +pub mod scroll_area; +pub mod separator; +pub mod sheet; +pub mod sidebar; +pub mod skeleton; +pub mod tooltip; diff --git a/crates/web/src/components/scroll_area/component.rs b/crates/web/src/components/scroll_area/component.rs new file mode 100644 index 0000000..5e3c37d --- /dev/null +++ b/crates/web/src/components/scroll_area/component.rs @@ -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) +} diff --git a/crates/web/src/components/scroll_area/mod.rs b/crates/web/src/components/scroll_area/mod.rs new file mode 100644 index 0000000..2590c01 --- /dev/null +++ b/crates/web/src/components/scroll_area/mod.rs @@ -0,0 +1,2 @@ +mod component; +pub use component::*; diff --git a/crates/web/src/components/scroll_area/style.css b/crates/web/src/components/scroll_area/style.css new file mode 100644 index 0000000..3de6743 --- /dev/null +++ b/crates/web/src/components/scroll_area/style.css @@ -0,0 +1 @@ +/* Scroll area doesn't require any additional styles */ diff --git a/crates/web/src/components/separator/component.rs b/crates/web/src/components/separator/component.rs new file mode 100644 index 0000000..3944479 --- /dev/null +++ b/crates/web/src/components/separator/component.rs @@ -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} + } + } +} diff --git a/crates/web/src/components/separator/mod.rs b/crates/web/src/components/separator/mod.rs new file mode 100644 index 0000000..2590c01 --- /dev/null +++ b/crates/web/src/components/separator/mod.rs @@ -0,0 +1,2 @@ +mod component; +pub use component::*; diff --git a/crates/web/src/components/separator/style.css b/crates/web/src/components/separator/style.css new file mode 100644 index 0000000..40b1bbe --- /dev/null +++ b/crates/web/src/components/separator/style.css @@ -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%; +} diff --git a/crates/web/src/components/sheet/component.rs b/crates/web/src/components/sheet/component.rs new file mode 100644 index 0000000..4b3b792 --- /dev/null +++ b/crates/web/src/components/sheet/component.rs @@ -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, +) -> 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, + 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, + 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, + r#as: Option, 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} } + } + } +} diff --git a/crates/web/src/components/sheet/mod.rs b/crates/web/src/components/sheet/mod.rs new file mode 100644 index 0000000..2590c01 --- /dev/null +++ b/crates/web/src/components/sheet/mod.rs @@ -0,0 +1,2 @@ +mod component; +pub use component::*; diff --git a/crates/web/src/components/sheet/style.css b/crates/web/src/components/sheet/style.css new file mode 100644 index 0000000..799c792 --- /dev/null +++ b/crates/web/src/components/sheet/style.css @@ -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); +} diff --git a/crates/web/src/components/sidebar/component.rs b/crates/web/src/components/sidebar/component.rs new file mode 100644 index 0000000..e837f79 --- /dev/null +++ b/crates/web/src/components/sidebar/component.rs @@ -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, + pub side: Signal, + pub is_mobile: Signal, + // From use_controlled: + open: Memo, + set_open: Callback, + // Mobile state: + open_mobile: Signal, +} + +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::() +} + +pub fn use_is_mobile() -> Signal { + 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::().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>, + #[props(default)] on_open_change: Callback, + #[props(extends = GlobalAttributes)] attributes: Vec, + 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::().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, + 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>, + #[props(extends = GlobalAttributes)] + #[props(extends = button)] + attributes: Vec, +) -> 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) -> 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, + 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, + 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, + 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, + 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, +) -> 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, + 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, Element>>, + #[props(extends = GlobalAttributes)] attributes: Vec, + 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, Element>>, + #[props(extends = GlobalAttributes)] attributes: Vec, + 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, + 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, + 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, + 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, + #[props(default)] tooltip: Option, + r#as: Option, 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| { + 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, Element>>, + #[props(extends = GlobalAttributes)] attributes: Vec, + 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, + 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, +) -> 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, + 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, + 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, Element>>, + #[props(extends = GlobalAttributes)] attributes: Vec, + 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} } + } + } +} diff --git a/crates/web/src/components/sidebar/mod.rs b/crates/web/src/components/sidebar/mod.rs new file mode 100644 index 0000000..2590c01 --- /dev/null +++ b/crates/web/src/components/sidebar/mod.rs @@ -0,0 +1,2 @@ +mod component; +pub use component::*; diff --git a/crates/web/src/components/sidebar/style.css b/crates/web/src/components/sidebar/style.css new file mode 100644 index 0000000..1f867bd --- /dev/null +++ b/crates/web/src/components/sidebar/style.css @@ -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; + overflow: hidden; + 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; + text-align: left; + text-decoration: none; + transition: width 200ms ease-out, height 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"] { + height: 2rem; + font-size: 0.875rem; +} + +.dx-sidebar-menu-button[data-sidebar="menu-button"][data-size="sm"] { + height: 1.75rem; + font-size: 0.75rem; +} + +.dx-sidebar-menu-button[data-sidebar="menu-button"][data-size="lg"] { + 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; +} diff --git a/crates/web/src/components/skeleton/component.rs b/crates/web/src/components/skeleton/component.rs new file mode 100644 index 0000000..1b7e8e4 --- /dev/null +++ b/crates/web/src/components/skeleton/component.rs @@ -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) -> Element { + let base = attributes!(div { + class: Styles::dx_skeleton, + }); + let merged = merge_attributes(vec![base, attributes]); + + rsx! { + div { ..merged } + } +} diff --git a/crates/web/src/components/skeleton/mod.rs b/crates/web/src/components/skeleton/mod.rs new file mode 100644 index 0000000..2590c01 --- /dev/null +++ b/crates/web/src/components/skeleton/mod.rs @@ -0,0 +1,2 @@ +mod component; +pub use component::*; diff --git a/crates/web/src/components/skeleton/style.css b/crates/web/src/components/skeleton/style.css new file mode 100644 index 0000000..6230d4e --- /dev/null +++ b/crates/web/src/components/skeleton/style.css @@ -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; + } +} diff --git a/crates/web/src/components/tooltip/component.rs b/crates/web/src/components/tooltip/component.rs new file mode 100644 index 0000000..fd69dc7 --- /dev/null +++ b/crates/web/src/components/tooltip/component.rs @@ -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} + } + } +} diff --git a/crates/web/src/components/tooltip/mod.rs b/crates/web/src/components/tooltip/mod.rs new file mode 100644 index 0000000..2590c01 --- /dev/null +++ b/crates/web/src/components/tooltip/mod.rs @@ -0,0 +1,2 @@ +mod component; +pub use component::*; diff --git a/crates/web/src/components/tooltip/style.css b/crates/web/src/components/tooltip/style.css new file mode 100644 index 0000000..857b87a --- /dev/null +++ b/crates/web/src/components/tooltip/style.css @@ -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; +} diff --git a/crates/web/src/server/services/articles.rs b/crates/web/src/server/services/articles.rs index 6ee9e1c..d2a8884 100644 --- a/crates/web/src/server/services/articles.rs +++ b/crates/web/src/server/services/articles.rs @@ -1,16 +1,21 @@ use crate::api::ArticleView; use anyhow::Result; use feedsignal_db::Db; +use uuid::Uuid; -/// Returns articles ranked by `final_score` descending, highest-relevance first. -pub async fn list_ranked(db: Db) -> Result> { +/// Returns articles ranked by `final_score` descending, highest-relevance +/// first. `feed_id` restricts the list to one feed; `None` joins every +/// subscribed feed into one ranked list. +pub async fn list_ranked(db: Db, feed_id: Option) -> Result> { + let feed_id = feed_id.map(|id| Uuid::parse_str(&id)).transpose()?; Ok(db - .list_ranked_articles(100) + .list_ranked_articles(feed_id, 100) .await? .into_iter() .map( - |(id, title, url, summary, topics, final_score)| ArticleView { + |(id, feed_id, title, url, summary, topics, final_score)| ArticleView { id, + feed_id, title, url, summary, diff --git a/crates/web/src/server/services/feeds.rs b/crates/web/src/server/services/feeds.rs index a48f9b7..1c9e142 100644 --- a/crates/web/src/server/services/feeds.rs +++ b/crates/web/src/server/services/feeds.rs @@ -1,3 +1,4 @@ +use crate::api::FeedView; use anyhow::Result; use feedsignal_db::Db; use uuid::Uuid; @@ -31,3 +32,17 @@ pub async fn subscribe(db: Db, url: String) -> Result { } Ok(count) } + +/// Lists every subscribed feed, for the sidebar's feed-navigation list. +pub async fn list(db: Db) -> Result> { + Ok(db + .list_feeds_with_titles() + .await? + .into_iter() + .map(|(id, title, url)| FeedView { + id: id.to_string(), + title, + url, + }) + .collect()) +}