From 68ae1b27eb39993c67670214980136d0b0f28257 Mon Sep 17 00:00:00 2001 From: tommy Date: Mon, 3 Nov 2025 14:55:07 +0800 Subject: [PATCH] =?UTF-8?q?admin=E5=9F=BA=E6=9C=AC=E5=B8=83=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/styling/admin.css | 319 +++++++++++++++++++++++++++++++++++++++ assets/styling/main.css | 44 +----- src/main.rs | 15 +- src/views/home.rs | 250 ++++++++++++++++++++++++++++++ src/views/mod.rs | 7 +- src/views/navbar.rs | 181 ++++++++++++++++++---- 6 files changed, 738 insertions(+), 78 deletions(-) create mode 100644 assets/styling/admin.css diff --git a/assets/styling/admin.css b/assets/styling/admin.css new file mode 100644 index 0000000..987973f --- /dev/null +++ b/assets/styling/admin.css @@ -0,0 +1,319 @@ +:root { + --sidebar-width: 280px; + --sidebar-collapsed-width: 72px; + --topbar-height: 72px; +} + +.ui-shell { + min-height: 100vh; + margin: 0; + width: 100%; + max-width: none; + padding: 0; + background-color: hsl(var(--muted)); + border-radius: 0; +} + +.ui-sidebar-layout.admin-shell { + display: grid; + grid-template-columns: var(--sidebar-width) minmax(0, 1fr); + min-height: 100vh; + background-color: hsl(var(--background)); +} + +.ui-sidebar-layout.admin-shell .ui-sidebar-rail { + display: none; +} + +.ui-sidebar { + display: flex; + flex-direction: column; + gap: 24px; + background-color: hsl(var(--card)); + border-right: 1px solid hsl(var(--border)); + padding: 28px 24px; + transition: width 0.25s ease, padding 0.25s ease; +} + +.ui-sidebar[data-collapsed="true"] { + width: var(--sidebar-collapsed-width); + padding: 28px 14px; +} + +.ui-sidebar-header, +.ui-sidebar-content, +.ui-sidebar-footer { + display: flex; + flex-direction: column; + gap: 20px; +} + +.sidebar-brand { + display: flex; + align-items: center; + gap: 12px; +} + +.sidebar-logo { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border-radius: 10px; + background: linear-gradient(135deg, hsl(var(--primary)), hsl(var(--secondary))); + color: hsl(var(--primary-foreground)); + font-size: 18px; +} + +.sidebar-name { + font-size: 1rem; + font-weight: 600; +} + +.sidebar-subtitle { + font-size: 0.75rem; + color: hsl(var(--muted-foreground)); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.ui-sidebar-group { + display: flex; + flex-direction: column; + gap: 8px; +} + +.ui-sidebar-group-label { + font-size: 0.7rem; + font-weight: 600; + color: hsl(var(--muted-foreground)); + letter-spacing: 0.08em; +} + +.ui-sidebar-menu { + width: 100%; +} + +.ui-sidebar-menu-list { + display: flex; + flex-direction: column; + gap: 6px; + padding: 0; + margin: 0; + list-style: none; +} + +.ui-sidebar-menu-button { + display: inline-flex; + width: 100%; + gap: 12px; + padding: 10px 12px; + border-radius: calc(var(--radius) - 2px); + border: 1px solid transparent; + background-color: transparent; + color: hsl(var(--foreground)); + transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease; + text-align: left; + text-decoration: none; +} + +.ui-sidebar-menu-button:hover { + background-color: hsl(var(--muted)); +} + +.ui-sidebar-menu-button[data-active="true"] { + background-color: hsl(var(--accent)); + border-color: hsl(var(--border)); +} + +.ui-sidebar-icon { + width: 28px; + height: 28px; + border-radius: 8px; + display: inline-flex; + align-items: center; + justify-content: center; + background-color: hsl(var(--muted)); + font-size: 0.95rem; +} + +.ui-sidebar-text { + display: flex; + flex-direction: column; + gap: 3px; +} + +.ui-sidebar-label { + font-size: 0.9rem; + font-weight: 600; +} + +.ui-sidebar-description { + font-size: 0.75rem; + color: hsl(var(--muted-foreground)); +} + +.ui-sidebar-badge { + margin-left: auto; + font-size: 0.75rem; + background-color: hsl(var(--secondary)); + color: hsl(var(--secondary-foreground)); + padding: 0.125rem 0.5rem; + border-radius: 999px; +} + +.sidebar-profile { + display: flex; + align-items: center; + gap: 12px; + padding: 12px; + border: 1px solid hsl(var(--border)); + border-radius: calc(var(--radius)); + background-color: hsl(var(--muted)); +} + +.sidebar-profile-name { + font-weight: 600; +} + +.sidebar-profile-role { + color: hsl(var(--muted-foreground)); + font-size: 0.8rem; +} + +.admin-shell-inset { + display: flex; + flex-direction: column; + background-color: hsl(var(--background)); + min-height: 100vh; +} + +.admin-shell-topbar { + position: sticky; + top: 0; + z-index: 5; + display: flex; + align-items: center; + gap: 18px; + justify-content: space-between; + padding: 24px 32px; + border-bottom: 1px solid hsl(var(--border)); + background-color: hsla(var(--background), 0.95); + backdrop-filter: blur(12px); + min-height: var(--topbar-height); +} + +.admin-shell-meta { + display: flex; + flex-direction: column; + gap: 4px; +} + +.admin-shell-title { + margin: 0; + font-size: 1.5rem; + font-weight: 600; +} + +.admin-shell-description { + margin: 0; + color: hsl(var(--muted-foreground)); + font-size: 0.95rem; +} + +.admin-shell-actions { + display: flex; + align-items: center; + gap: 12px; +} + +.admin-shell-content { + flex: 1; + width: 100%; + padding: 32px; + background-color: hsl(var(--muted)); + min-height: calc(100vh - var(--topbar-height)); +} + +.dashboard-root { + width: 100%; + margin: 0; +} + +.ui-sidebar-trigger { + display: inline-flex; + align-items: center; + gap: 8px; + border: 1px solid hsl(var(--border)); + background-color: hsl(var(--card)); + color: hsl(var(--foreground)); + border-radius: calc(var(--radius) - 2px); + padding: 0.5rem 0.8rem; + font-weight: 500; +} + +.ui-sidebar[data-collapsed="true"] .sidebar-name, +.ui-sidebar[data-collapsed="true"] .sidebar-subtitle, +.ui-sidebar[data-collapsed="true"] .ui-sidebar-text, +.ui-sidebar[data-collapsed="true"] .ui-sidebar-badge, +.ui-sidebar[data-collapsed="true"] .sidebar-profile div, +.ui-sidebar[data-collapsed="true"] .sidebar-profile-role, +.ui-sidebar[data-collapsed="true"] .ui-sidebar-group-label { + display: none; +} + +.ui-sidebar[data-collapsed="true"] .sidebar-profile { + justify-content: center; + padding: 12px 8px; +} + +.ui-sidebar[data-collapsed="true"] .ui-sidebar-menu-button { + justify-content: center; +} + +@media (max-width: 960px) { + .ui-sidebar-layout.admin-shell { + grid-template-columns: var(--sidebar-width) minmax(0, 1fr); + } +} + +@media (max-width: 768px) { + .ui-sidebar-layout.admin-shell { + grid-template-columns: 1fr; + } + + .ui-sidebar { + position: fixed; + inset: 0 auto 0 0; + transform: translateX(-100%); + width: var(--sidebar-width); + max-width: 90vw; + box-shadow: 0 20px 50px -24px rgba(15, 23, 42, 0.45); + z-index: 20; + } + + .ui-sidebar[data-collapsed="true"] { + transform: translateX(-100%); + } + + .ui-sidebar[data-collapsed="false"] { + transform: translateX(0); + } + + .admin-shell-topbar { + gap: 12px; + } + + .admin-shell-actions { + display: none; + } + + .admin-shell-content { + padding: 24px; + } + + .dashboard-root { + width: 100%; + } +} diff --git a/assets/styling/main.css b/assets/styling/main.css index e13b92d..9649ba8 100644 --- a/assets/styling/main.css +++ b/assets/styling/main.css @@ -1,42 +1,12 @@ +html, body { - background-color: #0f1116; - color: #ffffff; - font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; - margin: 20px; + height: 100%; } -#hero { +body { margin: 0; - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; + background-color: hsl(var(--muted)); + color: hsl(var(--foreground)); + font-family: 'Inter', 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + -webkit-font-smoothing: antialiased; } - -#links { - width: 400px; - text-align: left; - font-size: x-large; - color: white; - display: flex; - flex-direction: column; -} - -#links a { - color: white; - text-decoration: none; - margin-top: 20px; - margin: 10px 0px; - border: white 1px solid; - border-radius: 5px; - padding: 10px; -} - -#links a:hover { - background-color: #1f1f1f; - cursor: pointer; -} - -#header { - max-width: 1200px; -} \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 38b3aeb..ca68c68 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,7 +2,7 @@ // need dioxus use dioxus::prelude::*; -use views::{Blog, Home, Navbar}; +use views::{Components, Home, Navbar}; /// Define a components module that contains all shared components for our app. mod components; @@ -11,7 +11,7 @@ mod views; /// The Route enum is used to define the structure of internal routes in our app. All route enums need to derive /// the [`Routable`] trait, which provides the necessary methods for the router to work. -/// +/// /// Each variant represents a different URL pattern that can be matched by the router. If that pattern is matched, /// the components for that route will be rendered. #[derive(Debug, Clone, Routable, PartialEq)] @@ -24,12 +24,9 @@ enum Route { // the component for that route will be rendered. The component name that is rendered defaults to the variant name. #[route("/")] Home {}, - // The route attribute can include dynamic parameters that implement [`std::str::FromStr`] and [`std::fmt::Display`] with the `:` syntax. - // In this case, id will match any integer like `/blog/123` or `/blog/-456`. - #[route("/blog/:id")] - // Fields of the route variant will be passed to the component as props. In this case, the blog component must accept - // an `id` prop of type `i32`. - Blog { id: i32 }, + #[route("/components")] + // The components gallery reuses the full UI showcase so new primitives stay discoverable. + Components {}, } // We can import assets in dioxus with the `asset!` macro. This macro takes a path to an asset relative to the crate root. @@ -38,6 +35,7 @@ const FAVICON: Asset = asset!("/assets/favicon.ico"); // The asset macro also minifies some assets like CSS and JS to make bundled smaller const MAIN_CSS: Asset = asset!("/assets/styling/main.css"); const SHADCN_CSS: Asset = asset!("/assets/styling/shadcn.css"); +const ADMIN_CSS: Asset = asset!("/assets/styling/admin.css"); fn main() { // The `launch` function is the main entry point for a dioxus app. It takes a component and renders it with the platform feature @@ -58,6 +56,7 @@ fn App() -> Element { document::Link { rel: "icon", href: FAVICON } document::Link { rel: "stylesheet", href: MAIN_CSS } document::Link { rel: "stylesheet", href: SHADCN_CSS } + document::Link { rel: "stylesheet", href: ADMIN_CSS } // The router component renders the route enum we defined above. It will handle synchronization of the URL and render diff --git a/src/views/home.rs b/src/views/home.rs index 355d804..1da5bb3 100644 --- a/src/views/home.rs +++ b/src/views/home.rs @@ -15,6 +15,256 @@ use dioxus::prelude::*; /// The Home page component that will be rendered when the current route is `[Route::Home]` #[component] pub fn Home() -> Element { + let kpis = [ + ("Monthly Recurring Revenue", "$82.4k", "+4.1% vs last month"), + ("Active Accounts", "18,245", "+320 this week"), + ("Trial Conversion Rate", "32%", "Goal: 40%"), + ("Support CSAT", "94%", "230 responses"), + ]; + let experiments = [ + ( + "Guided onboarding checklist", + "QA", + "Validating instrumentation with the analytics team.", + ), + ( + "Usage-based alerts", + "Design", + "Final polish on notification copy before launch.", + ), + ( + "Unified billing dashboard", + "Build", + "Integration tests are running against staging.", + ), + ]; + let adoption_segments = [ + ( + "Onboarding completion", + 74.0f32, + "Teams finished the setup checklist with no blockers.", + ), + ( + "Automation adoption", + 58.0f32, + "Workflows triggered in the past 7 days across customers.", + ), + ( + "Weekly active accounts", + 83.0f32, + "Organizations engaging with analytics at least twice.", + ), + ]; + let alert_feed = [ + ( + "Incident response playbook", + "Workspace incidents resolved in under 4h for 92% of cases.", + ), + ( + "Billing sync delays", + "Elevated Stripe webhook latency observed over the weekend.", + ), + ( + "API deprecations", + "Notify integrators about the upcoming reporting v2 changes.", + ), + ]; + let standup_sections: [(&str, [&str; 2]); 3] = [ + ( + "Platform", + [ + "Ship metering hotfix across clusters", + "Pair with data engineering for realtime audits", + ], + ), + ( + "Product", + [ + "Enable proactive insights beta for 10 design partners", + "Finalize empty state content review with brand", + ], + ), + ( + "Customer Success", + [ + "Schedule QBR with Globex expansion team", + "Collect testimonials for the website refresh", + ], + ), + ]; + let priority_backlog = [ + ( + "Lifecycle emails", + "Growth", + "Net-new education journey that nurtures power users.", + ), + ( + "Data residency", + "Platform", + "Coordinate EU workspace pilot timeline with legal.", + ), + ( + "Journeys analytics", + "Product", + "Scope dashboards that highlight activation funnels.", + ), + ]; + + rsx! { + div { + class: "dashboard-root", + style: "display: flex; flex-direction: column; gap: 24px;", + section { + class: "dashboard-kpis", + style: "display: grid; gap: 16px; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));", + for (title, value, hint) in kpis { + Card { + CardHeader { + CardTitle { "{title}" } + CardDescription { "{hint}" } + } + CardContent { + span { + style: "font-size: 2rem; font-weight: 600;", + "{value}" + } + } + } + } + } + section { + class: "dashboard-panels", + style: "display: grid; gap: 24px; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); align-items: start;", + Card { + CardHeader { + CardTitle { "Product health" } + CardDescription { "Experiments, adoption, and alerts surfaced from telemetry." } + } + CardContent { + Tabs { + default_value: "experiments".to_string(), + TabsList { + TabsTrigger { value: "experiments".to_string(), "Experiments" } + TabsTrigger { value: "adoption".to_string(), "Adoption" } + TabsTrigger { value: "alerts".to_string(), "Alerts" } + } + TabsContent { value: "experiments".to_string(), + div { + style: "display: flex; flex-direction: column; gap: 16px;", + for (title, stage, note) in experiments { + div { + style: "display: flex; flex-direction: column; gap: 6px; background: hsl(var(--muted)); padding: 16px; border-radius: calc(var(--radius) - 2px);", + div { + style: "display: flex; justify-content: space-between; align-items: center; gap: 12px;", + span { style: "font-weight: 600;", "{title}" } + Badge { variant: BadgeVariant::Secondary, "{stage}" } + } + p { style: "color: hsl(var(--muted-foreground)); margin: 0;", "{note}" } + } + } + } + } + TabsContent { value: "adoption".to_string(), + div { + style: "display: flex; flex-direction: column; gap: 16px;", + for (label, percent, note) in adoption_segments { + div { + style: "display: flex; flex-direction: column; gap: 8px;", + div { + style: "display: flex; justify-content: space-between; align-items: baseline;", + span { style: "font-weight: 600;", "{label}" } + span { style: "font-size: 0.9rem; color: hsl(var(--muted-foreground));", "{percent:.0}%" } + } + Progress { value: percent } + p { style: "color: hsl(var(--muted-foreground)); margin: 0;", "{note}" } + } + } + } + } + TabsContent { value: "alerts".to_string(), + div { + style: "display: flex; flex-direction: column; gap: 12px;", + for (title, note) in alert_feed { + div { + style: "display: flex; flex-direction: column; gap: 4px;", + span { style: "font-weight: 600;", "{title}" } + p { style: "color: hsl(var(--muted-foreground)); margin: 0;", "{note}" } + } + } + } + } + } + } + CardFooter { + Button { + variant: ButtonVariant::Secondary, + r#type: "button".to_string(), + "Open product review" + } + } + } + Card { + CardHeader { + CardTitle { "Team stand-up" } + CardDescription { "Snapshots ready to paste into the leadership sync." } + } + CardContent { + Accordion { + default_value: Some("Platform".to_string()), + collapsible: true, + for (team, updates) in standup_sections { + AccordionItem { + value: team.to_string(), + AccordionTrigger { "{team}" } + AccordionContent { + ul { + style: "margin: 0; padding-left: 20px; display: flex; flex-direction: column; gap: 6px;", + for update in updates { + li { "{update}" } + } + } + } + } + } + } + } + } + Card { + CardHeader { + CardTitle { "Escalation queue" } + CardDescription { "Cross-functional work that needs executive unblockers." } + } + CardContent { + div { + style: "display: flex; flex-direction: column; gap: 16px;", + for (title, owner, note) in priority_backlog { + div { + style: "display: flex; flex-direction: column; gap: 4px;", + div { + style: "display: flex; justify-content: space-between; align-items: center;", + span { style: "font-weight: 600;", "{title}" } + Badge { variant: BadgeVariant::Outline, "{owner}" } + } + p { style: "color: hsl(var(--muted-foreground)); margin: 0;", "{note}" } + } + } + } + } + CardFooter { + Button { + variant: ButtonVariant::Ghost, + r#type: "button".to_string(), + "View roadmap" + } + } + } + } + } + } +} + +#[component] +pub fn Components() -> Element { rsx! { UiShowcase {} } diff --git a/src/views/mod.rs b/src/views/mod.rs index 0a41003..0321b90 100644 --- a/src/views/mod.rs +++ b/src/views/mod.rs @@ -2,17 +2,14 @@ //! enum will render one of these components. //! //! -//! The [`Home`] and [`Blog`] components will be rendered when the current route is [`Route::Home`] or [`Route::Blog`] respectively. +//! The [`Home`] and [`Components`] views back the dashboard and component gallery routes. //! //! //! The [`Navbar`] component will be rendered on all pages of our app since every page is under the layout. The layout defines //! a common wrapper around all child routes. mod home; -pub use home::Home; - -mod blog; -pub use blog::Blog; +pub use home::{Components, Home}; mod navbar; pub use navbar::Navbar; diff --git a/src/views/navbar.rs b/src/views/navbar.rs index 1b13536..9d040c4 100644 --- a/src/views/navbar.rs +++ b/src/views/navbar.rs @@ -1,32 +1,157 @@ -use crate::Route; +use crate::{ + components::ui::{ + Avatar, Badge, BadgeVariant, Button, ButtonVariant, Sidebar, SidebarContent, SidebarFooter, + SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarHeader, SidebarInset, + SidebarLayout, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarRail, + SidebarSeparator, SidebarTrigger, + }, + Route, +}; use dioxus::prelude::*; -const NAVBAR_CSS: Asset = asset!("/assets/styling/navbar.css"); - -/// The Navbar component that will be rendered on all pages of our app since every page is under the layout. -/// -/// -/// This layout component wraps the UI of [Route::Home] and [Route::Blog] in a common navbar. The contents of the Home and Blog -/// routes will be rendered under the outlet inside this component -#[component] -pub fn Navbar() -> Element { - rsx! { - document::Link { rel: "stylesheet", href: NAVBAR_CSS } - - div { - id: "navbar", - Link { - to: Route::Home {}, - "Home" - } - Link { - to: Route::Blog { id: 1 }, - "Blog" - } - } - - // The `Outlet` component is used to render the next component inside the layout. In this case, it will render either - // the [`Home`] or [`Blog`] component depending on the current route. - Outlet:: {} +fn page_meta(route: &Route) -> (&'static str, &'static str) { + match route { + Route::Home {} => ( + "Dashboard overview", + "Track product health, monitor key funnels, and coordinate the team from one place.", + ), + Route::Components {} => ( + "Component library", + "Browse every primitive wired into this starter so new views stay consistent.", + ), + } +} + +#[component] +pub fn Navbar() -> Element { + let collapsed = use_signal(|| false); + let current_route: Route = use_route(); + let collapsed_state = collapsed(); + let collapsed_setter = collapsed.clone(); + + let (page_title, page_description) = page_meta(¤t_route); + let is_dashboard = matches!(current_route, Route::Home { .. }); + let is_components = matches!(current_route, Route::Components { .. }); + + rsx! { + section { + class: "ui-shell shadcn", + SidebarLayout { + class: "admin-shell", + SidebarRail { } + Sidebar { + collapsed: collapsed_state, + SidebarHeader { + div { class: "sidebar-brand", + span { class: "sidebar-logo", "⚡" } + div { + span { class: "sidebar-name", "Dioxus Admin" } + span { class: "sidebar-subtitle", "v0.7 toolkit" } + } + } + } + SidebarContent { + SidebarGroup { + SidebarGroupLabel { "Overview" } + SidebarGroupContent { + SidebarMenu { + SidebarMenuItem { + SidebarMenuButton { + label: "Dashboard", + description: Some("KPIs, monitors, and recent activity".to_string()), + icon: Some("📊".to_string()), + active: is_dashboard, + href: Some(Route::Home {}.to_string()), + } + } + SidebarMenuItem { + SidebarMenuButton { + label: "Components", + description: Some("Living style guide of primitives".to_string()), + icon: Some("🧩".to_string()), + active: is_components, + href: Some(Route::Components {}.to_string()), + } + } + } + } + } + SidebarSeparator { } + SidebarGroup { + SidebarGroupLabel { "Shortcuts" } + SidebarGroupContent { + SidebarMenu { + SidebarMenuItem { + SidebarMenuButton { + label: "Team", + description: Some("Invite and manage collaborators".to_string()), + icon: Some("👥".to_string()), + badge: Some("4 pending".to_string()), + href: Some("#team".to_string()), + } + } + SidebarMenuItem { + SidebarMenuButton { + label: "Settings", + description: Some("Branding, auth, billing".to_string()), + icon: Some("⚙️".to_string()), + href: Some("#settings".to_string()), + } + } + } + } + } + } + SidebarFooter { + div { class: "sidebar-profile", + Avatar { + src: Some("https://avatars.githubusercontent.com/u/3236120?v=4".to_string()), + alt: Some("Administrator avatar".to_string()), + fallback: Some("DX".to_string()), + } + div { + span { class: "sidebar-profile-name", "Taylor Chen" } + span { class: "sidebar-profile-role", "Product Manager" } + } + } + Button { + class: Some("mt-2 w-full".to_string()), + variant: ButtonVariant::Secondary, + r#type: "button".to_string(), + "Switch account" + } + } + } + SidebarInset { + class: "admin-shell-inset", + header { + class: "admin-shell-topbar", + SidebarTrigger { + collapsed: collapsed_state, + on_toggle: move |next_collapsed: bool| { + collapsed_setter.clone().set(next_collapsed); + }, + } + div { class: "admin-shell-meta", + h1 { class: "admin-shell-title", "{page_title}" } + p { class: "admin-shell-description", "{page_description}" } + } + div { class: "admin-shell-actions", + Badge { variant: BadgeVariant::Secondary, "Operational" } + Button { + variant: ButtonVariant::Default, + class: Some("admin-shell-report".to_string()), + r#type: "button".to_string(), + "New report" + } + } + } + main { + class: "admin-shell-content", + Outlet:: {} + } + } + } + } } }