added target form

This commit is contained in:
euzu
2025-11-03 21:19:35 +01:00
parent 6ed1829636
commit f432e215ff
45 changed files with 1490 additions and 556 deletions
+13 -7
View File
@@ -655,13 +655,19 @@ Each input has the following attributes:
+ `xtream_live_stream_without_extension` default false, if set to true `.ts` extension is not added to the stream link.
+ `xtream_live_stream_use_prefix` default true, if set to true `/live/` prefix is added to the stream link.
- `aliases` for alias definitions for the same provider with different credentials
- `staged` for side loading processed playlists.
Instead of fully configuring everything yourself, you can “stage” a source — meaning you provide a ready-made playlist
from somewhere else. The system will only use that staged playlist when updating.
For actual streaming and fetching details, it will still use the main provider’s settings.
In plain words: If you don’t want to deal with Tuliprox mapping, or you already have a playlist in another online tool,
you can plug that playlist in as a staged input. It won’t replace the main provider — it’s just there to update the list.
All streaming and proxying and info still come from the original provider configuration.
- `staged` for side loading processed playlists.
If you already have a provider configured but want to load the playlist from a different source — for example,
from another playlist editor — you can specify a staged DTO.
Instead of fully configuring everything yourself, you can “stage” a source, meaning you provide a
ready-made playlist from somewhere else. During the update process, the playlist will be read from this staged source.
However, regular playlist queries (such as streaming or fetching details) will still go through the
main provider. The staged source is only used temporarily — just for updating the playlist.
In plain words: if you already have a playlist in another online tool or don’t want to deal with Tuliprox mapping,
you can plug that playlist in as a staged input. It won’t replace your main provider — it’s only there to update
the list. All streaming, proxying, and metadata still come from the provider’s configuration.
`staged` has the following properties:
- `type` is optional, default is `m3u`. Valid values are `m3u` and `xtream`
+4 -2
View File
@@ -308,7 +308,9 @@
"SOURCE_EDITOR": "Sources",
"SKIP_LIVE": "Skip Live",
"SKIP_VOD": "Skip VOD",
"SKIP_SERIES": "Skip Series"
"SKIP_SERIES": "Skip Series",
"ADD_WATCH": "Add Watch",
"ADD_MAPPING": "Add Mapping"
},
"TITLE": {
"USER_BOUQUET_EDITOR": "User group editor"
@@ -423,7 +425,7 @@
"BRICK_Target": "Target",
"BRICK_OutputM3u": "M3u",
"BRICK_OutputXtream": "Xtream",
"BRICK_OutputHdhomerun": "HDHR",
"BRICK_OutputHdHomeRun": "HDHR",
"BRICK_OutputStrm": "STRM"
},
"COUNTRY" : {
+5
View File
@@ -515,5 +515,10 @@
{
"keys": ["Nodes", "SourceEditor"],
"path": "M15 16v1.26l-6-3v-3.17L11.7 8H16V2h-6v4.9L7.3 10H3v6h5l7 3.5V22h6v-6z"
},
{
"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"
}
]
+3 -2
View File
@@ -84,7 +84,7 @@
--menu-text-active-color: var(--primary-color);
--menu-item-active-background-color: rgba(45, 70, 120, 0.55);
--icon-button-hover-color: #a8a8b3;
--card-background-color: rgba(18, 22, 33, 0.55);
--card-background-color: rgba(18, 22, 33, 0.9);
--card-color: var(--modest-text-color);
--sub-card-background-color: rgba(18, 22, 33, 0.8);
@@ -355,7 +355,8 @@
--brick-output-strm-background-color: #e74c3c88;
--brick-output-strm-shadow-color: #e74c3c80;
--brick-port-background-color: #486786;
--brick-port-background-color: #587293;
--brick-port-border-color: #ffffff;
--brick-port-valid-background-color: #2ecc71;
--brick-port-invalid-background-color: #e74c3c;
--source-editor-delete-color: #FF0000FF;
+3
View File
@@ -77,3 +77,6 @@
@forward "components/floating_background";
@forward "components/source_editor/source_editor";
@forward "components/source_editor/source_editor_form";
@forward "components/source_editor/source_editor_block";
@forward "components/source_editor/input_form";
@forward "components/cluster_flags";
@@ -0,0 +1,23 @@
.tp__cluster-flags-input {
display: flex;
flex-flow: row nowrap;
span {
cursor: pointer;
}
span:not(:first-child) {
border-top-left-radius: 0 !important;
border-bottom-left-radius: 0 !important;
}
span:not(:last-child) {
border-top-right-radius: 0 !important;
border-bottom-right-radius: 0 !important;
}
.tp__chip.active {
color: var(--tag-active-color);
background-color: var(--tag-active-background-color);
border-color: var(--tag-active-border-color);
}
}
+51 -1
View File
@@ -77,4 +77,54 @@
height: 100%;
overflow: auto;
}
}
}
/* Tabs header */
.tp__tab-header {
display: flex;
border-bottom: 2px solid var(--card-border-color);
background-color: var(--card-background-color);
border-radius: 8px 8px 0 0;
overflow: hidden;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
.tp__tab-button {
flex: 1;
padding: 0.6rem 1.2rem;
font-weight: 500;
font-size: 0.95rem;
text-align: center;
cursor: pointer;
background: none;
border: none;
color: var(--text-color);
transition: all 0.25s ease;
position: relative;
}
.tp__tab-button:hover {
color: var(--text-button-hover-color);
background-color: var(--text-button-hover-background-color);
}
.tp__tab-button.active {
color: var(--text-button-primary-color);
background-color: var(--text-button-primary-background-color);
font-weight: 600;
}
.tp__tab-button.active::after {
content: "";
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 3px;
border-radius: 2px 2px 0 0;
}
.tp__tab-button,
.tp__tab-button.active::after {
transition: all 0.25s ease;
}
@@ -49,6 +49,10 @@
gap: var(--gap-default);
height: 2.5rem;
align-items: center;
li {
margin-bottom: var(--gap-small);
}
}
&__card {
@@ -0,0 +1,20 @@
.tp__input-form {
&__body {
background-color: var(--card-background-color);
border: 1px solid var(--card-border-color);
display: flex;
flex-flow: column;
&__pages {
display: flex;
flex-flow: column;
width: 100%;
.tp__card {
border: none;
}
}
}
}
@@ -1,10 +1,12 @@
.tp__source-editor {
position: relative;
display: flex;
flex-flow: row nowrap;
box-sizing: border-box;
width: 100%;
height: 100%;
gap: var(--gap-default);
font-size: 1rem;
&__sidebar {
display: flex;
@@ -50,202 +52,29 @@
}
}
&__canvas {
&__canvas-wrapper {
position: relative;
box-sizing: border-box;
width: 100%;
height: 100%;
overflow: hidden;
border: 1px solid var(--card-border-color);
}
&__canvas,
&__connections {
position: absolute;
left: 0;
top:0;
box-sizing: border-box;
width: 100%;
height: 100%;
}
&__canvas {
}
&__connections {
position: absolute;
box-sizing: border-box;
width:100%;
height:100%;
border: 1px solid var(--card-border-color);
}
&__block {
position: absolute;
width: 100px;
height: 50px;
border: 1px solid var(--border-color);
background-color: var(--card-background-color);
display: flex;
align-items: center;
justify-content: space-between;
border-radius: 6px;
border-top-left-radius: 0 !important;
box-shadow: 0 1px 2px rgba(0,0,0,0.2);
cursor: default;
&-label {
flex: 1;
text-align: center;
pointer-events: none;
}
&-handle {
position: absolute;
width: 50%;
top: -13px;
left: 0;
height: 12px;
cursor: grab;
display: flex;
align-items: center;
justify-content: center;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
&-label {
pointer-events: none;
}
}
&-delete {
display: flex;
justify-content: center;
align-items: center;
position: absolute;
top: -13px;
right: 0;
height: 12px;
width: 12px;
border-radius: 50%;
cursor: pointer;
background-color: var(--source-editor-delete-color);
}
}
&__port {
position: absolute;
width: 10px;
height: 10px;
border-radius: 50%;
cursor: crosshair;
&--left {
left: -5px;
background-color: var(--brick-port-background-color);
}
&--right {
right: -5px;
background-color: var(--brick-port-background-color);
}
&--valid {
background-color: var(--brick-port-valid-background-color);
}
&--invalid {
background-color: var(--brick-port-invalid-background-color);
}
}
}
.tp__source-editor__brick {
&-InputXtream,
&-InputM3u {
border-color: var(--brick-input-border-color);
background-color: var(--brick-input-background-color);
.tp__source-editor__block-handle {
background: repeating-linear-gradient(
-55deg,
var(--brick-input-background-color),
var(--brick-input-background-color) 10px,
var(--brick-input-border-color) 10px,
var(--brick-input-border-color) 20px
);
border-left: 1px solid var(--brick-input-border-color);
border-top: 1px solid var(--brick-input-border-color);
border-right: 1px solid var(--brick-input-border-color);
}
}
&-Target {
border-color: var(--brick-target-border-color);
background-color: var(--brick-target-background-color);
.tp__source-editor__block-handle {
background: repeating-linear-gradient(
-55deg,
var(--brick-target-background-color),
var(--brick-target-background-color) 10px,
var(--brick-target-border-color) 10px,
var(--brick-target-border-color) 20px
);
border-left: 1px solid var(--brick-target-border-color);
border-top: 1px solid var(--brick-target-border-color);
border-right: 1px solid var(--brick-target-border-color);
}
}
&-OutputM3u {
border-color: var(--brick-output-m3u-border-color);
background-color: var(--brick-output-m3u-background-color);
.tp__source-editor__block-handle {
background: repeating-linear-gradient(
-55deg,
var(--brick-output-m3u-background-color),
var(--brick-output-m3u-background-color) 10px,
var(--brick-output-m3u-border-color) 10px,
var(--brick-output-m3u-border-color) 20px
);
border-left: 1px solid var(--brick-output-m3u-border-color);
border-top: 1px solid var(--brick-output-m3u-border-color);
border-right: 1px solid var(--brick-output-m3u-border-color);
}
}
&-OutputXtream {
border-color: var(--brick-output-xtream-border-color);
background-color: var(--brick-output-xtream-background-color);
.tp__source-editor__block-handle {
background: repeating-linear-gradient(
-55deg,
var(--brick-output-xtream-background-color),
var(--brick-output-xtream-background-color) 10px,
var(--brick-output-xtream-border-color) 10px,
var(--brick-output-xtream-border-color) 20px
);
border-left: 1px solid var(--brick-output-xtream-border-color);
border-top: 1px solid var(--brick-output-xtream-border-color);
border-right: 1px solid var(--brick-output-xtream-border-color);
}
}
&-OutputHdhomerun {
border-color: var(--brick-output-hdhomerun-border-color);
background-color: var(--brick-output-hdhomerun-background-color);
.tp__source-editor__block-handle {
background: repeating-linear-gradient(
-55deg,
var(--brick-output-hdhomerun-background-color),
var(--brick-output-hdhomerun-background-color) 10px,
var(--brick-output-hdhomerun-border-color) 10px,
var(--brick-output-hdhomerun-border-color) 20px
);
border-left: 1px solid var(--brick-output-hdhomerun-border-color);
border-top: 1px solid var(--brick-output-hdhomerun-border-color);
border-right: 1px solid var(--brick-output-hdhomerun-border-color);
}
}
&-OutputStrm {
border-color: var(--brick-output-strm-border-color);
background-color: var(--brick-output-strm-background-color);
.tp__source-editor__block-handle {
background: repeating-linear-gradient(
-55deg,
var(--brick-output-strm-background-color),
var(--brick-output-strm-background-color) 10px,
var(--brick-output-strm-border-color) 10px,
var(--brick-output-strm-border-color) 20px
);
border-left: 1px solid var(--brick-output-strm-border-color);
border-top: 1px solid var(--brick-output-strm-border-color);
border-right: 1px solid var(--brick-output-strm-border-color);
}
}
}
@@ -261,4 +90,15 @@
.clickable {
cursor: pointer;
}
.tp__form-page__toolbar {
margin-top: var(--padding-default);
padding-top: var(--padding-default);
display: flex;
flex-flow: row;
gap: var(--gap-large);
box-sizing: border-box;
width: 100%;
border-top: 1px solid var(--card-border-color);
}
@@ -0,0 +1,295 @@
$block-width: 100px;
$block-header-height: 12px;
$block-content-height: 50px;
$port-size: 10px;
.tp__source-editor__block {
position: absolute;
display: flex;
flex-flow: column;
align-items: center;
background-color: transparent;
border: none;
min-width: $block-width;
max-width: $block-width;
width: $block-width;
&-header {
position: relative;
display: flex;
flex-flow: row nowrap;
width: 100%;
min-height: $block-header-height;
max-height: $block-header-height;
height: $block-header-height;
&-actions {
display: flex;
flex-flow: row nowrap;
height: 100%;
flex: 1 1 auto;
justify-content: flex-end;
}
}
&-handle {
width: 50%;
height: 100%;
cursor: grab;
display: flex;
align-items: center;
justify-content: center;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
}
&-content {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
min-height: $block-content-height;
max-height: $block-content-height;
height: $block-content-height;
border: 1px solid var(--border-color);
background-color: var(--card-background-color);
border-radius: 6px;
border-top-left-radius: 0 !important;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
cursor: pointer;
padding: 0 var(--padding-small);
box-sizing: border-box;
&-body {
display: flex;
flex-flow: column;
align-items: center;
height: 100%;
width: 100%;
}
}
&-label {
display: block;
width: 100%;
height: 100%;
text-overflow: ellipsis;
text-align: center;
pointer-events: none;
align-content: center;
overflow: hidden;
white-space: nowrap
}
&-sub-label {
font-size: 0.8rem;
color: var(--modest-text-color);
text-align: right;
pointer-events: none;
align-self: flex-end;
}
&-delete {
display: flex;
justify-content: center;
align-items: center;
right: 0;
height: 12px;
width: 12px;
border-radius: 50%;
cursor: pointer;
background-color: var(--source-editor-delete-color);
}
&-port {
position: absolute;
width: $port-size;
height: $port-size;
border-radius: 50%;
border: 1px solid var(--brick-port-border-color);
cursor: crosshair;
z-index: 2;
&--left {
left: -5px;
background-color: var(--brick-port-background-color);
}
&--right {
right: -5px;
background-color: var(--brick-port-background-color);
}
&--valid {
background-color: var(--brick-port-valid-background-color);
}
&--invalid {
background-color: var(--brick-port-invalid-background-color);
}
}
}
.tp__source-editor__brick {
&-InputXtream,
&-InputM3u {
border-color: var(--brick-input-border-color);
background-color: var(--brick-input-background-color);
}
&-Target {
border-color: var(--brick-target-border-color);
background-color: var(--brick-target-background-color);
}
&-OutputM3u {
border-color: var(--brick-output-m3u-border-color);
background-color: var(--brick-output-m3u-background-color);
}
&-OutputXtream {
border-color: var(--brick-output-xtream-border-color);
background-color: var(--brick-output-xtream-background-color);
}
&-OutputHdHomeRun {
border-color: var(--brick-output-hdhomerun-border-color);
background-color: var(--brick-output-hdhomerun-background-color);
}
&-OutputStrm {
border-color: var(--brick-output-strm-border-color);
background-color: var(--brick-output-strm-background-color);
}
}
.tp__source-editor__block {
&-InputXtream,
&-InputM3u {
.tp__source-editor__block-content {
border-color: var(--brick-input-border-color);
background-color: var(--brick-input-background-color);
}
.tp__source-editor__block-handle {
background: repeating-linear-gradient(
-55deg,
var(--brick-input-background-color),
var(--brick-input-background-color) 10px,
var(--brick-input-border-color) 10px,
var(--brick-input-border-color) 20px
);
border-left: 1px solid var(--brick-input-border-color);
border-top: 1px solid var(--brick-input-border-color);
border-right: 1px solid var(--brick-input-border-color);
}
}
&-Target {
.tp__source-editor__block-content {
border-color: var(--brick-target-border-color);
background-color: var(--brick-target-background-color);
}
.tp__source-editor__block-handle {
background: repeating-linear-gradient(
-55deg,
var(--brick-target-background-color),
var(--brick-target-background-color) 10px,
var(--brick-target-border-color) 10px,
var(--brick-target-border-color) 20px
);
border-left: 1px solid var(--brick-target-border-color);
border-top: 1px solid var(--brick-target-border-color);
border-right: 1px solid var(--brick-target-border-color);
}
}
&-OutputM3u {
.tp__source-editor__block-content {
border-color: var(--brick-output-m3u-border-color);
background-color: var(--brick-output-m3u-background-color);
}
.tp__source-editor__block-handle {
background: repeating-linear-gradient(
-55deg,
var(--brick-output-m3u-background-color),
var(--brick-output-m3u-background-color) 10px,
var(--brick-output-m3u-border-color) 10px,
var(--brick-output-m3u-border-color) 20px
);
border-left: 1px solid var(--brick-output-m3u-border-color);
border-top: 1px solid var(--brick-output-m3u-border-color);
border-right: 1px solid var(--brick-output-m3u-border-color);
}
}
&-OutputXtream {
.tp__source-editor__block-content {
border-color: var(--brick-output-xtream-border-color);
background-color: var(--brick-output-xtream-background-color);
}
.tp__source-editor__block-handle {
background: repeating-linear-gradient(
-55deg,
var(--brick-output-xtream-background-color),
var(--brick-output-xtream-background-color) 10px,
var(--brick-output-xtream-border-color) 10px,
var(--brick-output-xtream-border-color) 20px
);
border-left: 1px solid var(--brick-output-xtream-border-color);
border-top: 1px solid var(--brick-output-xtream-border-color);
border-right: 1px solid var(--brick-output-xtream-border-color);
}
}
&-OutputHdHomeRun {
.tp__source-editor__block-content {
border-color: var(--brick-output-hdhomerun-border-color);
background-color: var(--brick-output-hdhomerun-background-color);
}
.tp__source-editor__block-handle {
background: repeating-linear-gradient(
-55deg,
var(--brick-output-hdhomerun-background-color),
var(--brick-output-hdhomerun-background-color) 10px,
var(--brick-output-hdhomerun-border-color) 10px,
var(--brick-output-hdhomerun-border-color) 20px
);
border-left: 1px solid var(--brick-output-hdhomerun-border-color);
border-top: 1px solid var(--brick-output-hdhomerun-border-color);
border-right: 1px solid var(--brick-output-hdhomerun-border-color);
}
}
&-OutputStrm {
.tp__source-editor__block-content {
border-color: var(--brick-output-strm-border-color);
background-color: var(--brick-output-strm-background-color);
}
.tp__source-editor__block-handle {
background: repeating-linear-gradient(
-55deg,
var(--brick-output-strm-background-color),
var(--brick-output-strm-background-color) 10px,
var(--brick-output-strm-border-color) 10px,
var(--brick-output-strm-border-color) 20px
);
border-left: 1px solid var(--brick-output-strm-border-color);
border-top: 1px solid var(--brick-output-strm-border-color);
border-right: 1px solid var(--brick-output-strm-border-color);
}
}
}
@@ -1,6 +1,27 @@
$form-width: 300px;
.tp__source-editor-form {
position: absolute;
left: 100%;
height: 100vh;
height: 100%;
top: 0;
left:100%;
width: $form-width;
z-index: 10;
transition: all 0.4s ease;
overflow: auto;
.tp__card {
background-color: var(--card-background-color);
}
//.tp__form-field,
//.tp__input {
// flex-flow: row;
// align-items: center;
//}
}
.tp__source-editor-form.active {
left: calc(100% - $form-width);
}
@@ -1,4 +1,4 @@
@use "../../../size";
@use "../../../size" as size;
.tp__proxy-user-credentials-form {
display: flex;
@@ -0,0 +1,75 @@
use shared::model::{ClusterFlags};
use yew::prelude::*;
use yew_i18n::use_translation;
#[derive(Properties, Clone, PartialEq, Debug)]
pub struct ClusterFlagsInputProps {
pub name: String,
#[prop_or_default]
pub value: Option<ClusterFlags>,
#[prop_or_default]
pub on_change: Callback<(String, Option<ClusterFlags>)>,
}
#[function_component]
pub fn ClusterFlagsInput(props: &ClusterFlagsInputProps) -> Html {
let translate = use_translation();
let flags = use_state(|| props.value.clone().unwrap_or_else(ClusterFlags::all));
{
let set_flags = flags.clone();
use_effect_with(props.value.clone(), move |val| {
set_flags.set(val.clone().unwrap_or_else(ClusterFlags::all));
});
}
let handle_change = {
let onchange = props.on_change.clone();
let name = props.name.clone();
Callback::from(move |new_flags: Option<ClusterFlags>| {
let cluster_flags = if new_flags.is_none_or(|f| f.is_empty()) {
None
} else {
new_flags
};
let name = name.clone();
onchange.emit((name, cluster_flags));
})
};
let handle_flag_click = {
let current_flags = flags.clone();
Callback::from(move |new_flag| {
let mut new_flags = (*current_flags).clone();
new_flags.toggle(new_flag);
current_flags.set(new_flags.clone());
if new_flags.is_empty() {
handle_change.emit(None);
} else {
handle_change.emit(Some(new_flags));
}
})
};
let handle_live_click = {
let handle_flag_click= handle_flag_click.clone();
Callback::from(move |_| {handle_flag_click.emit(ClusterFlags::Live); })
};
let handle_vod_click = {
let handle_flag_click= handle_flag_click.clone();
Callback::from(move |_| {handle_flag_click.emit(ClusterFlags::Vod); })
};
let handle_series_click = {
let handle_flag_click= handle_flag_click.clone();
Callback::from(move |_| {handle_flag_click.emit(ClusterFlags::Series); })
};
html! {
<div class="tp__cluster-flags-input">
<span onclick={handle_live_click} class={classes!("noselect", "tp__chip", "tp__cluster-flags-input-live", if flags.intersects(ClusterFlags::Live) {"active"} else {""})}>{ translate.t("LABEL.LIVE") }</span>
<span onclick={handle_vod_click} class={classes!("noselect", "tp__chip", "tp__cluster-flags-input-vod", if flags.intersects(ClusterFlags::Vod) {"active"} else {""})}>{ translate.t("LABEL.VOD") }</span>
<span onclick={handle_series_click} class={classes!("noselect", "tp__chip", "tp__cluster-flags-input-series", if flags.intersects(ClusterFlags::Series) {"active"} else {""})}>{ translate.t("LABEL.SERIES") }</span>
</div>
}
}
@@ -6,7 +6,6 @@ use crate::{config_field, config_field_empty, edit_field_number_u16, edit_field_
use crate::app::components::AppIcon;
use crate::app::components::config::config_view_context::ConfigViewContext;
use crate::app::components::config::config_page::ConfigForm;
use crate::app::components::config::macros::HasFormData;
const LABEL_HOST: &str = "LABEL.HOST";
const LABEL_PORT: &str = "LABEL.PORT";
@@ -8,7 +8,6 @@ use crate::{config_field_bool, edit_field_bool, generate_form_reducer, html_if};
use crate::app::components::config::config_page::ConfigForm;
use crate::app::components::config::hdhomerun_device_view::{HdHomerunDeviceView};
use crate::app::components::config::config_view_context::ConfigViewContext;
use crate::app::components::config::HasFormData;
const LABEL_ENABLED: &str = "LABEL.ENABLED";
const LABEL_DEVICE_AUTH: &str = "LABEL.DEVICE_AUTH";
@@ -2,7 +2,6 @@ use yew::prelude::*;
use yew_i18n::use_translation;
use shared::model::{HdHomeRunDeviceConfigDto};
use crate::app::components::{Card, CollapsePanel, TextButton};
use crate::app::components::config::macros::HasFormData;
use crate::{config_field, edit_field_number_u16, edit_field_number_u8, edit_field_text, generate_form_reducer, html_if};
generate_form_reducer!(
@@ -5,7 +5,6 @@ use crate::app::context::ConfigContext;
use crate::{config_field_empty, config_field_optional, edit_field_text_option, generate_form_reducer};
use crate::app::components::config::config_view_context::ConfigViewContext;
use crate::app::components::config::config_page::{ConfigForm};
use crate::app::components::config::macros::HasFormData;
const LABEL_URL: &str = "LABEL.URL";
const LABEL_URL_IPV4: &str = "LABEL.URL_IPV4";
const LABEL_URL_IPV6: &str = "LABEL.URL_IPV6";
@@ -6,7 +6,6 @@ use crate::app::context::ConfigContext;
use crate::{config_field_bool, config_field_child, edit_field_bool, generate_form_reducer};
use crate::app::components::config::config_view_context::ConfigViewContext;
use crate::app::components::config::config_page::{ConfigForm};
use crate::app::components::config::macros::HasFormData;
use crate::app::components::{Chip, RadioButtonGroup};
const LABEL_LOG_LEVEL: &str = "LABEL.LOG_LEVEL";
+19 -18
View File
@@ -50,8 +50,8 @@ macro_rules! config_field_bool {
($config:expr, $label:expr, $field:ident) => {
html! {
<div class="tp__form-field tp__form-field__bool">
<label>{$label}</label>
<$crate::app::components::ToggleSwitch value={$config.$field} readonly={true} />
<label>{$label}</label>
</div>
}
};
@@ -140,7 +140,7 @@ macro_rules! edit_field_text_option {
hidden={$hidden}
name={stringify!($field)}
autocomplete={true}
value={(*instance).data().$field.as_ref().map_or_else(String::new, |v|v.to_string())}
value={instance.form.$field.as_ref().map_or_else(String::new, |v|v.to_string())}
on_change={Callback::from(move |value: String| {
instance.dispatch($action(if value.is_empty() {
None
@@ -171,7 +171,7 @@ macro_rules! edit_field_text {
hidden={$hidden}
name={stringify!($field)}
autocomplete={true}
value={(*instance).data().$field.clone()}
value={instance.form.$field.clone()}
on_change={Callback::from(move |value: String| {
instance.dispatch($action(value));
})}
@@ -187,11 +187,11 @@ macro_rules! edit_field_bool {
let instance = $instance.clone();
html! {
<div class="tp__form-field tp__form-field__bool">
<label>{$label}</label>
<$crate::app::components::ToggleSwitch
value={(*instance).data().$field}
value={instance.form.$field}
readonly={false}
on_change={Callback::from(move |value| instance.dispatch($action(value)))} />
<label>{$label}</label>
</div>
}
}};
@@ -206,7 +206,7 @@ macro_rules! edit_field_number {
<$crate::app::components::number_input::NumberInput
label={$label}
name={stringify!($field)}
value={(*instance).data().$field as i64}
value={instance.form.$field as i64}
on_change={Callback::from(move |value: Option<i64>| {
match value {
Some(value) => {
@@ -231,7 +231,7 @@ macro_rules! edit_field_number_u8 {
<$crate::app::components::number_input::NumberInput
label={$label}
name={stringify!($field)}
value={(*instance).data().$field as i64}
value={instance.form.$field as i64}
on_change={Callback::from(move |value: Option<i64>| {
match value {
Some(value) => {
@@ -256,7 +256,7 @@ macro_rules! edit_field_number_u16 {
<$crate::app::components::number_input::NumberInput
label={$label}
name={stringify!($field)}
value={(*instance).data().$field as i64}
value={instance.form.$field as i64}
on_change={Callback::from(move |value: Option<i64>| {
match value {
Some(value) => {
@@ -282,7 +282,7 @@ macro_rules! edit_field_number_i16 {
<$crate::app::components::number_input::NumberInput
label={$label}
name={stringify!($field)}
value={(*instance).data().$field as i64}
value={instance.form.$field as i64}
on_change={Callback::from(move |value: Option<i64>| {
match value {
Some(value) => {
@@ -307,7 +307,7 @@ macro_rules! edit_field_number_u64 {
<$crate::app::components::number_input::NumberInput
label={$label}
name={stringify!($field)}
value={(*instance).data().$field as i64}
value={instance.form.$field as i64}
on_change={Callback::from(move |value: Option<i64>| {
match value {
Some(value) => {
@@ -332,7 +332,7 @@ macro_rules! edit_field_number_option {
<$crate::app::components::number_input::NumberInput
label={$label}
name={stringify!($field)}
value={(*instance).data().$field.map(|v| v as i64)}
value={instance.form.$field.map(|v| v as i64)}
on_change={Callback::from(move |value: Option<i64>| {
match value {
Some(value) => {
@@ -357,7 +357,7 @@ macro_rules! edit_field_date {
<$crate::app::components::date_input::DateInput
label={$label}
name={stringify!($field)}
value={(*instance).data().$field.clone()}
value={instance.form.$field.clone()}
on_change={Callback::from(move |value: Option<i64>| {
instance.dispatch($action(value));
})}
@@ -371,7 +371,7 @@ macro_rules! edit_field_date {
macro_rules! edit_field_list {
($instance:expr, $label:expr, $field:ident, $action:path, $placeholder:expr) => {{
let instance = $instance.clone();
let tag_list = (*instance).data().$field.iter()
let tag_list = instance.form.$field.iter()
.map(|s| std::rc::Rc::new($crate::app::components::Tag { label: s.clone(), class: None }))
.collect::<Vec<std::rc::Rc<$crate::app::components::Tag>>>();
html! {
@@ -394,7 +394,7 @@ macro_rules! edit_field_list {
macro_rules! edit_field_list_option {
($instance:expr, $label:expr, $field:ident, $action:path, $placeholder:expr) => {{
let instance = $instance.clone();
let tag_list = (*instance).data().$field.as_ref().map_or_else(Vec::new, |f| f.iter()
let tag_list = instance.form.$field.as_ref().map_or_else(Vec::new, |f| f.iter()
.map(|s| std::rc::Rc::new($crate::app::components::Tag { label: s.clone(), class: None }))
.collect::<Vec<std::rc::Rc<$crate::app::components::Tag>>>());
html! {
@@ -452,20 +452,21 @@ macro_rules! generate_form_reducer {
type Action = $action_name;
fn reduce(self: std::rc::Rc<Self>, action: Self::Action) -> std::rc::Rc<Self> {
let mut new_data = self.$data_field.clone();
let mut modified = self.modified;
match action {
let new_data = match action {
$(
$action_name::$set_name(v) => {
let mut new_data = self.$data_field.clone();
new_data.$field_name = v;
if !modified { modified = true; }
new_data
},
)*
$action_name::SetAll(v) => {
new_data = v;
modified = false;
v
},
}
};
$state_name { $data_field: new_data, modified }.into()
}
}
@@ -4,7 +4,6 @@ use shared::model::MainConfigDto;
use crate::app::context::ConfigContext;
use crate::app::components::config::config_view_context::ConfigViewContext;
use crate::app::components::config::config_page::ConfigForm;
use crate::app::components::config::macros::HasFormData;
use crate::{config_field_optional, config_field_bool, config_field, edit_field_text_option, edit_field_bool, generate_form_reducer, edit_field_number, edit_field_number_option, edit_field_text, edit_field_number_u8};
const LABEL_UPDATE_ON_BOOT: &str = "LABEL.UPDATE_ON_BOOT";
@@ -1,6 +1,5 @@
use crate::app::components::config::config_page::ConfigForm;
use crate::app::components::config::config_view_context::ConfigViewContext;
use crate::app::components::config::macros::HasFormData;
use crate::app::components::{Card, Chip, RadioButtonGroup};
use crate::{config_field, config_field_bool, config_field_bool_empty, config_field_child, config_field_empty, config_field_hide, config_field_optional, edit_field_bool, edit_field_list, edit_field_text, edit_field_text_option, generate_form_reducer};
use shared::model::{MessagingConfigDto, MsgKind, PushoverMessagingConfigDto, RestMessagingConfigDto, TelegramMessagingConfigDto};
@@ -4,7 +4,6 @@ use shared::model::ProxyConfigDto;
use crate::app::context::ConfigContext;
use crate::app::components::config::config_view_context::ConfigViewContext;
use crate::app::components::config::config_page::ConfigForm;
use crate::app::components::config::macros::HasFormData;
use crate::{
config_field, config_field_optional, config_field_optional_hide,
edit_field_text, edit_field_text_option, generate_form_reducer,
@@ -6,7 +6,6 @@ use shared::model::{CacheConfigDto, GeoIpConfigDto, RateLimitConfigDto, ReverseP
use crate::app::context::ConfigContext;
use crate::app::components::config::config_view_context::ConfigViewContext;
use crate::app::components::config::config_page::ConfigForm;
use crate::app::components::config::macros::HasFormData;
use crate::app::components::{Card};
use crate::{config_field, config_field_bool, config_field_custom, config_field_optional, edit_field_bool, edit_field_list, edit_field_number, edit_field_number_u64, edit_field_text, edit_field_text_option, generate_form_reducer};
@@ -1,7 +1,6 @@
use std::rc::Rc;
use std::str::FromStr;
use cron::Schedule;
use crate::app::components::{Card, Chip, DropDownOption, IconButton, NoContent};
use cron::{Schedule};
use crate::app::components::{Card, Chip, DropDownOption, DropDownSelection, IconButton, NoContent};
use crate::app::ConfigContext;
use yew::prelude::*;
use yew_i18n::use_translation;
@@ -48,11 +47,11 @@ pub fn SchedulesConfigView() -> Html {
let target_options = use_memo((selected_targets.clone(), all_targets.clone()),
move |(selected, all_targets)| all_targets
.iter()
.map(|t| Rc::new(DropDownOption {
.map(|t| DropDownOption {
id: t.clone(),
label: html! { t.clone() },
selected: (*selected).as_ref().is_some_and(|s| s.contains(t)),
})).collect::<Vec<Rc<DropDownOption>>>(),
}).collect::<Vec<DropDownOption>>(),
);
let form_state: UseReducerHandle<SchedulesConfigFormState> = use_reducer(|| {
@@ -95,11 +94,11 @@ pub fn SchedulesConfigView() -> Html {
let handle_target_selection = {
let set_selected_targets = selected_targets.clone();
Callback::from(move |(_name, selections):(String, Vec<Rc<DropDownOption>>)| {
if selections.is_empty() {
set_selected_targets.set(None);
} else {
set_selected_targets.set(Some(selections.iter().map(|t| t.id.clone()).collect()));
Callback::from(move |(_name, selections):(String, DropDownSelection)| {
match selections {
DropDownSelection::Empty => set_selected_targets.set(None),
DropDownSelection::Single(options) => set_selected_targets.set(Some(vec![options])),
DropDownSelection::Multi(options) => set_selected_targets.set(Some(options)),
}
})
};
@@ -221,8 +220,8 @@ pub fn SchedulesConfigView() -> Html {
{config_field_child!(translate.t(LABEL_TARGETS), {
html!{ <Select name="target"
multi_select={true}
onselect={handle_target_selection}
options={(*target_options).clone()}
on_select={handle_target_selection}
options={target_options.clone()}
/>
}})}
<IconButton name="AddSchedule" icon="ScheduleAdd" class="tp__button-primary" onclick={handle_add_schedule} />
@@ -8,7 +8,6 @@ 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";
@@ -111,7 +110,7 @@ pub fn VideoConfigView() -> Html {
html! {
<div class="tp__config-view__tags">
<ul>
{ for download_state.form.headers.iter().map(|(k,v)| html!{ <li>{k}{":"} {v}</li> }) }
{ for download_state.form.headers.iter().map(|(k,v)| html!{ <li>{"- "}{k}{": "} {v}</li> }) }
</ul>
</div>
}
@@ -2,7 +2,6 @@ use crate::app::components::{AppIcon, Card, Chip};
use crate::app::context::ConfigContext;
use crate::app::components::config::config_view_context::ConfigViewContext;
use crate::app::components::config::config_page::ConfigForm;
use crate::app::components::config::macros::HasFormData;
use crate::{config_field, config_field_bool, config_field_child, config_field_hide, config_field_optional, edit_field_bool, edit_field_list_option, edit_field_number, edit_field_text, edit_field_text_option, generate_form_reducer, html_if};
use yew::prelude::*;
use yew_i18n::use_translation;
@@ -7,6 +7,15 @@ use crate::app::components::{AppIcon, IconButton};
use crate::app::components::popup_menu::PopupMenu;
use crate::html_if;
#[derive(Debug, Clone, PartialEq)]
pub enum DropDownSelection {
Empty,
Single(String),
Multi(Vec<String>),
}
#[derive(Clone, PartialEq, Debug)]
pub struct DropDownOption {
pub(crate) id: String,
@@ -24,10 +33,10 @@ impl DropDownOption {
pub struct DropDownIconButtonProps {
pub name: String,
pub icon: String,
pub onselect: Callback<(String, Vec<String>)>,
pub on_select: Callback<(String, DropDownSelection)>,
#[prop_or_default]
pub class: String,
pub options: Vec<Rc<DropDownOption>>,
pub options: Rc<Vec<DropDownOption>>,
#[prop_or_default]
pub multi_select: bool,
#[prop_or_default]
@@ -73,7 +82,7 @@ pub fn DropDownIconButton(props: &DropDownIconButtonProps) -> Html {
let multi_select = props.multi_select;
let name = props.name.clone();
let selections = selections.clone();
let onselect = props.onselect.clone();
let onselect = props.on_select.clone();
let set_is_open = popup_is_open.clone();
Callback::from(move |(id, e): (String, MouseEvent)| {
e.prevent_default();
@@ -83,9 +92,13 @@ pub fn DropDownIconButton(props: &DropDownIconButtonProps) -> Html {
selections.insert(id.clone());
}
let selected_options = if multi_select {
selections.current().iter().map(Clone::clone).collect::<Vec<_>>()
if selections.current().is_empty() {
DropDownSelection::Empty
} else {
DropDownSelection::Multi(selections.current().iter().map(Clone::clone).collect::<Vec<_>>())
}
} else {
vec![id.clone()]
DropDownSelection::Single(id.clone())
};
onselect.emit((name.clone(), selected_options));
if !multi_select {
+2
View File
@@ -48,10 +48,12 @@ mod websocket_status;
mod floating_background;
mod source_editor;
mod cluster_flags_input;
// pub use self::input::*;
// pub use self::menu_item::*;
// pub use self::popup_menu::*;
pub use self::cluster_flags_input::*;
pub use self::toggle_switch::*;
pub use self::svg_icon::*;
pub use self::icon_button::*;
+14 -4
View File
@@ -3,7 +3,7 @@ use gloo_timers::callback::Timeout;
use web_sys::HtmlInputElement;
use yew::prelude::*;
use shared::model::SearchRequest;
use crate::app::components::{AppIcon, DropDownIconButton, DropDownOption, IconButton};
use crate::app::components::{AppIcon, DropDownIconButton, DropDownOption, DropDownSelection, IconButton};
use crate::html_if;
const DEBOUNCE_TIMEOUT_MS: u32 = 500;
@@ -19,7 +19,7 @@ pub struct SearchProps {
#[prop_or_default]
pub class: String,
#[prop_or_default]
pub options: Option<Vec<Rc<DropDownOption>>>,
pub options: Option<Rc<Vec<DropDownOption>>>,
pub onsearch: Option<Callback<SearchRequest>>,
#[prop_or(3)]
pub min_length: usize,
@@ -114,7 +114,17 @@ pub fn Search(props: &SearchProps) -> Html {
let handle_options_click = {
Callback::from(move |(_name, selections)| {
search_fields.set(Some(Rc::new(selections)));
match selections {
DropDownSelection::Empty => {
search_fields.set(None);
}
DropDownSelection::Multi(options) => {
search_fields.set(Some(Rc::new(options)));
}
DropDownSelection::Single(option) => {
search_fields.set(Some(Rc::new(vec![option])));
}
}
})
};
@@ -136,7 +146,7 @@ pub fn Search(props: &SearchProps) -> Html {
html_if!(
props.options.is_some(),
{
<DropDownIconButton multi_select={true} options={props.options.as_ref().unwrap().clone()} name="fields" icon="Popup" onselect={handle_options_click} />
<DropDownIconButton multi_select={true} options={props.options.as_ref().unwrap().clone()} name="fields" icon="Popup" on_select={handle_options_click} />
}
)
}
+6 -17
View File
@@ -1,8 +1,8 @@
use std::rc::Rc;
use yew::prelude::*;
use crate::app::components::{DropDownIconButton, DropDownOption};
use crate::app::components::{DropDownIconButton, DropDownOption, DropDownSelection};
fn map_selection(o: &Rc<DropDownOption>) -> Html {
fn map_selection(o: &DropDownOption) -> Html {
html! {<span>{o.label.clone()}</span>}
}
@@ -11,10 +11,10 @@ pub struct SelectProps {
pub name: String,
#[prop_or_default]
pub icon: Option<String>,
pub onselect: Callback<(String, Vec<Rc<DropDownOption>>)>,
pub on_select: Callback<(String, DropDownSelection)>,
#[prop_or_default]
pub class: String,
pub options: Vec<Rc<DropDownOption>>,
pub options: Rc<Vec<DropDownOption>>,
#[prop_or_default]
pub multi_select: bool,
}
@@ -26,24 +26,13 @@ pub fn Select(props: &SelectProps) -> Html {
let selected_options = use_state(Vec::new);
{
let set_selected_options = selected_options.clone();
use_effect_with(props.options.clone(), move |options: &Vec<Rc<DropDownOption>>| {
use_effect_with(props.options.clone(), move |options: &Rc<Vec<DropDownOption>>| {
let selections = options.iter().filter(|o| o.selected)
.map(map_selection).collect::<Vec<Html>>();
set_selected_options.set(selections);
});
}
let handle_options_click = {
let onselect = props.onselect.clone();
let options = props.options.clone();
let set_selections = selected_options.clone();
Callback::from(move |(name, selections): (String, Vec<String>)| {
let selected_options: Vec<Rc<DropDownOption>> = options.iter().filter(|&o| selections.contains(&o.id)).cloned().collect();
set_selections.set(selected_options.iter().map(map_selection).collect());
onselect.emit((name, selected_options));
})
};
let handle_click_button = {
let button_ref = button_ref.clone();
Callback::from(move |event: MouseEvent| {
@@ -69,7 +58,7 @@ pub fn Select(props: &SelectProps) -> Html {
options={props.options.clone()}
name={props.name.clone()}
icon={props.icon.as_ref().map_or_else(|| "Popup".to_owned(), |i|i.to_string())}
onselect={handle_options_click} />
on_select={props.on_select.clone()} />
</div>
</div>
}
@@ -0,0 +1,122 @@
use web_sys::MouseEvent;
use yew::{classes, function_component, html, Callback, Html, Properties};
use yew_i18n::use_translation;
use crate::html_if;
use crate::app::components::{Block, BlockInstance, BlockType, PortStatus};
#[derive(Properties, PartialEq)]
pub struct BlockProps {
pub(crate) block: Block,
pub(crate) delete_mode: bool,
pub(crate) delete_block: Callback<usize>,
pub(crate) port_status: PortStatus,
pub(crate) on_edit: Callback<usize>,
pub(crate) on_mouse_down: Callback<(usize, MouseEvent)>,
pub(crate) on_connection_drop: Callback<usize>, // to_id
pub(crate) on_connection_start: Callback<usize>, // from_id
}
#[function_component]
pub fn BlockView(props: &BlockProps) -> Html {
let translate = use_translation();
let delete_mode = props.delete_mode;
let delete_block = props.delete_block.clone();
let block = &props.block;
let port_status = props.port_status;
let block_id = block.id;
let block_type = block.block_type;
let style = format!("position:absolute; left:{}px; top:{}px;", block.position.0, block.position.1);
let from_id = block_id;
let to_id = block_id;
let is_target = matches!(block_type, BlockType::Target);
let is_input = !is_target && matches!(block_type, BlockType::InputM3u | BlockType::InputXtream);
let is_output = !is_input && !is_target;
let port_style = match port_status {
PortStatus::Valid => "tp__source-editor__port--valid",
PortStatus::Invalid => "tp__source-editor__port--invalid",
_ => "",
};
let handle_mouse_down = {
let on_block_mouse_down = props.on_mouse_down.clone();
Callback::from(move |e| on_block_mouse_down.emit((block_id, e)))
};
let handle_edit = {
let block_id = block_id;
let on_edit = props.on_edit.clone();
Callback::from(move |_| {
on_edit.emit(block_id)
})
};
let (title, show_type) = {
let dto_title = match &block.instance {
BlockInstance::Input(dto) => dto.name.clone(),
BlockInstance::Target(dto) => dto.name.clone(),
BlockInstance::Output(_output) => {
translate.t(&format!("SOURCE_EDITOR.BRICK_{}", block_type))
}
};
if dto_title.is_empty() {
(translate.t(&format!("SOURCE_EDITOR.BRICK_{}", block_type)), false)
} else {
(dto_title, true)
}
};
html! {
<div class={format!("tp__source-editor__block no-select tp__source-editor__block-{}", block_type)} style={style}>
<div class={"tp__source-editor__block-header"}>
// Block handle (drag)
<div class="tp__source-editor__block-handle" onmousedown={handle_mouse_down}/>
// Delete button for block
{
html_if!(delete_mode, {
<div class={"tp__source-editor__block-header-actions"}>
<div class="tp__source-editor__block-delete" onclick={
Callback::from(move |_| delete_block.emit(block_id))
}></div>
</div>
})
}
</div>
<div class={"tp__source-editor__block-content"} ondblclick={handle_edit}>
<div class={"tp__source-editor__block-content-body"}>
<div class="tp__source-editor__block-label">
{ title }
</div>
{
html_if!(show_type, {
<span class="tp__source-editor__block-sub-label">{translate.t(&format!("SOURCE_EDITOR.BRICK_{}", block_type))}</span>
})
}
</div>
{html_if!(is_target || is_output, {
// Left port
<div
class={classes!("tp__source-editor__block-port", "tp__source-editor__block-port--left", port_style)}
onmouseup={{
let on_connection_drop = props.on_connection_drop.clone();
Callback::from(move |_| on_connection_drop.emit(to_id))
}} />
})}
{html_if!(is_target || is_input, {
// Right port
<div
class="tp__source-editor__block-port tp__source-editor__block-port--right"
onmousedown={{
let on_connection_start = props.on_connection_start.clone();
Callback::from(move |_| on_connection_start.emit(from_id))
}} />
})}
</div>
</div>
}
}
@@ -1,12 +1,38 @@
use yew::{function_component, html, Html};
use yew::{classes, function_component, html, use_context, use_effect_with, use_state, Html};
use crate::app::components::source_editor::input_form::ConfigInputView;
use crate::app::components::{BlockInstance, EditMode, SourceEditorContext};
use crate::app::components::source_editor::output_form::ConfigOutputView;
use crate::app::components::source_editor::target_form::ConfigTargetView;
#[function_component]
pub fn SourceEditorForm() -> Html {
let source_editor_ctx =
use_context::<SourceEditorContext>().expect("SourceEditorContext not found");
let visible = use_state(|| false);
{
let visible_set = visible.clone();
use_effect_with(source_editor_ctx.edit_mode.clone(), move |edit_mode| {
match **edit_mode {
EditMode::Inactive => { visible_set.set(false); }
EditMode::Active(_) => { visible_set.set(true); }
}
});
}
html! {
<div class="tp__source-editor-form">
<ConfigInputView></ConfigInputView>
<div class={classes!("tp__source-editor-form", if *visible { "active" } else { "" }) }>
{
if let EditMode::Active(block) = &*source_editor_ctx.edit_mode {
match &block.instance {
BlockInstance::Input(input) => html! { <ConfigInputView block_id={block.id} input={Some(input.clone())}></ConfigInputView> },
BlockInstance::Target(target) => html! { <ConfigTargetView block_id={block.id} target={Some(target.clone())}></ConfigTargetView> },
BlockInstance::Output(output) => html! { <ConfigOutputView block_id={block.id} output={Some(output.clone())}></ConfigOutputView> },
}
} else {
html!{}
}
}
</div>
}
}
@@ -1,18 +1,18 @@
use std::fmt;
use std::rc::Rc;
use serde::{Deserialize, Serialize};
use yew::{Callback};
use shared::model::{ConfigInputDto};
use yew::{Callback, UseStateHandle};
use shared::model::{ConfigInputDto, ConfigTargetDto, TargetOutputDto};
// ----------------- Data Models -----------------
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)]
pub enum BlockType {
InputXtream,
InputM3u,
Target,
OutputM3u,
OutputXtream,
OutputHdhomerun,
OutputHdHomeRun,
OutputStrm,
}
@@ -23,7 +23,7 @@ impl BlockType {
pub const TARGET: &'static str = "Target";
pub const OUTPUT_M3U: &'static str = "OutputM3u";
pub const OUTPUT_XTREAM: &'static str = "OutputXtream";
pub const OUTPUT_HDHOMERUN: &'static str = "OutputHdhomerun";
pub const OUTPUT_HDHOMERUN: &'static str = "OutputHdHomeRun";
pub const OUTPUT_STRM: &'static str = "OutputStrm";
}
@@ -36,7 +36,7 @@ impl From<&str> for BlockType {
BlockType::TARGET => BlockType::Target,
BlockType::OUTPUT_M3U => BlockType::OutputM3u,
BlockType::OUTPUT_XTREAM => BlockType::OutputXtream,
BlockType::OUTPUT_HDHOMERUN => BlockType::OutputHdhomerun,
BlockType::OUTPUT_HDHOMERUN => BlockType::OutputHdHomeRun,
BlockType::OUTPUT_STRM => BlockType::OutputStrm,
_ => BlockType::Target, // fallback
}
@@ -59,30 +59,54 @@ impl fmt::Display for BlockType {
BlockType::Target => Self::TARGET,
BlockType::OutputM3u => Self::OUTPUT_M3U,
BlockType::OutputXtream => Self::OUTPUT_XTREAM,
BlockType::OutputHdhomerun => Self::OUTPUT_HDHOMERUN,
BlockType::OutputHdHomeRun => Self::OUTPUT_HDHOMERUN,
BlockType::OutputStrm => Self::OUTPUT_STRM,
};
write!(f, "{}", s)
}
}
pub(crate) type BlockId = usize;
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct Block {
pub id: usize,
pub id: BlockId,
pub block_type: BlockType,
pub position: (f32, f32),
pub instance: BlockInstance,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct Connection {
pub from: usize,
pub to: usize,
pub from: BlockId,
pub to: BlockId,
}
#[derive(Clone, Copy, PartialEq)]
pub enum PortStatus {
Valid,
Invalid,
Inactive
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum BlockInstance {
Input(Rc<ConfigInputDto>),
Target(Rc<ConfigTargetDto>),
Output(Rc<TargetOutputDto>)
}
#[derive(Clone, PartialEq)]
pub enum EditMode {
Inactive,
Active(Block),
}
#[derive(Clone, PartialEq)]
pub struct SourceEditorContext {
pub input: Option<Rc<ConfigInputDto>>,
pub on_form_change: Callback<ConfigInputDto>,
pub on_form_change: Callback<(BlockId, BlockInstance)>,
pub edit_mode: UseStateHandle<EditMode>
}
@@ -1,41 +1,57 @@
use log::error;
use std::rc::Rc;
use crate::app::components::source_editor::editor_model::{Block, BlockType, Connection};
use crate::app::components::source_editor::rules::can_connect;
use crate::app::components::source_editor::sidebar::SourceEditorSidebar;
use crate::html_if;
use web_sys::{HtmlElement, MouseEvent};
use yew::prelude::*;
use yew_i18n::use_translation;
use shared::model::ConfigInputDto;
use crate::app::components::{SourceEditorContext, SourceEditorForm};
use shared::model::{ConfigInputDto, ConfigTargetDto, HdHomeRunTargetOutputDto, M3uTargetOutputDto, StrmTargetOutputDto, TargetOutputDto, XtreamTargetOutputDto};
use crate::app::components::{BlockId, BlockInstance, EditMode, PortStatus, SourceEditorContext, SourceEditorForm};
use crate::app::components::source_editor::block::BlockView;
const PORT_INACTIVE: u32 = 0;
const PORT_VALID: u32 = 1;
const PORT_INVALID: u32 = 2;
const BLOCK_WIDTH: f32 = 100.0;
const BLOCK_HEIGHT: f32 = 50.0;
const BLOCK_HEADER_HEIGHT: f32 = 12.0;
const BLOCK_PORT_HEIGHT: f32 = 10.0;
const BLOCK_MIDDLE_Y: f32 = (BLOCK_HEIGHT + BLOCK_HEADER_HEIGHT + BLOCK_PORT_HEIGHT) / 2.0;
fn create_instance(block_type: BlockType) -> BlockInstance {
match block_type {
BlockType::InputXtream => BlockInstance::Input(Rc::new(ConfigInputDto::default())),
BlockType::InputM3u => BlockInstance::Input(Rc::new(ConfigInputDto::default())),
BlockType::Target => {
let mut dto = ConfigTargetDto::default();
dto.name = String::new();
BlockInstance::Target(Rc::new(dto))
},
BlockType::OutputM3u => BlockInstance::Output(Rc::new(TargetOutputDto::M3u(M3uTargetOutputDto::default()))),
BlockType::OutputXtream => BlockInstance::Output(Rc::new(TargetOutputDto::Xtream(XtreamTargetOutputDto::default()))),
BlockType::OutputHdHomeRun => BlockInstance::Output(Rc::new(TargetOutputDto::HdHomeRun(HdHomeRunTargetOutputDto::default()))),
BlockType::OutputStrm => BlockInstance::Output(Rc::new(TargetOutputDto::Strm(StrmTargetOutputDto::default()))),
}
}
// ----------------- Component -----------------
#[function_component]
pub fn SourceEditor() -> Html {
let translate = use_translation();
let canvas_ref = use_node_ref();
let blocks = use_state(Vec::<Block>::new);
let connections = use_state(Vec::<Connection>::new);
let next_id = use_state(|| 1usize);
// Dragging state
let dragging_block = use_state(|| None as Option<usize>);
let dragging_block = use_state(|| None);
let drag_offset = use_state(|| (0.0f32, 0.0f32));
let sidebar_drag_offset = use_state(|| (0.0f32, 0.0f32));
// Pending line for live connection
let pending_line = use_state(|| None as Option<((f32, f32), (f32, f32))>);
let pending_connection = use_state(|| None as Option<usize>);
let pending_line = use_state(|| None);
let pending_connection = use_state(|| None );
// Delete mode toggle
let delete_mode = use_state(|| false);
// ----------------- Drag Start from Sidebar -----------------
let on_drag_start = {
let handle_drag_start = {
let sidebar_drag_offset = sidebar_drag_offset.clone();
Callback::from(move |e: DragEvent| {
if let Some(target) = e.target_dyn_into::<HtmlElement>() {
@@ -51,7 +67,7 @@ pub fn SourceEditor() -> Html {
)};
// ----------------- Drop on Canvas -----------------
let on_drop = {
let handle_drop = {
let blocks = blocks.clone();
let next_id = next_id.clone();
let canvas_ref = canvas_ref.clone();
@@ -72,6 +88,7 @@ pub fn SourceEditor() -> Html {
id: *next_id,
block_type,
position: (mouse_x - offset_x, mouse_y - offset_y),
instance: create_instance(block_type),
});
blocks.set(current_blocks);
next_id.set(*next_id + 1);
@@ -79,24 +96,24 @@ pub fn SourceEditor() -> Html {
}
})
};
let on_drag_over = Callback::from(|e: DragEvent| e.prevent_default());
let handle_drag_over = Callback::from(|e: DragEvent| e.prevent_default());
// ----------------- Connection logic -----------------
let on_connection_start = {
let handle_connection_start = {
let pending_connection = pending_connection.clone();
let pending_line = pending_line.clone();
let blocks = blocks.clone();
Callback::from(move |from_id: usize| {
pending_connection.set(Some(from_id));
if let Some(block) = (*blocks).iter().find(|b| b.id == from_id) {
let x = block.position.0 + 100.0;
let y = block.position.1 + 25.0;
let x = block.position.0 + BLOCK_WIDTH;
let y = block.position.1 + BLOCK_MIDDLE_Y;
pending_line.set(Some(((x, y), (x, y))));
}
})
};
let on_connection_drop = {
let handle_connection_drop = {
let pending_connection = pending_connection.clone();
let connections = connections.clone();
let pending_line = pending_line.clone();
@@ -131,7 +148,7 @@ pub fn SourceEditor() -> Html {
// Snap pending line end to target port
if let Some(to_block) = (*blocks).iter().find(|b| b.id == to_id) {
let x = to_block.position.0;
let y = to_block.position.1 + 25.0;
let y = to_block.position.1 + BLOCK_MIDDLE_Y;
if let Some(((from_x, from_y), _)) = *pending_line {
pending_line.set(Some(((from_x, from_y), (x, y))));
}
@@ -143,7 +160,7 @@ pub fn SourceEditor() -> Html {
};
// ----------------- Drag block logic -----------------
let on_block_mouse_down = {
let handle_block_mouse_down = {
let dragging_block = dragging_block.clone();
let drag_offset = drag_offset.clone();
let canvas_ref = canvas_ref.clone();
@@ -163,7 +180,7 @@ pub fn SourceEditor() -> Html {
};
// ----------------- Mouse move for both pending line and block drag -----------------
let on_canvas_mouse_move = {
let handle_canvas_mouse_move = {
let pending_line = pending_line.clone();
let dragging_block = dragging_block.clone();
let drag_offset = drag_offset.clone();
@@ -175,12 +192,12 @@ pub fn SourceEditor() -> Html {
let mouse_x = e.client_x() as f32 - rect.left() as f32;
let mouse_y = e.client_y() as f32 - rect.top() as f32;
// 1️⃣ Update pending line (snap to nearest port if close)
// Update pending line (snap to nearest port if close)
if let Some(((from_x, from_y), _)) = *pending_line {
let mut snapped = (mouse_x, mouse_y);
for block in (*blocks).iter() {
let port_x = block.position.0;
let port_y = block.position.1 + 25.0;
let port_y = block.position.1 + BLOCK_MIDDLE_Y;
let dx = mouse_x - port_x;
let dy = mouse_y - port_y;
let dist = (dx * dx + dy * dy).sqrt();
@@ -191,7 +208,7 @@ pub fn SourceEditor() -> Html {
pending_line.set(Some(((from_x, from_y), snapped)));
}
// 2️⃣ Update dragging block
// Update dragging block
if let Some(block_id) = *dragging_block {
let (offset_x, offset_y) = *drag_offset;
let mut current_blocks = (*blocks).clone();
@@ -204,14 +221,14 @@ pub fn SourceEditor() -> Html {
})
};
let on_canvas_mouse_up = {
let handle_canvas_mouse_up = {
let dragging_block = dragging_block.clone();
Callback::from(move |_e: MouseEvent| {
dragging_block.set(None);
})
};
let on_canvas_right_click = {
let handle_canvas_right_click = {
let pending_connection = pending_connection.clone();
let pending_line = pending_line.clone();
Callback::from(move |e: MouseEvent| {
@@ -222,12 +239,12 @@ pub fn SourceEditor() -> Html {
};
// ----------------- Delete handlers -----------------
let toggle_delete_mode = {
let handle_toggle_delete_mode = {
let delete_mode = delete_mode.clone();
Callback::from(move |_| delete_mode.set(!*delete_mode))
};
let delete_block = {
let handle_delete_block = {
let blocks = blocks.clone();
let connections = connections.clone();
Callback::from(move |block_id: usize| {
@@ -241,7 +258,7 @@ pub fn SourceEditor() -> Html {
})
};
let delete_connection = {
let handle_delete_connection = {
let connections = connections.clone();
Callback::from(move |(from, to): (usize, usize)| {
let mut current_connections = (*connections).clone();
@@ -250,11 +267,50 @@ pub fn SourceEditor() -> Html {
})
};
let form_changed = Callback::<ConfigInputDto>::from(move |dto| error!("{dto:?}"));
let get_port_status = {
|block: &Block| {
if let Some(from_id) = *pending_connection {
if let Some(from_block) = (*blocks).iter().find(|b| b.id == from_id) {
return if can_connect(from_block, block, &connections, &blocks) {
PortStatus::Valid
} else {
PortStatus::Invalid
}
}
}
PortStatus::Inactive
}
};
let form_changed = {
let blocks = blocks.clone();
Callback::<(BlockId, BlockInstance)>::from(move |(block_id, instance): (BlockId, BlockInstance)| {
if let Some(block) = (*blocks).iter().find(|b| b.id == block_id) {
let mut current_blocks = (*blocks).clone();
current_blocks.retain(|b| b.id != block_id);
let mut new_block = block.clone();
new_block.instance = instance;
current_blocks.push(new_block);
blocks.set(current_blocks);
}
})
};
let edit_mode = use_state(|| EditMode::Inactive);
let handle_block_edit = {
let edit_mode_set = edit_mode.clone();
let blocks = blocks.clone();
Callback::from(move |block_id: usize| {
if let Some(block) = (*blocks).iter().find(|b| b.id == block_id) {
edit_mode_set.set(EditMode::Active(block.clone()));
}
})
};
let editor_context = SourceEditorContext {
input: None,
on_form_change: form_changed,
edit_mode: edit_mode.clone(),
};
// ----------------- Render -----------------
@@ -265,28 +321,29 @@ pub fn SourceEditor() -> Html {
<SourceEditorSidebar
delete_mode={*delete_mode}
on_drag_start={on_drag_start.clone()}
on_toggle_delete={toggle_delete_mode.clone()}
on_drag_start={handle_drag_start.clone()}
on_toggle_delete={handle_toggle_delete_mode.clone()}
/>
// Canvas
<div class="tp__source-editor__canvas-wrapper">
<div
ref={canvas_ref.clone()}
class="tp__source-editor__canvas graph-paper-advanced"
ondrop={on_drop.clone()}
ondragover={on_drag_over.clone()}
onmousemove={on_canvas_mouse_move}
onmouseup={on_canvas_mouse_up}
oncontextmenu={on_canvas_right_click}>
ondrop={handle_drop.clone()}
ondragover={handle_drag_over.clone()}
onmousemove={handle_canvas_mouse_move}
onmouseup={handle_canvas_mouse_up}
oncontextmenu={handle_canvas_right_click}>
// SVG for connections
<svg class="tp__source-editor__connections">
{ for (*connections).iter().filter_map(|c| {
let from_block = (*blocks).iter().find(|b| b.id == c.from)?;
let to_block = (*blocks).iter().find(|b| b.id == c.to)?;
let from_x = from_block.position.0 + 100.0;
let from_y = from_block.position.1 + 25.0;
let from_x = from_block.position.0 + BLOCK_WIDTH;
let from_y = from_block.position.1 + BLOCK_MIDDLE_Y;
let to_x = to_block.position.0;
let to_y = to_block.position.1 + 25.0;
let to_y = to_block.position.1 + BLOCK_MIDDLE_Y;
let dx = to_x - from_x;
let ctrl = dx * 0.5;
let d = format!(
@@ -303,7 +360,7 @@ pub fn SourceEditor() -> Html {
{ if *delete_mode {
let mid_x = (from_x + to_x) / 2.0;
let mid_y = (from_y + to_y) / 2.0;
let on_delete_connection = delete_connection.clone();
let on_delete_connection = handle_delete_connection.clone();
html! {
<circle cx={mid_x.to_string()} cy={mid_y.to_string()} r="6" fill="var(--source-editor-delete-color)" class="clickable"
onclick={
@@ -334,87 +391,21 @@ pub fn SourceEditor() -> Html {
</svg>
// Render blocks
{ for (*blocks).iter().map(|b| {
let block_id = b.id;
let style = format!("position:absolute; left:{}px; top:{}px;", b.position.0, b.position.1);
let from_id = block_id;
let to_id = block_id;
let delete_mode = *delete_mode;
let delete_block = delete_block.clone();
let is_target = matches!(b.block_type, BlockType::Target);
let is_input = !is_target && matches!(b.block_type, BlockType::InputM3u | BlockType::InputXtream);
let is_output = !is_input && !is_target;
let port_status = if let Some(from_id) = *pending_connection {
if let Some(from_block) = (*blocks).iter().find(|b| b.id == from_id) {
if can_connect(from_block, b, &connections, &blocks) {
PORT_VALID
} else {
PORT_INVALID
}
} else {
PORT_INACTIVE
}
} else {
PORT_INACTIVE
};
let port_style = match port_status {
PORT_VALID => "tp__source-editor__port--valid",
PORT_INVALID => "tp__source-editor__port--invalid",
_ => "",
};
{ for (*blocks).iter().map(|b|{
let port_status = get_port_status(b);
html! {
<div class={format!("tp__source-editor__block no-select tp__source-editor__brick-{}", b.block_type)}
style={style}>
// Block handle (drag)
<div
class="tp__source-editor__block-handle"
onmousedown={{
let on_block_mouse_down = on_block_mouse_down.clone();
let block_id = b.id;
Callback::from(move |e| on_block_mouse_down.emit((block_id, e)))
}}>
</div>
// Delete button for block
{
html_if!(delete_mode, {
<div class="tp__source-editor__block-delete" onclick={
let block_id = b.id;
Callback::from(move |_| delete_block.emit(block_id))
}></div>
})
}
{html_if!(is_target || is_output, {
// Left port
<div
class={classes!("tp__source-editor__port", "tp__source-editor__port--left", port_style)}
onmouseup={{
let on_connection_drop = on_connection_drop.clone();
Callback::from(move |_| on_connection_drop.emit(to_id))
}} />
})}
// Block label
<div class="tp__source-editor__block-label">
{ translate.t(&format!("SOURCE_EDITOR.BRICK_{}", b.block_type)) }
</div>
{html_if!(is_target || is_input, {
// Right port
<div
class="tp__source-editor__port tp__source-editor__port--right"
onmousedown={{
let on_connection_start = on_connection_start.clone();
Callback::from(move |_| on_connection_start.emit(from_id))
}} />
})}
</div>
}
}) }
<BlockView
block={b.clone()}
delete_mode={*delete_mode}
delete_block={handle_delete_block.clone()}
port_status={port_status}
on_edit={handle_block_edit.clone()}
on_mouse_down={handle_block_mouse_down.clone()}
on_connection_drop={handle_connection_drop.clone()}
on_connection_start={handle_connection_start.clone()}
/>
}}) }
</div>
</div>
<SourceEditorForm />
</div>
@@ -1,23 +1,17 @@
use crate::app::components::config::HasFormData;
use crate::app::components::{Card, RadioButtonGroup, SourceEditorContext};
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::select::Select;
use crate::app::components::{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 std::collections::HashMap;
use std::fmt::Display;
use std::rc::Rc;
use yew::{
function_component, html, use_context, use_effect_with, use_memo, use_reducer, Callback, Html,
UseReducerHandle,
};
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.FETCH_METHOD";
const LABEL_FETCH_METHOD: &str = "LABEL.METHOD";
// const LABEL_HEADERS: &str = "LABEL.HEADERS";
const LABEL_URL: &str = "LABEL.URL";
// const LABEL_EPG: &str = "LABEL.EPG";
@@ -25,11 +19,9 @@ 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_OPTIONS: &str = "LABEL.OPTIONS";
// const LABEL_ALIASES: &str = "LABEL.ALIASES";
const LABEL_PRIORITY: &str = "LABEL.PRIORITY";
const LABEL_MAX_CONNECTIONS: &str = "LABEL.MAX_CONNECTIONS";
const LABEL_STAGED: &str = "LABEL.STAGED";
// 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";
@@ -37,6 +29,24 @@ const LABEL_XTREAM_SKIP_SERIES: &str = "LABEL.SKIP_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";
#[derive(Copy, Clone, PartialEq, Eq)]
enum InputFormPage {
Main,
Options,
Staged,
}
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(),
})
}
}
// generate_form_reducer!(
// state: EpgConfigDtoFormState { form: EpgConfigDto },
// action_name: EpgConfigDtoFormAction,
@@ -91,28 +101,29 @@ generate_form_reducer!(
}
);
#[derive(Properties, PartialEq, Clone)]
pub struct ConfigInputViewProps {
pub(crate) block_id: usize,
pub(crate) input: Option<Rc<ConfigInputDto>>,
}
#[function_component]
pub fn ConfigInputView() -> Html {
pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html {
let translate = use_translation();
let source_editor_ctx =
use_context::<SourceEditorContext>().expect("SourceEditorContext not found");
let source_editor_ctx = use_context::<SourceEditorContext>().expect("SourceEditorContext not found");
let fetch_methods = use_memo((), |_| {
[InputFetchMethod::GET, InputFetchMethod::POST]
.iter()
.map(ToString::to_string)
.collect::<Vec<String>>()
});
let input_types = use_memo((), |_| {
[
InputType::M3u,
InputType::Xtream,
InputType::M3uBatch,
InputType::XtreamBatch,
]
.iter()
.map(ToString::to_string)
.collect::<Vec<String>>()
});
let view_visible = use_state(|| InputFormPage::Main.to_string());
let on_tab_click = {
let view_visible = view_visible.clone();
Callback::from(move |page: InputFormPage| view_visible.set(page.to_string()))
};
let input_form_state: UseReducerHandle<ConfigInputFormState> =
use_reducer(|| ConfigInputFormState {
@@ -130,40 +141,31 @@ pub fn ConfigInputView() -> Html {
modified: false,
});
{
let on_form_change = source_editor_ctx.on_form_change.clone();
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 staged_input_types = use_memo(staged_input_state.clone(), |staged_state| {
let default_it = staged_state.form.input_type;
[
InputType::M3u,
InputType::Xtream,
// InputType::M3uBatch,
// InputType::XtreamBatch,
]
.iter()
.map(|t| DropDownOption {
id: t.to_string(),
label: html! { t.to_string() },
selected: *t == default_it,
}).collect::<Vec<DropDownOption>>()
});
use_effect_with(
(input_form_state, input_options_state, staged_input_state),
move |(input_form, input_options, staged_input)| {
let mut form = input_form.form.clone();
form.options = if input_options.form.is_empty() {
None
} else {
Some(input_options.form.clone())
};
form.staged = if staged_input.form.is_empty() {
None
} else {
Some(staged_input.form.clone())
};
let _modified =
input_form.modified || input_options.modified || staged_input.modified;
on_form_change.emit(input_form.form.clone());
},
);
}
{
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 config_input = source_editor_ctx.input.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()));
@@ -194,7 +196,6 @@ pub fn ConfigInputView() -> Html {
let render_options = || {
html! {
<Card class="tp__config-view__card">
<h1>{translate.t(LABEL_OPTIONS)}</h1>
{ 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) }
@@ -205,15 +206,14 @@ pub fn ConfigInputView() -> Html {
};
let render_staged = || {
let staged_input_type_selection = Rc::new(vec![staged_input_state.form.method.to_string()]);
let staged_method_selection = Rc::new(vec![staged_input_state.form.method.to_string()]);
let staged_input_state_disp = staged_input_state.clone();
let staged_input_state_1 = staged_input_state.clone();
let staged_input_state_2 = staged_input_state.clone();
html! {
<Card class="tp__config-view__card">
<h1>{translate.t(LABEL_STAGED)}</h1>
{ 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) }
{ edit_field_text_option!(staged_input_state, translate.t(LABEL_PASSWORD), password, StagedInputFormAction::Password, true) }
{ config_field_child!(translate.t(LABEL_FETCH_METHOD), {
html! {
@@ -221,7 +221,7 @@ pub fn ConfigInputView() -> Html {
multi_select={false} none_allowed={false}
on_select={Callback::from(move |selections: Rc<Vec<String>>| {
if let Some(first) = selections.first() {
staged_input_state_disp.dispatch(StagedInputFormAction::Method(first.parse::<InputFetchMethod>().unwrap_or(InputFetchMethod::GET)));
staged_input_state_1.dispatch(StagedInputFormAction::Method(first.parse::<InputFetchMethod>().unwrap_or(InputFetchMethod::GET)));
}
})}
options={&fetch_methods}
@@ -230,19 +230,28 @@ pub fn ConfigInputView() -> Html {
}})}
{ config_field_child!(translate.t(LABEL_INPUT_TYPE), {
html! {
<RadioButtonGroup
multi_select={false} none_allowed={false}
on_select={Callback::from(move |selections: Rc<Vec<String>>| {
if let Some(first) = selections.first() {
staged_input_state.dispatch(StagedInputFormAction::InputType(first.parse::<InputType>().unwrap_or(InputType::Xtream)));
<Select
name={"staged_input_types"}
multi_select={false}
on_select={Callback::from(move |(_, selections):(String, DropDownSelection)| {
match selections {
DropDownSelection::Empty => {
staged_input_state_2.dispatch(StagedInputFormAction::InputType(InputType::Xtream));
}
DropDownSelection::Single(option) => {
staged_input_state_2.dispatch(StagedInputFormAction::InputType(option.parse::<InputType>().unwrap_or(InputType::Xtream)));
}
DropDownSelection::Multi(options) => {
if let Some(first) = options.first() {
staged_input_state_2.dispatch(StagedInputFormAction::InputType(first.parse::<InputType>().unwrap_or(InputType::Xtream)));
}
}
}
})}
options={&input_types}
selected={staged_input_type_selection}
options={staged_input_types.clone()}
/>
}})}
// pub method: InputFetchMethod,
// pub input_type: InputType,
//{ edit_field_list!(staged_input_state, translate.t(LABEL_HEADERS), headers, StagedInputFormAction::Headers, translate.t(LABEL_ADD_HEADER)) }
</Card>
}
@@ -250,18 +259,16 @@ pub fn ConfigInputView() -> Html {
let render_input = || {
let input_method_selection = Rc::new(vec![input_form_state.form.method.to_string()]);
let input_input_type_selection = Rc::new(vec![input_form_state.form.input_type.to_string()]);
let input_form_state_disp = input_form_state.clone();
html! {
<Card class="tp__config-view__card">
{ edit_field_bool!(input_form_state, translate.t(LABEL_ENABLED), enabled, ConfigInputFormAction::Enabled) }
{ edit_field_text!(input_form_state, translate.t(LABEL_NAME), url, ConfigInputFormAction::Name) }
{ edit_field_text!(input_form_state, translate.t(LABEL_NAME), name, ConfigInputFormAction::Name) }
{ 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) }
{ edit_field_text_option!(input_form_state, translate.t(LABEL_PASSWORD), password, ConfigInputFormAction::Password, true) }
// pub input_type: InputType,
//{ edit_field_list!(input_form_state, translate.t(LABEL_HEADERS), headers, ConfigInputFormAction::Headers, translate.t(LABEL_ADD_HEADER)) }
{ edit_field_text_option!(input_form_state, translate.t(LABEL_PERSIST), persist, ConfigInputFormAction::Persist) }
{ edit_field_number_i16!(input_form_state, translate.t(LABEL_PRIORITY), priority, ConfigInputFormAction::Priority) }
{ edit_field_number_u16!(input_form_state, translate.t(LABEL_MAX_CONNECTIONS), max_connections, ConfigInputFormAction::MaxConnections) }
{ config_field_child!(translate.t(LABEL_FETCH_METHOD), {
@@ -277,19 +284,20 @@ pub fn ConfigInputView() -> Html {
selected={input_method_selection}
/>
}})}
{ config_field_child!(translate.t(LABEL_INPUT_TYPE), {
html! {
<RadioButtonGroup
multi_select={false} none_allowed={false}
on_select={Callback::from(move |selections: Rc<Vec<String>>| {
if let Some(first) = selections.first() {
input_form_state.dispatch(ConfigInputFormAction::InputType(first.parse::<InputType>().unwrap_or(InputType::Xtream)));
}
})}
options={input_types.clone()}
selected={input_input_type_selection}
/>
}})}
// { config_field_child!(translate.t(LABEL_INPUT_TYPE), {
// html! {
// <RadioButtonGroup
// multi_select={false} none_allowed={false}
// on_select={Callback::from(move |selections: Rc<Vec<String>>| {
// if let Some(first) = selections.first() {
// input_form_state.dispatch(ConfigInputFormAction::InputType(first.parse::<InputType>().unwrap_or(InputType::Xtream)));
// }
// })}
// options={input_types.clone()}
// selected={input_input_type_selection}
// />
// }})}
{ edit_field_text_option!(input_form_state, translate.t(LABEL_PERSIST), persist, ConfigInputFormAction::Persist) }
// pub epg: Option<EpgConfigDto>,
// pub aliases: Option<Vec<ConfigInputAliasDto>>,
@@ -297,18 +305,89 @@ pub fn ConfigInputView() -> Html {
}
};
let handle_apply_input = { // source_editor_ctx.on_form_change.emit();
let source_editor_ctx = source_editor_ctx.clone();
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 block_id = props.block_id;
Callback::from(move |_| {
let mut input = input_form_state.data().clone();
let options = input_options_state.data();
if !options.is_empty() {
input.options = Some(options.clone());
}
let staged_input = staged_input_state.data();
if !staged_input.is_empty() {
input.staged = Some(staged_input.clone());
}
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 render_edit_mode = || {
html! {
<div class="tp__input-config-view__body tp__config-view-page__body">
<div class="tp__input-form__body">
<div class="tp__tab-header">
{
for [
InputFormPage::Main,
InputFormPage::Options,
InputFormPage::Staged
].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! {
<button
class={classes!("tp__tab-button", if active { "active" } else { "" })}
onclick={on_tab_click}
>
{ page_str.clone() }
</button>
}
})
}
</div>
<div class="tp__input-form__body__pages">
<Panel value={InputFormPage::Main.to_string()} active={view_visible.to_string()}>
{render_input()}
</Panel>
<Panel value={InputFormPage::Options.to_string()} active={view_visible.to_string()}>
{render_options()}
</Panel>
<Panel value={InputFormPage::Staged.to_string()} active={view_visible.to_string()}>
{render_staged()}
</Panel>
</div>
</div>
}
};
html! {
<div class="tp__input-form-view tp__config-view-page">
<div class="tp__input-form tp__config-view-page">
<div class="tp__input-form__toolbar tp__form-page__toolbar">
<TextButton class="primary" name="apply_input"
icon="Accept"
title={ translate.t("LABEL.OK")}
onclick={handle_apply_input}></TextButton>
<TextButton class="secondary" name="cancel_input"
icon="Cancel"
title={ translate.t("LABEL.CANCEL")}
onclick={handle_cancel}></TextButton>
</div>
{ render_edit_mode() }
</div>
}
@@ -3,7 +3,10 @@ mod editor_model;
mod sidebar;
mod rules;
mod input_form;
mod target_form;
mod output_form;
mod editor_form;
mod block;
pub use editor_view::*;
pub use editor_form::*;
@@ -0,0 +1,17 @@
use shared::model::{TargetOutputDto};
use std::rc::Rc;
use yew::{function_component, html, Html, Properties};
#[derive(Properties, PartialEq, Clone)]
pub struct ConfigOutputViewProps {
pub(crate) block_id: usize,
pub(crate) output: Option<Rc<TargetOutputDto>>,
}
#[function_component]
pub fn ConfigOutputView(props: &ConfigOutputViewProps) -> Html {
html! {
<div class="tp__output-form tp__config-view-page">
</div>
}
}
@@ -23,7 +23,7 @@ pub fn can_connect(from_block: &Block, to_block: &Block, connections: &[Connecti
to_block.block_type,
BlockType::OutputM3u
| BlockType::OutputXtream
| BlockType::OutputHdhomerun
| BlockType::OutputHdHomeRun
| BlockType::OutputStrm
);
@@ -61,7 +61,7 @@ pub fn can_connect(from_block: &Block, to_block: &Block, connections: &[Connecti
match out_block.block_type {
BlockType::OutputM3u => count_m3u += 1,
BlockType::OutputXtream => count_xtream += 1,
BlockType::OutputHdhomerun => count_hdhomerun += 1,
BlockType::OutputHdHomeRun => count_hdhomerun += 1,
BlockType::OutputStrm => count_strm += 1,
_ => {}
}
@@ -71,7 +71,7 @@ pub fn can_connect(from_block: &Block, to_block: &Block, connections: &[Connecti
match to_block.block_type {
BlockType::OutputM3u if count_m3u >= 1 => return false,
BlockType::OutputXtream if count_xtream >= 1 => return false,
BlockType::OutputHdhomerun if count_hdhomerun >= 1 => return false,
BlockType::OutputHdHomeRun if count_hdhomerun >= 1 => return false,
BlockType::OutputStrm if count_strm >= 4 => return false,
_ => {}
}
@@ -15,7 +15,7 @@ pub const BLOCK_TYPES_TARGET: [BlockType; 1] = [
pub const BLOCK_TYPES_OUTPUT: [BlockType; 4] = [
BlockType::OutputM3u,
BlockType::OutputXtream,
BlockType::OutputHdhomerun,
BlockType::OutputHdHomeRun,
BlockType::OutputStrm];
@@ -25,6 +25,7 @@ fn create_brick(t: &BlockType, on_drag_start: Callback<DragEvent>, label: String
draggable={"true"}
data-block-type={t.to_string()}
ondragstart={on_drag_start}>
{ label }
</div>
}
@@ -0,0 +1,273 @@
use crate::app::components::config::HasFormData;
use crate::app::components::select::Select;
use crate::app::components::{BlockInstance, Card, ClusterFlagsInput, DropDownOption, DropDownSelection, EditMode, Panel, SourceEditorContext, TextButton};
use crate::{config_field_child, edit_field_bool, edit_field_list_option, edit_field_text, generate_form_reducer};
use shared::model::{ClusterFlags, ConfigTargetDto, ConfigTargetOptions, ProcessingOrder};
use std::fmt::Display;
use std::rc::Rc;
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_ENABLED: &str = "LABEL.ENABLED";
const LABEL_NAME: &str = "LABEL.NAME";
const LABEL_FILTER: &str = "LABEL.FILTER";
const LABEL_MAPPING: &str = "LABEL.MAPPING";
const LABEL_WATCH: &str = "LABEL.WATCH";
const LABEL_ADD_MAPPING: &str = "LABEL.ADD_MAPPING";
const LABEL_ADD_WATCH: &str = "LABEL.ADD_WATCH";
const LABEL_USE_MEMORY_CACHE: &str = "LABEL.USE_MEMORY_CACHE";
const LABEL_PROCESSING_ORDER: &str = "LABEL.PROCESSING_ORDER";
const LABEL_IGNORE_LOGO: &str = "LABEL.IGNORE_LOGO";
const LABEL_SHARE_LIVE_STREAMS: &str = "LABEL.SHARE_LIVE_STREAMS";
const LABEL_REMOVE_DUPLICATES: &str = "LABEL.REMOVE_DUPLICATES";
const LABEL_FORCE_REDIRECT: &str = "LABEL.FORCE_REDIRECT";
#[derive(Copy, Clone, PartialEq, Eq)]
enum TargetFormPage {
Main,
Options,
}
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(),
})
}
}
// pub sort: Option<ConfigSortDto>,
// pub rename: Option<Vec<ConfigRenameDto>>,
// pub favourites: Option<Vec<ConfigFavouritesDto>>,
generate_form_reducer!(
state: ConfigTargetOptionsFormState { form: ConfigTargetOptions },
action_name: ConfigTargetOptionsFormAction,
fields {
IgnoreLogo => ignore_logo: bool,
ShareLiveStreams => share_live_streams: bool,
RemoveDuplicates => remove_duplicates: bool,
ForceRedirect => force_redirect: Option<ClusterFlags>,
}
);
generate_form_reducer!(
state: ConfigTargetFormState { form: ConfigTargetDto },
action_name: ConfigTargetFormAction,
fields {
Enabled => enabled: bool,
Name => name: String,
ProcessingOrder => processing_order: ProcessingOrder,
Filter => filter: String,
Mapping => mapping: Option<Vec<String>>,
Watch => watch: Option<Vec<String>>,
UseMemoryCache => use_memory_cache: bool,
}
);
#[derive(Properties, PartialEq, Clone)]
pub struct ConfigTargetViewProps {
pub(crate) block_id: usize,
pub(crate) target: Option<Rc<ConfigTargetDto>>,
}
#[function_component]
pub fn ConfigTargetView(props: &ConfigTargetViewProps) -> Html {
let translate = use_translation();
let source_editor_ctx = use_context::<SourceEditorContext>().expect("SourceEditorContext not found");
let target_form_state: UseReducerHandle<ConfigTargetFormState> =
use_reducer(|| ConfigTargetFormState {
form: ConfigTargetDto::default(),
modified: false,
});
let target_options_state: UseReducerHandle<ConfigTargetOptionsFormState> =
use_reducer(|| ConfigTargetOptionsFormState {
form: ConfigTargetOptions::default(),
modified: false,
});
let view_visible = use_state(|| TargetFormPage::Main.to_string());
let on_tab_click = {
let view_visible = view_visible.clone();
Callback::from(move |page: TargetFormPage| view_visible.set(page.to_string()))
};
let processing_orders = use_memo(target_form_state.clone(), |target_state: &UseReducerHandle<ConfigTargetFormState>| {
let default_po = target_state.form.processing_order;
[
ProcessingOrder::Frm,
ProcessingOrder::Fmr,
ProcessingOrder::Rfm,
ProcessingOrder::Rmf,
ProcessingOrder::Mfr,
ProcessingOrder::Mrf,
]
.iter()
.map(|t| DropDownOption {
id: t.to_string(),
label: html! { t.to_string() },
selected: *t == default_po,
}).collect::<Vec<DropDownOption>>()
});
{
let target_form_state = target_form_state.clone();
let target_options_state = target_options_state.clone();
let config_target = props.target.clone();
use_effect_with(config_target, move |cfg| {
if let Some(target) = cfg {
target_form_state.dispatch(ConfigTargetFormAction::SetAll(target.as_ref().clone()));
target_options_state.dispatch(ConfigTargetOptionsFormAction::SetAll(
target.options.as_ref()
.map_or_else(ConfigTargetOptions::default, |d| d.clone()),
));
} else {
target_form_state.dispatch(ConfigTargetFormAction::SetAll(ConfigTargetDto::default()));
target_options_state.dispatch(ConfigTargetOptionsFormAction::SetAll(ConfigTargetOptions::default()));
}
|| ()
});
}
let render_options = || {
let target_options_state_1 = target_options_state.clone();
html! {
<Card class="tp__config-view__card">
{ 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! {
<ClusterFlagsInput
name="force_redirect"
value={target_options_state.form.force_redirect}
on_change={Callback::from(move |(_name, flags):(String, Option<ClusterFlags>)| {
target_options_state_1.dispatch(ConfigTargetOptionsFormAction::ForceRedirect(flags));
})}
/>
}})}
</Card>
}
};
let render_target = || {
let target_form_state_1 = target_form_state.clone();
html! {
<Card class="tp__config-view__card">
{ 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) }
{ config_field_child!(translate.t(LABEL_PROCESSING_ORDER), {
html! {
<Select
name={"processing_order"}
multi_select={false}
on_select={Callback::from(move |(_, selections):(String, DropDownSelection)| {
match selections {
DropDownSelection::Empty => {
target_form_state_1.dispatch(ConfigTargetFormAction::ProcessingOrder(ProcessingOrder::Frm));
}
DropDownSelection::Single(option) => {
target_form_state_1.dispatch(ConfigTargetFormAction::ProcessingOrder(option.parse::<ProcessingOrder>().unwrap_or(ProcessingOrder::Frm)));
}
DropDownSelection::Multi(options) => {
if let Some(first) = options.first() {
target_form_state_1.dispatch(ConfigTargetFormAction::ProcessingOrder(first.parse::<ProcessingOrder>().unwrap_or(ProcessingOrder::Frm)));
}
}
}
})}
options={processing_orders.clone()}
/>
}})}
{ edit_field_list_option!(target_form_state, translate.t(LABEL_MAPPING), mapping, ConfigTargetFormAction::Mapping, translate.t(LABEL_ADD_MAPPING)) }
{ edit_field_list_option!(target_form_state, translate.t(LABEL_WATCH), watch, ConfigTargetFormAction::Watch, translate.t(LABEL_ADD_WATCH)) }
</Card>
}
};
let render_edit_mode = || {
html! {
<div class="tp__input-form__body">
<div class="tp__tab-header">
{
for [
TargetFormPage::Main,
TargetFormPage::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! {
<button
class={classes!("tp__tab-button", if active { "active" } else { "" })}
onclick={on_tab_click}
>
{ page_str.clone() }
</button>
}
})
}
</div>
<div class="tp__input-form__body__pages">
<Panel value={TargetFormPage::Main.to_string()} active={view_visible.to_string()}>
{render_target()}
</Panel>
<Panel value={TargetFormPage::Options.to_string()} active={view_visible.to_string()}>
{render_options()}
</Panel>
</div>
</div>
}
};
let handle_apply_target = {
let source_editor_ctx = source_editor_ctx.clone();
let target_form_state = target_form_state.clone();
let target_options_state = target_options_state.clone();
let block_id = props.block_id;
Callback::from(move |_| {
let mut target = target_form_state.data().clone();
let target_options = target_options_state.data();
if !target_options.is_empty() {
target.options = Some(target_options.clone());
}
source_editor_ctx.on_form_change.emit((block_id, BlockInstance::Target(Rc::new(target))));
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! {
<div class="tp__target-form tp__config-view-page">
<div class="tp__input-form__toolbar tp__form-page__toolbar">
<TextButton class="primary" name="apply_input"
icon="Accept"
title={ translate.t("LABEL.OK")}
onclick={handle_apply_target}></TextButton>
<TextButton class="secondary" name="cancel_input"
icon="Cancel"
title={ translate.t("LABEL.CANCEL")}
onclick={handle_cancel}></TextButton>
</div>
{ render_edit_mode() }
</div>
}
}
+4 -5
View File
@@ -1,4 +1,3 @@
use std::rc::Rc;
use crate::app::components::{DropDownOption, Search, TextButton, UserlistContext, UserlistPage};
use yew::prelude::*;
use yew_i18n::use_translation;
@@ -9,9 +8,9 @@ pub fn UserlistList() -> Html {
let translate = use_translation();
let userlist_ctx = use_context::<UserlistContext>().expect("Userlist context not found");
let search_fields = use_memo((), |_| vec![
Rc::new(DropDownOption::new("username", html! { translate.t("LABEL.NAME") }, true)),
Rc::new(DropDownOption::new("playlist", html! { translate.t("LABEL.PLAYLIST") }, false)),
Rc::new(DropDownOption::new("server", html! { translate.t("LABEL.SERVER") }, false)),
DropDownOption::new("username", html! { translate.t("LABEL.NAME") }, true),
DropDownOption::new("playlist", html! { translate.t("LABEL.PLAYLIST") }, false),
DropDownOption::new("server", html! { translate.t("LABEL.SERVER") }, false),
]);
@@ -44,7 +43,7 @@ pub fn UserlistList() -> Html {
<div class="tp__userlist-list__header tp__list-list__header">
<h1>{ translate.t("LABEL.USERS")}</h1>
<div class="tp__userlist-list__header-toolbar">
<Search min_length={1} onsearch={handle_search} options={(*search_fields).clone()}/>
<Search min_length={1} onsearch={handle_search} options={search_fields.clone()}/>
<TextButton class="primary" name="new_userlist"
icon="PersonAdd"
title={ translate.t("LABEL.NEW_USER")}
@@ -1,16 +1,16 @@
use std::rc::Rc;
use crate::app::components::config::HasFormData;
use crate::app::components::select::Select;
use crate::app::components::userlist::proxy_type_input::ProxyTypeInput;
use crate::app::components::{DropDownOption, DropDownSelection, TextButton, UserStatus};
use crate::app::TargetUser;
use crate::hooks::use_service_context;
use crate::{config_field_child, config_field_custom, edit_field_bool, edit_field_date, edit_field_number, edit_field_text, edit_field_text_option, generate_form_reducer};
use chrono::{Duration, Utc};
use yew::prelude::*;
use yew_i18n::use_translation;
use shared::model::{ApiProxyServerInfoDto, ConfigTargetDto, ProxyType, ProxyUserCredentialsDto, ProxyUserStatus};
use shared::utils::generate_random_string;
use crate::app::TargetUser;
use crate::{config_field_child, config_field_custom, edit_field_bool, edit_field_date, edit_field_number, edit_field_text, edit_field_text_option, generate_form_reducer};
use crate::app::components::select::Select;
use crate::app::components::{DropDownOption, TextButton, UserStatus};
use crate::app::components::config::HasFormData;
use crate::app::components::userlist::proxy_type_input::ProxyTypeInput;
use crate::hooks::use_service_context;
use std::rc::Rc;
use yew::prelude::*;
use yew_i18n::use_translation;
const DEFAULT_MAX_CONNECTIONS: u32 = 1;
const DEFAULT_EXPIRATION_DAYS: i64 = 365;
@@ -59,29 +59,29 @@ pub fn ProxyUserCredentialsForm(props: &ProxyUserCredentialsFormProps) -> Html {
ProxyUserStatus::Banned,
ProxyUserStatus::Trial,
ProxyUserStatus::Disabled,
ProxyUserStatus::Pending].iter().map(|s| Rc::new(DropDownOption {
ProxyUserStatus::Pending].iter().map(|s| DropDownOption {
id: s.to_string(),
label: html! { <UserStatus status={Some(*s)} /> },
selected: status.as_ref() == Some(s),
})).collect::<Vec<Rc<DropDownOption>>>(),
}).collect::<Vec<DropDownOption>>(),
);
let targets = use_memo((props.targets.clone(), (*selected_target).clone()),
|(targets, selected)|
targets.iter().map(|t| Rc::new(DropDownOption {
id: t.name.clone(),
label: html! { t.name.clone() },
selected: selected.as_ref().is_some_and(|ut: &String| ut == &t.name),
})).collect::<Vec<Rc<DropDownOption>>>(),
targets.iter().map(|t| DropDownOption {
id: t.name.clone(),
label: html! { t.name.clone() },
selected: selected.as_ref().is_some_and(|ut: &String| ut == &t.name),
}).collect::<Vec<DropDownOption>>(),
);
let server = use_memo((props.server.clone(), form_state.data().server.clone()),
|(server_list, user_server)|
server_list.iter().map(|s| Rc::new(DropDownOption {
id: s.name.to_string(),
label: html! { s.name.clone() },
selected: user_server.as_ref() == Some(&s.name),
})).collect::<Vec<Rc<DropDownOption>>>(),
server_list.iter().map(|s| DropDownOption {
id: s.name.to_string(),
label: html! { s.name.clone() },
selected: user_server.as_ref() == Some(&s.name),
}).collect::<Vec<DropDownOption>>(),
);
{
@@ -154,7 +154,7 @@ pub fn ProxyUserCredentialsForm(props: &ProxyUserCredentialsFormProps) -> Html {
};
}
} else {
nothing_to_save();
nothing_to_save();
}
} else {
services.toastr.error(translate_clone.t("MESSAGES.SAVE.USER.TARGET_NOT_SELECTED"));
@@ -175,27 +175,29 @@ pub fn ProxyUserCredentialsForm(props: &ProxyUserCredentialsFormProps) -> Html {
} else {*/ config_field_child!(translate.t("LABEL.PLAYLIST"), {
html! { <Select name="target"
multi_select={false}
onselect={Callback::from(move |(_name, selections):(String, Vec<Rc<DropDownOption>>)| {
if let Some(target_option) = selections.first() {
set_selected_target.set(Some(target_option.id.clone()));
} else {
set_selected_target.set(None);
}
on_select={Callback::from(move |(_name, selections):(String, DropDownSelection)| {
let target = match selections {
DropDownSelection::Empty => None,
DropDownSelection::Single(option) => Some(option),
DropDownSelection::Multi(options) => options.first().cloned(),
};
set_selected_target.set(target);
})}
options={(*targets).clone()}
options={targets.clone()}
/>
}})} //}
{ config_field_child!(translate.t("LABEL.STATUS"), {
html! { <Select name="status"
multi_select={false}
onselect={Callback::from(move |(_name, selections):(String, Vec<Rc<DropDownOption>>)| {
if let Some(status_option) = selections.first() {
if let Ok(status) = status_option.id.parse::<ProxyUserStatus>() {
instance_status.dispatch(UserFormAction::Status(Some(status)));
}
}
on_select={Callback::from(move |(_name, selections):(String, DropDownSelection)| {
let status = match selections {
DropDownSelection::Empty => None,
DropDownSelection::Single(option) => option.parse::<ProxyUserStatus>().ok(),
DropDownSelection::Multi(options) => options.first().as_ref().and_then(|f| f.parse::<ProxyUserStatus>().ok())
};
instance_status.dispatch(UserFormAction::Status(status));
})}
options={(*proxy_user_status).clone()}
options={proxy_user_status.clone()}
/>
}})}
{ if *update {
@@ -218,14 +220,15 @@ pub fn ProxyUserCredentialsForm(props: &ProxyUserCredentialsFormProps) -> Html {
html! {
<Select name="server"
multi_select={false}
onselect={Callback::from(move |(_name, selections):(String, Vec<Rc<DropDownOption>>)| {
if let Some(server_option) = selections.first() {
instance_server.dispatch(UserFormAction::Server(Some(server_option.id.clone())));
} else {
instance_server.dispatch(UserFormAction::Server(None));
};
on_select={Callback::from(move |(_name, selections):(String, DropDownSelection)| {
let server = match selections {
DropDownSelection::Empty => None,
DropDownSelection::Single(option) => Some(option.clone()),
DropDownSelection::Multi(options) => options.first().cloned(),
};
instance_server.dispatch(UserFormAction::Server(server));
})}
options={(*server_list).clone()}
options={server_list.clone()}
/>
}})}
{ edit_field_number!(form_state, translate.t("LABEL.MAX_CONNECTIONS"), max_connections, UserFormAction::MaxConnections) }
+21 -2
View File
@@ -17,6 +17,15 @@ pub struct ConfigTargetOptions {
pub force_redirect: Option<ClusterFlags>,
}
impl ConfigTargetOptions {
pub fn is_empty(&self) -> bool {
!self.ignore_logo
&& !self.share_live_streams
&& !self.remove_duplicates
&& (self.force_redirect.is_none() || self.force_redirect.is_some_and(|f| f.has_full_flags() || f.is_empty()))
}
}
#[allow(clippy::struct_excessive_bools)]
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
@@ -78,7 +87,7 @@ impl XtreamTargetOutputDto {
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct M3uTargetOutputDto {
#[serde(skip_serializing_if = "Option::is_none")]
@@ -102,7 +111,7 @@ impl M3uTargetOutputDto {
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct StrmTargetOutputDto {
pub directory: String,
@@ -134,6 +143,16 @@ pub struct HdHomeRunTargetOutputDto {
pub use_output: Option<TargetType>,
}
impl Default for HdHomeRunTargetOutputDto {
fn default() -> Self {
Self {
device: String::new(),
username: String::new(),
use_output: Some(TargetType::M3u),
}
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
#[serde(deny_unknown_fields, tag = "type", rename_all = "lowercase")]
pub enum TargetOutputDto {
+29
View File
@@ -1,4 +1,5 @@
use std::fmt::Display;
use std::str::FromStr;
use enum_iterator::Sequence;
#[derive(Debug, Copy, Clone, serde::Serialize, serde::Deserialize, Sequence, PartialEq, Eq, Default)]
@@ -38,4 +39,32 @@ impl Display for ProcessingOrder {
Self::Mrf => Self::MRF,
})
}
}
impl FromStr for ProcessingOrder {
type Err = &'static str;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let normalized = s.trim().to_lowercase();
match normalized.as_str() {
// Short codes
"frm" => Ok(Self::Frm),
"fmr" => Ok(Self::Fmr),
"rfm" => Ok(Self::Rfm),
"rmf" => Ok(Self::Rmf),
"mfr" => Ok(Self::Mfr),
"mrf" => Ok(Self::Mrf),
// Accept the exact Display text (using your constants)
x if x == Self::FRM => Ok(Self::Frm),
x if x == Self::FMR => Ok(Self::Fmr),
x if x == Self::RFM => Ok(Self::Rfm),
x if x == Self::RMF => Ok(Self::Rmf),
x if x == Self::MFR => Ok(Self::Mfr),
x if x == Self::MRF => Ok(Self::Mrf),
_ => Err("invalid processing order"),
}
}
}