save config

This commit is contained in:
euzu
2025-08-29 23:12:15 +02:00
parent 3e07e4f177
commit 2dd6185851
12 changed files with 436 additions and 65 deletions
+2 -1
View File
@@ -275,7 +275,8 @@
"USER_STATUS_PENDING": "Pending",
"VIEW": "View",
"ADD_CHAT_ID": "Add ChatId...",
"ADD_ATTRIBUTE": "Add Attribute..."
"ADD_ATTRIBUTE": "Add Attribute...",
"ADD_EXTENSION": "Add Extension..."
},
"TITLE": {
"USER_BOUQUET_EDITOR": "User group editor"
+2 -1
View File
@@ -68,4 +68,5 @@
@forward "components/tabset";
@forward "components/select";
@forward "components/form";
@forward "components/api_user/api_user_view";
@forward "components/api_user/api_user_view";
@forward "components/keyvalue_editor";
@@ -0,0 +1,23 @@
.tp__keyvalue-editor {
display: flex;
flex-flow: column;
box-sizing: border-box;
gap: var(--gap-small);
&__entries {
display: flex;
flex-flow: row wrap;
box-sizing: border-box;
gap: var(--gap-small);
}
label{
font-size: 1.1rem;
}
&__inputs {
display: flex;
flex-flow: row;
box-sizing: border-box;
gap: var(--gap-small);
}
}
@@ -39,6 +39,70 @@ pub fn ApiConfigView() -> Html {
});
}
{
let on_form_change = config_view_ctx.on_form_change.clone();
let deps = (form_state.form.clone(), form_state.modified);
use_effect_with(deps, move |(form, modified)| {
on_form_change.emit(ConfigForm::Api(*modified, form.clone()));
|| ()
});
}
{
let on_form_change = config_view_ctx.on_form_change.clone();
let deps = (form_state.form.clone(), form_state.modified);
use_effect_with(deps, move |(form, modified)| {
on_form_change.emit(ConfigForm::Api(*modified, form.clone()));
|| ()
});
}
{
let on_form_change = config_view_ctx.on_form_change.clone();
let deps = (form_state.form.clone(), form_state.modified);
use_effect_with(deps, move |(form, modified)| {
on_form_change.emit(ConfigForm::Api(*modified, form.clone()));
|| ()
});
}
{
let on_form_change = config_view_ctx.on_form_change.clone();
let deps = (form_state.form.clone(), form_state.modified);
use_effect_with(deps, move |(form, modified)| {
on_form_change.emit(ConfigForm::Api(*modified, form.clone()));
|| ()
});
}
{
let on_form_change = config_view_ctx.on_form_change.clone();
let deps = (form_state.form.clone(), form_state.modified);
use_effect_with(deps, move |(form, modified)| {
on_form_change.emit(ConfigForm::Api(*modified, form.clone()));
|| ()
});
}
{
let on_form_change = config_view_ctx.on_form_change.clone();
let deps = (form_state.form.clone(), form_state.modified);
use_effect_with(deps, move |(form, modified)| {
on_form_change.emit(ConfigForm::Api(*modified, form.clone()));
|| ()
});
}
{
let on_form_change = config_view_ctx.on_form_change.clone();
let deps = (form_state.form.clone(), form_state.modified);
use_effect_with(deps, move |(form, modified)| {
on_form_change.emit(ConfigForm::Api(*modified, form.clone()));
|| ()
});
}
{
let form_state = form_state.clone();
let api_config = config_ctx
@@ -46,7 +110,8 @@ pub fn ApiConfigView() -> Html {
.as_ref()
.map(|c| c.config.api.clone());
use_effect_with((api_config, config_view_ctx.edit_mode.clone()), move |(cfg, _mode)| {
let deps = (api_config, *config_view_ctx.edit_mode);
use_effect_with(deps, move |(cfg, _mode)| {
if let Some(api) = cfg {
form_state.dispatch(ApiConfigFormAction::SetAll(api.clone()));
} else {
@@ -54,6 +119,7 @@ pub fn ApiConfigView() -> Html {
}
|| ()
});
}
let render_empty = || {
@@ -1,6 +1,6 @@
use shared::error::TuliproxError;
use shared::info_err;
use shared::model::{ConfigApiDto, HdHomeRunConfigDto, IpCheckConfigDto, MainConfigDto, MessagingConfigDto, ProxyConfigDto, ReverseProxyConfigDto, ScheduleConfigDto, WebUiConfigDto};
use shared::model::{ConfigApiDto, HdHomeRunConfigDto, IpCheckConfigDto, MainConfigDto, MessagingConfigDto, ProxyConfigDto, ReverseProxyConfigDto, ScheduleConfigDto, VideoConfigDto, WebUiConfigDto};
use std::fmt;
use std::str::FromStr;
@@ -72,7 +72,7 @@ pub enum ConfigForm {
Main(bool, MainConfigDto),
Api(bool, ConfigApiDto),
Schedules(bool, ScheduleConfigDto),
Video(bool, MessagingConfigDto),
Video(bool, VideoConfigDto),
Messaging(bool, MessagingConfigDto),
WebUi(bool, WebUiConfigDto),
ReverseProxy(bool, ReverseProxyConfigDto),
@@ -123,15 +123,23 @@ pub fn ReverseProxyConfigView() -> Html {
use_effect_with((reverse_proxy_cfg, config_view_ctx.edit_mode.clone()), move |(cfg, _mode)| {
if let Some(rp) = cfg {
reverse_proxy_state.dispatch(ReverseProxyConfigFormAction::SetAll((*rp).clone()));
if let Some(cache) = &rp.cache {
cache_state.dispatch(CacheConfigFormAction::SetAll(cache.clone()));
}
if let Some(rl) = &rp.rate_limit {
rate_limit_state.dispatch(RateLimitConfigFormAction::SetAll(rl.clone()));
}
if let Some(stream) = &rp.stream {
stream_state.dispatch(StreamConfigFormAction::SetAll(stream.clone()));
}
cache_state.dispatch(CacheConfigFormAction::SetAll(cache.clone()));
} else {
cache_state.dispatch(CacheConfigFormAction::SetAll(CacheConfigDto::default()));
}
if let Some(rl) = &rp.rate_limit {
rate_limit_state.dispatch(RateLimitConfigFormAction::SetAll(rl.clone()));
} else {
rate_limit_state.dispatch(RateLimitConfigFormAction::SetAll(RateLimitConfigDto::default()));
}
if let Some(stream) = &rp.stream {
stream_state.dispatch(StreamConfigFormAction::SetAll(stream.clone()));
} else {
stream_state.dispatch(StreamConfigFormAction::SetAll(StreamConfigDto::default()));
}
} else {
reverse_proxy_state.dispatch(ReverseProxyConfigFormAction::SetAll(ReverseProxyConfigDto::default()));
cache_state.dispatch(CacheConfigFormAction::SetAll(CacheConfigDto::default()));
@@ -1,9 +1,13 @@
use crate::app::components::{Card, Chip};
use std::collections::HashMap;
use crate::app::components::{Card, Chip, KeyValueEditor};
use crate::app::context::ConfigContext;
use crate::{config_field_bool, config_field_child, config_field_empty, config_field_optional};
use shared::model::VideoDownloadConfigDto;
use crate::{config_field_bool, config_field_child, config_field_empty, config_field_optional, edit_field_bool, edit_field_text_option, edit_field_list, generate_form_reducer, config_field_bool_empty};
use shared::model::{VideoDownloadConfigDto, VideoConfigDto};
use yew::prelude::*;
use yew_i18n::use_translation;
use crate::app::components::config::config_page::ConfigForm;
use crate::app::components::config::config_view_context::ConfigViewContext;
use crate::app::components::config::macros::HasFormData;
const LABEL_DOWNLOAD: &str = "LABEL.DOWNLOAD";
const LABEL_ORGANIZE_INTO_DIRECTORIES: &str = "LABEL.ORGANIZE_INTO_DIRECTORIES";
@@ -12,26 +16,97 @@ const LABEL_EPISODE_PATTERN: &str = "LABEL.EPISODE_PATTERN";
const LABEL_HEADERS: &str = "LABEL.HEADERS";
const LABEL_EXTENSIONS: &str = "LABEL.EXTENSIONS";
const LABEL_WEB_SEARCH: &str = "LABEL.WEB_SEARCH";
const LABEL_ADD_EXTENSION: &str = "LABEL.ADD_EXTENSION";
generate_form_reducer!(
state: VideoDownloadConfigFormState { form: VideoDownloadConfigDto },
action_name: VideoDownloadConfigFormAction,
fields {
OrganizeIntoDirectories => organize_into_directories: bool,
Directory => directory: Option<String>,
EpisodePattern => episode_pattern: Option<String>,
Headers => headers: HashMap<String, String>,
}
);
generate_form_reducer!(
state: VideoConfigFormState { form: VideoConfigDto },
action_name: VideoConfigFormAction,
fields {
WebSearch => web_search: Option<String>,
Extensions => extensions: Vec<String>,
}
);
#[function_component]
pub fn VideoConfigView() -> Html {
let translate = use_translation();
let config_ctx = use_context::<ConfigContext>().expect("ConfigContext not found");
let config_view_ctx = use_context::<ConfigViewContext>().expect("ConfigViewContext not found");
let download_state: UseReducerHandle<VideoDownloadConfigFormState> = use_reducer(|| {
VideoDownloadConfigFormState { form: VideoDownloadConfigDto::default(), modified: false }
});
let video_state: UseReducerHandle<VideoConfigFormState> = use_reducer(|| {
VideoConfigFormState { form: VideoConfigDto::default(), modified: false }
});
let handle_headers = {
let download_state = download_state.clone();
Callback::from(move |headers: HashMap<String, String>| {
download_state.dispatch(VideoDownloadConfigFormAction::Headers(headers));
})
};
{
let video_state = video_state.clone();
let download_state = download_state.clone();
let video_cfg = config_ctx.config.as_ref().and_then(|c| c.config.video.clone());
use_effect_with(video_cfg, move |video_cfg| {
if let Some(video) = video_cfg {
video_state.dispatch(VideoConfigFormAction::Extensions(video.extensions.clone()));
video_state.dispatch(VideoConfigFormAction::WebSearch(video.web_search.clone()));
if let Some(download) = video.download.as_ref() {
download_state.dispatch(VideoDownloadConfigFormAction::OrganizeIntoDirectories(download.organize_into_directories));
download_state.dispatch(VideoDownloadConfigFormAction::Directory(download.directory.clone()));
download_state.dispatch(VideoDownloadConfigFormAction::EpisodePattern(download.episode_pattern.clone()));
download_state.dispatch(VideoDownloadConfigFormAction::Headers(download.headers.clone()));
} else {
download_state.dispatch(VideoDownloadConfigFormAction::SetAll(VideoDownloadConfigDto::default()));
}
} else {
video_state.dispatch(VideoConfigFormAction::SetAll(VideoConfigDto::default()));
download_state.dispatch(VideoDownloadConfigFormAction::SetAll(VideoDownloadConfigDto::default()));
}
|| ()
});
}
{
// Sync form changes with parent context
let on_form_change = config_view_ctx.on_form_change.clone();
let download_state = download_state.clone();
let video_state = video_state.clone();
use_effect_with((video_state, download_state), move |(v, d)| {
let mut form = v.form.clone();
form.download = Some(d.form.clone());
let modified = v.modified || d.modified;
on_form_change.emit(ConfigForm::Video(modified, form));
});
}
let render_extensions = |extensions: &Vec<String>| html! {
<Card>
{ config_field_child!(translate.t(LABEL_EXTENSIONS), {
html! {
<div class="tp__config-view__tags">
{
html! { for extensions.iter().map(|t| html! { <Chip label={t.clone()} /> }) }
}
{ html! { for extensions.iter().map(|t| html! { <Chip label={t.clone()} /> }) } }
</div>
}})}
</Card>
};
let render_download = |download: Option<&VideoDownloadConfigDto>| html! {
let render_download_view = |download: Option<&VideoDownloadConfigDto>| html! {
match download {
Some(entry) => html! {
<Card class="tp__config-view__card">
@@ -43,17 +118,7 @@ pub fn VideoConfigView() -> Html {
html! {
<div class="tp__config-view__tags">
<ul>
{
if entry.headers.is_empty() {
html! {}
} else {
html! {
for entry.headers.iter().map(|(key, value)| html! {
<li>{key}{":"} {value}</li>
})
}
}
}
{ for entry.headers.iter().map(|(k,v)| html!{ <li>{k}{":"} {v}</li> }) }
</ul>
</div>
}
@@ -63,52 +128,75 @@ pub fn VideoConfigView() -> Html {
None => html! {
<Card class="tp__config-view__card">
<h1>{translate.t(LABEL_DOWNLOAD)}</h1>
{ config_field_empty!(translate.t(LABEL_ORGANIZE_INTO_DIRECTORIES)) }
{ config_field_bool_empty!(translate.t(LABEL_ORGANIZE_INTO_DIRECTORIES)) }
{ config_field_empty!(translate.t(LABEL_DIRECTORY)) }
{ config_field_empty!(translate.t(LABEL_EPISODE_PATTERN)) }
{ config_field_empty!(translate.t(LABEL_HEADERS)) }
</Card>
},
}
}
};
let render_empty = || {
html! {
<>
let render_empty = || html! {
<>
<div class="tp__video-config-config-view__body tp__config-view-page__header">
{ config_field_empty!(translate.t(LABEL_WEB_SEARCH)) }
</div>
<div class="tp__video-config-config-view__body tp__config-view-page__body">
{ config_field_empty!(translate.t(LABEL_EXTENSIONS)) }
{ render_download(None) }
{ render_download_view(None) }
</div>
</>
</>
};
let render_view_mode = || {
if let Some(config) = &config_ctx.config {
if let Some(video) = &config.config.video {
html! {
<>
<div class="tp__video-config-view__body tp__config-view-page__body">
{ config_field_optional!(video, translate.t(LABEL_WEB_SEARCH), web_search) }
</div>
<div class="tp__video-config-view__body tp__config-view-page__body">
{ render_extensions(&video.extensions) }
{ render_download_view(video.download.as_ref()) }
</div>
</>
}
} else { render_empty() }
} else { render_empty() }
};
let render_edit_mode = || {
html! {
<>
<div class="tp__video-config-view__body tp__config-view-page__body">
{ edit_field_text_option!(video_state, translate.t(LABEL_WEB_SEARCH), web_search, VideoConfigFormAction::WebSearch) }
</div>
<div class="tp__video-config-view__body tp__config-view-page__body">
<Card class="tp__config-view__card">
{ edit_field_list!(video_state, translate.t(LABEL_EXTENSIONS), extensions, VideoConfigFormAction::Extensions, translate.t(LABEL_ADD_EXTENSION)) }
</Card>
<Card class="tp__config-view__card">
<h1>{translate.t(LABEL_DOWNLOAD)}</h1>
{ edit_field_bool!(download_state, translate.t(LABEL_ORGANIZE_INTO_DIRECTORIES), organize_into_directories, VideoDownloadConfigFormAction::OrganizeIntoDirectories) }
{ edit_field_text_option!(download_state, translate.t(LABEL_DIRECTORY), directory, VideoDownloadConfigFormAction::Directory) }
{ edit_field_text_option!(download_state, translate.t(LABEL_EPISODE_PATTERN), episode_pattern, VideoDownloadConfigFormAction::EpisodePattern) }
<KeyValueEditor
label={Some(translate.t(LABEL_HEADERS))}
entries={download_state.form.headers.clone()}
readonly={false}
on_change={handle_headers}
/>
</Card>
</div>
</>
}
};
html! {
<div class="tp__video-config-view tp__config-view-page">
{
if let Some(config) = &config_ctx.config {
if let Some(video) = &config.config.video {
html! {
<>
<div class="tp__video-config-view__body tp__config-view-page__body">
{ config_field_optional!(video, translate.t(LABEL_WEB_SEARCH), web_search) }
</div>
<div class="tp__video-config-view__body tp__config-view-page__body">
{ render_extensions(&video.extensions) }
{ render_download(video.download.as_ref()) }
</div>
</>
}
} else {
{ render_empty() }
}
} else {
{ render_empty() }
}
}
{ if *config_view_ctx.edit_mode { render_edit_mode() } else { render_view_mode() } }
</div>
}
}
}
@@ -102,10 +102,15 @@ pub fn WebUiConfigView() -> Html {
webui_state.dispatch(WebUiConfigFormAction::SetAll((*webui).clone()));
if let Some(auth) = &webui.auth {
auth_state.dispatch(WebUiAuthConfigFormAction::SetAll(auth.clone()));
} else {
auth_state.dispatch(WebUiAuthConfigFormAction::SetAll(WebAuthConfigDto::default()));
}
if let Some(csp) = &webui.content_security_policy {
csp_state.dispatch(CspConfigFormAction::SetAll(csp.clone()));
} else {
csp_state.dispatch(CspConfigFormAction::SetAll(ContentSecurityPolicyConfigDto::default()));
}
} else {
webui_state.dispatch(WebUiConfigFormAction::SetAll(WebUiConfigDto::default()));
auth_state.dispatch(WebUiAuthConfigFormAction::SetAll(WebAuthConfigDto::default()));
@@ -0,0 +1,165 @@
use std::collections::HashMap;
use std::rc::Rc;
use web_sys::HtmlInputElement;
use yew::prelude::*;
use crate::app::components::chip::Chip;
#[derive(Clone, PartialEq, Debug)]
pub struct KeyValue {
pub key: String,
pub value: String,
}
#[derive(Properties, Clone, PartialEq)]
pub struct KeyValueEditorProps {
#[prop_or_default]
pub label: Option<String>,
pub entries: HashMap<String, String>,
#[prop_or_else(Callback::noop)]
pub on_change: Callback<HashMap<String, String>>,
#[prop_or(true)]
pub readonly: bool,
#[prop_or_else(|| "Add key".to_string())]
pub key_placeholder: String,
#[prop_or_else(|| "Add value".to_string())]
pub value_placeholder: String,
}
#[function_component]
pub fn KeyValueEditor(props: &KeyValueEditorProps) -> Html {
let KeyValueEditorProps {
label,
entries,
on_change,
readonly,
key_placeholder,
value_placeholder,
} = props.clone();
// local state for editing
let entry_state = use_state(|| {
entries.iter().map(|(k,v)| Rc::new(KeyValue { key: k.clone(), value: v.clone() })).collect::<Vec<_>>()
});
let new_key = use_state(String::default);
let new_value = use_state(String::default);
// keep local state in sync when parent updates
{
let entry_state = entry_state.clone();
use_effect_with(entries.clone(), move |entries| {
entry_state.set(
entries.iter()
.map(|(k,v)| Rc::new(KeyValue { key: k.clone(), value: v.clone() }))
.collect()
);
|| ()
});
}
// remove existing entry
let on_remove = {
let entry_state = entry_state.clone();
let on_change = on_change.clone();
Callback::from(move |key: String| {
let mut updated = (*entry_state).clone();
updated.retain(|kv| kv.key != key);
// emit new HashMap
let map = updated.iter().map(|kv| (kv.key.clone(), kv.value.clone())).collect::<HashMap<_,_>>();
on_change.emit(map.clone());
entry_state.set(updated);
})
};
// input change for new key/value
let on_input_key = {
let new_key = new_key.clone();
Callback::from(move |e: InputEvent| {
let input: HtmlInputElement = e.target_unchecked_into();
new_key.set(input.value());
})
};
let on_input_value = {
let new_value = new_value.clone();
Callback::from(move |e: InputEvent| {
let input: HtmlInputElement = e.target_unchecked_into();
new_value.set(input.value());
})
};
// add new entry on enter in value field
let on_keydown_value = {
let new_key = new_key.clone();
let new_value = new_value.clone();
let entry_state = entry_state.clone();
let on_change = on_change.clone();
Callback::from(move |e: KeyboardEvent| {
if e.key() == "Enter" {
e.prevent_default();
let key = (*new_key).trim().to_string();
let value = (*new_value).trim().to_string();
if !key.is_empty() && !value.is_empty() && !entry_state.iter().any(|kv| kv.key == key) {
let mut updated = (*entry_state).clone();
updated.push(Rc::new(KeyValue { key: key.clone(), value: value.clone() }));
// emit new HashMap
let map = updated.iter().map(|kv| (kv.key.clone(), kv.value.clone())).collect::<HashMap<_,_>>();
on_change.emit(map.clone());
entry_state.set(updated);
}
new_key.set(String::new());
new_value.set(String::new());
}
})
};
html! {
<div class="tp__keyvalue-editor">
{ if let Some(lbl) = &label {
html! { <label>{ lbl }</label> }
} else { html!{} } }
<div class="tp__keyvalue-editor__entries">
{ for (*entry_state).iter().map(|kv| {
let key_clone = kv.key.clone();
html! {
<Chip
label={format!("{}: {}", kv.key, kv.value)}
removable={!readonly}
on_remove={if readonly { Callback::noop() } else { on_remove.reform(move |_| key_clone.clone()) }}
/>
}
})}
</div>
{
if readonly {
html! {}
} else {
html! {
<div class="tp__keyvalue-editor__inputs">
<div class="tp__input">
<div class=" tp__input-wrapper">
<input
type="text"
value={(*new_key).clone()}
oninput={on_input_key}
placeholder={key_placeholder.clone()}
/>
</div>
</div>
<div class="tp__input">
<div class=" tp__input-wrapper">
<input
type="text"
value={(*new_value).clone()}
oninput={on_input_value}
onkeydown={on_keydown_value}
placeholder={value_placeholder.clone()}
/>
</div>
</div>
</div>
}
}
}
</div>
}
}
+2
View File
@@ -41,6 +41,7 @@ mod date_input;
mod role_based_content;
mod api_user;
mod cell_value;
mod key_value_editor;
// pub use self::input::*;
// pub use self::menu_item::*;
// pub use self::popup_menu::*;
@@ -79,5 +80,6 @@ pub use self::csv_table::*;
pub use self::tabset::*;
pub use self::role_based_content::*;
pub use self::cell_value::*;
pub use self::key_value_editor::*;
//pub use self::number_input::*;
//pub use self::date_input::*;
+13 -1
View File
@@ -4,7 +4,7 @@ fn default_token_ttl_mins() -> u32 {
30
}
#[derive(Default, Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct WebAuthConfigDto {
#[serde(default = "default_as_true")]
@@ -16,3 +16,15 @@ pub struct WebAuthConfigDto {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub userfile: Option<String>,
}
impl Default for WebAuthConfigDto {
fn default() -> Self {
Self {
enabled: default_as_true(),
issuer: String::new(),
secret: String::new(),
token_ttl_mins: default_token_ttl_mins(),
userfile: None,
}
}
}
+4 -4
View File
@@ -30,13 +30,13 @@ impl FromStr for MsgKind {
type Err = TuliproxError;
fn from_str(s: &str) -> Result<Self, TuliproxError> {
if s.eq("Info") {
if s.eq_ignore_ascii_case("info") {
Ok(Self::Info)
} else if s.eq("Stats") {
} else if s.eq_ignore_ascii_case("stats") {
Ok(Self::Stats)
} else if s.eq("Error") {
} else if s.eq_ignore_ascii_case("error") {
Ok(Self::Error)
} else if s.eq("Watch") {
} else if s.eq_ignore_ascii_case("watch") {
Ok(Self::Watch)
} else {
create_tuliprox_error_result!(TuliproxErrorKind::Info, "Unknown MsgKind: {}", s)