diff --git a/ui/assets/tailwind.css b/ui/assets/tailwind.css index ec9dfe7..691fd8b 100644 --- a/ui/assets/tailwind.css +++ b/ui/assets/tailwind.css @@ -12,10 +12,10 @@ --color-gray-300: oklch(87.2% 0.01 258.338); --color-gray-500: oklch(55.1% 0.027 264.364); --color-gray-700: oklch(37.3% 0.034 259.733); + --color-black: #000; --color-white: #fff; --spacing: 0.25rem; --container-xs: 20rem; - --container-sm: 24rem; --container-md: 28rem; --container-lg: 32rem; --container-4xl: 56rem; @@ -267,6 +267,9 @@ .z-\[60\] { z-index: 60; } + .z-\[100\] { + z-index: 100; + } .container { width: 100%; @media (width >= 40rem) { @@ -324,6 +327,9 @@ .mb-0\.5 { margin-bottom: calc(var(--spacing) * 0.5); } + .mb-2 { + margin-bottom: calc(var(--spacing) * 2); + } .mb-3 { margin-bottom: calc(var(--spacing) * 3); } @@ -336,6 +342,9 @@ .mb-7 { margin-bottom: calc(var(--spacing) * 7); } + .ml-2 { + margin-left: calc(var(--spacing) * 2); + } .ml-3 { margin-left: calc(var(--spacing) * 3); } @@ -348,6 +357,9 @@ .block { display: block; } + .contents { + display: contents; + } .flex { display: flex; } @@ -416,6 +428,9 @@ .h-\[45vh\] { height: 45vh; } + .h-\[380px\] { + height: 380px; + } .h-full { height: 100%; } @@ -488,6 +503,9 @@ .w-\[37px\] { width: 37px; } + .w-\[320px\] { + width: 320px; + } .w-\[452px\] { width: 452px; } @@ -659,6 +677,13 @@ margin-block-end: calc(calc(var(--spacing) * 1.5) * calc(1 - var(--tw-space-y-reverse))); } } + .space-y-3 { + :where(& > :not(:last-child)) { + --tw-space-y-reverse: 0; + margin-block-start: calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse)); + margin-block-end: calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse))); + } + } .space-y-4 { :where(& > :not(:last-child)) { --tw-space-y-reverse: 0; @@ -793,6 +818,12 @@ background-color: color-mix(in oklab, var(--background) 50%, transparent); } } + .bg-black\/20 { + background-color: color-mix(in srgb, #000 20%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-black) 20%, transparent); + } + } .bg-button-primary-background\/90 { background-color: var(--button-primary-background); @supports (color: color-mix(in lab, red, red)) { @@ -949,9 +980,18 @@ .py-7 { padding-block: calc(var(--spacing) * 7); } + .py-8 { + padding-block: calc(var(--spacing) * 8); + } .pt-2 { padding-top: calc(var(--spacing) * 2); } + .pt-12 { + padding-top: calc(var(--spacing) * 12); + } + .pr-4 { + padding-right: calc(var(--spacing) * 4); + } .pb-12 { padding-bottom: calc(var(--spacing) * 12); } @@ -1203,6 +1243,9 @@ .fade-in { --tw-enter-opacity: 0; } + .running { + animation-play-state: running; + } .group-data-\[state\=checked\]\:translate-x-5 { &:is(:where(.group)[data-state="checked"] *) { --tw-translate-x: calc(var(--spacing) * 5); diff --git a/ui/src/main.rs b/ui/src/main.rs index 084dfd3..19c9e87 100644 --- a/ui/src/main.rs +++ b/ui/src/main.rs @@ -1,6 +1,4 @@ use dioxus::prelude::*; -#[cfg(feature = "desktop")] -use n0_error::Result; use std::sync::OnceLock; use tracing::info; use tracing_appender::non_blocking::WorkerGuard; @@ -9,16 +7,16 @@ use tracing_subscriber::{fmt, prelude::*, EnvFilter}; use crate::components::{Head, Splash, UpdateDialog}; use crate::state::AppState; use crate::views::{ - Chrome, JoinProxy, Login, ProxiesList, SelectProject, Settings, TunnelBandwidth, + Chrome, JoinProxy, Login, ProxiesList, SelectProject, Settings, TrayNavHandler, TunnelBandwidth, }; #[cfg(feature = "desktop")] use dioxus_desktop::{ trayicon::{ - menu::{Menu, MenuItem, PredefinedMenuItem}, + menu::{IconMenuItem, Menu, MenuItem, NativeIcon, PredefinedMenuItem}, Icon, TrayIcon, TrayIconBuilder, }, - use_tray_menu_event_handler, use_window, + use_muda_event_handler, use_window, }; mod components; @@ -51,6 +49,7 @@ static MANUAL_UPDATE_CHECK_FLAG: std::sync::atomic::AtomicBool = #[derive(Debug, Clone, Routable, PartialEq)] #[rustfmt::skip] enum Route { + #[layout(TrayNavHandler)] #[route("/")] Login{}, #[layout(Chrome)] @@ -95,9 +94,6 @@ fn main() { #[cfg(all(feature = "desktop", target_os = "linux"))] gtk::init().unwrap(); - #[cfg(feature = "desktop")] - let _tray_icon = init_menu_bar().unwrap(); - #[cfg(feature = "desktop")] { use dioxus_desktop::{Config, LogicalSize, WindowBuilder, WindowCloseBehaviour}; @@ -300,36 +296,52 @@ fn App() -> Element { }); } + let mut open_add_tunnel_from_tray = use_signal(|| false); + let mut login_state_for_tray = use_signal(|| None::); + + // Create tray when app state is ready. Must run before early return. #[cfg(feature = "desktop")] - use_tray_menu_event_handler(move |event| -> () { - // The event ID corresponds to the menu item text - let _: () = match event.id.0.as_str() { - "About Datum" => { - let _ = open::that("https://datum.net"); - () - } - "Show Window" => { - use_window().set_visible(true); - () - } - "Hide" => { - use_window().set_visible(false); - () - } - "Check for Updates..." => { - manual_update_check.set(true); - update_check_in_progress.set(true); - () - } - "Quit" => { - std::process::exit(0); + { + let mut tray_holder = use_signal(|| None::); + + use_effect(move || { + if !app_state_ready() { + return; } - _ => { - eprintln!("Unknown menu event: {}", event.id.0); - () + if (*tray_holder.peek()).is_some() { + return; } - }; - }); + let tray = init_tray(); + tray_holder.set(Some(tray)); + }); + + let window = use_window(); + use_muda_event_handler(move |event| -> () { + let _: () = match event.id.0.as_str() { + "about" => { + let _ = open::that("https://datum.net"); + } + "show" => { + window.set_visible(true); + window.set_focus(); + } + "hide" => { + window.set_visible(false); + } + "new_tunnel" => { + if login_state_for_tray() == Some(lib::datum_cloud::LoginState::Missing) + || login_state_for_tray().is_none() + { + return; + } + window.set_visible(true); + window.set_focus(); + open_add_tunnel_from_tray.set(true); + } + _ => {} + }; + }); + } if !app_state_ready() { return rsx! { @@ -345,6 +357,11 @@ fn App() -> Element { provide_context(auth_changed); let state_for_auth_watch = consume_context::(); + use_effect(move || { + let _ = auth_changed(); + let state = consume_context::(); + login_state_for_tray.set(Some(state.datum().login_state())); + }); use_future(move || { let state_for_auth_watch = state_for_auth_watch.clone(); let mut auth_changed = auth_changed; @@ -365,6 +382,9 @@ fn App() -> Element { in_progress: update_check_in_progress, }); + // Tray can trigger opening the add tunnel dialog; Chrome (inside Router) handles navigation + dialog. + provide_context(open_add_tunnel_from_tray); + rsx! { div { class: "theme-alpha", TitleBar {} @@ -387,43 +407,52 @@ fn App() -> Element { } #[cfg(feature = "desktop")] -fn init_menu_bar() -> Result { - // Initialize the tray menu - +fn init_tray() -> TrayIcon { use n0_error::StdResultExt; + let tray_menu = Menu::new(); + let about_item = MenuItem::with_id("about", "About Datum", true, None); + let show_item = MenuItem::with_id("show", "Show Window", true, None); + let hide_item = MenuItem::with_id("hide", "Hide", true, None); + + #[cfg(target_os = "macos")] + let new_tunnel_item = IconMenuItem::with_id_and_native_icon( + "new_tunnel", + "New Tunnel", + true, + Some(NativeIcon::Add), + None, + ); + #[cfg(not(target_os = "macos"))] + let new_tunnel_item = IconMenuItem::with_id("new_tunnel", "New Tunnel", true, None, None); - // Create menu items with IDs for event handling - let about_item = MenuItem::new("About Datum", true, None); - let show_item = MenuItem::new("Show Window", true, None); - let hide_item = MenuItem::new("Hide", true, None); - let separator1 = PredefinedMenuItem::separator(); - let check_updates_item = MenuItem::new("Check for Updates...", true, None); - let separator2 = PredefinedMenuItem::separator(); - let quit_item = MenuItem::new("Quit", true, None); + let version_item = MenuItem::with_id( + "version", + format!("Version {}", env!("CARGO_PKG_VERSION")), + false, + None, + ); + let sep = PredefinedMenuItem::separator(); - // Build the menu structure (macOS-style: About, Show, Hide, sep, Check for Updates, sep, Quit) tray_menu .append_items(&[ + &new_tunnel_item, + &sep, &about_item, &show_item, &hide_item, - &separator1, - &check_updates_item, - &separator2, - &quit_item, + &sep, + &version_item, ]) .expect("Failed to build tray menu"); - let icon = icon(); - - // Build the tray icon TrayIconBuilder::new() .with_menu(Box::new(tray_menu)) .with_tooltip("Datum") - .with_icon(icon) + .with_icon(icon()) .build() .std_context("building tray icon") + .expect("failed to build tray icon") } /// Load an icon from a PNG file for the tray diff --git a/ui/src/views/mod.rs b/ui/src/views/mod.rs index 9d302f0..94e9bbc 100644 --- a/ui/src/views/mod.rs +++ b/ui/src/views/mod.rs @@ -10,6 +10,7 @@ mod navbar; mod proxies_list; mod select_project; mod settings; +mod tray_nav_handler; mod tunnel_bandwidth; pub use join_proxy::JoinProxy; @@ -18,4 +19,5 @@ pub use navbar::*; pub use proxies_list::{ProxiesList, TunnelCard}; pub use select_project::SelectProject; pub use settings::Settings; +pub use tray_nav_handler::TrayNavHandler; pub use tunnel_bandwidth::TunnelBandwidth; diff --git a/ui/src/views/navbar.rs b/ui/src/views/navbar.rs index 85b6010..b4fcb0a 100644 --- a/ui/src/views/navbar.rs +++ b/ui/src/views/navbar.rs @@ -29,12 +29,21 @@ pub fn Chrome() -> Element { let mut add_tunnel_dialog_open = use_signal(|| false); let mut invite_user_dialog_open = use_signal(|| false); let mut editing_tunnel = use_signal(|| None::); + let mut open_add_tunnel_from_tray = consume_context::>(); provide_context(OpenEditTunnelDialog { editing_tunnel, dialog_open: add_tunnel_dialog_open, }); + use_effect(move || { + if open_add_tunnel_from_tray() { + nav.push(Route::ProxiesList {}); + add_tunnel_dialog_open.set(true); + open_add_tunnel_from_tray.set(false); + } + }); + use_effect(move || { let _ = auth_changed(); if state.datum().login_state() == LoginState::Missing { diff --git a/ui/src/views/tray_nav_handler.rs b/ui/src/views/tray_nav_handler.rs new file mode 100644 index 0000000..985ffb9 --- /dev/null +++ b/ui/src/views/tray_nav_handler.rs @@ -0,0 +1,22 @@ +//! Minimal layout that handles tray "New tunnel" navigation. Must be inside Router +//! (as a route layout) so use_navigator works. Used for Login which has no Chrome. + +use crate::Route; +use dioxus::prelude::*; + +#[component] +pub fn TrayNavHandler() -> Element { + let nav = use_navigator(); + let open_add_tunnel_from_tray = consume_context::>(); + + use_effect(move || { + if open_add_tunnel_from_tray() { + nav.push(Route::ProxiesList {}); + // Don't reset here - Chrome will open the dialog and reset when it mounts + } + }); + + rsx! { + Outlet:: {} + } +}