diff --git a/.gitignore b/.gitignore index 0451a9fa7..1953e7413 100644 --- a/.gitignore +++ b/.gitignore @@ -29,4 +29,6 @@ docker/tuliprox docker/resources # Ignore VSCode settings and files -/.vscode \ No newline at end of file +/.vscode + +.claude diff --git a/Cargo.lock b/Cargo.lock index 06a2181d3..af0780d03 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1096,7 +1096,7 @@ dependencies = [ [[package]] name = "frontend" -version = "3.2.13" +version = "3.2.14" dependencies = [ "anyhow", "base64", @@ -3793,7 +3793,7 @@ dependencies = [ [[package]] name = "shared" -version = "3.2.13" +version = "3.2.14" dependencies = [ "base64", "bitflags 2.10.0", @@ -4356,7 +4356,7 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "tuliprox" -version = "3.2.13" +version = "3.2.14" dependencies = [ "arc-swap", "async-compression", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 0f72bca30..9a606172d 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tuliprox" -version = "3.2.13" +version = "3.2.14" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/backend/src/api/api_utils.rs b/backend/src/api/api_utils.rs index 83606dcfd..34a4c4a8e 100644 --- a/backend/src/api/api_utils.rs +++ b/backend/src/api/api_utils.rs @@ -1,5 +1,5 @@ use crate::api::endpoints::xtream_api::{get_xtream_player_api_stream_url, ApiStreamContext}; -use crate::api::model::UserSession; +use crate::api::model::{UserSession}; use crate::api::model::{ create_channel_unavailable_stream, create_custom_video_stream_response, create_provider_connections_exhausted_stream, create_provider_stream, @@ -17,12 +17,12 @@ use crate::utils::request; use crate::utils::{debug_if_enabled, trace_if_enabled}; use crate::BUILD_TIMESTAMP; use arc_swap::ArcSwapOption; -use axum::http::HeaderMap; +use axum::http::{HeaderMap}; use axum::response::IntoResponse; use chrono::{DateTime, Utc}; use futures::{StreamExt, TryStreamExt}; use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation}; -use log::{debug, error, log_enabled, trace, warn}; +use log::{debug, error, log_enabled, trace, warn}; use reqwest::header::RETRY_AFTER; use serde::Serialize; use shared::model::{Claims, InputFetchMethod, PlaylistEntry, PlaylistItemType, StreamChannel, TargetType, UserConnectionPermission, XtreamCluster}; @@ -373,16 +373,13 @@ async fn resolve_streaming_strategy( ) }; - let available = match allocation { - ProviderAllocation::Exhausted - | ProviderAllocation::Available(_) => true, - ProviderAllocation::GracePeriod(_) => false, - }; - - if available { - ProviderStreamState::Available(Some(provider), url) - } else { - ProviderStreamState::GracePeriod(Some(provider), url) + match allocation { + ProviderAllocation::Exhausted => { + let stream = create_provider_connections_exhausted_stream(&app_state.app_config, &[]); + ProviderStreamState::Custom(stream) + }, + ProviderAllocation::Available(_) => ProviderStreamState::Available(Some(provider), url), + ProviderAllocation::GracePeriod(_) => ProviderStreamState::GracePeriod(Some(provider), url), } } } @@ -418,7 +415,7 @@ fn get_grace_period_millis( } } -#[allow(clippy::too_many_arguments)] +#[allow(clippy::too_many_arguments, clippy::too_many_lines)] async fn create_stream_response_details( app_state: &Arc, stream_options: &StreamOptions, @@ -450,6 +447,22 @@ async fn create_stream_response_details( .as_ref() .and_then(|guard| guard.allocation.get_provider_name()); + if let ProviderStreamState::GracePeriod(ref provider_grace_check, ref stream_url) = streaming_strategy.provider_stream_state { + tokio::time::sleep(tokio::time::Duration::from_millis(grace_period_millis)).await; + if let Some(provider_name) = provider_grace_check.as_ref() { + if app_state.active_provider.is_over_limit(provider_name).await { + app_state.connection_manager.update_stream_detail(&fingerprint.addr, CustomVideoStreamType::ProviderConnectionsExhausted).await; + debug!("Provider connections exhausted after grace period for provider: {provider_name}"); + let stream = create_provider_connections_exhausted_stream(&app_state.app_config, &[]); + streaming_strategy.provider_stream_state = ProviderStreamState::Custom(stream); + let provider_handle = streaming_strategy.provider_handle.take(); + app_state.connection_manager.release_provider_handle(provider_handle).await; + } + } else { + streaming_strategy.provider_stream_state = ProviderStreamState::Available(provider_grace_check.clone(), stream_url.clone()); + } + } + match streaming_strategy.provider_stream_state { // custom stream means we display our own stream like connection exhausted, channel-unavailable... ProviderStreamState::Custom(provider_stream) => { @@ -837,8 +850,7 @@ pub async fn stream_response( share_stream, connection_permission, None, - ) - .await; + ).await; if stream_details.has_stream() { // let content_length = get_stream_content_length(provider_response.as_ref()); @@ -853,15 +865,20 @@ pub async fn stream_response( } else { None }; - stream_channel.shared = share_stream; + + let mut is_stream_shared = share_stream; + if let Some((_header, _status_code, _url, Some(_custom_video))) = stream_details.stream_info.as_ref() { + if stream_details.stream.is_some() { + is_stream_shared = false; + } + } + + stream_channel.shared = is_stream_shared; let stream = ActiveClientStream::new(stream_details, app_state, user, connection_permission, fingerprint, stream_channel, Some(session_token), req_headers) .await; - let stream_resp = if share_stream { - debug_if_enabled!( - "Streaming shared stream request from {}", - sanitize_sensitive_info(stream_url) - ); + let stream_resp = if is_stream_shared { + debug_if_enabled!("Streaming shared stream request from {}",sanitize_sensitive_info(stream_url)); // Shared Stream response let shared_headers = provider_response .as_ref() @@ -898,9 +915,7 @@ pub async fn stream_response( ); if log_enabled!(log::Level::Debug) { if session_url.eq(&stream_url) { - debug!( - "Streaming stream request from {}", - sanitize_sensitive_info(stream_url) + debug!("Streaming stream request from {}", sanitize_sensitive_info(stream_url) ); } else { debug!( @@ -977,9 +992,7 @@ async fn try_shared_stream_response_if_any( if let Some((stream, provider)) = SharedStreamManager::subscribe_shared_stream(app_state, stream_url, &fingerprint.addr).await { - debug_if_enabled!( - "Using shared stream {}", - sanitize_sensitive_info(stream_url) + debug_if_enabled!("Using shared stream {}", sanitize_sensitive_info(stream_url) ); if let Some(headers) = app_state .shared_stream_manager @@ -991,6 +1004,7 @@ async fn try_shared_stream_response_if_any( axum::http::StatusCode::OK, ))); let mut stream_details = StreamDetails::from_stream(stream); + stream_details.provider_name = provider; stream_channel.shared = true; let stream = diff --git a/backend/src/api/model/streams/shared_stream_manager.rs b/backend/src/api/model/streams/shared_stream_manager.rs index 514657836..db1d77698 100644 --- a/backend/src/api/model/streams/shared_stream_manager.rs +++ b/backend/src/api/model/streams/shared_stream_manager.rs @@ -210,7 +210,7 @@ impl SharedStreamState { result = broadcast_rx.recv() => { match result { Ok(data) => { - // Wenn der Client pausiert, einfach skippen oder warten + // If the client press pause, skip if client_tx_clone.is_closed() { continue; } diff --git a/frontend/Cargo.toml b/frontend/Cargo.toml index a1d1c1a3f..c206be646 100644 --- a/frontend/Cargo.toml +++ b/frontend/Cargo.toml @@ -1,10 +1,10 @@ [package] name = "frontend" -version = "3.2.13" +version = "3.2.14" edition = "2021" [dependencies] -shared = { version = "3.2.13", path = "../shared" } +shared = { version = "3.2.14", path = "../shared" } chrono = "0" yew = "0.21" yew-router = "0.18" diff --git a/frontend/public/assets/i18n/en.json b/frontend/public/assets/i18n/en.json index 89efa9ab1..a52e93860 100644 --- a/frontend/public/assets/i18n/en.json +++ b/frontend/public/assets/i18n/en.json @@ -274,8 +274,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", @@ -320,14 +325,25 @@ "SKIP_SERIES": "Skip Series", "ADD_WATCH": "Add Watch", "ADD_MAPPING": "Add Mapping", - "SKIP_LIVE_DIRECT_SOURCE": "Skip Live Direct Source", - "SKIP_VOD_DIRECT_SOURCE": "Skip Vod Direct Source", - "SKIP_SERIES_DIRECT_SOURCE": "Skip Series Direct Source", - "RESOLVE_VOD": "Resolve Vod", - "RESOLVE_VOD_DELAY_SEC": "Resolve Vod Delay (sec)", - "RESOLVE_SERIES": "Resolve Series", - "RESOLVE_SERIES_DELAY_SEC": "Resolve Series Delay (sec)", - "COPY_CREDENTIALS": "Copy Credentials" + "RESOLVE_DELAY_SEC": "Resolve Delay (sec)", + "COPY_CREDENTIALS": "Copy Credentials", + "EXPORT_STYLE": "Export Style", + "STRM_PROPS": "Properties", + "ADD_PROPERTY": "Add Property", + "TRAKT_API_KEY": "API-Key", + "TRAKT_API_VERSION": "API-Version", + "TRAKT_API_URL": "API-URL", + "TRAKT_LISTS": "Lists", + "TRAKT_USER": "Trakt User", + "TRAKT_LIST_SLUG": "List Slug", + "TRAKT_CATEGORY_NAME": "Category name", + "TRAKT_CONTENT_TYPE": "Content Type", + "TRAKT_FUZZY_MATCH_THRESHOLD": "Fuzzy matchthreshold", + "ADD_TRAKT_LIST": "Add a Trakt list", + "EPG_LOGO_OVERRIDE": "Logo Override", + "API_CONFIGURATION" : "API Configuration", + "HEADER_NAME": "Header Name", + "HEADER_VALUE": "Header Value" }, "TITLE": { "USER_BOUQUET_EDITOR": "Playlist Category Selection" @@ -337,7 +353,7 @@ "INVALID_REGEXP": "Invalid regexp", "INVALID_FILETYPE": "Invalid filetype", "TARGET_NOT_EXISTS": "Target does not exist", - "CONFIRM_DELETE": "You really want to delete?", + "CONFIRM_DELETE": "You really want to delete it?", "CONFIG_CHANGED": "Configuration on server changed.", "USER_DELETED": "User successfully deleted", "SCHEDULE_NOT_SET": "Please enter a schedule before adding", @@ -369,11 +385,11 @@ "SAVE": { "MAIN_CONFIG": { "SUCCESS": "Main config saved!", - "FAIL": "Failed to save main config!" + "FAIL": "Failed to save the main config!" }, "BOUQUET": { "SUCCESS": "Successfully saved bouquet", - "FAIL": "Failed to save bouquet!" + "FAIL": "Failed to save the bouquet!" }, "USER": { "SUCCESS": "Successfully saved user", @@ -384,7 +400,7 @@ "TARGET_NOT_FOUND": "Target not found!", "MAX_CONNECTIONS_INVALID": "MaxConnections invalid", "NOTHING_TO_SAVE": "Nothing changed", - "TARGET_NOT_SELECTED": "Playlist not selected" + "TARGET_NOT_SELECTED": "Playlist isn't selected" } }, "PLAYLIST_UPDATE": { @@ -405,7 +421,7 @@ "INFO": { "RESTART_TO_APPLY_CHANGES": "You need to restart to apply changes.", "SCHEDULE_EXAMPLE": "Schedule example", - "PROCESS_PARALLEL": "Process Parallel: If you have multiple providers you can enable it. Accessing your provider in parallel can cause a ban.", + "PROCESS_PARALLEL": "Process Parallel: If you have multiple providers, you can enable it. Accessing your provider in parallel can cause a ban.", "WEB_ROOT": "Web-Root: Contains the web-ui files. They are normally placed in the same directory under ./web" }, "HINT": { @@ -423,7 +439,7 @@ "RESOURCE_REWRITE_DISABLE": "If activated, the URL rewrite of resources in reverse proxy mode is disabled", "DISABLED_HEADER": "Remove headers before sending requests to the upstream provider", "REFERER_HEADER": "If activated, the referer header is not sent to the provider", - "X_HEADER": "If activated, headers starting with X- are removed before proxying the request", + "X_HEADER": "If activated, headers starting with 'X-' are removed before proxying the request", "CUSTOM_HEADERS": "Headers listed here are removed before sending the upstream request", "THROTTLE": "Supported units are KB/s,MB/s,KiB/s,MiB/s,kbps,mbps,Mibps" }, diff --git a/frontend/public/assets/icons.json b/frontend/public/assets/icons.json index 4f0bd687f..10342a29d 100644 --- a/frontend/public/assets/icons.json +++ b/frontend/public/assets/icons.json @@ -13,7 +13,7 @@ }, { "keys": [ - "Delete" + "Delete", "Remove" ], "path": "M 4.1422511,6.2868364 H 2.7137817 a 0.7142347,0.7142347 0 0 1 0,-1.4284691 H 8.4276589 V 2.7142341 A 0.7142347,0.7142347 0 0 1 9.1418939,2 h 5.7138771 a 0.7142347,0.7142347 0 0 1 0.714235,0.7142341 v 2.1441332 h 5.713877 a 0.7142347,0.7142347 0 1 1 0,1.4284691 H 19.855414 V 21.285765 A 0.7142347,0.7142347 0 0 1 19.141179,22 H 4.8564857 A 0.7142347,0.7142347 0 0 1 4.1422511,21.285765 Z M 14.141537,4.8583673 v -1.42847 H 9.8561289 v 1.42847 z M 5.5707207,20.571531 H 18.426945 V 6.2868364 H 5.5707207 Z M 9.8561289,17.714592 A 0.7142347,0.7142347 0 0 1 9.1418939,17.000357 V 9.8580104 a 0.7142347,0.7142347 0 0 1 1.4284691,0 v 7.1423466 a 0.7142347,0.7142347 0 0 1 -0.7142341,0.714235 z m 4.2854081,0 A 0.7142347,0.7142347 0 0 1 13.427302,17.000357 V 9.8580104 a 0.7142347,0.7142347 0 0 1 1.428469,0 v 7.1423466 a 0.7142347,0.7142347 0 0 1 -0.714234,0.714235 z" }, @@ -525,5 +525,9 @@ { "keys": ["Accept"], "path": "m18 7-1.41-1.41-6.34 6.34 1.41 1.41zm4.24-1.41L11.66 16.17 7.48 12l-1.41 1.41L11.66 19l12-12zM.41 13.41 6 19l1.41-1.41L1.83 12z" + }, + { + "keys": [ "Add" ], + "path": "M13 7h-2v4H7v2h4v4h2v-4h4v-2h-4V7zm-1-5C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z" } ] \ No newline at end of file diff --git a/frontend/scss/_theme.scss b/frontend/scss/_theme.scss index e6e38b754..e97a2d1e1 100644 --- a/frontend/scss/_theme.scss +++ b/frontend/scss/_theme.scss @@ -239,6 +239,7 @@ --menu-button-hover-color: #ffffff; --menu-button-hover-background-color: rgba(32, 39, 51, 0.8); + --menu-button-warn-color: #b96665; --menu-button-warn-hover-color: #dd7573; --menu-button-warn-hover-background-color: #4c3434; diff --git a/frontend/scss/app/_component.scss b/frontend/scss/app/_component.scss index a48bf4915..7367da42a 100644 --- a/frontend/scss/app/_component.scss +++ b/frontend/scss/app/_component.scss @@ -80,3 +80,4 @@ @forward "components/source_editor/source_editor_block"; @forward "components/source_editor/input_form"; @forward "components/cluster_flags"; +@forward "components/titled_card"; diff --git a/frontend/scss/app/components/_chip.scss b/frontend/scss/app/components/_chip.scss index 54dcb734a..2f7da74c5 100644 --- a/frontend/scss/app/components/_chip.scss +++ b/frontend/scss/app/components/_chip.scss @@ -21,11 +21,10 @@ font-weight: bold; cursor: pointer; color: var(--modest-text-color); - .svg-icon { - fill: var(--menu-button-warn-hover-color); - } + fill: var(--menu-button-warn-color); &:hover { color: var(--menu-button-warn-hover-color); + fill: var(--menu-button-warn-hover-color); } } } diff --git a/frontend/scss/app/components/_titled_card.scss b/frontend/scss/app/components/_titled_card.scss new file mode 100644 index 000000000..c36731c31 --- /dev/null +++ b/frontend/scss/app/components/_titled_card.scss @@ -0,0 +1,17 @@ +.tp__titled-card { + position: relative; + padding: 1em 0.5em 0.5em 0.5em; + border: 1px solid var(--card-border-color); + border-radius: var(--border-radius); +} + +.tp__titled-card__title { + position: absolute; + top: -0.7em; + left: 15px; + padding: 0 4px; + z-index: 2; + font-size: 14px; + line-height: 1.2; + background: linear-gradient(to bottom, #00000000 50%, var(--card-background-color) 50%); +} diff --git a/frontend/scss/app/components/_toggle_switch.scss b/frontend/scss/app/components/_toggle_switch.scss index 5f85868f6..6c8e0bac2 100644 --- a/frontend/scss/app/components/_toggle_switch.scss +++ b/frontend/scss/app/components/_toggle_switch.scss @@ -8,6 +8,8 @@ $toggle-switch-toggle-end-pos: calc($toggle-switch-width - ($toggle-switch-toggl position: relative; display: inline-block; width: $toggle-switch-width; + min-width: $toggle-switch-width; + max-width: $toggle-switch-width; height: $toggle-switch-height; cursor: pointer; border: 2px solid transparent; diff --git a/frontend/scss/app/components/config/_config_view.scss b/frontend/scss/app/components/config/_config_view.scss index 252a4570b..6842ef861 100644 --- a/frontend/scss/app/components/config/_config_view.scss +++ b/frontend/scss/app/components/config/_config_view.scss @@ -59,11 +59,36 @@ gap: var(--gap-large); background-color: var(--sub-card-background-color); overflow: hidden; + h1 { font-size: 1.2rem; font-weight: bold; } } + + &__cols-2, + &__cols-3 { + display: grid; + gap: var(--gap-default); + + .tp__form-field { + min-width: 0; + } + } + + &__cols-2 { + grid-template-columns: 1fr 1fr; + @media (max-width: size.$mobile-breakpoint) { + grid-template-columns: 1fr; + } + } + + &__cols-3 { + grid-template-columns: 1fr 1fr 1fr; + @media (max-width: size.$mobile-breakpoint) { + grid-template-columns: 1fr; + } + } } .tp__config-view-page { @@ -111,6 +136,7 @@ gap: var(--gap-default); box-sizing: border-box; } + &__schedule { display: flex; flex-flow: column; @@ -123,14 +149,16 @@ text-align: left; } - th,td { + th, td { width: 1%; white-space: nowrap; } + th:last-child, td:last-child { width: auto; } + td { vertical-align: middle; } @@ -141,28 +169,34 @@ } } + &__footer { margin-top: var(--margin-default); padding-top: var(--margin-default); - border-top : 1px solid var(--card-border-color); + border-top: 1px solid var(--card-border-color); + svg { 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); } @@ -174,6 +208,7 @@ border-color: var(--warn-color); } } + &__inactive { button { border-color: var(--warn-color); @@ -205,8 +240,9 @@ &__info { display: flex; flex-flow: row; - color:var(--warn-color); + color: var(--warn-color); fill: currentColor; + span { white-space: wrap; } diff --git a/frontend/scss/app/components/source_editor/_source_editor_form.scss b/frontend/scss/app/components/source_editor/_source_editor_form.scss index 231d28a5c..65cdbcfc1 100644 --- a/frontend/scss/app/components/source_editor/_source_editor_form.scss +++ b/frontend/scss/app/components/source_editor/_source_editor_form.scss @@ -1,4 +1,4 @@ -$form-width: 300px; +$form-width: 380px; .tp__source-editor-form-wrapper { position: absolute; @@ -38,4 +38,17 @@ $form-width: 300px; } } } + + .tp__form-list__item { + display: flex; + flex-flow: row nowrap; + gap: var(--gap-small); + align-items: center; + .tp__icon-button { + fill: var(--menu-button-warn-color); + &:hover { + fill: var(--menu-button-warn-hover-color); + } + } + } } \ No newline at end of file diff --git a/frontend/src/app/components/mod.rs b/frontend/src/app/components/mod.rs index 690bdf312..29b37babf 100644 --- a/frontend/src/app/components/mod.rs +++ b/frontend/src/app/components/mod.rs @@ -49,6 +49,7 @@ mod websocket_status; mod floating_background; mod source_editor; mod cluster_flags_input; +mod title_card; // pub use self::input::*; // pub use self::menu_item::*; // pub use self::popup_menu::*; @@ -92,6 +93,7 @@ pub(crate) use self::key_value_editor::*; pub(crate) use self::loading_screen::*; pub(crate) use self::websocket_status::*; pub(crate) use self::source_editor::*; +pub(crate) use self::title_card::*; //pub use self::number_input::*; //pub use self::date_input::*; 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..3f50574dc --- /dev/null +++ b/frontend/src/app/components/source_editor/alias_item_form.rs @@ -0,0 +1,105 @@ +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 { + 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, +} + +#[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: 0, + 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.form.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..65b49dc91 --- /dev/null +++ b/frontend/src/app/components/source_editor/epg_source_item_form.rs @@ -0,0 +1,86 @@ +use crate::app::components::{Card, TextButton}; +use crate::{edit_field_bool, edit_field_number_i16, 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"; +const LABEL_EPG_PRIORITY: &str = "LABEL.PRIORITY"; +const LABEL_EPG_LOGO_OVERRIDE: &str = "LABEL.EPG_LOGO_OVERRIDE"; + +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.form.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) } + { edit_field_number_i16!(form_state, translate.t(LABEL_EPG_PRIORITY), priority, EpgSourceFormAction::Priority) } + { edit_field_bool!(form_state, translate.t(LABEL_EPG_LOGO_OVERRIDE), logo_override, EpgSourceFormAction::LogoOverride) } + +
+ + +
+
+ } +} diff --git a/frontend/src/app/components/source_editor/input_form.rs b/frontend/src/app/components/source_editor/input_form.rs index 4100ca505..e8ca22386 100644 --- a/frontend/src/app/components/source_editor/input_form.rs +++ b/frontend/src/app/components/source_editor/input_form.rs @@ -1,30 +1,36 @@ use crate::app::components::config::HasFormData; +use crate::app::components::key_value_editor::KeyValueEditor; use crate::app::components::select::Select; -use crate::app::components::{BlockId, BlockInstance, Card, DropDownOption, DropDownSelection, EditMode, Panel, RadioButtonGroup, SourceEditorContext, TextButton}; -use crate::{config_field_child, edit_field_bool, edit_field_number_i16, edit_field_number_u16, edit_field_text, edit_field_text_option, generate_form_reducer}; -use shared::model::{ConfigInputDto, ConfigInputOptionsDto, InputFetchMethod, InputType, StagedInputDto}; +use crate::app::components::{AliasItemForm, BlockId, BlockInstance, Card, DropDownOption, DropDownSelection, EditMode, EpgSourceItemForm, IconButton, Panel, RadioButtonGroup, SourceEditorContext, TextButton, TitledCard}; +use crate::{config_field_child, edit_field_bool, 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, ConfigInputDto, ConfigInputOptionsDto, EpgConfigDto, EpgSourceDto, InputFetchMethod, InputType, StagedInputDto}; +use std::collections::HashMap; use std::fmt::Display; use std::rc::Rc; +use web_sys::MouseEvent; use yew::{classes, function_component, html, use_context, use_effect_with, use_memo, use_reducer, use_state, Callback, Html, Properties, UseReducerHandle}; use yew_i18n::use_translation; const LABEL_NAME: &str = "LABEL.NAME"; const LABEL_INPUT_TYPE: &str = "LABEL.INPUT_TYPE"; const LABEL_FETCH_METHOD: &str = "LABEL.METHOD"; -// const LABEL_HEADERS: &str = "LABEL.HEADERS"; +const LABEL_HEADERS: &str = "LABEL.HEADERS"; const LABEL_URL: &str = "LABEL.URL"; -// const LABEL_EPG: &str = "LABEL.EPG"; +const LABEL_EPG_SOURCES: &str = "LABEL.EPG_SOURCES"; const LABEL_USERNAME: &str = "LABEL.USERNAME"; const LABEL_PASSWORD: &str = "LABEL.PASSWORD"; const LABEL_PERSIST: &str = "LABEL.PERSIST"; const LABEL_ENABLED: &str = "LABEL.ENABLED"; -// const LABEL_ALIASES: &str = "LABEL.ALIASES"; +const LABEL_ALIASES: &str = "LABEL.ALIASES"; const LABEL_PRIORITY: &str = "LABEL.PRIORITY"; const LABEL_MAX_CONNECTIONS: &str = "LABEL.MAX_CONNECTIONS"; -// const LABEL_ADD_HEADER: &str = "LABEL.HEADERS"; -const LABEL_XTREAM_SKIP_LIVE: &str = "LABEL.SKIP_LIVE"; -const LABEL_XTREAM_SKIP_VOD: &str = "LABEL.SKIP_VOD"; -const LABEL_XTREAM_SKIP_SERIES: &str = "LABEL.SKIP_SERIES"; +const LABEL_EXP_DATE: &str = "LABEL.EXP_DATE"; +const LABEL_ADD_EPG_SOURCE: &str = "LABEL.ADD_EPG_SOURCE"; +const LABEL_ADD_ALIAS: &str = "LABEL.ADD_ALIAS"; +const LABEL_SKIP: &str = "LABEL.SKIP"; +const LABEL_XTREAM_SKIP_LIVE: &str = "LABEL.LIVE"; +const LABEL_XTREAM_SKIP_VOD: &str = "LABEL.VOD"; +const LABEL_XTREAM_SKIP_SERIES: &str = "LABEL.SERIES"; const LABEL_XTREAM_LIVE_STREAM_USE_PREFIX: &str = "LABEL.LIVE_STREAM_USE_PREFIX"; const LABEL_XTREAM_LIVE_STREAM_WITHOUT_EXTENSION: &str = "LABEL.LIVE_STREAM_WITHOUT_EXTENSION"; @@ -34,28 +40,20 @@ enum InputFormPage { Main, Options, Staged, + Advanced, } impl Display for InputFormPage { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { write!(f, "{}", match *self { - InputFormPage::Main => "Main".to_string(), - InputFormPage::Options => "Options".to_string(), - InputFormPage::Staged => "Staged".to_string(), + InputFormPage::Main => "Main", + InputFormPage::Options => "Options", + InputFormPage::Staged => "Staged", + InputFormPage::Advanced => "Advanced", }) } } -// generate_form_reducer!( -// state: EpgConfigDtoFormState { form: EpgConfigDto }, -// action_name: EpgConfigDtoFormAction, -// fields { -// Enabled => enabled: bool, -// PeriodMillis => period_millis: u64, -// BurstSize => burst_size: u32, -// } -// ); - generate_form_reducer!( state: ConfigInputOptionsDtoFormState { form: ConfigInputOptionsDto }, action_name: ConfigInputOptionsFormAction, @@ -85,17 +83,16 @@ generate_form_reducer!( state: ConfigInputFormState { form: ConfigInputDto }, action_name: ConfigInputFormAction, fields { - Name => name: String, - //InputType => input_type: InputType, - // Headers => headers: HashMap, - Url => url: String, - Username => username: Option, - Password => password: Option, - Persist => persist: Option, - Enabled => enabled: bool, - Priority => priority: i16, - MaxConnections => max_connections: u16, - Method => method: InputFetchMethod, + Name => name: String, + Url => url: String, + Username => username: Option, + Password => password: Option, + Persist => persist: Option, + Enabled => enabled: bool, + Priority => priority: i16, + MaxConnections => max_connections: u16, + Method => method: InputFetchMethod, + ExpDate => exp_date: Option, } ); @@ -107,7 +104,6 @@ pub struct ConfigInputViewProps { #[function_component] pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html { - let translate = use_translation(); let source_editor_ctx = use_context::().expect("SourceEditorContext not found"); let fetch_methods = use_memo((), |_| { @@ -139,6 +135,15 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html { modified: false, }); + // State for EPG sources, Aliases, and Headers + let epg_sources_state = use_state(Vec::::new); + 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; [ @@ -159,42 +164,133 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html { let input_form_state = input_form_state.clone(); let input_options_state = input_options_state.clone(); let staged_input_state = staged_input_state.clone(); + let epg_sources_state = epg_sources_state.clone(); + let aliases_state = aliases_state.clone(); + let headers_state = headers_state.clone(); let config_input = props.input.clone(); use_effect_with(config_input, move |cfg| { if let Some(input) = cfg { input_form_state.dispatch(ConfigInputFormAction::SetAll(input.as_ref().clone())); + input_options_state.dispatch(ConfigInputOptionsFormAction::SetAll( - input - .options - .as_ref() - .map_or_else(ConfigInputOptionsDto::default, |d| d.clone()), + input.options.as_ref().map_or_else(ConfigInputOptionsDto::default, |d| d.clone()), )); + staged_input_state.dispatch(StagedInputFormAction::SetAll( - input - .staged - .as_ref() - .map_or_else(StagedInputDto::default, |c| c.clone()), + input.staged.as_ref().map_or_else(StagedInputDto::default, |c| c.clone()), )); + + // Load headers + headers_state.set(input.headers.clone()); + + // Load EPG sources + epg_sources_state.set(input.epg.as_ref().and_then(|epg| epg.sources.clone()).unwrap_or_default()); + + // Load aliases + aliases_state.set(input.aliases.clone().unwrap_or_default()); } else { input_form_state.dispatch(ConfigInputFormAction::SetAll(ConfigInputDto::default())); - input_options_state.dispatch(ConfigInputOptionsFormAction::SetAll( - ConfigInputOptionsDto::default(), - )); - staged_input_state - .dispatch(StagedInputFormAction::SetAll(StagedInputDto::default())); + input_options_state.dispatch(ConfigInputOptionsFormAction::SetAll(ConfigInputOptionsDto::default())); + staged_input_state.dispatch(StagedInputFormAction::SetAll(StagedInputDto::default())); + headers_state.set(HashMap::new()); + epg_sources_state.set(Vec::new()); + aliases_state.set(Vec::new()); } || () }); } + let handle_add_epg_item = { + let epg_sources = epg_sources_state.clone(); + let show_epg_form = show_epg_form_state.clone(); + Callback::from(move |source: EpgSourceDto| { + let mut sources = (*epg_sources).clone(); + sources.push(source); + epg_sources.set(sources); + show_epg_form.set(false); + }) + }; + + let handle_close_add_epg_item = { + let show_epg_form = show_epg_form_state.clone(); + Callback::from(move |_| { + show_epg_form.set(false); + }) + }; + + let handle_show_add_epg_item = { + let show_epg_form = show_epg_form_state.clone(); + Callback::from(move |_| { + show_epg_form.set(true); + }) + }; + + let handle_add_alias_item = { + let aliases = aliases_state.clone(); + let show_alias_form = show_alias_form_state.clone(); + Callback::from(move |alias: ConfigInputAliasDto| { + let mut items = (*aliases).clone(); + items.push(alias); + aliases.set(items); + show_alias_form.set(false); + }) + }; + + let handle_close_add_alias_item = { + let show_alias_form = show_alias_form_state.clone(); + Callback::from(move |()| { + show_alias_form.set(false); + }) + }; + + let handle_show_add_alias_item = { + let show_alias_form = show_alias_form_state.clone(); + Callback::from(move |_| { + show_alias_form.set(true); + }) + }; + + + let handle_remove_alias_list_item = { + let alias_list = aliases_state.clone(); + Callback::from(move |(idx, e): (String, MouseEvent)| { + e.prevent_default(); + if let Ok(index) = idx.parse::() { + let mut items = (*alias_list).clone(); + if index < items.len() { + items.remove(index); + alias_list.set(items); + } + } + }) + }; + + let handle_remove_epg_source = { + let epg_list = epg_sources_state.clone(); + Callback::from(move |(idx, e): (String, MouseEvent)| { + e.prevent_default(); + if let Ok(index) = idx.parse::() { + let mut items = (*epg_list).clone(); + if index < items.len() { + items.remove(index); + epg_list.set(items); + } + } + }) + }; + let render_options = || { html! { - { edit_field_bool!(input_options_state, translate.t(LABEL_XTREAM_SKIP_LIVE), xtream_skip_live, ConfigInputOptionsFormAction::XtreamSkipLive) } - { edit_field_bool!(input_options_state, translate.t(LABEL_XTREAM_SKIP_VOD), xtream_skip_vod, ConfigInputOptionsFormAction::XtreamSkipVod) } - { edit_field_bool!(input_options_state, translate.t(LABEL_XTREAM_SKIP_SERIES), xtream_skip_series, ConfigInputOptionsFormAction::XtreamSkipSeries) } + +
+ { edit_field_bool!(input_options_state, translate.t(LABEL_XTREAM_SKIP_LIVE), xtream_skip_live, ConfigInputOptionsFormAction::XtreamSkipLive) } + { edit_field_bool!(input_options_state, translate.t(LABEL_XTREAM_SKIP_VOD), xtream_skip_vod, ConfigInputOptionsFormAction::XtreamSkipVod) } + { edit_field_bool!(input_options_state, translate.t(LABEL_XTREAM_SKIP_SERIES), xtream_skip_series, ConfigInputOptionsFormAction::XtreamSkipSeries) } +
+
{ edit_field_bool!(input_options_state, translate.t(LABEL_XTREAM_LIVE_STREAM_USE_PREFIX), xtream_live_stream_use_prefix, ConfigInputOptionsFormAction::XtreamLiveStreamUsePrefix) } { edit_field_bool!(input_options_state, translate.t(LABEL_XTREAM_LIVE_STREAM_WITHOUT_EXTENSION), xtream_live_stream_without_extension, ConfigInputOptionsFormAction::XtreamLiveStreamWithoutExtension) }
@@ -208,6 +304,7 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html { html! { { edit_field_text!(staged_input_state, translate.t(LABEL_URL), url, StagedInputFormAction::Url) } +
{ edit_field_text_option!(staged_input_state, translate.t(LABEL_USERNAME), username, StagedInputFormAction::Username) } { edit_field_text_option!(staged_input_state, translate.t(LABEL_PASSWORD), password, StagedInputFormAction::Password, true) } { config_field_child!(translate.t(LABEL_FETCH_METHOD), { @@ -247,6 +344,7 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html { options={staged_input_types.clone()} /> }})} +
//{ edit_field_list!(staged_input_state, translate.t(LABEL_HEADERS), headers, StagedInputFormAction::Headers, translate.t(LABEL_ADD_HEADER)) }
@@ -256,17 +354,28 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html { let render_input = || { let input_method_selection = Rc::new(vec![input_form_state.form.method.to_string()]); let input_form_state_disp = input_form_state.clone(); + let aliases = aliases_state.clone(); + let show_alias_form = show_alias_form_state.clone(); + html! { - { edit_field_bool!(input_form_state, translate.t(LABEL_ENABLED), enabled, ConfigInputFormAction::Enabled) } + if *show_alias_form { + + } else { +
{ edit_field_text!(input_form_state, translate.t(LABEL_NAME), name, ConfigInputFormAction::Name) } + { edit_field_bool!(input_form_state, translate.t(LABEL_ENABLED), enabled, ConfigInputFormAction::Enabled) } +
{ edit_field_text!(input_form_state, translate.t(LABEL_URL), url, ConfigInputFormAction::Url) } +
{ edit_field_text_option!(input_form_state, translate.t(LABEL_USERNAME), username, ConfigInputFormAction::Username) } { edit_field_text_option!(input_form_state, translate.t(LABEL_PASSWORD), password, ConfigInputFormAction::Password, true) } - { edit_field_number_u16!(input_form_state, translate.t(LABEL_MAX_CONNECTIONS), max_connections, ConfigInputFormAction::MaxConnections) } - { edit_field_number_i16!(input_form_state, translate.t(LABEL_PRIORITY), priority, ConfigInputFormAction::Priority) } - // pub input_type: InputType, - //{ edit_field_list!(input_form_state, translate.t(LABEL_HEADERS), headers, ConfigInputFormAction::Headers, translate.t(LABEL_ADD_HEADER)) } + { edit_field_number_u16!(input_form_state, translate.t(LABEL_MAX_CONNECTIONS), max_connections, ConfigInputFormAction::MaxConnections) } + { edit_field_number_i16!(input_form_state, translate.t(LABEL_PRIORITY), priority, ConfigInputFormAction::Priority) } + { edit_field_date!(input_form_state, translate.t(LABEL_EXP_DATE), exp_date, ConfigInputFormAction::ExpDate) } { config_field_child!(translate.t(LABEL_FETCH_METHOD), { html! { Html { selected={input_method_selection} /> }})} +
{ edit_field_text_option!(input_form_state, translate.t(LABEL_PERSIST), persist, ConfigInputFormAction::Persist) } + // Aliases Section + { config_field_child!(translate.t(LABEL_ALIASES), { + let aliases_list = aliases.clone(); + html! { +
+
+ { + for (*aliases_list).iter().enumerate().map(|(idx, alias)| { + html! { +
+ +
+ {&alias.name}{" - "}{&alias.url} +
+
+ } + }) + } +
+ +
+ } + })} + } +
+ } + }; - // pub epg: Option, - // pub aliases: Option>, + let render_advanced = || { + let headers = headers_state.clone(); + let epg_sources = epg_sources_state.clone(); + let show_epg_form = show_epg_form_state.clone(); + + html! { + + if *show_epg_form { + + } else { + // Headers Section + { config_field_child!(translate.t(LABEL_HEADERS), { + let headers_set = headers.clone(); + html! { + | { + headers_set.set(new_headers); + })} + /> + } + })} + + // EPG Sources Section + { config_field_child!(translate.t(LABEL_EPG_SOURCES), { + let epg_sources_list = epg_sources.clone(); + + html! { +
+
+ { + for (*epg_sources_list).iter().enumerate().map(|(idx, source)| { + html! { +
+ +
+ {&source.url} +
+
+ } + }) + } +
+ +
+ } + })} + }
} }; @@ -293,6 +500,9 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html { let input_form_state = input_form_state.clone(); let input_options_state = input_options_state.clone(); let staged_input_state = staged_input_state.clone(); + let headers_state = headers_state.clone(); + let epg_sources_state = epg_sources_state.clone(); + let aliases_state = aliases_state.clone(); let block_id = props.block_id; Callback::from(move |_| { let mut input = input_form_state.data().clone(); @@ -311,15 +521,42 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html { Some(staged_input.clone()) }; + // Handle Headers + input.headers = (*headers_state).clone(); + + // Handle EPG: update sources but preserve other fields if present + let epg_sources = (*epg_sources_state).clone(); + if let Some(mut epg_cfg) = input.epg.take() { + epg_cfg.sources = if epg_sources.is_empty() { None } else { Some(epg_sources) }; + input.epg = if epg_cfg.sources.is_some() || epg_cfg.smart_match.is_some() { + Some(epg_cfg) + } else { + None + }; + } else if !epg_sources.is_empty() { + input.epg = Some(EpgConfigDto { + sources: Some(epg_sources), + ..EpgConfigDto::default() + }); + } + + // Handle Aliases + let aliases = (*aliases_state).clone(); + input.aliases = if aliases.is_empty() { + None + } else { + Some(aliases) + }; + source_editor_ctx.on_form_change.emit((block_id, BlockInstance::Input(Rc::new(input)))); source_editor_ctx.edit_mode.set(EditMode::Inactive); }) }; let handle_cancel = { - let source_editor_ctx = source_editor_ctx.clone(); - Callback::from(move |_| { - source_editor_ctx.edit_mode.set(EditMode::Inactive); - }) + let source_editor_ctx = source_editor_ctx.clone(); + Callback::from(move |_| { + source_editor_ctx.edit_mode.set(EditMode::Inactive); + }) }; let render_edit_mode = || { @@ -330,7 +567,8 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html { for [ InputFormPage::Main, InputFormPage::Options, - InputFormPage::Staged + InputFormPage::Staged, + InputFormPage::Advanced ].iter().map(|page| { let page_str = page.to_string(); let active = *view_visible == page_str; @@ -360,6 +598,9 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html { {render_staged()} + + {render_advanced()} + } @@ -368,14 +609,14 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html { html! {
- +
{ render_edit_mode() }
diff --git a/frontend/src/app/components/source_editor/mod.rs b/frontend/src/app/components/source_editor/mod.rs index 352f80d99..4843c8ce7 100644 --- a/frontend/src/app/components/source_editor/mod.rs +++ b/frontend/src/app/components/source_editor/mod.rs @@ -8,7 +8,13 @@ mod output_form; mod editor_form; mod block_view; mod output_xtream_form; +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::*; @@ -19,4 +25,10 @@ pub(crate) use target_form::*; pub(crate) use output_form::*; pub(crate) use editor_form::*; pub(crate) use block_view::*; -pub(crate) use output_xtream_form::*; \ No newline at end of file +pub(crate) use output_xtream_form::*; +pub(crate) use output_m3u_form::*; +pub(crate) use output_strm_form::*; +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_form.rs b/frontend/src/app/components/source_editor/output_form.rs index 1397099c8..a2f801f9f 100644 --- a/frontend/src/app/components/source_editor/output_form.rs +++ b/frontend/src/app/components/source_editor/output_form.rs @@ -1,4 +1,4 @@ -use crate::app::components::{BlockId, BlockType, EditMode, SourceEditorContext, XtreamTargetOutputView}; +use crate::app::components::{BlockId, BlockType, EditMode, SourceEditorContext, XtreamTargetOutputView, M3uTargetOutputView, StrmTargetOutputView, HdHomeRunTargetOutputView}; use shared::model::TargetOutputDto; use std::rc::Rc; use yew::{function_component, html, use_context, Html, Properties}; @@ -21,7 +21,14 @@ pub fn ConfigOutputView(props: &ConfigOutputViewProps) -> Html { BlockType::InputXtream | BlockType::InputM3u | BlockType::Target => html! {}, - BlockType::OutputM3u => html! {}, + BlockType::OutputM3u => { + let output = props.output.as_ref() + .and_then(|to| if let TargetOutputDto::M3u(m3u) = &**to { + Some(Rc::new(m3u.clone())) + } else { None }); + + html! { } + } BlockType::OutputXtream => { let output = props.output.as_ref() .and_then(|to| if let TargetOutputDto::Xtream(xtream) = &**to { @@ -30,8 +37,22 @@ pub fn ConfigOutputView(props: &ConfigOutputViewProps) -> Html { html! { } } - BlockType::OutputHdHomeRun => html! {}, - BlockType::OutputStrm => html! {}, + BlockType::OutputHdHomeRun => { + let output = props.output.as_ref() + .and_then(|to| if let TargetOutputDto::HdHomeRun(hdhomerun) = &**to { + Some(Rc::new(hdhomerun.clone())) + } else { None }); + + html! { } + } + BlockType::OutputStrm => { + let output = props.output.as_ref() + .and_then(|to| if let TargetOutputDto::Strm(strm) = &**to { + Some(Rc::new(strm.clone())) + } else { None }); + + html! { } + } } } EditMode::Inactive => html! {} diff --git a/frontend/src/app/components/source_editor/output_hdhomerun_form.rs b/frontend/src/app/components/source_editor/output_hdhomerun_form.rs new file mode 100644 index 000000000..0fe3ca9ad --- /dev/null +++ b/frontend/src/app/components/source_editor/output_hdhomerun_form.rs @@ -0,0 +1,138 @@ +use crate::app::components::config::HasFormData; +use crate::app::components::select::Select; +use crate::app::components::{BlockId, BlockInstance, Card, DropDownOption, DropDownSelection, EditMode, SourceEditorContext, TextButton}; +use crate::{config_field_child, edit_field_text, generate_form_reducer}; +use shared::model::{HdHomeRunTargetOutputDto, TargetOutputDto, TargetType}; +use std::rc::Rc; +use yew::{function_component, html, use_context, use_effect_with, use_memo, use_reducer, Callback, Html, Properties, UseReducerHandle}; +use yew_i18n::use_translation; + +const LABEL_DEVICE: &str = "LABEL.DEVICE"; +const LABEL_USERNAME: &str = "LABEL.USERNAME"; +const LABEL_USE_OUTPUT: &str = "LABEL.USE_OUTPUT"; + +generate_form_reducer!( + state: HdHomeRunTargetOutputFormState { form: HdHomeRunTargetOutputDto }, + action_name: HdHomeRunTargetOutputFormAction, + fields { + Device => device: String, + Username => username: String, + UseOutput => use_output: Option, + } +); + +#[derive(Properties, PartialEq, Clone)] +pub struct HdHomeRunTargetOutputViewProps { + pub(crate) block_id: BlockId, + pub(crate) output: Option>, +} + +#[function_component] +pub fn HdHomeRunTargetOutputView(props: &HdHomeRunTargetOutputViewProps) -> Html { + let translate = use_translation(); + let source_editor_ctx = use_context::().expect("SourceEditorContext not found"); + + let output_form_state: UseReducerHandle = + use_reducer(|| HdHomeRunTargetOutputFormState { + form: HdHomeRunTargetOutputDto::default(), + modified: false, + }); + + let target_types = use_memo(output_form_state.form.use_output, |use_output| { + let default_type = use_output.unwrap_or(TargetType::M3u); + [ + TargetType::M3u, + TargetType::Xtream, + ] + .iter() + .map(|t| DropDownOption { + id: t.to_string(), + label: html! { t.to_string() }, + selected: *t == default_type, + }).collect::>() + }); + + { + let output_form_state = output_form_state.clone(); + let config_output = props.output.clone(); + + use_effect_with(config_output, move |cfg| { + if let Some(output) = cfg { + output_form_state.dispatch(HdHomeRunTargetOutputFormAction::SetAll(output.as_ref().clone())); + } else { + output_form_state.dispatch(HdHomeRunTargetOutputFormAction::SetAll(HdHomeRunTargetOutputDto::default())); + } + || () + }); + } + + let render_output = || { + let output_form_state_1 = output_form_state.clone(); + html! { + + { edit_field_text!(output_form_state, translate.t(LABEL_DEVICE), device, HdHomeRunTargetOutputFormAction::Device) } + { edit_field_text!(output_form_state, translate.t(LABEL_USERNAME), username, HdHomeRunTargetOutputFormAction::Username) } + { config_field_child!(translate.t(LABEL_USE_OUTPUT), { + html! { + { + output_form_state_1.dispatch(StrmTargetOutputFormAction::Style(StrmExportStyle::Kodi)); + } + DropDownSelection::Single(option) => { + output_form_state_1.dispatch(StrmTargetOutputFormAction::Style(option.parse::().unwrap_or(StrmExportStyle::Kodi))); + } + DropDownSelection::Multi(options) => { + if let Some(first) = options.first() { + output_form_state_1.dispatch(StrmTargetOutputFormAction::Style(first.parse::().unwrap_or(StrmExportStyle::Kodi))); + } + } + } + })} + options={export_styles.clone()} + /> + } + })} + { edit_field_text_option!(output_form_state, translate.t(LABEL_FILTER), filter, StrmTargetOutputFormAction::Filter) } + + } + }; + + let render_options = || { + html! { + + { edit_field_bool!(output_form_state, translate.t(LABEL_FLAT), flat, StrmTargetOutputFormAction::Flat) } + { edit_field_bool!(output_form_state, translate.t(LABEL_UNDERSCORE_WHITESPACE), underscore_whitespace, StrmTargetOutputFormAction::UnderscoreWhitespace) } + { edit_field_bool!(output_form_state, translate.t(LABEL_CLEANUP), cleanup, StrmTargetOutputFormAction::Cleanup) } + { edit_field_bool!(output_form_state, translate.t(LABEL_ADD_QUALITY_TO_FILENAME), add_quality_to_filename, StrmTargetOutputFormAction::AddQualityToFilename) } + { edit_field_list_option!(output_form_state, translate.t(LABEL_STRM_PROPS), strm_props, StrmTargetOutputFormAction::StrmProps, translate.t(LABEL_ADD_PROPERTY)) } + + } + }; + + let render_edit_mode = || { + html! { +
+
+ { + for [ + StrmFormPage::Main, + StrmFormPage::Options, + ].iter().map(|page| { + let page_str = page.to_string(); + let active = *view_visible == page_str; + let on_tab_click = { + let on_tab_click = on_tab_click.clone(); + let page = *page; + Callback::from(move |_| on_tab_click.emit(page)) + }; + html! { + + } + }) + } +
+
+ + {render_main()} + + + {render_options()} + +
+
+ } + }; + + let handle_apply = { + let source_editor_ctx = source_editor_ctx.clone(); + let output_form_state = output_form_state.clone(); + let block_id = props.block_id; + Callback::from(move |_| { + let output = output_form_state.data().clone(); + source_editor_ctx.on_form_change.emit((block_id, BlockInstance::Output(Rc::new(TargetOutputDto::Strm(output))))); + source_editor_ctx.edit_mode.set(EditMode::Inactive); + }) + }; + + let handle_cancel = { + let source_editor_ctx = source_editor_ctx.clone(); + Callback::from(move |_| { + source_editor_ctx.edit_mode.set(EditMode::Inactive); + }) + }; + + html! { +
+
+ + +
+ { render_edit_mode() } +
+ } +} 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 fa81d3fb2..a60e697df 100644 --- a/frontend/src/app/components/source_editor/output_xtream_form.rs +++ b/frontend/src/app/components/source_editor/output_xtream_form.rs @@ -1,21 +1,26 @@ use crate::app::components::config::HasFormData; -use crate::app::components::{BlockId, BlockInstance, Card, EditMode, Panel, SourceEditorContext, TextButton}; -use crate::{ edit_field_bool, edit_field_number_u16, edit_field_text_option, generate_form_reducer}; -use shared::model::{ XtreamTargetOutputDto, TargetOutputDto}; +use crate::app::components::{BlockId, BlockInstance, Card, EditMode, IconButton, Panel, SourceEditorContext, TextButton, TraktListItemForm, TitledCard}; +use crate::{config_field_child, edit_field_bool, edit_field_number_u16, edit_field_text, edit_field_text_option, generate_form_reducer}; +use shared::model::{TargetOutputDto, TraktApiConfigDto, TraktConfigDto, TraktContentType, TraktListConfigDto, XtreamTargetOutputDto}; use std::fmt::Display; use std::rc::Rc; +use web_sys::MouseEvent; use yew::{classes, function_component, html, use_context, use_effect_with, use_reducer, use_state, Callback, Html, Properties, UseReducerHandle}; use yew_i18n::use_translation; -const LABEL_SKIP_LIVE_DIRECT_SOURCE: &str = "LABEL.SKIP_LIVE_DIRECT_SOURCE"; -const LABEL_SKIP_VOD_DIRECT_SOURCE: &str = "LABEL.SKIP_VOD_DIRECT_SOURCE"; -const LABEL_SKIP_SERIES_DIRECT_SOURCE: &str = "LABEL.SKIP_SERIES_DIRECT_SOURCE"; -const LABEL_RESOLVE_VOD: &str = "LABEL.RESOLVE_VOD"; -const LABEL_RESOLVE_VOD_DELAY: &str = "LABEL.RESOLVE_VOD_DELAY_SEC"; -const LABEL_RESOLVE_SERIES: &str = "LABEL.RESOLVE_SERIES"; -const LABEL_RESOLVE_SERIES_DELAY: &str = "LABEL.RESOLVE_SERIES_DELAY_SEC"; +const LABEL_SKIP_DIRECT_SOURCE: &str = "LABEL.SKIP_DIRECT_SOURCE"; +const LABEL_LIVE: &str = "LABEL.LIVE"; +const LABEL_VOD: &str = "LABEL.VOD"; +const LABEL_SERIES: &str = "LABEL.SERIES"; +const LABEL_RESOLVE: &str = "LABEL.RESOLVE"; +const LABEL_RESOLVE_DELAY_SEC: &str = "LABEL.RESOLVE_DELAY_SEC"; const LABEL_FILTER: &str = "LABEL.FILTER"; - +const LABEL_TRAKT_API_KEY: &str = "LABEL.TRAKT_API_KEY"; +const LABEL_TRAKT_API_VERSION: &str = "LABEL.TRAKT_API_VERSION"; +const LABEL_TRAKT_API_URL: &str = "LABEL.TRAKT_API_URL"; +const LABEL_TRAKT_LISTS: &str = "LABEL.TRAKT_LISTS"; +const LABEL_ADD_TRAKT_LIST: &str = "LABEL.ADD_TRAKT_LIST"; +const LABEL_API_CONFIGURATION: &str = "LABEL.API_CONFIGURATION"; #[derive(Copy, Clone, PartialEq, Eq)] enum OutputFormPage { @@ -26,22 +31,20 @@ enum OutputFormPage { impl Display for OutputFormPage { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { write!(f, "{}", match *self { - OutputFormPage::Main => "Main".to_string(), - OutputFormPage::Trakt => "Trakt".to_string(), + OutputFormPage::Main => "Main", + OutputFormPage::Trakt => "Trakt", }) } } -// -// generate_form_reducer!( -// state: TraktConfigFormState { form: TraktConfigDto }, -// action_name: TraktConfigFormAction, -// fields { -// IgnoreLogo => ignore_logo: bool, -// ShareLiveStreams => share_live_streams: bool, -// RemoveDuplicates => remove_duplicates: bool, -// ForceRedirect => force_redirect: Option, -// } -// ); +generate_form_reducer!( + state: TraktApiConfigFormState { form: TraktApiConfigDto }, + action_name: TraktApiConfigFormAction, + fields { + Key => key: String, + Version => version: String, + Url => url: String, + } +); generate_form_reducer!( state: XtreamTargetOutputFormState { form: XtreamTargetOutputDto }, @@ -74,11 +77,18 @@ pub fn XtreamTargetOutputView(props: &XtreamTargetOutputViewProps) -> Html { form: XtreamTargetOutputDto::default(), modified: false, }); - // let target_options_state: UseReducerHandle = - // use_reducer(|| TraktConfigFormState { - // form: TraktConfigDto::default(), - // modified: false, - // }); + + let trakt_api_state: UseReducerHandle = + use_reducer(|| TraktApiConfigFormState { + form: TraktApiConfigDto::default(), + modified: false, + }); + + // State for Trakt lists + let trakt_lists_state = use_state(Vec::::new); + + // State for showing trakt list form + let show_trakt_list_form_state = use_state(|| false); let view_visible = use_state(|| OutputFormPage::Main.to_string()); @@ -89,33 +99,172 @@ pub fn XtreamTargetOutputView(props: &XtreamTargetOutputViewProps) -> Html { { let output_form_state = output_form_state.clone(); + let trakt_api_state = trakt_api_state.clone(); + let trakt_lists_state = trakt_lists_state.clone(); let config_output = props.output.clone(); use_effect_with(config_output, move |cfg| { if let Some(target) = cfg { output_form_state.dispatch(XtreamTargetOutputFormAction::SetAll(target.as_ref().clone())); + + // Load Trakt configuration + if let Some(trakt) = &target.trakt { + trakt_api_state.dispatch(TraktApiConfigFormAction::SetAll(trakt.api.clone())); + trakt_lists_state.set(trakt.lists.clone()); + } else { + trakt_api_state.dispatch(TraktApiConfigFormAction::SetAll(TraktApiConfigDto::default())); + trakt_lists_state.set(Vec::new()); + } } else { output_form_state.dispatch(XtreamTargetOutputFormAction::SetAll(XtreamTargetOutputDto::default())); + trakt_api_state.dispatch(TraktApiConfigFormAction::SetAll(TraktApiConfigDto::default())); + trakt_lists_state.set(Vec::new()); } || () }); } + + let handle_add_trakt_list_item = { + let trakt_list = trakt_lists_state.clone(); + let show_trakt_list_form = show_trakt_list_form_state.clone(); + + Callback::from(move |item: TraktListConfigDto| { + let mut items = (*trakt_list).clone(); + items.push(item); + trakt_list.set(items); + show_trakt_list_form.set(false); + }) + }; + + let handle_remove_trakt_list_item = { + let trakt_list = trakt_lists_state.clone(); + Callback::from(move |(idx, _e): (String, MouseEvent)| { + if let Ok(index) = idx.parse::() { + let mut items = (*trakt_list).clone(); + if index < items.len() { + items.remove(index); + trakt_list.set(items); + } + } + }) + }; + + let handle_close_trakt_list_form = { + let show_trakt_list_form = show_trakt_list_form_state.clone(); + Callback::from(move |()| { + show_trakt_list_form.set(false); + }) + }; + + let handle_show_trakt_list_form = { + let show_trakt_list_form = show_trakt_list_form_state.clone(); + Callback::from(move |_name| { + show_trakt_list_form.set(true); + }) + }; + let render_output = || { html! { - { edit_field_bool!(output_form_state, translate.t(LABEL_SKIP_LIVE_DIRECT_SOURCE), skip_live_direct_source, XtreamTargetOutputFormAction::SkipLiveDirectSource) } - { edit_field_bool!(output_form_state, translate.t(LABEL_SKIP_VOD_DIRECT_SOURCE), skip_video_direct_source, XtreamTargetOutputFormAction::SkipVideoDirectSource) } - { edit_field_bool!(output_form_state, translate.t(LABEL_SKIP_SERIES_DIRECT_SOURCE), skip_series_direct_source, XtreamTargetOutputFormAction::SkipSeriesDirectSource) } - { edit_field_bool!(output_form_state, translate.t(LABEL_RESOLVE_VOD), resolve_vod, XtreamTargetOutputFormAction::ResolveVod) } - { edit_field_bool!(output_form_state, translate.t(LABEL_RESOLVE_SERIES), resolve_series, XtreamTargetOutputFormAction::ResolveSeries) } - { edit_field_number_u16!(output_form_state, translate.t(LABEL_RESOLVE_VOD_DELAY), resolve_vod_delay, XtreamTargetOutputFormAction::ResolveVodDelay) } - { edit_field_number_u16!(output_form_state, translate.t(LABEL_RESOLVE_SERIES_DELAY), resolve_series_delay, XtreamTargetOutputFormAction::ResolveSeriesDelay) } + +
+ { edit_field_bool!(output_form_state, translate.t(LABEL_LIVE), skip_live_direct_source, XtreamTargetOutputFormAction::SkipLiveDirectSource) } + { edit_field_bool!(output_form_state, translate.t(LABEL_VOD), skip_video_direct_source, XtreamTargetOutputFormAction::SkipVideoDirectSource) } + { edit_field_bool!(output_form_state, translate.t(LABEL_SERIES), skip_series_direct_source, XtreamTargetOutputFormAction::SkipSeriesDirectSource) } +
+
+ +
+ { edit_field_bool!(output_form_state, translate.t(LABEL_VOD), resolve_vod, XtreamTargetOutputFormAction::ResolveVod) } + { edit_field_bool!(output_form_state, translate.t(LABEL_SERIES), resolve_series, XtreamTargetOutputFormAction::ResolveSeries) } +
+
+ +
+ { edit_field_number_u16!(output_form_state, translate.t(LABEL_VOD), resolve_vod_delay, XtreamTargetOutputFormAction::ResolveVodDelay) } + { edit_field_number_u16!(output_form_state, translate.t(LABEL_SERIES), resolve_series_delay, XtreamTargetOutputFormAction::ResolveSeriesDelay) } +
+
{ edit_field_text_option!(output_form_state, translate.t(LABEL_FILTER), filter, XtreamTargetOutputFormAction::Filter) }
} }; + let render_trakt = || { + let trakt_lists = trakt_lists_state.clone(); + let trakt_api = trakt_api_state.clone(); + let show_trakt_list_form = show_trakt_list_form_state.clone(); + + html! { + + if *show_trakt_list_form { + + } else { + // Trakt API Configuration +
+

{translate.t(LABEL_API_CONFIGURATION)}

+ { edit_field_text!(trakt_api, translate.t(LABEL_TRAKT_API_KEY), key, TraktApiConfigFormAction::Key) } + { edit_field_text!(trakt_api, translate.t(LABEL_TRAKT_API_VERSION), version, TraktApiConfigFormAction::Version) } + { edit_field_text!(trakt_api, translate.t(LABEL_TRAKT_API_URL), url, TraktApiConfigFormAction::Url) } +
+ + // Trakt Lists + { config_field_child!(translate.t(LABEL_TRAKT_LISTS), { + let trakt_lists_list = trakt_lists.clone(); + html! { +
+
+ { + for (*trakt_lists_list).iter().enumerate().map(|(idx, list)| { + let content_type_str = match list.content_type { + TraktContentType::Vod => "Vod", + TraktContentType::Series => "Series", + TraktContentType::Both => "Both", + }; + html! { +
+ +
+ + {&list.user} + {" / "} + {&list.list_slug} + {" - "} + {&list.category_name} + {" ("} + {content_type_str} + {", "} + {list.fuzzy_match_threshold} + {"%)"} + +
+
+ } + }) + } +
+ + +
+ } + })} + } +
+ } + }; let render_edit_mode = || { html! { @@ -149,7 +298,7 @@ pub fn XtreamTargetOutputView(props: &XtreamTargetOutputViewProps) -> Html { {render_output()} - {"TODO..."} + {render_trakt()} @@ -159,9 +308,23 @@ pub fn XtreamTargetOutputView(props: &XtreamTargetOutputViewProps) -> Html { let handle_apply_target = { let source_editor_ctx = source_editor_ctx.clone(); let output_form_state = output_form_state.clone(); + let trakt_api_state = trakt_api_state.clone(); + let trakt_lists_state = trakt_lists_state.clone(); let block_id = props.block_id; Callback::from(move |_| { - let output = output_form_state.data().clone(); + let mut output = output_form_state.data().clone(); + + // Handle Trakt configuration + let trakt_lists = (*trakt_lists_state).clone(); + output.trakt = if trakt_lists.is_empty() { + None + } else { + Some(TraktConfigDto { + api: trakt_api_state.data().clone(), + lists: trakt_lists, + }) + }; + source_editor_ctx.on_form_change.emit((block_id, BlockInstance::Output(Rc::new(TargetOutputDto::Xtream(output))))); source_editor_ctx.edit_mode.set(EditMode::Inactive); }) @@ -176,14 +339,14 @@ pub fn XtreamTargetOutputView(props: &XtreamTargetOutputViewProps) -> Html { html! {
- +
{ render_edit_mode() }
diff --git a/frontend/src/app/components/source_editor/target_form.rs b/frontend/src/app/components/source_editor/target_form.rs index 05a5742fe..af03f6465 100644 --- a/frontend/src/app/components/source_editor/target_form.rs +++ b/frontend/src/app/components/source_editor/target_form.rs @@ -30,10 +30,10 @@ enum TargetFormPage { impl Display for TargetFormPage { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - write!(f, "{}", match *self { - TargetFormPage::Main => "Main".to_string(), - TargetFormPage::Options => "Options".to_string(), - }) + match *self { + TargetFormPage::Main => write!(f, "Main"), + TargetFormPage::Options => write!(f, "Options"), + } } } @@ -139,8 +139,10 @@ pub fn ConfigTargetView(props: &ConfigTargetViewProps) -> Html { let target_options_state_1 = target_options_state.clone(); html! { +
{ edit_field_bool!(target_options_state, translate.t(LABEL_IGNORE_LOGO), ignore_logo, ConfigTargetOptionsFormAction::IgnoreLogo) } { edit_field_bool!(target_options_state, translate.t(LABEL_SHARE_LIVE_STREAMS), share_live_streams, ConfigTargetOptionsFormAction::ShareLiveStreams) } +
{ edit_field_bool!(target_options_state, translate.t(LABEL_REMOVE_DUPLICATES), remove_duplicates, ConfigTargetOptionsFormAction::RemoveDuplicates) } { config_field_child!(translate.t(LABEL_FORCE_REDIRECT), { html! { @@ -160,8 +162,10 @@ pub fn ConfigTargetView(props: &ConfigTargetViewProps) -> Html { let target_form_state_1 = target_form_state.clone(); html! { +
{ edit_field_bool!(target_form_state, translate.t(LABEL_ENABLED), enabled, ConfigTargetFormAction::Enabled) } { edit_field_bool!(target_form_state, translate.t(LABEL_USE_MEMORY_CACHE), use_memory_cache, ConfigTargetFormAction::UseMemoryCache) } +
{ edit_field_text!(target_form_state, translate.t(LABEL_NAME), name, ConfigTargetFormAction::Name) } { edit_field_text!(target_form_state, translate.t(LABEL_FILTER), filter, ConfigTargetFormAction::Filter) } @@ -260,14 +264,14 @@ pub fn ConfigTargetView(props: &ConfigTargetViewProps) -> Html { html! {
- +
{ render_edit_mode() }
diff --git a/frontend/src/app/components/source_editor/trakt_list_item_form.rs b/frontend/src/app/components/source_editor/trakt_list_item_form.rs new file mode 100644 index 000000000..8ffb90a2d --- /dev/null +++ b/frontend/src/app/components/source_editor/trakt_list_item_form.rs @@ -0,0 +1,136 @@ +use crate::app::components::select::Select; +use crate::app::components::{Card, DropDownOption, DropDownSelection, TextButton}; +use crate::{config_field_child, edit_field_number_u8, edit_field_text, generate_form_reducer}; +use shared::model::{TraktContentType, TraktListConfigDto}; +use yew::{function_component, html, use_memo, use_reducer, Callback, Html, Properties, UseReducerHandle}; +use yew_i18n::use_translation; + +const LABEL_TRAKT_USER: &str = "LABEL.TRAKT_USER"; +const LABEL_TRAKT_LIST_SLUG: &str = "LABEL.TRAKT_LIST_SLUG"; +const LABEL_TRAKT_CATEGORY_NAME: &str = "LABEL.TRAKT_CATEGORY_NAME"; +const LABEL_TRAKT_CONTENT_TYPE: &str = "LABEL.TRAKT_CONTENT_TYPE"; +const LABEL_TRAKT_FUZZY_MATCH_THRESHOLD: &str = "LABEL.TRAKT_FUZZY_MATCH_THRESHOLD"; + +generate_form_reducer!( + state: TraktListFormState { form: TraktListConfigDto }, + action_name: TraktListFormAction, + fields { + User => user: String, + ListSlug => list_slug: String, + CategoryName => category_name: String, + ContentType => content_type: TraktContentType, + FuzzyMatchThreshold => fuzzy_match_threshold: u8, + } +); + +#[derive(Properties, PartialEq, Clone)] +pub struct TraktListItemFormProps { + pub on_submit: Callback, + pub on_cancel: Callback<()>, + #[prop_or_default] + pub initial: Option, +} + +#[function_component] +pub fn TraktListItemForm(props: &TraktListItemFormProps) -> Html { + let translate = use_translation(); + + let form_state: UseReducerHandle = use_reducer(|| { + TraktListFormState { + form: props.initial.clone().unwrap_or_else(|| TraktListConfigDto { + user: String::new(), + list_slug: String::new(), + category_name: String::new(), + content_type: TraktContentType::Both, + fuzzy_match_threshold: 80, + }), + modified: false, + } + }); + + let content_type_options = use_memo(form_state.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 handle_submit = { + let form_state = form_state.clone(); + let on_submit = props.on_submit.clone(); + Callback::from(move |_| { + let data = form_state.form.clone(); + if !data.user.trim().is_empty() + && !data.list_slug.trim().is_empty() + && !data.category_name.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_TRAKT_USER), user, TraktListFormAction::User) } + { edit_field_text!(form_state, translate.t(LABEL_TRAKT_LIST_SLUG), list_slug, TraktListFormAction::ListSlug) } + { edit_field_text!(form_state, translate.t(LABEL_TRAKT_CATEGORY_NAME), category_name, TraktListFormAction::CategoryName) } + + { config_field_child!(translate.t(LABEL_TRAKT_CONTENT_TYPE), { + let form_state_ct = form_state.clone(); + html! { +