diff --git a/frontend/scss/app/components/_chip.scss b/frontend/scss/app/components/_chip.scss index fbcd2b783..f4f712959 100644 --- a/frontend/scss/app/components/_chip.scss +++ b/frontend/scss/app/components/_chip.scss @@ -18,11 +18,9 @@ margin-left: 0.5rem; font-weight: bold; cursor: pointer; - color: #666; - fill: var(--menu-button-warn-hover-color); - + color: var(--modest-text-color); &:hover { - color: red; + color: var(--menu-button-warn-hover-color); } } } diff --git a/frontend/scss/app/components/_icon_button.scss b/frontend/scss/app/components/_icon_button.scss index 7e1de3f33..a8020d451 100644 --- a/frontend/scss/app/components/_icon_button.scss +++ b/frontend/scss/app/components/_icon_button.scss @@ -21,4 +21,41 @@ .svg-icon { pointer-events: none; } +} + +.tp__icon-button.tp__button-primary { + background-color: var(--text-button-primary-background-color); + color: var(--text-button-primary-color); + fill : var(--text-button-primary-color); + &:focus, + &:hover { + background-color: var(--text-button-primary-hover-background-color); + color: var(--text-button-primary-hover-color); + fill : var(--text-button-primary-hover-color); + outline: none; + } +} +.tp__icon-button.tp__button-secondary { + background-color: var(--text-button-secondary-background-color); + color: var(--text-button-secondary-color); + fill : var(--text-button-secondary-color); + &:focus, + &:hover { + background-color: var(--text-button-secondary-hover-background-color); + color: var(--text-button-secondary-hover-color); + fill : var(--text-button-secondary-hover-color); + outline: none; + } +} +.tp__icon-button.tp__button-active { + background-color: var(--text-button-active-background-color); + color: var(--text-button-active-color); + fill : var(--text-button-active-color); + &:focus, + &:hover { + background-color: var(--text-button-active-hover-background-color); + color: var(--text-button-active-hover-color); + fill : var(--text-button-active-hover-color); + outline: none; + } } \ No newline at end of file diff --git a/frontend/scss/app/components/_select.scss b/frontend/scss/app/components/_select.scss index d8afc1698..bd7b6cfc5 100644 --- a/frontend/scss/app/components/_select.scss +++ b/frontend/scss/app/components/_select.scss @@ -47,5 +47,12 @@ gap: var(--gap-default); color: var(--text-color); pointer-events: none; + + span { + color: var(--tag-color); + background-color: var(--tag-background-color); + padding: var(--padding-micro) var(--padding-mini); + border-radius: var(--border-radius); + } } } diff --git a/frontend/scss/app/components/config/_config_view.scss b/frontend/scss/app/components/config/_config_view.scss index ef93e8691..d65b5a310 100644 --- a/frontend/scss/app/components/config/_config_view.scss +++ b/frontend/scss/app/components/config/_config_view.scss @@ -91,24 +91,66 @@ } .tp__schedules-config-view { + &__body { + display: flex; + flex-flow: column; + gap: var(--gap-default); + box-sizing: border-box; + overflow: auto; + } &__schedule { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(size.$config-grid-cell-width, 1fr)); - max-width: calc(size.$config-max-grid-cells * size.$config-grid-cell-width + 2 * 1rem); + display: flex; + flex-flow: column; gap: var(--gap-default); box-sizing: border-box; padding: var(--padding-default); - border: 1px solid var(--card-border-color); - border-radius: var(--border-radius); + + .tp__config-view__table { + th { + text-align: left; + } + + th,td { + width: 1%; + white-space: nowrap; + } + th:last-child, + td:last-child { + width: auto; + } + td { + vertical-align: middle; + } + + .tp__config-view__tags { + padding-left: var(--padding-default); + } + } + } &__footer { margin-top: var(--margin-default); padding-top: var(--margin-default); border-top : 1px solid var(--card-border-color); svg { - width: 240px; - height: 60px; + width: 320px; + height: 90px; } fill: var(--label-color); } + &__editor { + display: flex; + flex-flow: row wrap; + align-items: flex-end; + gap: var(--gap-default); + box-sizing: border-box; + .tp__icon-button { + svg { + width: 2.3rem; + } + } + } + &__delete-btn { + fill: var(--menu-button-warn-hover-color); + } } diff --git a/frontend/src/app/components/config/config_page.rs b/frontend/src/app/components/config/config_page.rs index cc593b62a..7eab1795c 100644 --- a/frontend/src/app/components/config/config_page.rs +++ b/frontend/src/app/components/config/config_page.rs @@ -1,6 +1,7 @@ use shared::error::TuliproxError; use shared::info_err; -use shared::model::{ConfigApiDto, HdHomeRunConfigDto, IpCheckConfigDto, MainConfigDto, MessagingConfigDto, ProxyConfigDto, ReverseProxyConfigDto, ScheduleConfigDto, VideoConfigDto, WebUiConfigDto}; +use shared::model::{ConfigApiDto, HdHomeRunConfigDto, IpCheckConfigDto, MainConfigDto, MessagingConfigDto, + ProxyConfigDto, ReverseProxyConfigDto, SchedulesConfigDto, VideoConfigDto, WebUiConfigDto}; use std::fmt; use std::str::FromStr; @@ -71,7 +72,7 @@ impl fmt::Display for ConfigPage { pub enum ConfigForm { Main(bool, MainConfigDto), Api(bool, ConfigApiDto), - Schedules(bool, ScheduleConfigDto), + Schedules(bool, SchedulesConfigDto), Video(bool, VideoConfigDto), Messaging(bool, MessagingConfigDto), WebUi(bool, WebUiConfigDto), diff --git a/frontend/src/app/components/config/schedules_config_view.rs b/frontend/src/app/components/config/schedules_config_view.rs index 6096b00fb..a74ce9a73 100644 --- a/frontend/src/app/components/config/schedules_config_view.rs +++ b/frontend/src/app/components/config/schedules_config_view.rs @@ -1,49 +1,221 @@ -use crate::app::components::{Chip, NoContent}; +use std::rc::Rc; +use crate::app::components::{Card, Chip, DropDownOption, IconButton, NoContent}; use crate::app::ConfigContext; use yew::prelude::*; use yew_i18n::use_translation; -use crate::{config_field, config_field_child}; +use shared::model::{validate_cron, ScheduleConfigDto, SchedulesConfigDto}; +use crate::app::components::config::config_page::ConfigForm; +use crate::app::components::config::config_view_context::ConfigViewContext; +use crate::app::components::config::HasFormData; +use crate::{config_field_child, generate_form_reducer, html_if}; +use crate::app::components::input::Input; +use crate::app::components::select::Select; +use crate::hooks::use_service_context; + +const LABEL_SCHEDULE: &str = "LABEL.SCHEDULE"; +const LABEL_TARGETS: &str = "LABEL.TARGETS"; +const LABEL_ALL: &str = "LABEL.ALL"; + +generate_form_reducer!( + state: SchedulesConfigFormState { form: SchedulesConfigDto }, + action_name: SchedulesConfigFormAction, + fields { + Schedules => schedules: Option>, + } +); #[function_component] pub fn SchedulesConfigView() -> Html { let translate = use_translation(); + let services_ctx = use_service_context(); let config_ctx = use_context::().expect("Config context not found"); + let config_view_ctx = use_context::().expect("ConfigViewContext not found"); + let selected_targets = use_state(|| None::>); + let selected_schedule = use_state(|| None); - html! { -
-
- { - match config_ctx.config.as_ref() { - Some(config) => { - match config.config.schedules.as_ref() { - Some(schedules) => html! { - { for schedules.iter().map(|entry| html! { -
- { config_field!(entry, translate.t("LABEL.SCHEDULE"), schedule) } - { config_field_child!(translate.t("LABEL.TARGETS"), { - html! {
- - { match entry.targets.as_ref() { - Some(targets) if !targets.is_empty() => html! { - for targets.iter().map(|t| html! { }) - }, - _ => html! {}, - } - } -
} - })} -
- }) } - }, - None => html! { } - }}, - None => html! { } + let all_targets = use_memo(config_ctx.config.clone(), + move |config| config + .as_ref() + .map(|c| &c.sources.sources) + .into_iter() + .flat_map(|sources| sources.iter()) + .flat_map(|s| s.targets.iter()) + .map(|t| Rc::new(DropDownOption { + id: t.name.clone(), + label: html! { t.name.clone() }, + selected: false, + })).collect::>>(), + ); + + let form_state: UseReducerHandle = use_reducer(|| { + SchedulesConfigFormState { form: SchedulesConfigDto::default(), modified: false } + }); + + { + let on_form_change = config_view_ctx.on_form_change.clone(); + use_effect_with(form_state.clone(), move |state| { + on_form_change.emit(ConfigForm::Schedules(state.modified, state.form.clone())); + }); + } + + { + let form_state = form_state.clone(); + let config = config_ctx.config.as_ref().map(|c| c.config.clone()); + use_effect_with((config, config_view_ctx.edit_mode.clone()), move |(cfg, _mode)| { + if let Some(conf) = cfg { + let schedules_config = SchedulesConfigDto::from(conf); + form_state.dispatch(SchedulesConfigFormAction::SetAll(schedules_config.clone())); + } else { + form_state.dispatch(SchedulesConfigFormAction::SetAll(SchedulesConfigDto::default())); + } + || () + }); + } + + let handle_schedule_selection = { + let set_selected_schedule = selected_schedule.clone(); + Callback::from(move |text:String| { + let trimmed = text.trim().to_string(); + if trimmed.is_empty() { + set_selected_schedule.set(None); + } else { + set_selected_schedule.set(Some(trimmed)); + } + }) + }; + + let handle_target_selection = { + let set_selected_targets = selected_targets.clone(); + Callback::from(move |(_name, selections):(String, Vec>)| { + if selections.is_empty() { + set_selected_targets.set(Some(selections.iter().map(|t| t.id.clone()).collect())); + } else { + set_selected_targets.set(None); + } + }) + }; + + let handle_add_schedule = { + let services = services_ctx.clone(); + let form_state = form_state.clone(); + let set_selected_targets = selected_targets.clone(); + let set_selected_schedule = selected_schedule.clone(); + Callback::from(move |_| { + if let Some(schedule) = (*set_selected_schedule).as_ref() { + let targets = (*set_selected_targets).clone(); + match validate_cron(schedule) { + Ok(()) => { + let dto = ScheduleConfigDto { + schedule: schedule.clone(), + targets, + }; + let mut new_schedules = form_state.data().schedules.as_ref().cloned().unwrap_or_default(); + new_schedules.push(dto); + form_state.dispatch(SchedulesConfigFormAction::Schedules(Some(new_schedules))); + } + Err(err) => { + services.toastr.error(err); } } -
- + } + }) + }; + + let handle_remove = { + let form_state = form_state.clone(); + Callback::from(move |target: String| { + if let Some(schedules) = form_state.data().schedules.as_ref() { + let new_schedules: Vec = schedules.iter().filter(|&s| s.schedule != target).cloned().collect(); + if new_schedules.is_empty() { + form_state.dispatch(SchedulesConfigFormAction::Schedules(None)); + } else { + form_state.dispatch(SchedulesConfigFormAction::Schedules(Some(new_schedules))); + } + } + }) + }; + + let render_view_mode = |deletable: bool| { + match form_state.data().schedules.as_ref() { + Some(schedules) => html! { + +
+ + + + {html_if!(deletable, {})} + + + + + + { for schedules.iter().map(|entry| { + let handle_remove_clone = handle_remove.clone(); + let schedule = entry.schedule.clone(); + html! { + + { html_if!(deletable, { + + })} + + + + }}) } + +
{ translate.t(LABEL_SCHEDULE) }{ translate.t(LABEL_TARGETS) }
+ + { entry.schedule.clone() } +
+ { + match entry.targets.as_ref() { + Some(targets) if !targets.is_empty() => html! { + { for targets.iter().map(|t| { + html! { + }})} + }, + _ => html! { + + }, + } + } +
+
+
+
+ }, + None => html! { } + } + }; + + let render_edit_mode = || { + html! { +
+ {config_field_child!(translate.t(LABEL_SCHEDULE), { + html!{ } + })} + {config_field_child!(translate.t(LABEL_TARGETS), { + html!{