diff --git a/frontend/src/main.rs b/frontend/src/main.rs index 01127b8..2d18c52 100644 --- a/frontend/src/main.rs +++ b/frontend/src/main.rs @@ -6,914 +6,9 @@ clippy::wildcard_imports )] -use chrono::{prelude::*, Duration}; -use seed::{prelude::*, *}; - -mod common; -mod component; -mod data; mod domain; -mod page; - -// ------ ------ -// Init -// ------ ------ - -fn init(url: Url, orders: &mut impl Orders) -> Model { - orders - .skip() - .stream(streams::window_event(Ev::BeforeUnload, Msg::BeforeUnload)) - .subscribe(Msg::UrlRequested) - .subscribe(Msg::UrlChanged) - .subscribe(Msg::Data) - .stream(streams::window_event(Ev::Click, |_| Msg::HideMenu)) - .notify(data::Msg::InitializeSession); - - let data = data::init(url, &mut orders.proxy(Msg::Data)); - - set_theme(&data.settings.theme); - - Model { - navbar: Navbar { - title: String::from("Valens"), - items: Vec::new(), - menu_visible: false, - }, - page: None, - settings_dialog_visible: false, - data, - } -} - -// ------ ------ -// Urls -// ------ ------ - -const LOGIN: &str = "login"; -const ADMIN: &str = "admin"; -const BODY_WEIGHT: &str = "body_weight"; -const BODY_FAT: &str = "body_fat"; -const MENSTRUAL_CYCLE: &str = "menstrual_cycle"; -const EXERCISES: &str = "exercises"; -const EXERCISE: &str = "exercise"; -const MUSCLES: &str = "muscles"; -const ROUTINES: &str = "routines"; -const ROUTINE: &str = "routine"; -const TRAINING: &str = "training"; -const TRAINING_SESSION: &str = "training_session"; - -struct_urls!(); -impl<'a> Urls<'a> { - pub fn home(self) -> Url { - self.base_url() - } - pub fn login(self) -> Url { - self.base_url().set_hash_path([LOGIN]) - } - pub fn admin(self) -> Url { - self.base_url().set_hash_path([ADMIN]) - } - pub fn body_weight(self) -> Url { - self.base_url().set_hash_path([BODY_WEIGHT]) - } - pub fn body_fat(self) -> Url { - self.base_url().set_hash_path([BODY_FAT]) - } - pub fn menstrual_cycle(self) -> Url { - self.base_url().set_hash_path([MENSTRUAL_CYCLE]) - } - pub fn exercises(self) -> Url { - self.base_url().set_hash_path([EXERCISES]) - } - pub fn exercise(self) -> Url { - self.base_url().set_hash_path([EXERCISE]) - } - pub fn muscles(self) -> Url { - self.base_url().set_hash_path([MUSCLES]) - } - pub fn routines(self) -> Url { - self.base_url().set_hash_path([ROUTINES]) - } - pub fn routine(self) -> Url { - self.base_url().set_hash_path([ROUTINE]) - } - pub fn training(self) -> Url { - self.base_url().set_hash_path([TRAINING]) - } - pub fn training_session(self) -> Url { - self.base_url().set_hash_path([TRAINING_SESSION]) - } -} - -// ------ ------ -// Model -// ------ ------ - -struct Model { - navbar: Navbar, - page: Option, - settings_dialog_visible: bool, - data: data::Model, -} - -pub struct Navbar { - title: String, - items: Vec<(EventHandler, String)>, - menu_visible: bool, -} - -enum Page { - Home(page::home::Model), - Login(page::login::Model), - Admin(page::admin::Model), - BodyWeight(page::body_weight::Model), - BodyFat(page::body_fat::Model), - MenstrualCycle(page::menstrual_cycle::Model), - Exercises(page::exercises::Model), - Exercise(page::exercise::Model), - Muscles(page::muscles::Model), - Routines(page::routines::Model), - Routine(page::routine::Model), - Training(page::training::Model), - TrainingSession(page::training_session::Model), - NotFound, -} - -impl Page { - fn init( - mut url: Url, - orders: &mut impl Orders, - navbar: &mut Navbar, - data_model: &data::Model, - ) -> Self { - navbar.items.clear(); - - if data_model.session.is_some() { - match url.next_hash_path_part() { - None => Self::Home(page::home::init( - url, - &mut orders.proxy(Msg::Home), - data_model, - navbar, - )), - Some(LOGIN) => Self::Login(page::login::init( - url, - &mut orders.proxy(Msg::Login), - navbar, - )), - Some(ADMIN) => Self::Admin(page::admin::init( - url, - &mut orders.proxy(Msg::Admin), - navbar, - )), - Some(BODY_WEIGHT) => Self::BodyWeight(page::body_weight::init( - url, - &mut orders.proxy(Msg::BodyWeight), - data_model, - navbar, - )), - Some(BODY_FAT) => Self::BodyFat(page::body_fat::init( - url, - &mut orders.proxy(Msg::BodyFat), - data_model, - navbar, - )), - Some(MENSTRUAL_CYCLE) => Self::MenstrualCycle(page::menstrual_cycle::init( - url, - &mut orders.proxy(Msg::MenstrualCycle), - data_model, - navbar, - )), - Some(EXERCISES) => Self::Exercises(page::exercises::init( - url, - &mut orders.proxy(Msg::Exercises), - navbar, - )), - Some(EXERCISE) => Self::Exercise(page::exercise::init( - url, - &mut orders.proxy(Msg::Exercise), - data_model, - navbar, - )), - Some(MUSCLES) => Self::Muscles(page::muscles::init(data_model, navbar)), - Some(ROUTINES) => Self::Routines(page::routines::init( - url, - &mut orders.proxy(Msg::Routines), - navbar, - )), - Some(ROUTINE) => Self::Routine(page::routine::init( - url, - &mut orders.proxy(Msg::Routine), - data_model, - navbar, - )), - Some(TRAINING) => Self::Training(page::training::init( - url, - &mut orders.proxy(Msg::Training), - data_model, - navbar, - )), - Some(TRAINING_SESSION) => Self::TrainingSession(page::training_session::init( - url, - &mut orders.proxy(Msg::TrainingSession), - data_model, - navbar, - )), - Some(_) => Self::NotFound, - } - } else { - match url.next_hash_path_part() { - Some(ADMIN) => Self::Admin(page::admin::init( - url, - &mut orders.proxy(Msg::Admin), - navbar, - )), - None | Some(_) => { - Urls::new(url.to_hash_base_url()).login().go_and_push(); - Self::Login(page::login::init( - url, - &mut orders.proxy(Msg::Login), - navbar, - )) - } - } - } - } -} - -// ------ ------ -// Update -// ------ ------ - -enum Msg { - BeforeUnload(web_sys::Event), - UrlRequested(subs::UrlRequested), - UrlChanged(subs::UrlChanged), - - ToggleMenu, - HideMenu, - ShowSettingsDialog, - CloseSettingsDialog, - BeepVolumeChanged(String), - SetTheme(data::Theme), - ToggleAutomaticMetronome, - ToggleNotifications, - ToggleShowRPE, - ToggleShowTUT, - UpdateApp, - GoUp, - LogOut, - - // ------ Pages ------ - Home(page::home::Msg), - Login(page::login::Msg), - Admin(page::admin::Msg), - BodyWeight(page::body_weight::Msg), - BodyFat(page::body_fat::Msg), - MenstrualCycle(page::menstrual_cycle::Msg), - Exercises(page::exercises::Msg), - Exercise(page::exercise::Msg), - Muscles(page::muscles::Msg), - Routines(page::routines::Msg), - Routine(page::routine::Msg), - Training(page::training::Msg), - TrainingSession(page::training_session::Msg), - - // ------ Data ------ - Data(data::Msg), -} - -fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders) { - match msg { - Msg::BeforeUnload(event) => { - if warn_about_unsaved_changes(model) { - let event = event.unchecked_into::(); - event.prevent_default(); - event.set_return_value(""); - } - } - Msg::UrlRequested(subs::UrlRequested(_, url_request)) => { - if warn_about_unsaved_changes(model) { - if Ok(true) - == window().confirm_with_message( - "Do you want to leave this page? Changes will not be saved.", - ) - { - return; - } - url_request.handled_and_prevent_refresh(); - } - } - Msg::UrlChanged(subs::UrlChanged(url)) => { - model.page = Some(Page::init(url, orders, &mut model.navbar, &model.data)); - orders.send_msg(Msg::Data(data::Msg::ClearErrors)); - window().scroll_to_with_scroll_to_options(web_sys::ScrollToOptions::new().top(0.)); - } - - Msg::ToggleMenu => model.navbar.menu_visible = not(model.navbar.menu_visible), - Msg::HideMenu => { - if model.navbar.menu_visible { - model.navbar.menu_visible = false; - } else { - orders.skip(); - } - } - Msg::ShowSettingsDialog => { - model.settings_dialog_visible = true; - } - Msg::CloseSettingsDialog => { - model.settings_dialog_visible = false; - } - Msg::BeepVolumeChanged(input) => { - if let Ok(value) = input.parse::() { - orders.send_msg(Msg::Data(data::Msg::SetBeepVolume(value))); - } - } - Msg::SetTheme(theme) => { - set_theme(&theme); - orders.send_msg(Msg::Data(data::Msg::SetTheme(theme))); - } - Msg::ToggleAutomaticMetronome => { - orders.send_msg(Msg::Data(data::Msg::SetAutomaticMetronome(not(model - .data - .settings - .automatic_metronome)))); - } - Msg::ToggleNotifications => match web_sys::Notification::permission() { - web_sys::NotificationPermission::Granted => { - orders.send_msg(Msg::Data(data::Msg::SetNotifications(not(model - .data - .settings - .notifications)))); - } - web_sys::NotificationPermission::Denied => {} - _ => { - orders - .skip() - .perform_cmd(async { - if let Ok(promise) = web_sys::Notification::request_permission() { - #[allow(unused_must_use)] - { - wasm_bindgen_futures::JsFuture::from(promise).await; - } - } - }) - .send_msg(Msg::Data(data::Msg::SetNotifications(true))); - } - }, - Msg::ToggleShowRPE => { - orders.send_msg(Msg::Data(data::Msg::SetShowRPE(not(model - .data - .settings - .show_rpe)))); - } - Msg::ToggleShowTUT => { - orders.send_msg(Msg::Data(data::Msg::SetShowTUT(not(model - .data - .settings - .show_tut)))); - } - Msg::UpdateApp => { - orders.skip().send_msg(Msg::Data(data::Msg::UpdateApp)); - } - Msg::GoUp => match &model.page { - Some(Page::Home(_) | Page::Login(_)) => {} - Some(Page::Admin(_)) => { - orders.request_url(crate::Urls::new(&model.data.base_url).login()); - } - Some( - Page::BodyWeight(_) - | Page::BodyFat(_) - | Page::MenstrualCycle(_) - | Page::Training(_) - | Page::NotFound, - ) - | None => { - orders.request_url(crate::Urls::new(&model.data.base_url).home()); - } - Some(Page::Exercise(_)) => { - orders.request_url(crate::Urls::new(&model.data.base_url).exercises()); - } - Some(Page::Routine(_)) => { - orders.request_url(crate::Urls::new(&model.data.base_url).routines()); - } - Some( - Page::TrainingSession(_) - | Page::Exercises(_) - | Page::Muscles(_) - | Page::Routines(_), - ) => { - orders.request_url(crate::Urls::new(&model.data.base_url).training()); - } - }, - Msg::LogOut => { - orders.skip().notify(data::Msg::DeleteSession); - } - - // ------ Pages ------ - Msg::Home(msg) => { - if let Some(Page::Home(page_model)) = &mut model.page { - page::home::update(msg, page_model, &mut orders.proxy(Msg::Home)); - } - } - Msg::Login(msg) => { - if let Some(Page::Login(page_model)) = &mut model.page { - page::login::update(msg, page_model, &mut orders.proxy(Msg::Login)); - } - } - Msg::Admin(msg) => { - if let Some(Page::Admin(page_model)) = &mut model.page { - page::admin::update(msg, page_model, &model.data, &mut orders.proxy(Msg::Admin)); - } - } - Msg::BodyWeight(msg) => { - if let Some(Page::BodyWeight(page_model)) = &mut model.page { - page::body_weight::update( - msg, - page_model, - &model.data, - &mut orders.proxy(Msg::BodyWeight), - ); - } - } - Msg::BodyFat(msg) => { - if let Some(Page::BodyFat(page_model)) = &mut model.page { - page::body_fat::update( - msg, - page_model, - &model.data, - &mut orders.proxy(Msg::BodyFat), - ); - } - } - Msg::MenstrualCycle(msg) => { - if let Some(Page::MenstrualCycle(page_model)) = &mut model.page { - page::menstrual_cycle::update( - msg, - page_model, - &model.data, - &mut orders.proxy(Msg::MenstrualCycle), - ); - } - } - Msg::Exercises(msg) => { - if let Some(Page::Exercises(page_model)) = &mut model.page { - page::exercises::update( - msg, - page_model, - &model.data, - &mut orders.proxy(Msg::Exercises), - ); - } - } - Msg::Exercise(msg) => { - if let Some(Page::Exercise(page_model)) = &mut model.page { - page::exercise::update( - msg, - page_model, - &model.data, - &mut orders.proxy(Msg::Exercise), - ); - } - } - Msg::Muscles(msg) => { - if let Some(Page::Muscles(page_model)) = &mut model.page { - page::muscles::update(&msg, page_model); - } - } - Msg::Routines(msg) => { - if let Some(Page::Routines(page_model)) = &mut model.page { - page::routines::update( - msg, - page_model, - &model.data, - &mut orders.proxy(Msg::Routines), - ); - } - } - Msg::Routine(msg) => { - if let Some(Page::Routine(page_model)) = &mut model.page { - page::routine::update( - msg, - page_model, - &model.data, - &mut orders.proxy(Msg::Routine), - ); - } - } - Msg::Training(msg) => { - if let Some(Page::Training(page_model)) = &mut model.page { - page::training::update( - msg, - page_model, - &model.data, - &mut orders.proxy(Msg::Training), - ); - } - } - Msg::TrainingSession(msg) => { - if let Some(Page::TrainingSession(page_model)) = &mut model.page { - page::training_session::update( - msg, - page_model, - &model.data, - &mut orders.proxy(Msg::TrainingSession), - ); - } - } - - Msg::Data(msg) => data::update(msg, &mut model.data, &mut orders.proxy(Msg::Data)), - } -} - -fn warn_about_unsaved_changes(model: &Model) -> bool { - if let Some(page) = &model.page { - if let Page::Exercise(model) = page { - model.has_unsaved_changes() - } else if let Page::Routine(model) = page { - model.has_unsaved_changes() - } else if let Page::TrainingSession(model) = page { - model.has_unsaved_changes() - } else { - false - } - } else { - false - } -} - -// ------ ------ -// View -// ------ ------ - -fn view(model: &Model) -> impl IntoNodes { - if model.settings_dialog_visible { - nodes![ - view_navbar(&model.navbar, &model.page, &model.data), - Node::NoChange, - view_settings_dialog(&model.data), - data::view(&model.data).map_msg(Msg::Data), - ] - } else { - nodes![ - view_navbar(&model.navbar, &model.page, &model.data), - view_page(&model.page, &model.data), - div![], - data::view(&model.data).map_msg(Msg::Data), - ] - } -} - -fn view_navbar(navbar: &Navbar, page: &Option, data_model: &data::Model) -> Node { - nav![ - C!["navbar"], - C!["is-fixed-top"], - C!["is-primary"], - C!["has-shadow"], - C!["has-text-weight-bold"], - div![ - C!["container"], - div![ - C!["navbar-brand"], - C!["is-flex-grow-1"], - a![ - C!["navbar-item"], - if let Some(Page::Home(_) | Page::Login(_)) = page { - C!["has-text-primary"] - } else { - C![] - }, - C!["is-size-5"], - ev(Ev::Click, |_| Msg::GoUp), - span![C!["icon"], i![C!["fas fa-chevron-left"]]] - ], - div![C!["navbar-item"], C!["is-size-5"], &navbar.title], - div![C!["mx-auto"]], - &navbar - .items - .iter() - .map(|item| { - a![ - C!["navbar-item"], - C!["is-size-5"], - C!["mx-1"], - &item.0, - span![C!["icon"], i![C![format!("fas fa-{}", item.1)]]], - ] - }) - .collect::>(), - IF![data_model.session.is_some() => - a![ - C!["navbar-burger"], - C!["ml-0"], - C![IF!(navbar.menu_visible => "is-active")], - attrs! { - At::from("role") => "button", - At::AriaLabel => "menu", - At::AriaExpanded => navbar.menu_visible, - }, - ev(Ev::Click, |event| { - event.stop_propagation(); - Msg::ToggleMenu - }), - span![attrs! {At::AriaHidden => "true"}], - span![attrs! {At::AriaHidden => "true"}], - span![attrs! {At::AriaHidden => "true"}], - span![attrs! {At::AriaHidden => "true"}], - ] - ], - ], - IF![data_model.session.is_some() => - div![ - C!["navbar-menu"], - C!["is-flex-grow-0"], - C![IF!(navbar.menu_visible => "is-active")], - div![ - C!["navbar-end"], - match &data_model.session { - Some(s) => nodes![ - a![ - C!["navbar-item"], - ev(Ev::Click, |_| Msg::ShowSettingsDialog), - span![C!["icon"], C!["px-5"], i![C!["fas fa-gear"]]], - "Settings" - ], - a![ - C!["navbar-item"], - ev(Ev::Click, |_| Msg::Data(data::Msg::Refresh)), - span![C!["icon"], C!["px-5"], i![C!["fas fa-rotate"]]], - format!( - "Refresh data ({})", - view_duration(Utc::now() - data_model.last_refresh) - ), - ], - a![ - C!["navbar-item"], - ev(Ev::Click, |_| Msg::LogOut), - span![C!["icon"], C!["px-5"], i![C!["fas fa-sign-out-alt"]]], - format!("Logout ({})", s.name), - ] - ], - None => nodes![], - } - ], - ] - ] - ] - ] -} - -fn view_duration(duration: Duration) -> String { - if duration < Duration::minutes(1) { - String::from("now") - } else if duration < Duration::hours(1) { - format!("{} min ago", duration.num_minutes()) - } else if duration < Duration::days(1) { - format!("{} h ago", duration.num_hours()) - } else { - format!("{} days ago", duration.num_days()) - } -} - -fn view_page(page: &Option, data_model: &data::Model) -> Node { - div![ - C!["container"], - C!["is-max-desktop"], - C!["py-4"], - match page { - Some(Page::Home(model)) => page::home::view(model, data_model).map_msg(Msg::Home), - Some(Page::Login(model)) => page::login::view(model, data_model).map_msg(Msg::Login), - Some(Page::Admin(model)) => page::admin::view(model, data_model).map_msg(Msg::Admin), - Some(Page::BodyWeight(model)) => - page::body_weight::view(model, data_model).map_msg(Msg::BodyWeight), - Some(Page::BodyFat(model)) => - page::body_fat::view(model, data_model).map_msg(Msg::BodyFat), - Some(Page::MenstrualCycle(model)) => - page::menstrual_cycle::view(model, data_model).map_msg(Msg::MenstrualCycle), - Some(Page::Exercises(model)) => - page::exercises::view(model, data_model).map_msg(Msg::Exercises), - Some(Page::Exercise(model)) => - page::exercise::view(model, data_model).map_msg(Msg::Exercise), - Some(Page::Muscles(model)) => - page::muscles::view(model, data_model).map_msg(Msg::Muscles), - Some(Page::Routines(model)) => - page::routines::view(model, data_model).map_msg(Msg::Routines), - Some(Page::Routine(model)) => - page::routine::view(model, data_model).map_msg(Msg::Routine), - Some(Page::Training(model)) => - page::training::view(model, data_model).map_msg(Msg::Training), - Some(Page::TrainingSession(model)) => - page::training_session::view(model, data_model).map_msg(Msg::TrainingSession), - Some(Page::NotFound) => page::not_found::view(), - None => common::view_loading(), - } - ] -} - -fn view_settings_dialog(data_model: &data::Model) -> Node { - common::view_dialog( - "primary", - "Settings", - nodes![ - p![ - h1![C!["subtitle"], "Beep volume"], - input![ - C!["slider"], - C!["is-fullwidth"], - C!["is-info"], - attrs! { - At::Type => "range", - At::Value => data_model.settings.beep_volume, - At::Min => 0, - At::Max => 100, - At::Step => 10, - }, - input_ev(Ev::Input, Msg::BeepVolumeChanged), - ] - ], - p![ - C!["mb-5"], - h1![C!["subtitle"], "Theme"], - div![ - C!["field"], - C!["has-addons"], - p![ - C!["control"], - button![ - C!["button"], - C![IF![data_model.settings.theme == data::Theme::Light => "is-link"]], - &ev(Ev::Click, move |_| Msg::SetTheme(data::Theme::Light)), - span![C!["icon"], C!["is-small"], i![C!["fas fa-sun"]]], - span!["Light"], - ] - ], - p![ - C!["control"], - span![ - C!["button"], - C![IF![data_model.settings.theme == data::Theme::Dark => "is-link"]], - &ev(Ev::Click, move |_| Msg::SetTheme(data::Theme::Dark)), - span![C!["icon"], i![C!["fas fa-moon"]]], - span!["Dark"], - ] - ], - p![ - C!["control"], - span![ - C!["button"], - C![IF![data_model.settings.theme == data::Theme::System => "is-link"]], - &ev(Ev::Click, move |_| Msg::SetTheme(data::Theme::System)), - span![C!["icon"], i![C!["fas fa-desktop"]]], - span!["System"], - ] - ], - ], - ], - p![ - C!["mb-5"], - h1![C!["subtitle"], "Metronome"], - button![ - C!["button"], - if data_model.settings.automatic_metronome { - C!["is-primary"] - } else { - C![] - }, - ev(Ev::Click, |_| Msg::ToggleAutomaticMetronome), - if data_model.settings.automatic_metronome { - "Automatic" - } else { - "Manual" - }, - ], - ], - p![ - C!["mb-5"], - h1![C!["subtitle"], "Rating of Perceived Exertion (RPE)"], - div![ - C!["field"], - C!["is-grouped"], - div![ - C!["control"], - button![ - C!["button"], - if data_model.settings.show_rpe { - C!["is-primary"] - } else { - C![] - }, - ev(Ev::Click, |_| Msg::ToggleShowRPE), - if data_model.settings.show_rpe { - "Enabled" - } else { - "Disabled" - }, - ] - ], - ], - ], - p![ - C!["mb-5"], - h1![C!["subtitle"], "Time Under Tension (TUT)"], - div![ - C!["field"], - C!["is-grouped"], - div![ - C!["control"], - button![ - C!["button"], - if data_model.settings.show_tut { - C!["is-primary"] - } else { - C![] - }, - ev(Ev::Click, |_| Msg::ToggleShowTUT), - if data_model.settings.show_tut { - "Enabled" - } else { - "Disabled" - }, - ] - ], - ], - ], - { - let permission = web_sys::Notification::permission(); - let notifications_enabled = data_model.settings.notifications; - p![ - C!["mb-5"], - h1![C!["subtitle"], "Notifications"], - button![ - C!["button"], - match permission { - web_sys::NotificationPermission::Granted => - if notifications_enabled { - C!["is-primary"] - } else { - C![] - }, - web_sys::NotificationPermission::Denied => C!["is-danger"], - _ => C![], - }, - ev(Ev::Click, |_| Msg::ToggleNotifications), - match permission { - web_sys::NotificationPermission::Granted => - if notifications_enabled { - "Enabled" - } else { - "Disabled" - }, - web_sys::NotificationPermission::Denied => - "Not allowed in browser settings", - _ => "Enable", - }, - ], - if let web_sys::NotificationPermission::Denied = permission { - p![ - C!["mt-3"], - "To enable notifications, tap the lock icon in the address bar and change the notification permissions. If Valens is installed as a web app and no address bar is visible, open Valens in the corresponding browser first. Note that notifications are always blocked by the browser in icognito mode or private browsing." - ] - } else { - empty![] - } - ] - }, - p![ - h1![C!["subtitle"], "Version"], - common::view_versions(&data_model.version), - IF![&data_model.version != env!("VALENS_VERSION") => - button![ - C!["button"], - C!["is-link"], - C!["mt-5"], - ev(Ev::Click, |_| Msg::UpdateApp), - "Update" - ] - ], - ], - ], - &ev(Ev::Click, |_| Msg::CloseSettingsDialog), - ) -} - -fn set_theme(theme: &data::Theme) { - if let Some(window) = web_sys::window() { - if let Some(document) = window.document() { - if let Some(html_element) = document.document_element() { - let _ = match theme { - data::Theme::System => html_element.remove_attribute("data-theme"), - data::Theme::Light => html_element.set_attribute("data-theme", "light"), - data::Theme::Dark => html_element.set_attribute("data-theme", "dark"), - }; - } - } - } -} - -// ------ ------ -// Start -// ------ ------ +mod ui; fn main() { - App::start("app", init, update, view); + ui::main(); } diff --git a/frontend/src/ui.rs b/frontend/src/ui.rs new file mode 100644 index 0000000..6ca8f6a --- /dev/null +++ b/frontend/src/ui.rs @@ -0,0 +1,910 @@ +use chrono::{prelude::*, Duration}; +use seed::{prelude::*, *}; + +mod common; +mod component; +mod data; +mod page; + +// ------ ------ +// Init +// ------ ------ + +fn init(url: Url, orders: &mut impl Orders) -> Model { + orders + .skip() + .stream(streams::window_event(Ev::BeforeUnload, Msg::BeforeUnload)) + .subscribe(Msg::UrlRequested) + .subscribe(Msg::UrlChanged) + .subscribe(Msg::Data) + .stream(streams::window_event(Ev::Click, |_| Msg::HideMenu)) + .notify(data::Msg::InitializeSession); + + let data = data::init(url, &mut orders.proxy(Msg::Data)); + + set_theme(&data.settings.theme); + + Model { + navbar: Navbar { + title: String::from("Valens"), + items: Vec::new(), + menu_visible: false, + }, + page: None, + settings_dialog_visible: false, + data, + } +} + +// ------ ------ +// Urls +// ------ ------ + +const LOGIN: &str = "login"; +const ADMIN: &str = "admin"; +const BODY_WEIGHT: &str = "body_weight"; +const BODY_FAT: &str = "body_fat"; +const MENSTRUAL_CYCLE: &str = "menstrual_cycle"; +const EXERCISES: &str = "exercises"; +const EXERCISE: &str = "exercise"; +const MUSCLES: &str = "muscles"; +const ROUTINES: &str = "routines"; +const ROUTINE: &str = "routine"; +const TRAINING: &str = "training"; +const TRAINING_SESSION: &str = "training_session"; + +struct_urls!(); +impl<'a> Urls<'a> { + pub fn home(self) -> Url { + self.base_url() + } + pub fn login(self) -> Url { + self.base_url().set_hash_path([LOGIN]) + } + pub fn admin(self) -> Url { + self.base_url().set_hash_path([ADMIN]) + } + pub fn body_weight(self) -> Url { + self.base_url().set_hash_path([BODY_WEIGHT]) + } + pub fn body_fat(self) -> Url { + self.base_url().set_hash_path([BODY_FAT]) + } + pub fn menstrual_cycle(self) -> Url { + self.base_url().set_hash_path([MENSTRUAL_CYCLE]) + } + pub fn exercises(self) -> Url { + self.base_url().set_hash_path([EXERCISES]) + } + pub fn exercise(self) -> Url { + self.base_url().set_hash_path([EXERCISE]) + } + pub fn muscles(self) -> Url { + self.base_url().set_hash_path([MUSCLES]) + } + pub fn routines(self) -> Url { + self.base_url().set_hash_path([ROUTINES]) + } + pub fn routine(self) -> Url { + self.base_url().set_hash_path([ROUTINE]) + } + pub fn training(self) -> Url { + self.base_url().set_hash_path([TRAINING]) + } + pub fn training_session(self) -> Url { + self.base_url().set_hash_path([TRAINING_SESSION]) + } +} + +// ------ ------ +// Model +// ------ ------ + +struct Model { + navbar: Navbar, + page: Option, + settings_dialog_visible: bool, + data: data::Model, +} + +pub struct Navbar { + title: String, + items: Vec<(EventHandler, String)>, + menu_visible: bool, +} + +enum Page { + Home(page::home::Model), + Login(page::login::Model), + Admin(page::admin::Model), + BodyWeight(page::body_weight::Model), + BodyFat(page::body_fat::Model), + MenstrualCycle(page::menstrual_cycle::Model), + Exercises(page::exercises::Model), + Exercise(page::exercise::Model), + Muscles(page::muscles::Model), + Routines(page::routines::Model), + Routine(page::routine::Model), + Training(page::training::Model), + TrainingSession(page::training_session::Model), + NotFound, +} + +impl Page { + fn init( + mut url: Url, + orders: &mut impl Orders, + navbar: &mut Navbar, + data_model: &data::Model, + ) -> Self { + navbar.items.clear(); + + if data_model.session.is_some() { + match url.next_hash_path_part() { + None => Self::Home(page::home::init( + url, + &mut orders.proxy(Msg::Home), + data_model, + navbar, + )), + Some(LOGIN) => Self::Login(page::login::init( + url, + &mut orders.proxy(Msg::Login), + navbar, + )), + Some(ADMIN) => Self::Admin(page::admin::init( + url, + &mut orders.proxy(Msg::Admin), + navbar, + )), + Some(BODY_WEIGHT) => Self::BodyWeight(page::body_weight::init( + url, + &mut orders.proxy(Msg::BodyWeight), + data_model, + navbar, + )), + Some(BODY_FAT) => Self::BodyFat(page::body_fat::init( + url, + &mut orders.proxy(Msg::BodyFat), + data_model, + navbar, + )), + Some(MENSTRUAL_CYCLE) => Self::MenstrualCycle(page::menstrual_cycle::init( + url, + &mut orders.proxy(Msg::MenstrualCycle), + data_model, + navbar, + )), + Some(EXERCISES) => Self::Exercises(page::exercises::init( + url, + &mut orders.proxy(Msg::Exercises), + navbar, + )), + Some(EXERCISE) => Self::Exercise(page::exercise::init( + url, + &mut orders.proxy(Msg::Exercise), + data_model, + navbar, + )), + Some(MUSCLES) => Self::Muscles(page::muscles::init(data_model, navbar)), + Some(ROUTINES) => Self::Routines(page::routines::init( + url, + &mut orders.proxy(Msg::Routines), + navbar, + )), + Some(ROUTINE) => Self::Routine(page::routine::init( + url, + &mut orders.proxy(Msg::Routine), + data_model, + navbar, + )), + Some(TRAINING) => Self::Training(page::training::init( + url, + &mut orders.proxy(Msg::Training), + data_model, + navbar, + )), + Some(TRAINING_SESSION) => Self::TrainingSession(page::training_session::init( + url, + &mut orders.proxy(Msg::TrainingSession), + data_model, + navbar, + )), + Some(_) => Self::NotFound, + } + } else { + match url.next_hash_path_part() { + Some(ADMIN) => Self::Admin(page::admin::init( + url, + &mut orders.proxy(Msg::Admin), + navbar, + )), + None | Some(_) => { + Urls::new(url.to_hash_base_url()).login().go_and_push(); + Self::Login(page::login::init( + url, + &mut orders.proxy(Msg::Login), + navbar, + )) + } + } + } + } +} + +// ------ ------ +// Update +// ------ ------ + +enum Msg { + BeforeUnload(web_sys::Event), + UrlRequested(subs::UrlRequested), + UrlChanged(subs::UrlChanged), + + ToggleMenu, + HideMenu, + ShowSettingsDialog, + CloseSettingsDialog, + BeepVolumeChanged(String), + SetTheme(data::Theme), + ToggleAutomaticMetronome, + ToggleNotifications, + ToggleShowRPE, + ToggleShowTUT, + UpdateApp, + GoUp, + LogOut, + + // ------ Pages ------ + Home(page::home::Msg), + Login(page::login::Msg), + Admin(page::admin::Msg), + BodyWeight(page::body_weight::Msg), + BodyFat(page::body_fat::Msg), + MenstrualCycle(page::menstrual_cycle::Msg), + Exercises(page::exercises::Msg), + Exercise(page::exercise::Msg), + Muscles(page::muscles::Msg), + Routines(page::routines::Msg), + Routine(page::routine::Msg), + Training(page::training::Msg), + TrainingSession(page::training_session::Msg), + + // ------ Data ------ + Data(data::Msg), +} + +fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders) { + match msg { + Msg::BeforeUnload(event) => { + if warn_about_unsaved_changes(model) { + let event = event.unchecked_into::(); + event.prevent_default(); + event.set_return_value(""); + } + } + Msg::UrlRequested(subs::UrlRequested(_, url_request)) => { + if warn_about_unsaved_changes(model) { + if Ok(true) + == window().confirm_with_message( + "Do you want to leave this page? Changes will not be saved.", + ) + { + return; + } + url_request.handled_and_prevent_refresh(); + } + } + Msg::UrlChanged(subs::UrlChanged(url)) => { + model.page = Some(Page::init(url, orders, &mut model.navbar, &model.data)); + orders.send_msg(Msg::Data(data::Msg::ClearErrors)); + window().scroll_to_with_scroll_to_options(web_sys::ScrollToOptions::new().top(0.)); + } + + Msg::ToggleMenu => model.navbar.menu_visible = not(model.navbar.menu_visible), + Msg::HideMenu => { + if model.navbar.menu_visible { + model.navbar.menu_visible = false; + } else { + orders.skip(); + } + } + Msg::ShowSettingsDialog => { + model.settings_dialog_visible = true; + } + Msg::CloseSettingsDialog => { + model.settings_dialog_visible = false; + } + Msg::BeepVolumeChanged(input) => { + if let Ok(value) = input.parse::() { + orders.send_msg(Msg::Data(data::Msg::SetBeepVolume(value))); + } + } + Msg::SetTheme(theme) => { + set_theme(&theme); + orders.send_msg(Msg::Data(data::Msg::SetTheme(theme))); + } + Msg::ToggleAutomaticMetronome => { + orders.send_msg(Msg::Data(data::Msg::SetAutomaticMetronome(not(model + .data + .settings + .automatic_metronome)))); + } + Msg::ToggleNotifications => match web_sys::Notification::permission() { + web_sys::NotificationPermission::Granted => { + orders.send_msg(Msg::Data(data::Msg::SetNotifications(not(model + .data + .settings + .notifications)))); + } + web_sys::NotificationPermission::Denied => {} + _ => { + orders + .skip() + .perform_cmd(async { + if let Ok(promise) = web_sys::Notification::request_permission() { + #[allow(unused_must_use)] + { + wasm_bindgen_futures::JsFuture::from(promise).await; + } + } + }) + .send_msg(Msg::Data(data::Msg::SetNotifications(true))); + } + }, + Msg::ToggleShowRPE => { + orders.send_msg(Msg::Data(data::Msg::SetShowRPE(not(model + .data + .settings + .show_rpe)))); + } + Msg::ToggleShowTUT => { + orders.send_msg(Msg::Data(data::Msg::SetShowTUT(not(model + .data + .settings + .show_tut)))); + } + Msg::UpdateApp => { + orders.skip().send_msg(Msg::Data(data::Msg::UpdateApp)); + } + Msg::GoUp => match &model.page { + Some(Page::Home(_) | Page::Login(_)) => {} + Some(Page::Admin(_)) => { + orders.request_url(crate::ui::Urls::new(&model.data.base_url).login()); + } + Some( + Page::BodyWeight(_) + | Page::BodyFat(_) + | Page::MenstrualCycle(_) + | Page::Training(_) + | Page::NotFound, + ) + | None => { + orders.request_url(crate::ui::Urls::new(&model.data.base_url).home()); + } + Some(Page::Exercise(_)) => { + orders.request_url(crate::ui::Urls::new(&model.data.base_url).exercises()); + } + Some(Page::Routine(_)) => { + orders.request_url(crate::ui::Urls::new(&model.data.base_url).routines()); + } + Some( + Page::TrainingSession(_) + | Page::Exercises(_) + | Page::Muscles(_) + | Page::Routines(_), + ) => { + orders.request_url(crate::ui::Urls::new(&model.data.base_url).training()); + } + }, + Msg::LogOut => { + orders.skip().notify(data::Msg::DeleteSession); + } + + // ------ Pages ------ + Msg::Home(msg) => { + if let Some(Page::Home(page_model)) = &mut model.page { + page::home::update(msg, page_model, &mut orders.proxy(Msg::Home)); + } + } + Msg::Login(msg) => { + if let Some(Page::Login(page_model)) = &mut model.page { + page::login::update(msg, page_model, &mut orders.proxy(Msg::Login)); + } + } + Msg::Admin(msg) => { + if let Some(Page::Admin(page_model)) = &mut model.page { + page::admin::update(msg, page_model, &model.data, &mut orders.proxy(Msg::Admin)); + } + } + Msg::BodyWeight(msg) => { + if let Some(Page::BodyWeight(page_model)) = &mut model.page { + page::body_weight::update( + msg, + page_model, + &model.data, + &mut orders.proxy(Msg::BodyWeight), + ); + } + } + Msg::BodyFat(msg) => { + if let Some(Page::BodyFat(page_model)) = &mut model.page { + page::body_fat::update( + msg, + page_model, + &model.data, + &mut orders.proxy(Msg::BodyFat), + ); + } + } + Msg::MenstrualCycle(msg) => { + if let Some(Page::MenstrualCycle(page_model)) = &mut model.page { + page::menstrual_cycle::update( + msg, + page_model, + &model.data, + &mut orders.proxy(Msg::MenstrualCycle), + ); + } + } + Msg::Exercises(msg) => { + if let Some(Page::Exercises(page_model)) = &mut model.page { + page::exercises::update( + msg, + page_model, + &model.data, + &mut orders.proxy(Msg::Exercises), + ); + } + } + Msg::Exercise(msg) => { + if let Some(Page::Exercise(page_model)) = &mut model.page { + page::exercise::update( + msg, + page_model, + &model.data, + &mut orders.proxy(Msg::Exercise), + ); + } + } + Msg::Muscles(msg) => { + if let Some(Page::Muscles(page_model)) = &mut model.page { + page::muscles::update(&msg, page_model); + } + } + Msg::Routines(msg) => { + if let Some(Page::Routines(page_model)) = &mut model.page { + page::routines::update( + msg, + page_model, + &model.data, + &mut orders.proxy(Msg::Routines), + ); + } + } + Msg::Routine(msg) => { + if let Some(Page::Routine(page_model)) = &mut model.page { + page::routine::update( + msg, + page_model, + &model.data, + &mut orders.proxy(Msg::Routine), + ); + } + } + Msg::Training(msg) => { + if let Some(Page::Training(page_model)) = &mut model.page { + page::training::update( + msg, + page_model, + &model.data, + &mut orders.proxy(Msg::Training), + ); + } + } + Msg::TrainingSession(msg) => { + if let Some(Page::TrainingSession(page_model)) = &mut model.page { + page::training_session::update( + msg, + page_model, + &model.data, + &mut orders.proxy(Msg::TrainingSession), + ); + } + } + + Msg::Data(msg) => data::update(msg, &mut model.data, &mut orders.proxy(Msg::Data)), + } +} + +fn warn_about_unsaved_changes(model: &Model) -> bool { + if let Some(page) = &model.page { + if let Page::Exercise(model) = page { + model.has_unsaved_changes() + } else if let Page::Routine(model) = page { + model.has_unsaved_changes() + } else if let Page::TrainingSession(model) = page { + model.has_unsaved_changes() + } else { + false + } + } else { + false + } +} + +// ------ ------ +// View +// ------ ------ + +fn view(model: &Model) -> impl IntoNodes { + if model.settings_dialog_visible { + nodes![ + view_navbar(&model.navbar, &model.page, &model.data), + Node::NoChange, + view_settings_dialog(&model.data), + data::view(&model.data).map_msg(Msg::Data), + ] + } else { + nodes![ + view_navbar(&model.navbar, &model.page, &model.data), + view_page(&model.page, &model.data), + div![], + data::view(&model.data).map_msg(Msg::Data), + ] + } +} + +fn view_navbar(navbar: &Navbar, page: &Option, data_model: &data::Model) -> Node { + nav![ + C!["navbar"], + C!["is-fixed-top"], + C!["is-primary"], + C!["has-shadow"], + C!["has-text-weight-bold"], + div![ + C!["container"], + div![ + C!["navbar-brand"], + C!["is-flex-grow-1"], + a![ + C!["navbar-item"], + if let Some(Page::Home(_) | Page::Login(_)) = page { + C!["has-text-primary"] + } else { + C![] + }, + C!["is-size-5"], + ev(Ev::Click, |_| Msg::GoUp), + span![C!["icon"], i![C!["fas fa-chevron-left"]]] + ], + div![C!["navbar-item"], C!["is-size-5"], &navbar.title], + div![C!["mx-auto"]], + &navbar + .items + .iter() + .map(|item| { + a![ + C!["navbar-item"], + C!["is-size-5"], + C!["mx-1"], + &item.0, + span![C!["icon"], i![C![format!("fas fa-{}", item.1)]]], + ] + }) + .collect::>(), + IF![data_model.session.is_some() => + a![ + C!["navbar-burger"], + C!["ml-0"], + C![IF!(navbar.menu_visible => "is-active")], + attrs! { + At::from("role") => "button", + At::AriaLabel => "menu", + At::AriaExpanded => navbar.menu_visible, + }, + ev(Ev::Click, |event| { + event.stop_propagation(); + Msg::ToggleMenu + }), + span![attrs! {At::AriaHidden => "true"}], + span![attrs! {At::AriaHidden => "true"}], + span![attrs! {At::AriaHidden => "true"}], + span![attrs! {At::AriaHidden => "true"}], + ] + ], + ], + IF![data_model.session.is_some() => + div![ + C!["navbar-menu"], + C!["is-flex-grow-0"], + C![IF!(navbar.menu_visible => "is-active")], + div![ + C!["navbar-end"], + match &data_model.session { + Some(s) => nodes![ + a![ + C!["navbar-item"], + ev(Ev::Click, |_| Msg::ShowSettingsDialog), + span![C!["icon"], C!["px-5"], i![C!["fas fa-gear"]]], + "Settings" + ], + a![ + C!["navbar-item"], + ev(Ev::Click, |_| Msg::Data(data::Msg::Refresh)), + span![C!["icon"], C!["px-5"], i![C!["fas fa-rotate"]]], + format!( + "Refresh data ({})", + view_duration(Utc::now() - data_model.last_refresh) + ), + ], + a![ + C!["navbar-item"], + ev(Ev::Click, |_| Msg::LogOut), + span![C!["icon"], C!["px-5"], i![C!["fas fa-sign-out-alt"]]], + format!("Logout ({})", s.name), + ] + ], + None => nodes![], + } + ], + ] + ] + ] + ] +} + +fn view_duration(duration: Duration) -> String { + if duration < Duration::minutes(1) { + String::from("now") + } else if duration < Duration::hours(1) { + format!("{} min ago", duration.num_minutes()) + } else if duration < Duration::days(1) { + format!("{} h ago", duration.num_hours()) + } else { + format!("{} days ago", duration.num_days()) + } +} + +fn view_page(page: &Option, data_model: &data::Model) -> Node { + div![ + C!["container"], + C!["is-max-desktop"], + C!["py-4"], + match page { + Some(Page::Home(model)) => page::home::view(model, data_model).map_msg(Msg::Home), + Some(Page::Login(model)) => page::login::view(model, data_model).map_msg(Msg::Login), + Some(Page::Admin(model)) => page::admin::view(model, data_model).map_msg(Msg::Admin), + Some(Page::BodyWeight(model)) => + page::body_weight::view(model, data_model).map_msg(Msg::BodyWeight), + Some(Page::BodyFat(model)) => + page::body_fat::view(model, data_model).map_msg(Msg::BodyFat), + Some(Page::MenstrualCycle(model)) => + page::menstrual_cycle::view(model, data_model).map_msg(Msg::MenstrualCycle), + Some(Page::Exercises(model)) => + page::exercises::view(model, data_model).map_msg(Msg::Exercises), + Some(Page::Exercise(model)) => + page::exercise::view(model, data_model).map_msg(Msg::Exercise), + Some(Page::Muscles(model)) => + page::muscles::view(model, data_model).map_msg(Msg::Muscles), + Some(Page::Routines(model)) => + page::routines::view(model, data_model).map_msg(Msg::Routines), + Some(Page::Routine(model)) => + page::routine::view(model, data_model).map_msg(Msg::Routine), + Some(Page::Training(model)) => + page::training::view(model, data_model).map_msg(Msg::Training), + Some(Page::TrainingSession(model)) => + page::training_session::view(model, data_model).map_msg(Msg::TrainingSession), + Some(Page::NotFound) => page::not_found::view(), + None => common::view_loading(), + } + ] +} + +fn view_settings_dialog(data_model: &data::Model) -> Node { + common::view_dialog( + "primary", + "Settings", + nodes![ + p![ + h1![C!["subtitle"], "Beep volume"], + input![ + C!["slider"], + C!["is-fullwidth"], + C!["is-info"], + attrs! { + At::Type => "range", + At::Value => data_model.settings.beep_volume, + At::Min => 0, + At::Max => 100, + At::Step => 10, + }, + input_ev(Ev::Input, Msg::BeepVolumeChanged), + ] + ], + p![ + C!["mb-5"], + h1![C!["subtitle"], "Theme"], + div![ + C!["field"], + C!["has-addons"], + p![ + C!["control"], + button![ + C!["button"], + C![IF![data_model.settings.theme == data::Theme::Light => "is-link"]], + &ev(Ev::Click, move |_| Msg::SetTheme(data::Theme::Light)), + span![C!["icon"], C!["is-small"], i![C!["fas fa-sun"]]], + span!["Light"], + ] + ], + p![ + C!["control"], + span![ + C!["button"], + C![IF![data_model.settings.theme == data::Theme::Dark => "is-link"]], + &ev(Ev::Click, move |_| Msg::SetTheme(data::Theme::Dark)), + span![C!["icon"], i![C!["fas fa-moon"]]], + span!["Dark"], + ] + ], + p![ + C!["control"], + span![ + C!["button"], + C![IF![data_model.settings.theme == data::Theme::System => "is-link"]], + &ev(Ev::Click, move |_| Msg::SetTheme(data::Theme::System)), + span![C!["icon"], i![C!["fas fa-desktop"]]], + span!["System"], + ] + ], + ], + ], + p![ + C!["mb-5"], + h1![C!["subtitle"], "Metronome"], + button![ + C!["button"], + if data_model.settings.automatic_metronome { + C!["is-primary"] + } else { + C![] + }, + ev(Ev::Click, |_| Msg::ToggleAutomaticMetronome), + if data_model.settings.automatic_metronome { + "Automatic" + } else { + "Manual" + }, + ], + ], + p![ + C!["mb-5"], + h1![C!["subtitle"], "Rating of Perceived Exertion (RPE)"], + div![ + C!["field"], + C!["is-grouped"], + div![ + C!["control"], + button![ + C!["button"], + if data_model.settings.show_rpe { + C!["is-primary"] + } else { + C![] + }, + ev(Ev::Click, |_| Msg::ToggleShowRPE), + if data_model.settings.show_rpe { + "Enabled" + } else { + "Disabled" + }, + ] + ], + ], + ], + p![ + C!["mb-5"], + h1![C!["subtitle"], "Time Under Tension (TUT)"], + div![ + C!["field"], + C!["is-grouped"], + div![ + C!["control"], + button![ + C!["button"], + if data_model.settings.show_tut { + C!["is-primary"] + } else { + C![] + }, + ev(Ev::Click, |_| Msg::ToggleShowTUT), + if data_model.settings.show_tut { + "Enabled" + } else { + "Disabled" + }, + ] + ], + ], + ], + { + let permission = web_sys::Notification::permission(); + let notifications_enabled = data_model.settings.notifications; + p![ + C!["mb-5"], + h1![C!["subtitle"], "Notifications"], + button![ + C!["button"], + match permission { + web_sys::NotificationPermission::Granted => + if notifications_enabled { + C!["is-primary"] + } else { + C![] + }, + web_sys::NotificationPermission::Denied => C!["is-danger"], + _ => C![], + }, + ev(Ev::Click, |_| Msg::ToggleNotifications), + match permission { + web_sys::NotificationPermission::Granted => + if notifications_enabled { + "Enabled" + } else { + "Disabled" + }, + web_sys::NotificationPermission::Denied => + "Not allowed in browser settings", + _ => "Enable", + }, + ], + if let web_sys::NotificationPermission::Denied = permission { + p![ + C!["mt-3"], + "To enable notifications, tap the lock icon in the address bar and change the notification permissions. If Valens is installed as a web app and no address bar is visible, open Valens in the corresponding browser first. Note that notifications are always blocked by the browser in icognito mode or private browsing." + ] + } else { + empty![] + } + ] + }, + p![ + h1![C!["subtitle"], "Version"], + common::view_versions(&data_model.version), + IF![&data_model.version != env!("VALENS_VERSION") => + button![ + C!["button"], + C!["is-link"], + C!["mt-5"], + ev(Ev::Click, |_| Msg::UpdateApp), + "Update" + ] + ], + ], + ], + &ev(Ev::Click, |_| Msg::CloseSettingsDialog), + ) +} + +fn set_theme(theme: &data::Theme) { + if let Some(window) = web_sys::window() { + if let Some(document) = window.document() { + if let Some(html_element) = document.document_element() { + let _ = match theme { + data::Theme::System => html_element.remove_attribute("data-theme"), + data::Theme::Light => html_element.set_attribute("data-theme", "light"), + data::Theme::Dark => html_element.set_attribute("data-theme", "dark"), + }; + } + } + } +} + +// ------ ------ +// Start +// ------ ------ + +pub fn main() { + App::start("app", init, update, view); +} diff --git a/frontend/src/common.rs b/frontend/src/ui/common.rs similarity index 99% rename from frontend/src/common.rs rename to frontend/src/ui/common.rs index 78b008f..ec8fde6 100644 --- a/frontend/src/common.rs +++ b/frontend/src/ui/common.rs @@ -4,7 +4,7 @@ use chrono::{prelude::*, Duration}; use plotters::prelude::*; use seed::{prelude::*, *}; -use crate::{data, domain}; +use crate::{domain, ui::data}; pub const ENTER_KEY: u32 = 13; diff --git a/frontend/src/component.rs b/frontend/src/ui/component.rs similarity index 100% rename from frontend/src/component.rs rename to frontend/src/ui/component.rs diff --git a/frontend/src/component/exercise_list.rs b/frontend/src/ui/component/exercise_list.rs similarity index 99% rename from frontend/src/component/exercise_list.rs rename to frontend/src/ui/component/exercise_list.rs index f21af1e..3855e7d 100644 --- a/frontend/src/component/exercise_list.rs +++ b/frontend/src/ui/component/exercise_list.rs @@ -1,6 +1,9 @@ use seed::{prelude::*, *}; -use crate::{common, data, domain}; +use crate::{ + domain, + ui::{common, data}, +}; // ------ ------ // Model diff --git a/frontend/src/data.rs b/frontend/src/ui/data.rs similarity index 99% rename from frontend/src/data.rs rename to frontend/src/ui/data.rs index 2343f56..3fc7e87 100644 --- a/frontend/src/data.rs +++ b/frontend/src/ui/data.rs @@ -6,7 +6,10 @@ use gloo_storage::Storage; use seed::{prelude::*, *}; use serde_json::{json, Map}; -use crate::{common, domain}; +use crate::{ + domain, + ui::{self, common}, +}; const STORAGE_KEY_SETTINGS: &str = "settings"; const STORAGE_KEY_ONGOING_TRAINING_SESSION: &str = "ongoing training session"; @@ -1240,9 +1243,9 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders) { } Msg::SessionReceived(Ok(new_session)) => { model.session = Some(new_session); - orders.send_msg(Msg::Refresh).request_url( - crate::Urls::new(model.base_url.clone().set_hash_path([""; 0])).home(), - ); + orders + .send_msg(Msg::Refresh) + .request_url(ui::Urls::new(model.base_url.clone().set_hash_path([""; 0])).home()); } Msg::SessionReceived(Err(message)) => { model.session = None; @@ -1278,7 +1281,7 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders) { } Msg::SessionDeleted(Ok(())) => { model.session = None; - orders.request_url(crate::Urls::new(&model.base_url).login()); + orders.request_url(ui::Urls::new(&model.base_url).login()); } Msg::SessionDeleted(Err(message)) => { model diff --git a/frontend/src/page.rs b/frontend/src/ui/page.rs similarity index 100% rename from frontend/src/page.rs rename to frontend/src/ui/page.rs diff --git a/frontend/src/page/admin.rs b/frontend/src/ui/page/admin.rs similarity index 99% rename from frontend/src/page/admin.rs rename to frontend/src/ui/page/admin.rs index 21244e2..6be61a8 100644 --- a/frontend/src/page/admin.rs +++ b/frontend/src/ui/page/admin.rs @@ -1,13 +1,12 @@ use seed::{prelude::*, *}; -use crate::common; -use crate::data; +use crate::ui::{self, common, data}; // ------ ------ // Init // ------ ------ -pub fn init(_: Url, orders: &mut impl Orders, navbar: &mut crate::Navbar) -> Model { +pub fn init(_: Url, orders: &mut impl Orders, navbar: &mut ui::Navbar) -> Model { orders .subscribe(Msg::DataEvent) .notify(data::Msg::ReadVersion) diff --git a/frontend/src/page/body_fat.rs b/frontend/src/ui/page/body_fat.rs similarity index 99% rename from frontend/src/page/body_fat.rs rename to frontend/src/ui/page/body_fat.rs index af83677..4937a55 100644 --- a/frontend/src/page/body_fat.rs +++ b/frontend/src/ui/page/body_fat.rs @@ -1,8 +1,7 @@ use chrono::prelude::*; use seed::{prelude::*, *}; -use crate::common; -use crate::data; +use crate::ui::{self, common, data}; // ------ ------ // Init @@ -12,7 +11,7 @@ pub fn init( mut url: Url, orders: &mut impl Orders, data_model: &data::Model, - navbar: &mut crate::Navbar, + navbar: &mut ui::Navbar, ) -> Model { if url.next_hash_path_part() == Some("add") { orders.send_msg(Msg::ShowAddBodyFatDialog); @@ -211,7 +210,7 @@ pub fn update( } Msg::CloseBodyFatDialog => { model.dialog = Dialog::Hidden; - Url::go_and_replace(&crate::Urls::new(&data_model.base_url).body_fat()); + Url::go_and_replace(&ui::Urls::new(&data_model.base_url).body_fat()); } Msg::DateChanged(date) => match model.dialog { diff --git a/frontend/src/page/body_weight.rs b/frontend/src/ui/page/body_weight.rs similarity index 99% rename from frontend/src/page/body_weight.rs rename to frontend/src/ui/page/body_weight.rs index 5d54039..a043f73 100644 --- a/frontend/src/page/body_weight.rs +++ b/frontend/src/ui/page/body_weight.rs @@ -5,8 +5,7 @@ use chrono::prelude::*; use chrono::Duration; use seed::{prelude::*, *}; -use crate::common; -use crate::data; +use crate::ui::{self, common, data}; // ------ ------ // Init @@ -16,7 +15,7 @@ pub fn init( mut url: Url, orders: &mut impl Orders, data_model: &data::Model, - navbar: &mut crate::Navbar, + navbar: &mut ui::Navbar, ) -> Model { if url.next_hash_path_part() == Some("add") { orders.send_msg(Msg::ShowAddBodyWeightDialog); @@ -116,7 +115,7 @@ pub fn update( } Msg::CloseBodyWeightDialog => { model.dialog = Dialog::Hidden; - Url::go_and_replace(&crate::Urls::new(&data_model.base_url).body_weight()); + Url::go_and_replace(&ui::Urls::new(&data_model.base_url).body_weight()); } Msg::DateChanged(date) => match model.dialog { diff --git a/frontend/src/page/exercise.rs b/frontend/src/ui/page/exercise.rs similarity index 97% rename from frontend/src/page/exercise.rs rename to frontend/src/ui/page/exercise.rs index fe6aa12..e329025 100644 --- a/frontend/src/page/exercise.rs +++ b/frontend/src/ui/page/exercise.rs @@ -3,10 +3,10 @@ use std::collections::BTreeMap; use chrono::prelude::*; use seed::{prelude::*, *}; -use crate::common; -use crate::data; -use crate::domain; -use crate::page::training; +use crate::{ + domain, + ui::{self, common, data, page::training}, +}; // ------ ------ // Init @@ -16,7 +16,7 @@ pub fn init( mut url: Url, orders: &mut impl Orders, data_model: &data::Model, - navbar: &mut crate::Navbar, + navbar: &mut ui::Navbar, ) -> Model { let exercise_id = url .next_hash_path_part() @@ -108,7 +108,7 @@ pub fn update( Msg::EditExercise => { model.editing = true; Url::go_and_push( - &crate::Urls::new(&data_model.base_url) + &ui::Urls::new(&data_model.base_url) .exercise() .add_hash_path_part(model.exercise_id.to_string()) .add_hash_path_part("edit"), @@ -137,7 +137,7 @@ pub fn update( model.dialog = Dialog::Hidden; model.loading = false; Url::go_and_replace( - &crate::Urls::new(&data_model.base_url) + &ui::Urls::new(&data_model.base_url) .routine() .add_hash_path_part(model.exercise_id.to_string()), ); @@ -189,7 +189,7 @@ pub fn update( model.editing = false; model.mark_as_unchanged(); Url::go_and_push( - &crate::Urls::new(&data_model.base_url) + &ui::Urls::new(&data_model.base_url) .exercise() .add_hash_path_part(model.exercise_id.to_string()), ); @@ -699,7 +699,7 @@ fn view_sets( C!["mb-2"], a![ attrs! { - At::Href => crate::Urls::new(base_url).training_session().add_hash_path_part(t.id.to_string()), + At::Href => ui::Urls::new(base_url).training_session().add_hash_path_part(t.id.to_string()), }, span![style! {St::WhiteSpace => "nowrap" }, t.date.to_string()] ], @@ -707,7 +707,7 @@ fn view_sets( if let Some(routine_id) = t.routine_id { a![ attrs! { - At::Href => crate::Urls::new(base_url).routine().add_hash_path_part(t.routine_id.unwrap().to_string()), + At::Href => ui::Urls::new(base_url).routine().add_hash_path_part(t.routine_id.unwrap().to_string()), }, match &routines.get(&routine_id) { Some(routine) => raw![&routine.name], diff --git a/frontend/src/page/exercises.rs b/frontend/src/ui/page/exercises.rs similarity index 97% rename from frontend/src/page/exercises.rs rename to frontend/src/ui/page/exercises.rs index 93e1936..ec5d793 100644 --- a/frontend/src/page/exercises.rs +++ b/frontend/src/ui/page/exercises.rs @@ -2,15 +2,13 @@ use std::collections::BTreeMap; use seed::{prelude::*, *}; -use crate::common; -use crate::component; -use crate::data; +use crate::ui::{self, common, component, data}; // ------ ------ // Init // ------ ------ -pub fn init(mut url: Url, orders: &mut impl Orders, navbar: &mut crate::Navbar) -> Model { +pub fn init(mut url: Url, orders: &mut impl Orders, navbar: &mut ui::Navbar) -> Model { if url.next_hash_path_part() == Some("add") { orders.send_msg(Msg::ShowAddExerciseDialog); } @@ -97,7 +95,7 @@ pub fn update( } Msg::CloseExerciseDialog => { model.dialog = Dialog::Hidden; - Url::go_and_replace(&crate::Urls::new(&data_model.base_url).exercises()); + Url::go_and_replace(&ui::Urls::new(&data_model.base_url).exercises()); } Msg::ExerciseList(msg) => { @@ -150,7 +148,7 @@ pub fn update( }, Msg::GoToExercise(id) => { - let url = crate::Urls::new(&data_model.base_url) + let url = ui::Urls::new(&data_model.base_url) .exercise() .add_hash_path_part(id.to_string()); url.go_and_push(); diff --git a/frontend/src/page/home.rs b/frontend/src/ui/page/home.rs similarity index 94% rename from frontend/src/page/home.rs rename to frontend/src/ui/page/home.rs index b812f5a..5c70c7d 100644 --- a/frontend/src/page/home.rs +++ b/frontend/src/ui/page/home.rs @@ -1,7 +1,7 @@ use chrono::prelude::*; use seed::{prelude::*, *}; -use crate::{common, data}; +use crate::ui::{self, common, data}; // ------ ------ // Init @@ -12,7 +12,7 @@ pub fn init( _url: Url, _orders: &mut impl Orders, data_model: &data::Model, - navbar: &mut crate::Navbar, + navbar: &mut ui::Navbar, ) -> Model { navbar .title @@ -136,19 +136,19 @@ pub fn view(_model: &Model, data_model: &data::Model) -> Node { "Training", &training_subtitle, &training_content, - crate::Urls::new(&data_model.base_url).training() + ui::Urls::new(&data_model.base_url).training() ), view_tile( "Body weight", &body_weight_subtitle, &body_weight_content, - crate::Urls::new(&data_model.base_url).body_weight() + ui::Urls::new(&data_model.base_url).body_weight() ), view_tile( "Body fat", &body_fat_subtitle, &body_fat_content, - crate::Urls::new(&data_model.base_url).body_fat() + ui::Urls::new(&data_model.base_url).body_fat() ), IF![ data_model.session.as_ref().unwrap().sex == 0 => { @@ -156,7 +156,7 @@ pub fn view(_model: &Model, data_model: &data::Model) -> Node { "Menstrual cycle", &menstrual_cycle_subtitle, &menstrual_cycle_content, - crate::Urls::new(&data_model.base_url).menstrual_cycle()) + ui::Urls::new(&data_model.base_url).menstrual_cycle()) } ], ] diff --git a/frontend/src/page/login.rs b/frontend/src/ui/page/login.rs similarity index 90% rename from frontend/src/page/login.rs rename to frontend/src/ui/page/login.rs index 8337f72..eaff00c 100644 --- a/frontend/src/page/login.rs +++ b/frontend/src/ui/page/login.rs @@ -1,21 +1,18 @@ use seed::{prelude::*, *}; -use crate::common; -use crate::data; +use crate::ui::{self, common, data}; // ------ ------ // Init // ------ ------ -pub fn init(url: Url, orders: &mut impl Orders, navbar: &mut crate::Navbar) -> Model { +pub fn init(url: Url, orders: &mut impl Orders, navbar: &mut ui::Navbar) -> Model { orders.notify(data::Msg::ReadUsers); navbar.title = String::from("Valens"); navbar.items = vec![( ev(Ev::Click, move |_| { - crate::Urls::new(url.to_hash_base_url()) - .admin() - .go_and_load(); + ui::Urls::new(url.to_hash_base_url()).admin().go_and_load(); }), String::from("gears"), )]; diff --git a/frontend/src/page/menstrual_cycle.rs b/frontend/src/ui/page/menstrual_cycle.rs similarity index 98% rename from frontend/src/page/menstrual_cycle.rs rename to frontend/src/ui/page/menstrual_cycle.rs index bd53666..f66e29c 100644 --- a/frontend/src/page/menstrual_cycle.rs +++ b/frontend/src/ui/page/menstrual_cycle.rs @@ -1,8 +1,7 @@ use chrono::prelude::*; use seed::{prelude::*, *}; -use crate::common; -use crate::data; +use crate::ui::{self, common, data}; // ------ ------ // Init @@ -12,7 +11,7 @@ pub fn init( mut url: Url, orders: &mut impl Orders, data_model: &data::Model, - navbar: &mut crate::Navbar, + navbar: &mut ui::Navbar, ) -> Model { if url.next_hash_path_part() == Some("add") { orders.send_msg(Msg::ShowAddPeriodDialog); @@ -112,7 +111,7 @@ pub fn update( } Msg::ClosePeriodDialog => { model.dialog = Dialog::Hidden; - Url::go_and_replace(&crate::Urls::new(&data_model.base_url).menstrual_cycle()); + Url::go_and_replace(&ui::Urls::new(&data_model.base_url).menstrual_cycle()); } Msg::DateChanged(date) => match model.dialog { diff --git a/frontend/src/page/muscles.rs b/frontend/src/ui/page/muscles.rs similarity index 96% rename from frontend/src/page/muscles.rs rename to frontend/src/ui/page/muscles.rs index 7b944f4..4ca5e34 100644 --- a/frontend/src/page/muscles.rs +++ b/frontend/src/ui/page/muscles.rs @@ -1,15 +1,16 @@ use chrono::prelude::*; use seed::{prelude::*, *}; -use crate::common; -use crate::data; -use crate::domain; +use crate::{ + domain, + ui::{self, common, data}, +}; // ------ ------ // Init // ------ ------ -pub fn init(data_model: &data::Model, navbar: &mut crate::Navbar) -> Model { +pub fn init(data_model: &data::Model, navbar: &mut ui::Navbar) -> Model { navbar.title = String::from("Muscles"); Model { diff --git a/frontend/src/page/not_found.rs b/frontend/src/ui/page/not_found.rs similarity index 81% rename from frontend/src/page/not_found.rs rename to frontend/src/ui/page/not_found.rs index 8443e3d..474fbbb 100644 --- a/frontend/src/page/not_found.rs +++ b/frontend/src/ui/page/not_found.rs @@ -1,6 +1,6 @@ use seed::prelude::*; -use crate::common; +use crate::ui::common; pub fn view() -> Node { common::view_error_not_found("Page") diff --git a/frontend/src/page/routine.rs b/frontend/src/ui/page/routine.rs similarity index 99% rename from frontend/src/page/routine.rs rename to frontend/src/ui/page/routine.rs index 69f7021..0bbfc04 100644 --- a/frontend/src/page/routine.rs +++ b/frontend/src/ui/page/routine.rs @@ -3,11 +3,10 @@ use std::collections::{BTreeMap, BTreeSet}; use chrono::prelude::*; use seed::{prelude::*, *}; -use crate::common; -use crate::component; -use crate::data; -use crate::domain; -use crate::page::training; +use crate::{ + domain, + ui::{self, common, component, data, page::training}, +}; // ------ ------ // Init @@ -17,7 +16,7 @@ pub fn init( mut url: Url, orders: &mut impl Orders, data_model: &data::Model, - navbar: &mut crate::Navbar, + navbar: &mut ui::Navbar, ) -> Model { let routine_id = url .next_hash_path_part() @@ -305,7 +304,7 @@ pub fn update( Msg::EditRoutine => { model.editing = true; Url::go_and_push( - &crate::Urls::new(&data_model.base_url) + &ui::Urls::new(&data_model.base_url) .routine() .add_hash_path_part(model.routine_id.to_string()) .add_hash_path_part("edit"), @@ -334,7 +333,7 @@ pub fn update( model.dialog = Dialog::Hidden; model.loading = false; Url::go_and_replace( - &crate::Urls::new(&data_model.base_url) + &ui::Urls::new(&data_model.base_url) .routine() .add_hash_path_part(model.routine_id.to_string()), ); @@ -650,7 +649,7 @@ pub fn update( model.editing = false; model.mark_as_unchanged(); Url::go_and_push( - &crate::Urls::new(&data_model.base_url) + &ui::Urls::new(&data_model.base_url) .routine() .add_hash_path_part(model.routine_id.to_string()), ); @@ -993,7 +992,7 @@ fn view_routine_part( a![ attrs! { At::Href => { - crate::Urls::new(&data_model.base_url) + ui::Urls::new(&data_model.base_url) .exercise() .add_hash_path_part(exercise_id.to_string()) } @@ -1242,7 +1241,7 @@ fn view_previous_exercises(model: &Model, data_model: &data::Model) -> Node C!["m-2"], a![ attrs! { - At::Href => crate::Urls::new(&data_model.base_url).exercise().add_hash_path_part(exercise_id.to_string()), + At::Href => ui::Urls::new(&data_model.base_url).exercise().add_hash_path_part(exercise_id.to_string()), }, &data_model.exercises.get(exercise_id).unwrap().name ] diff --git a/frontend/src/page/routines.rs b/frontend/src/ui/page/routines.rs similarity index 98% rename from frontend/src/page/routines.rs rename to frontend/src/ui/page/routines.rs index fb3017d..d5e54d3 100644 --- a/frontend/src/page/routines.rs +++ b/frontend/src/ui/page/routines.rs @@ -1,13 +1,12 @@ use seed::{prelude::*, *}; -use crate::common; -use crate::data; +use crate::ui::{self, common, data}; // ------ ------ // Init // ------ ------ -pub fn init(mut url: Url, orders: &mut impl Orders, navbar: &mut crate::Navbar) -> Model { +pub fn init(mut url: Url, orders: &mut impl Orders, navbar: &mut ui::Navbar) -> Model { if url.next_hash_path_part() == Some("add") { orders.send_msg(Msg::ShowAddRoutineDialog); } @@ -102,7 +101,7 @@ pub fn update( } Msg::CloseRoutineDialog => { model.dialog = Dialog::Hidden; - Url::go_and_replace(&crate::Urls::new(&data_model.base_url).routines()); + Url::go_and_replace(&ui::Urls::new(&data_model.base_url).routines()); } Msg::SearchTermChanged(search_term) => { @@ -399,7 +398,7 @@ fn view_table_row(id: u32, name: &str, archived: bool, base_url: &Url) -> Node { - crate::Urls::new(base_url) + ui::Urls::new(base_url) .routine() .add_hash_path_part(id.to_string()) } diff --git a/frontend/src/page/training.rs b/frontend/src/ui/page/training.rs similarity index 96% rename from frontend/src/page/training.rs rename to frontend/src/ui/page/training.rs index 2d8ee0e..8049451 100644 --- a/frontend/src/page/training.rs +++ b/frontend/src/ui/page/training.rs @@ -3,8 +3,7 @@ use std::collections::BTreeMap; use chrono::prelude::*; use seed::{prelude::*, *}; -use crate::common; -use crate::data; +use crate::ui::{self, common, data}; // ------ ------ // Init @@ -14,7 +13,7 @@ pub fn init( mut url: Url, orders: &mut impl Orders, data_model: &data::Model, - navbar: &mut crate::Navbar, + navbar: &mut ui::Navbar, ) -> Model { if url.next_hash_path_part() == Some("add") { orders.send_msg(Msg::ShowAddTrainingSessionDialog); @@ -97,7 +96,7 @@ pub fn update( } Msg::CloseTrainingSessionDialog => { model.dialog = Dialog::Hidden; - Url::go_and_replace(&crate::Urls::new(&data_model.base_url).training()); + Url::go_and_replace(&ui::Urls::new(&data_model.base_url).training()); } Msg::DateChanged(date) => match model.dialog { @@ -190,7 +189,7 @@ pub fn update( data_model.training_sessions.last_key_value() { orders.request_url( - crate::Urls::new(&data_model.base_url) + ui::Urls::new(&data_model.base_url) .training_session() .add_hash_path_part(training_session_id.to_string()) .add_hash_path_part("edit"), @@ -283,7 +282,7 @@ pub fn view(model: &Model, data_model: &data::Model) -> Node { C!["has-text-link"], C!["p-3"], attrs! { - At::Href => crate::Urls::new(&data_model.base_url).routines(), + At::Href => ui::Urls::new(&data_model.base_url).routines(), }, "Routines", ] @@ -297,7 +296,7 @@ pub fn view(model: &Model, data_model: &data::Model) -> Node { C!["has-text-link"], C!["p-3"], attrs! { - At::Href => crate::Urls::new(&data_model.base_url).exercises(), + At::Href => ui::Urls::new(&data_model.base_url).exercises(), }, "Exercises", ] @@ -311,7 +310,7 @@ pub fn view(model: &Model, data_model: &data::Model) -> Node { C!["has-text-link"], C!["p-3"], attrs! { - At::Href => crate::Urls::new(&data_model.base_url).muscles(), + At::Href => ui::Urls::new(&data_model.base_url).muscles(), }, "Muscles", ] @@ -626,7 +625,7 @@ pub fn view_table( tr![ td![a![ attrs! { - At::Href => crate::Urls::new(base_url).training_session().add_hash_path_part(t.id.to_string()), + At::Href => ui::Urls::new(base_url).training_session().add_hash_path_part(t.id.to_string()), }, span![style! {St::WhiteSpace => "nowrap" }, t.date.to_string()] ]], @@ -634,7 +633,7 @@ pub fn view_table( if let Some(routine_id) = t.routine_id { a![ attrs! { - At::Href => crate::Urls::new(base_url).routine().add_hash_path_part(t.routine_id.unwrap().to_string()), + At::Href => ui::Urls::new(base_url).routine().add_hash_path_part(t.routine_id.unwrap().to_string()), }, match &routines.get(&routine_id) { Some(routine) => raw![&routine.name], diff --git a/frontend/src/page/training_session.rs b/frontend/src/ui/page/training_session.rs similarity index 99% rename from frontend/src/page/training_session.rs rename to frontend/src/ui/page/training_session.rs index 31c1deb..e1d94fc 100644 --- a/frontend/src/page/training_session.rs +++ b/frontend/src/ui/page/training_session.rs @@ -4,9 +4,10 @@ use std::collections::{BTreeMap, HashMap, HashSet}; use chrono::{prelude::*, Duration}; use seed::{prelude::*, *}; -use crate::data; -use crate::domain; -use crate::{common, component}; +use crate::{ + domain, + ui::{self, common, component, data}, +}; // ------ ------ // Init @@ -16,7 +17,7 @@ pub fn init( mut url: Url, orders: &mut impl Orders, data_model: &data::Model, - navbar: &mut crate::Navbar, + navbar: &mut ui::Navbar, ) -> Model { let training_session_id = url .next_hash_path_part() @@ -35,9 +36,7 @@ pub fn init( navbar.title = String::from("Training session"); navbar.items = vec![( - ev(Ev::Click, |_| { - crate::Msg::TrainingSession(Msg::ShowSMTDialog) - }), + ev(Ev::Click, |_| ui::Msg::TrainingSession(Msg::ShowSMTDialog)), String::from("stopwatch"), )]; @@ -963,7 +962,7 @@ pub fn update( data_model.settings.show_tut, ); Url::go_and_push( - &crate::Urls::new(&data_model.base_url) + &ui::Urls::new(&data_model.base_url) .training_session() .add_hash_path_part(model.training_session_id.to_string()) .add_hash_path_part("guide"), @@ -991,7 +990,7 @@ pub fn update( ); orders.force_render_now().send_msg(Msg::ScrollToSection); Url::go_and_push( - &crate::Urls::new(&data_model.base_url) + &ui::Urls::new(&data_model.base_url) .training_session() .add_hash_path_part(model.training_session_id.to_string()) .add_hash_path_part("guide"), @@ -1118,7 +1117,7 @@ pub fn update( Msg::EditTrainingSession => { model.editing = true; Url::go_and_push( - &crate::Urls::new(&data_model.base_url) + &ui::Urls::new(&data_model.base_url) .training_session() .add_hash_path_part(model.training_session_id.to_string()) .add_hash_path_part("edit"), @@ -2021,7 +2020,7 @@ fn view_title(training_session: &data::TrainingSession, data_model: &data::Model common::view_title( &a![ attrs! { - At::Href => crate::Urls::new(&data_model.base_url).routine().add_hash_path_part(routine.id.to_string()), + At::Href => ui::Urls::new(&data_model.base_url).routine().add_hash_path_part(routine.id.to_string()), }, &routine.name ], @@ -2055,7 +2054,7 @@ fn view_list(model: &Model, data_model: &data::Model) -> Vec> { C!["has-text-weight-bold"], a![ attrs! { - At::Href => crate::Urls::new(&data_model.base_url).exercise().add_hash_path_part(e.exercise_id.to_string()), + At::Href => ui::Urls::new(&data_model.base_url).exercise().add_hash_path_part(e.exercise_id.to_string()), }, span![style! {St::WhiteSpace => "nowrap" }, &e.exercise_name] ] @@ -2319,7 +2318,7 @@ fn view_training_session_form(model: &Model, data_model: &data::Model) -> Vec { - crate::Urls::new(&data_model.base_url) + ui::Urls::new(&data_model.base_url) .exercise() .add_hash_path_part(s.exercise_id.to_string()) }, @@ -2900,7 +2899,7 @@ fn show_guide_timer(exercise: &ExerciseForm) -> bool { #[cfg(test)] mod tests { use super::*; - use crate::common::InputField; + use common::InputField; use pretty_assertions::assert_eq; #[test]