From cd523c6ad243e73fbf451ed380eaafe54f2b49b0 Mon Sep 17 00:00:00 2001 From: moe Date: Mon, 1 Dec 2025 15:36:38 +0100 Subject: [PATCH] Refactor forms to use separate item forms with Cancel/Submit pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Create separate item form components: - Created epg_source_item_form.rs for EPG sources - Created alias_item_form.rs for input aliases - Created trakt_list_item_form.rs for Trakt lists Refactor parent forms: - input_form.rs now uses EpgSourceItemForm and AliasItemForm - output_xtream_form.rs now uses TraktListItemForm - Add-buttons toggle form visibility instead of inline inputs - Each item form has Cancel/Submit buttons - Removed inline form reducers, moved to item forms UI improvements: - Consistent form pattern across all list items - Better UX with explicit Cancel/Submit workflow - Cleaner code without dyn_into DOM manipulation i18n updates: - Added EPG_SOURCE_URL, EPG_SOURCES, ADD_EPG_SOURCE labels - Added ALIAS_NAME, ADD_ALIAS labels - Added all Trakt labels (TRAKT_USER, TRAKT_LIST_SLUG, etc.) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- frontend/public/assets/i18n/en.json | 15 ++ .../source_editor/alias_item_form.rs | 106 +++++++++++++ .../source_editor/epg_source_item_form.rs | 82 ++++++++++ .../components/source_editor/input_form.rs | 140 ++++++++---------- .../src/app/components/source_editor/mod.rs | 8 +- .../source_editor/output_xtream_form.rs | 115 ++++---------- .../source_editor/trakt_list_item_form.rs | 139 +++++++++++++++++ 7 files changed, 446 insertions(+), 159 deletions(-) create mode 100644 frontend/src/app/components/source_editor/alias_item_form.rs create mode 100644 frontend/src/app/components/source_editor/epg_source_item_form.rs create mode 100644 frontend/src/app/components/source_editor/trakt_list_item_form.rs diff --git a/frontend/public/assets/i18n/en.json b/frontend/public/assets/i18n/en.json index 89efa9ab1..942fdfa50 100644 --- a/frontend/public/assets/i18n/en.json +++ b/frontend/public/assets/i18n/en.json @@ -245,6 +245,16 @@ "DEVICE_TYPE": "Device Type", "DEVICE_UDN": "Device UDN", "TUNER_COUNT": "Tuner Count", + "TRAKT_USER": "Trakt User", + "TRAKT_LIST_SLUG": "Trakt List Slug", + "TRAKT_CATEGORY_NAME": "Trakt Category Name", + "TRAKT_CONTENT_TYPE": "Trakt Content Type", + "TRAKT_FUZZY_MATCH_THRESHOLD": "Trakt Fuzzy Match Threshold (%)", + "TRAKT_API_KEY": "Trakt API Key", + "TRAKT_API_VERSION": "Trakt API Version", + "TRAKT_API_URL": "Trakt API URL", + "TRAKT_LISTS": "Trakt Lists", + "ADD_TRAKT_LIST": "Add Trakt List", "SOURCE": "Source", "PRIORITY": "Priority", "LOGO_OVERRIDE": "logo Override", @@ -274,8 +284,13 @@ "WATCH": "Watch", "INPUT_TYPE": "Input Type", "EPG": "Epg", + "EPG_SOURCE_URL": "EPG Source URL", + "EPG_SOURCES": "EPG Sources", + "ADD_EPG_SOURCE": "Add EPG Source", "PERSIST": "Persist", "ALIASES": "Aliases", + "ALIAS_NAME": "Alias Name", + "ADD_ALIAS": "Add Alias", "MAX_CON": "Max Con.", "MAX_CONNECTIONS": "Max Connections", "METHOD": "Method", diff --git a/frontend/src/app/components/source_editor/alias_item_form.rs b/frontend/src/app/components/source_editor/alias_item_form.rs new file mode 100644 index 000000000..582085b69 --- /dev/null +++ b/frontend/src/app/components/source_editor/alias_item_form.rs @@ -0,0 +1,106 @@ +use crate::app::components::{Card, TextButton}; +use crate::{edit_field_date, edit_field_number_i16, edit_field_number_u16, edit_field_text, edit_field_text_option, generate_form_reducer}; +use shared::model::ConfigInputAliasDto; +use yew::{function_component, html, use_reducer, Callback, Html, Properties, UseReducerHandle}; +use yew_i18n::use_translation; + +const LABEL_ALIAS_NAME: &str = "LABEL.ALIAS_NAME"; +const LABEL_URL: &str = "LABEL.URL"; +const LABEL_USERNAME: &str = "LABEL.USERNAME"; +const LABEL_PASSWORD: &str = "LABEL.PASSWORD"; +const LABEL_PRIORITY: &str = "LABEL.PRIORITY"; +const LABEL_MAX_CONNECTIONS: &str = "LABEL.MAX_CONNECTIONS"; +const LABEL_EXP_DATE: &str = "LABEL.EXP_DATE"; + +generate_form_reducer!( + state: AliasFormState { form: ConfigInputAliasDto }, + action_name: AliasFormAction, + fields { + Id => id: u16, + Name => name: String, + Url => url: String, + Username => username: Option, + Password => password: Option, + Priority => priority: i16, + MaxConnections => max_connections: u16, + ExpDate => exp_date: Option, + } +); + +#[derive(Properties, PartialEq, Clone)] +pub struct AliasItemFormProps { + pub on_submit: Callback, + pub on_cancel: Callback<()>, + #[prop_or_default] + pub initial: Option, + #[prop_or(0)] + pub next_id: u16, +} + +#[function_component] +pub fn AliasItemForm(props: &AliasItemFormProps) -> Html { + let translate = use_translation(); + + let form_state: UseReducerHandle = use_reducer(|| { + AliasFormState { + form: props.initial.clone().unwrap_or_else(|| ConfigInputAliasDto { + id: props.next_id, + name: String::new(), + url: String::new(), + username: None, + password: None, + priority: 0, + max_connections: 1, + exp_date: None, + }), + modified: false, + } + }); + + let handle_submit = { + let form_state = form_state.clone(); + let on_submit = props.on_submit.clone(); + Callback::from(move |_| { + let data = form_state.data().clone(); + if !data.name.trim().is_empty() && !data.url.trim().is_empty() { + on_submit.emit(data); + } + }) + }; + + let handle_cancel = { + let on_cancel = props.on_cancel.clone(); + Callback::from(move |_| { + on_cancel.emit(()); + }) + }; + + html! { + + { edit_field_text!(form_state, translate.t(LABEL_ALIAS_NAME), name, AliasFormAction::Name) } + { edit_field_text!(form_state, translate.t(LABEL_URL), url, AliasFormAction::Url) } + { edit_field_text_option!(form_state, translate.t(LABEL_USERNAME), username, AliasFormAction::Username) } + { edit_field_text_option!(form_state, translate.t(LABEL_PASSWORD), password, AliasFormAction::Password, true) } + { edit_field_number_i16!(form_state, translate.t(LABEL_PRIORITY), priority, AliasFormAction::Priority) } + { edit_field_number_u16!(form_state, translate.t(LABEL_MAX_CONNECTIONS), max_connections, AliasFormAction::MaxConnections) } + { edit_field_date!(form_state, translate.t(LABEL_EXP_DATE), exp_date, AliasFormAction::ExpDate) } + +
+ + +
+
+ } +} diff --git a/frontend/src/app/components/source_editor/epg_source_item_form.rs b/frontend/src/app/components/source_editor/epg_source_item_form.rs new file mode 100644 index 000000000..2d69f0867 --- /dev/null +++ b/frontend/src/app/components/source_editor/epg_source_item_form.rs @@ -0,0 +1,82 @@ +use crate::app::components::{Card, TextButton}; +use crate::{edit_field_text, generate_form_reducer}; +use shared::model::EpgSourceDto; +use yew::{function_component, html, use_reducer, Callback, Html, Properties, UseReducerHandle}; +use yew_i18n::use_translation; + +const LABEL_EPG_SOURCE_URL: &str = "LABEL.EPG_SOURCE_URL"; + +generate_form_reducer!( + state: EpgSourceFormState { form: EpgSourceDto }, + action_name: EpgSourceFormAction, + fields { + Url => url: String, + Priority => priority: i16, + LogoOverride => logo_override: bool, + } +); + +#[derive(Properties, PartialEq, Clone)] +pub struct EpgSourceItemFormProps { + pub on_submit: Callback, + pub on_cancel: Callback<()>, + #[prop_or_default] + pub initial: Option, +} + +#[function_component] +pub fn EpgSourceItemForm(props: &EpgSourceItemFormProps) -> Html { + let translate = use_translation(); + + let form_state: UseReducerHandle = use_reducer(|| { + EpgSourceFormState { + form: props.initial.clone().unwrap_or_else(|| EpgSourceDto { + url: String::new(), + priority: 0, + logo_override: false, + }), + modified: false, + } + }); + + let handle_submit = { + let form_state = form_state.clone(); + let on_submit = props.on_submit.clone(); + Callback::from(move |_| { + let data = form_state.data().clone(); + if !data.url.trim().is_empty() { + on_submit.emit(data); + } + }) + }; + + let handle_cancel = { + let on_cancel = props.on_cancel.clone(); + Callback::from(move |_| { + on_cancel.emit(()); + }) + }; + + html! { + + { edit_field_text!(form_state, translate.t(LABEL_EPG_SOURCE_URL), url, EpgSourceFormAction::Url) } + +
+ + +
+
+ } +} diff --git a/frontend/src/app/components/source_editor/input_form.rs b/frontend/src/app/components/source_editor/input_form.rs index d10347373..7db740e03 100644 --- a/frontend/src/app/components/source_editor/input_form.rs +++ b/frontend/src/app/components/source_editor/input_form.rs @@ -169,6 +169,10 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html { let aliases_state = use_state(|| Vec::::new()); let headers_state = use_state(|| HashMap::::new()); + // State for showing item forms + let show_epg_form_state = use_state(|| false); + let show_alias_form_state = use_state(|| false); + let staged_input_types = use_memo(staged_input_state.form.input_type, |input_type| { let default_it = input_type; [ @@ -339,6 +343,8 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html { let headers = headers_state.clone(); let epg_sources = epg_sources_state.clone(); let aliases = aliases_state.clone(); + let show_epg_form = show_epg_form_state.clone(); + let show_alias_form = show_alias_form_state.clone(); html! { @@ -360,14 +366,17 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html { // EPG Sources Section { config_field_child!(translate.t(LABEL_EPG_SOURCES), { - let epg_sources_add = epg_sources.clone(); let epg_sources_list = epg_sources.clone(); + let epg_sources_add = epg_sources.clone(); + let show_epg_form_toggle = show_epg_form.clone(); + let show_epg_form_check = show_epg_form.clone(); + html! {
{ - for (*epg_sources).iter().enumerate().map(|(idx, source)| { - let epg_sources_remove = epg_sources.clone(); + for (*epg_sources_list).iter().enumerate().map(|(idx, source)| { + let epg_sources_remove = epg_sources_list.clone(); html! {
@@ -388,50 +397,48 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html { }) }
-
- - -
+ on_cancel={Callback::from(move |_| { + show_epg_form.set(false); + })} + /> + } else { + + }
} })} // Aliases Section { config_field_child!(translate.t(LABEL_ALIASES), { - let aliases_add = aliases.clone(); let aliases_list = aliases.clone(); + let aliases_add = aliases.clone(); + let show_alias_form_toggle = show_alias_form.clone(); + let show_alias_form_check = show_alias_form.clone(); + let next_id = (*aliases_list).iter().map(|a| a.id).max().unwrap_or(0) + 1; + html! {
{ - for (*aliases).iter().enumerate().map(|(idx, alias)| { - let aliases_remove = aliases.clone(); + for (*aliases_list).iter().enumerate().map(|(idx, alias)| { + let aliases_remove = aliases_list.clone(); html! {
@@ -452,48 +459,31 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html { }) }
-
- - - -
+ on_cancel={Callback::from(move |_| { + show_alias_form.set(false); + })} + /> + } else { + + }
} })} diff --git a/frontend/src/app/components/source_editor/mod.rs b/frontend/src/app/components/source_editor/mod.rs index 70f327855..4843c8ce7 100644 --- a/frontend/src/app/components/source_editor/mod.rs +++ b/frontend/src/app/components/source_editor/mod.rs @@ -12,6 +12,9 @@ mod output_m3u_form; mod output_strm_form; mod output_hdhomerun_form; mod layout; +mod epg_source_item_form; +mod alias_item_form; +mod trakt_list_item_form; pub(crate) use editor_view::*; pub(crate) use editor_model::*; @@ -25,4 +28,7 @@ pub(crate) use block_view::*; pub(crate) use output_xtream_form::*; pub(crate) use output_m3u_form::*; pub(crate) use output_strm_form::*; -pub(crate) use output_hdhomerun_form::*; \ No newline at end of file +pub(crate) use output_hdhomerun_form::*; +pub(crate) use epg_source_item_form::*; +pub(crate) use alias_item_form::*; +pub(crate) use trakt_list_item_form::*; \ No newline at end of file diff --git a/frontend/src/app/components/source_editor/output_xtream_form.rs b/frontend/src/app/components/source_editor/output_xtream_form.rs index 20c8ce717..2a676a979 100644 --- a/frontend/src/app/components/source_editor/output_xtream_form.rs +++ b/frontend/src/app/components/source_editor/output_xtream_form.rs @@ -52,18 +52,6 @@ generate_form_reducer!( } ); -generate_form_reducer!( - state: TraktListConfigFormState { form: TraktListConfigDto }, - action_name: TraktListConfigFormAction, - fields { - User => user: String, - ListSlug => list_slug: String, - CategoryName => category_name: String, - ContentType => content_type: TraktContentType, - FuzzyMatchThreshold => fuzzy_match_threshold: u8, - } -); - generate_form_reducer!( state: XtreamTargetOutputFormState { form: XtreamTargetOutputDto }, action_name: XtreamTargetOutputFormAction, @@ -105,12 +93,8 @@ pub fn XtreamTargetOutputView(props: &XtreamTargetOutputViewProps) -> Html { // State for Trakt lists let trakt_lists_state = use_state(|| Vec::::new()); - // State for adding new Trakt list - let trakt_list_add_state: UseReducerHandle = - use_reducer(|| TraktListConfigFormState { - form: TraktListConfigDto::default(), - modified: false, - }); + // State for showing trakt list form + let show_trakt_list_form_state = use_state(|| false); let view_visible = use_state(|| OutputFormPage::Main.to_string()); @@ -164,29 +148,7 @@ pub fn XtreamTargetOutputView(props: &XtreamTargetOutputViewProps) -> Html { let render_trakt = || { let trakt_lists = trakt_lists_state.clone(); let trakt_api = trakt_api_state.clone(); - let trakt_list_add = trakt_list_add_state.clone(); - - // Create content type options - let content_type_options = use_memo(trakt_list_add.form.content_type, |content_type| { - let default_ct = content_type; - vec![ - DropDownOption { - id: "vod".to_string(), - label: html! { "Vod" }, - selected: default_ct == &TraktContentType::Vod, - }, - DropDownOption { - id: "series".to_string(), - label: html! { "Series" }, - selected: default_ct == &TraktContentType::Series, - }, - DropDownOption { - id: "both".to_string(), - label: html! { "Both" }, - selected: default_ct == &TraktContentType::Both, - }, - ] - }); + let show_trakt_list_form = show_trakt_list_form_state.clone(); html! { @@ -200,13 +162,17 @@ pub fn XtreamTargetOutputView(props: &XtreamTargetOutputViewProps) -> Html { // Trakt Lists { config_field_child!(translate.t(LABEL_TRAKT_LISTS), { + let trakt_lists_list = trakt_lists.clone(); let trakt_lists_add = trakt_lists.clone(); + let show_trakt_list_form_toggle = show_trakt_list_form.clone(); + let show_trakt_list_form_check = show_trakt_list_form.clone(); + html! {
{ - for (*trakt_lists).iter().enumerate().map(|(idx, list)| { - let trakt_lists_remove = trakt_lists.clone(); + for (*trakt_lists_list).iter().enumerate().map(|(idx, list)| { + let trakt_lists_remove = trakt_lists_list.clone(); let content_type_str = match list.content_type { TraktContentType::Vod => "Vod", TraktContentType::Series => "Series", @@ -243,47 +209,30 @@ pub fn XtreamTargetOutputView(props: &XtreamTargetOutputViewProps) -> Html { }) }
-
- { edit_field_text!(trakt_list_add, translate.t(LABEL_TRAKT_USER), user, TraktListConfigFormAction::User) } - { edit_field_text!(trakt_list_add, translate.t(LABEL_TRAKT_LIST_SLUG), list_slug, TraktListConfigFormAction::ListSlug) } - { edit_field_text!(trakt_list_add, translate.t(LABEL_TRAKT_CATEGORY_NAME), category_name, TraktListConfigFormAction::CategoryName) } - { config_field_child!(translate.t(LABEL_TRAKT_CONTENT_TYPE), { - let trakt_list_add_ct = trakt_list_add.clone(); - html! { - { + if let Ok(ct) = option.parse::() { + form_state_ct.dispatch(TraktListFormAction::ContentType(ct)); + } + } + _ => {} + } + })} + options={content_type_options.clone()} + /> + } + })} + + { edit_field_number_u8!(form_state, translate.t(LABEL_TRAKT_FUZZY_MATCH_THRESHOLD), fuzzy_match_threshold, TraktListFormAction::FuzzyMatchThreshold) } + +
+ + +
+ + } +}