diff --git a/assets/main.css b/assets/main.css index 968674c..1ed9d8a 100644 --- a/assets/main.css +++ b/assets/main.css @@ -57,6 +57,16 @@ h6 { min-height: 100vh; } +/* ===== Mobile Header ===== */ +.mobile-header { + display: none; +} + +/* ===== Sidebar Backdrop ===== */ +.sidebar-backdrop { + display: none; +} + /* ===== Sidebar ===== */ .sidebar { width: 260px; @@ -2940,7 +2950,7 @@ h6 { color: var(--text-primary); } -/* ===== Responsive: Dashboard Pages ===== */ +/* ===== Responsive: Tablet (max-width: 1024px) ===== */ @media (max-width: 1024px) { .news-grid, @@ -2988,10 +2998,97 @@ h6 { .news-grid--compact { grid-template-columns: repeat(2, 1fr); } + + .main-content { + padding: 32px 24px; + } + + .chat-page { + margin: -32px -24px; + height: calc(100vh - 64px); + } } +/* ===== Responsive: Mobile (max-width: 768px) ===== */ @media (max-width: 768px) { + /* -- Mobile header bar with hamburger -- */ + .mobile-header { + display: flex; + align-items: center; + gap: 12px; + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 90; + height: 56px; + padding: 0 16px; + background-color: var(--bg-sidebar); + border-bottom: 1px solid var(--border-primary); + } + + .mobile-menu-btn { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border: none; + border-radius: 8px; + background: transparent; + color: var(--text-primary); + cursor: pointer; + transition: background-color 0.15s ease; + } + + .mobile-menu-btn:hover { + background-color: var(--bg-surface); + } + + .mobile-header-title { + font-family: 'Space Grotesk', sans-serif; + font-size: 18px; + font-weight: 700; + color: var(--text-heading); + } + + /* -- Sidebar: hidden off-screen, slides in as overlay -- */ + .app-shell { + flex-direction: column; + } + + .sidebar { + position: fixed; + top: 0; + left: 0; + bottom: 0; + z-index: 200; + transform: translateX(-100%); + transition: transform 0.25s ease; + width: 280px; + min-width: 280px; + } + + .sidebar--open { + transform: translateX(0); + } + + .sidebar-backdrop { + display: block; + position: fixed; + inset: 0; + z-index: 199; + background-color: rgba(0, 0, 0, 0.5); + } + + /* -- Main content: add top padding for mobile header -- */ + .main-content { + padding: 72px 16px 24px; + min-height: calc(100vh - 56px); + } + + /* -- Dashboard grids -- */ .news-grid, .tools-grid, .pricing-grid { @@ -3002,9 +3099,16 @@ h6 { grid-template-columns: 1fr; } + .dashboard-grid { + grid-template-columns: 1fr; + } + + /* -- Chat page -- */ .chat-page { flex-direction: column; height: auto; + min-height: calc(100vh - 56px); + margin: -72px -16px -24px; } .chat-sidebar-panel { @@ -3015,11 +3119,34 @@ h6 { border-bottom: 1px solid var(--border-primary); } + .chat-messages, + .chat-message-list { + padding: 16px; + } + + .chat-input-bar { + padding: 12px 16px; + } + + .chat-model-bar { + padding: 8px 16px; + } + + .chat-bubble { + max-width: 90%; + } + + /* -- Page header -- */ .page-header { flex-direction: column; gap: 12px; } + .page-title { + font-size: 22px; + } + + /* -- Stats bars -- */ .analytics-stats-bar { flex-direction: column; } @@ -3028,8 +3155,171 @@ h6 { flex-direction: column; } + /* -- Sub navigation (Developer/Org tabs) -- */ + .sub-nav { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + white-space: nowrap; + padding-bottom: 12px; + } + + .sub-nav-item { + flex-shrink: 0; + } + + /* -- Modal -- */ .modal-content { min-width: unset; margin: 16px; + max-width: calc(100vw - 32px); + } + + /* -- Dashboard filters -- */ + .dashboard-filters { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + flex-wrap: nowrap; + padding-bottom: 4px; + } + + .filter-tab { + flex-shrink: 0; + } + + /* -- Providers page -- */ + .providers-layout { + grid-template-columns: 1fr; + } + + /* -- Tables: horizontal scroll -- */ + .knowledge-table-wrapper, + .org-table-wrapper { + margin: 0 -16px; + padding: 0 16px; + } + + /* -- Settings -- */ + .settings-input { + max-width: 100%; + } + + /* -- Placeholder pages -- */ + .placeholder-card { + padding: 32px 20px; + } + + /* -- Article detail -- */ + .article-detail-title { + font-size: 18px; + } + + .article-detail-content { + padding-right: 32px; + } + + /* -- CTA banner -- */ + .cta-banner { + padding: 32px 20px; + } + + .cta-title { + font-size: 22px; + } + + .cta-actions { + flex-direction: column; + align-items: stretch; + } + + /* -- Pricing cards -- */ + .pricing-card { + padding: 24px 20px; + } +} + +/* ===== Responsive: Small Phones (max-width: 480px) ===== */ +@media (max-width: 480px) { + + .main-content { + padding: 64px 12px 16px; + } + + .chat-page { + margin: -64px -12px -16px; + } + + .hero-title { + font-size: 28px; + } + + .hero-subtitle { + font-size: 15px; + } + + .section-title { + font-size: 24px; + } + + .page-title { + font-size: 20px; + } + + .overview-heading { + font-size: 22px; + } + + .dashboard-card { + padding: 16px; + } + + .tool-card { + padding: 16px; + } + + .news-card-body { + padding: 14px; + } + + .social-proof-stats { + gap: 16px; + } + + .proof-divider { + display: none; + } + + .proof-stat-value { + font-size: 20px; + } + + .sidebar { + width: 260px; + min-width: 260px; + } + + .chat-bubble { + max-width: 95%; + padding: 10px 14px; + } + + .modal-content { + padding: 20px; + } + + .landing-nav-inner { + padding: 12px 16px; + } + + .features-section, + .how-it-works-section { + padding: 48px 16px; + } + + .step-card { + padding: 24px 16px; + } + + .feature-card { + padding: 20px 16px; } } \ No newline at end of file diff --git a/assets/tailwind.css b/assets/tailwind.css index ab4799a..c7c9fdd 100644 --- a/assets/tailwind.css +++ b/assets/tailwind.css @@ -356,6 +356,95 @@ } } } + .dropdown { + @layer daisyui.l1.l2.l3 { + position: relative; + display: inline-block; + position-area: var(--anchor-v, bottom) var(--anchor-h, span-right); + & > *:not(:has(~ [class*="dropdown-content"])):focus { + --tw-outline-style: none; + outline-style: none; + @media (forced-colors: active) { + outline: 2px solid transparent; + outline-offset: 2px; + } + } + .dropdown-content { + position: absolute; + } + &.dropdown-close .dropdown-content, &:not(details, .dropdown-open, .dropdown-hover:hover, :focus-within) .dropdown-content, &.dropdown-hover:not(:hover) [tabindex]:first-child:focus:not(:focus-visible) ~ .dropdown-content { + display: none; + transform-origin: top; + opacity: 0%; + scale: 95%; + } + &[popover], .dropdown-content { + z-index: 999; + @media (prefers-reduced-motion: no-preference) { + animation: dropdown 0.2s; + transition-property: opacity, scale, display; + transition-behavior: allow-discrete; + transition-duration: 0.2s; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + } + } + @starting-style { + &[popover], .dropdown-content { + scale: 95%; + opacity: 0; + } + } + &:not(.dropdown-close) { + &.dropdown-open, &:not(.dropdown-hover):focus, &:focus-within { + > [tabindex]:first-child { + pointer-events: none; + } + .dropdown-content { + opacity: 100%; + scale: 100%; + } + } + &.dropdown-hover:hover { + .dropdown-content { + opacity: 100%; + scale: 100%; + } + } + } + &:is(details) { + summary { + &::-webkit-details-marker { + display: none; + } + } + } + &:where([popover]) { + background: #0000; + } + &[popover] { + position: fixed; + color: inherit; + @supports not (position-area: bottom) { + margin: auto; + &.dropdown-close, &.dropdown-open:not(:popover-open) { + display: none; + transform-origin: top; + opacity: 0%; + scale: 95%; + } + &::backdrop { + background-color: color-mix(in oklab, #000 30%, #0000); + } + } + &.dropdown-close, &:not(.dropdown-open, :popover-open) { + display: none; + transform-origin: top; + opacity: 0%; + scale: 95%; + } + } + } + } .btn { :where(&) { @layer daisyui.l1.l2.l3 { diff --git a/src/components/app_shell.rs b/src/components/app_shell.rs index 8f47801..c129558 100644 --- a/src/components/app_shell.rs +++ b/src/components/app_shell.rs @@ -1,4 +1,6 @@ use dioxus::prelude::*; +use dioxus_free_icons::icons::bs_icons::{BsList, BsX}; +use dioxus_free_icons::Icon; use crate::components::sidebar::Sidebar; use crate::i18n::{t, tw, Locale}; @@ -14,6 +16,7 @@ use crate::Route; #[component] pub fn AppShell() -> Element { let locale = use_context::>(); + let mut mobile_menu_open = use_signal(|| false); // use_resource memoises the async call and avoids infinite re-render // loops that use_effect + spawn + signal writes can cause. @@ -26,12 +29,44 @@ pub fn AppShell() -> Element { match auth_snapshot { Some(Ok(info)) if info.authenticated => { + let menu_open = *mobile_menu_open.read(); + let sidebar_cls = if menu_open { + "sidebar sidebar--open" + } else { + "sidebar" + }; + rsx! { div { class: "app-shell", + // Mobile top bar (visible only on small screens via CSS) + header { class: "mobile-header", + button { + class: "mobile-menu-btn", + onclick: move |_| { + let current = *mobile_menu_open.read(); + mobile_menu_open.set(!current); + }, + if menu_open { + Icon { icon: BsX, width: 24, height: 24 } + } else { + Icon { icon: BsList, width: 24, height: 24 } + } + } + span { class: "mobile-header-title", "CERTifAI" } + } + // Backdrop overlay when sidebar is open on mobile + if menu_open { + div { + class: "sidebar-backdrop", + onclick: move |_| mobile_menu_open.set(false), + } + } Sidebar { email: info.email, name: info.name, avatar_url: info.avatar_url, + class: sidebar_cls, + on_nav: move |_| mobile_menu_open.set(false), } main { class: "main-content", Outlet:: {} } } diff --git a/src/components/sidebar.rs b/src/components/sidebar.rs index fcfc324..0cc8ac3 100644 --- a/src/components/sidebar.rs +++ b/src/components/sidebar.rs @@ -27,8 +27,19 @@ struct NavItem { /// * `name` - User display name (shown in header if non-empty). /// * `email` - Email address displayed beneath the avatar placeholder. /// * `avatar_url` - URL for the avatar image (unused placeholder for now). +/// * `class` - CSS class override (e.g. to add `sidebar--open` on mobile). +/// * `on_nav` - Callback fired when a nav link is clicked (used to close +/// the mobile menu). #[component] -pub fn Sidebar(name: String, email: String, avatar_url: String) -> Element { +pub fn Sidebar( + name: String, + email: String, + avatar_url: String, + #[props(default = "sidebar".to_string())] + class: String, + #[props(default)] + on_nav: EventHandler<()>, +) -> Element { let locale = use_context::>(); let locale_val = *locale.read(); @@ -82,7 +93,7 @@ pub fn Sidebar(name: String, email: String, avatar_url: String) -> Element { let logout_label = t(locale_val, "common.logout"); rsx! { - aside { class: "sidebar", + aside { class: "{class}", div { class: "sidebar-top-row", SidebarHeader { name, email: email.clone(), avatar_url } LocalePicker {} @@ -104,7 +115,10 @@ pub fn Sidebar(name: String, email: String, avatar_url: String) -> Element { }; let cls = if is_active { "sidebar-link active" } else { "sidebar-link" }; rsx! { - Link { to: item.route, class: cls, + Link { + to: item.route, + class: cls, + onclick: move |_| on_nav.call(()), {item.icon} span { "{item.label}" } }