Merge branch 'euzu:develop' into feature/remove_x_header_from_upstream_request

This commit is contained in:
knylbyte
2025-10-31 17:05:29 +01:00
committed by GitHub
19 changed files with 1019 additions and 10 deletions
@@ -386,7 +386,6 @@ async fn xtream_get_item_for_stream_id_from_memory(
if let Some(pl_item) = item {
let mut xc_item = pl_item.clone();
xc_item.provider_id = mapping.provider_id;
xc_item.item_type = PlaylistItemType::Catchup;
Ok(xc_item)
} else {
Err(str_to_io_error(&format!("Failed to read xtream item for id {virtual_id}")))
@@ -452,7 +451,6 @@ pub async fn xtream_get_item_for_stream_id(
let cluster = try_cluster!(xtream_cluster, mapping.item_type, virtual_id)?;
let mut item = xtream_read_item_for_stream_id(app_config, mapping.parent_virtual_id, &storage_path, cluster)?;
item.provider_id = mapping.provider_id;
item.item_type = PlaylistItemType::Catchup;
Ok(item)
}
_ => {
+11 -1
View File
@@ -304,7 +304,8 @@
"PROPRIETARY_DISCOVERY": "Proprietary Discovery",
"COUNTRY": "Country",
"GEOIP": "Geo-IP",
"UPDATE_GEOIP_DB": "Update Geo-IP db"
"UPDATE_GEOIP_DB": "Update Geo-IP db",
"SOURCE_EDITOR": "Sources"
},
"TITLE": {
"USER_BOUQUET_EDITOR": "User group editor"
@@ -413,6 +414,15 @@
"WELCOME": "Welcome to tuliprox setup. This wizard will guide you through the initial setup process."
}
},
"SOURCE_EDITOR": {
"BRICK_InputXtream": "Xtream",
"BRICK_InputM3u": "M3u",
"BRICK_Target": "Target",
"BRICK_OutputM3u": "M3u",
"BRICK_OutputXtream": "Xtream",
"BRICK_OutputHdhomerun": "HDHR",
"BRICK_OutputStrm": "STRM"
},
"COUNTRY" : {
"Loopback": "Loopback",
"LAN":"LAN",
+4
View File
@@ -511,5 +511,9 @@
{
"keys": ["Disconnect"],
"path": "M3.42 2.36L2.01 3.78 4.1 5.87C2.79 7.57 2 9.69 2 12c0 3.7 2.01 6.92 4.99 8.65l1-1.73C5.61 17.53 4 14.96 4 12c0-1.76.57-3.38 1.53-4.69l1.43 1.44C6.36 9.68 6 10.8 6 12c0 2.22 1.21 4.15 3 5.19l1-1.74c-1.19-.7-2-1.97-2-3.45 0-.65.17-1.25.44-1.79l1.58 1.58L10 12c0 1.1.9 2 2 2l.21-.02 7.52 7.52 1.41-1.41L3.42 2.36zm14.29 11.46c.18-.57.29-1.19.29-1.82 0-3.31-2.69-6-6-6-.63 0-1.25.11-1.82.29l1.72 1.72c.03 0 .06-.01.1-.01 2.21 0 4 1.79 4 4 0 .04-.01.07-.01.11l1.72 1.71zM12 4c4.42 0 8 3.58 8 8 0 1.2-.29 2.32-.77 3.35l1.49 1.49C21.53 15.4 22 13.76 22 12c0-5.52-4.48-10-10-10-1.76 0-3.4.48-4.84 1.28l1.48 1.48C9.66 4.28 10.8 4 12 4z"
},
{
"keys": ["Nodes", "SourceEditor"],
"path": "M15 16v1.26l-6-3v-3.17L11.7 8H16V2h-6v4.9L7.3 10H3v6h5l7 3.5V22h6v-6z"
}
]
+13
View File
@@ -92,7 +92,20 @@ li {
align-items: center;
}
.tp__full-height {
box-sizing: border-box;
height: 100%;
align-items: center;
}
.disabled {
pointer-events: none;
color: var(--hint-color);
}
.no-select {
user-select: none; /* Standard */
-webkit-user-select: none; /* Chrome, Safari, Opera */
-moz-user-select: none; /* Firefox */
-ms-user-select: none; /* IE/Edge */
}
+54
View File
@@ -319,6 +319,60 @@
--floating-particle-gradient-color-1: rgba(255,255,255,0.3);
--floating-particle-gradient-color-2: rgba(255,255,255,0);
/* INPUT BLOCKS — cool blues */
--brick-input-border-color: #4a90e2cc;
--brick-input-background-color: #4a90e255;
--brick-input-shadow-color: #4a90e280;
/* TARGET BLOCKS — balanced green/teal */
--brick-target-border-color: #2aa198cc; /* Solarized teal */
--brick-target-background-color: #2aa19855;
--brick-target-shadow-color: #2aa19880;
/* GENERIC OUTPUT — warm amber */
--brick-output-border-color: #f39c12cc;
--brick-output-background-color: #f39c1255;
--brick-output-shadow-color: #f39c1280;
/* INDIVIDUAL OUTPUT TYPES — distinct but cohesive */
--brick-output-xtream-border-color: #0099ffcc; /* vivid sky blue */
--brick-output-xtream-background-color: #0099ff44;
--brick-output-xtream-shadow-color: #0099ff80;
--brick-output-m3u-border-color: #8bc34acc; /* light green */
--brick-output-m3u-background-color: #8bc34a44;
--brick-output-m3u-shadow-color: #8bc34a80;
--brick-output-hdhomerun-border-color: #9b59b6cc; /* purple */
--brick-output-hdhomerun-background-color: #9b59b655;
--brick-output-hdhomerun-shadow-color: #9b59b680;
--brick-output-strm-border-color: #e74c3ccc; /* strong red */
--brick-output-strm-background-color: #e74c3c55;
--brick-output-strm-shadow-color: #e74c3c80;
//--brick-input-border-color: #569cd6aa;
//--brick-input-background-color: #569cd688;
//--brick-target-border-color: #65a181aa;
//--brick-target-background-color: #65a18188;
//--brick-output-border-color: #FF9D23aa;
//--brick-output-background-color: #FF9D2388;
//
//--brick-output-xtream-border-color: #06b6d4aa;
//--brick-output-xtream-background-color: #06b6d488;
//--brick-output-m3u-border-color: #10b981aa;
//--brick-output-m3u-background-color: #10b98188;
//--brick-output-hdhomerun-border-color: #6366f1aa;
//--brick-output-hdhomerun-background-color: #6366f188;
//--brick-output-strm-border-color: #f43f5eaa;
//--brick-output-strm-background-color: #f43f5e88;
--brick-port-background-color: #486786;
--brick-port-valid-background-color: #2ecc71;
--brick-port-invalid-background-color: #e74c3c;
--source-editor-delete-color: #FF0000FF;
}
+1
View File
@@ -23,6 +23,7 @@
padding: 0 var(--padding-larger);
align-items: center;
overflow: hidden;
height: 100vh;
}
}
}
+1
View File
@@ -75,3 +75,4 @@
@forward "components/websocket_status";
@forward "components/playlist/epg_view";
@forward "components/floating_background";
@forward "components/source_editor";
@@ -0,0 +1,264 @@
.tp__source-editor {
display: flex;
flex-flow: row nowrap;
box-sizing: border-box;
width: 100%;
height: 100%;
gap: var(--gap-default);
&__sidebar {
display: flex;
flex-flow: column;
box-sizing: border-box;
height: 100%;
gap: var(--gap-small);
&-actions {
display: flex;
flex-flow: row wrap;
box-sizing: border-box;
gap: var(--gap-small);
border-bottom: 2px solid var(--border-color);
border-radius: 0;
&-active {
color: var(--text-button-active-color);
background-color: var(--text-button-active-background-color);
}
}
&-bricks {
display: flex;
flex-flow: column;
box-sizing: border-box;
gap: var(--gap-large);
&-group {
display: flex;
flex-flow: column;
box-sizing: border-box;
gap: var(--gap-small);
}
.tp__source-editor__brick {
border: 1px solid var(--border-color);
cursor: grab;
padding: var(--padding-small) var(--padding-default);
border-radius: var(--border-radius);
text-align: center;
}
}
}
&__canvas {
position: relative;
box-sizing: border-box;
width: 100%;
height: 100%;
}
&__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);
}
}
}
.graph-paper-advanced {
background-image:
linear-gradient(#e0e0e060 1px, transparent 1px),
linear-gradient(90deg, #e0e0e060 1px, transparent 1px),
linear-gradient(#c0c0c044 2px, transparent 2px), /* horizontal thick */
linear-gradient(90deg, #c0c0c044 2px, transparent 2px); /* vertical thick */
background-size: 20px 20px, 20px 20px, 100px 100px, 100px 100px;
}
.clickable {
cursor: pointer;
}
@@ -158,7 +158,7 @@ pub fn StreamsTable(props: &StreamsTableProps) -> Html {
"CHANNEL" => html! {dto.channel.title.as_str()},
"GROUP" => html! {dto.channel.group.as_str()},
"CLIENT_IP" => html! { strip_port(&dto.client_ip)},
"COUNTRY" => html! { dto.country.as_ref().map_or_else(String::new, |c| t_safe(&translate, &format!("COUNTRY.{c}"))) },
"COUNTRY" => html! { dto.country.as_ref().map_or_else(String::new, |c| t_safe(&translate, &format!("COUNTRY.{c}")).unwrap_or_else(||c.to_string())) },
"PROVIDER" => html! {dto.provider.as_str()},
"SHARED" => html! { <ToggleSwitch value={dto.channel.shared} readonly={true} /> },
"USER_AGENT" => html! { dto.user_agent.as_str() },
+5 -2
View File
@@ -1,4 +1,4 @@
use crate::app::components::{AppIcon, DashboardView, EpgView, IconButton, InputRow, Panel, PlaylistEditorView, PlaylistExplorerView, PlaylistUpdateView, Sidebar, StatsView, StreamsView, ToastrView, UserlistView, WebsocketStatus};
use crate::app::components::{AppIcon, DashboardView, EpgView, IconButton, InputRow, Panel, PlaylistEditorView, PlaylistExplorerView, PlaylistUpdateView, Sidebar, SourceEditor, StatsView, StreamsView, ToastrView, UserlistView, WebsocketStatus};
use crate::app::context::{ConfigContext, PlaylistContext, StatusContext};
use crate::hooks::{use_server_status, use_service_context};
use crate::model::{EventMessage, ViewType};
@@ -8,7 +8,7 @@ use std::rc::Rc;
use yew::prelude::*;
use yew::suspense::use_future;
use yew_i18n::use_translation;
use crate::app::components::config::ConfigView;
use crate::app::components::config::{ConfigView};
use crate::app::components::loading_indicator::{BusyIndicator};
use crate::provider::DialogProvider;
use crate::services::{ToastCloseMode, ToastOptions};
@@ -181,6 +181,9 @@ pub fn Home() -> Html {
<Panel class="tp__full-width" value={ViewType::Config.to_string()} active={view_visible.to_string()}>
<ConfigView/>
</Panel>
<Panel class="tp__full-width tp__full-height" value={ViewType::SourceEditor.to_string()} active={view_visible.to_string()}>
<SourceEditor/>
</Panel>
<Panel class="tp__full-width" value={ViewType::PlaylistUpdate.to_string()} active={view_visible.to_string()}>
<PlaylistUpdateView/>
</Panel>
+3 -1
View File
@@ -47,7 +47,7 @@ mod theme;
mod websocket_status;
mod floating_background;
mod source_editor;
// pub use self::input::*;
// pub use self::menu_item::*;
// pub use self::popup_menu::*;
@@ -89,5 +89,7 @@ pub use self::cell_value::*;
pub use self::key_value_editor::*;
pub use self::loading_screen::*;
pub use self::websocket_status::*;
pub use self::source_editor::*;
//pub use self::number_input::*;
//pub use self::date_input::*;
+2
View File
@@ -139,6 +139,7 @@ pub fn Sidebar(props: &SidebarProps) -> Html {
<CollapsePanel title={translate.t("LABEL.SETTINGS")}>
<MenuItem class={if *active_menu == ViewType::Config { "active" } else {""}} icon="Config" name={ViewType::Config.to_string()} label={translate.t("LABEL.CONFIG")} onclick={&handle_menu_click}></MenuItem>
<MenuItem class={if *active_menu == ViewType::Users { "active" } else {""}} icon="UserOutline" name={ViewType::Users.to_string()} label={translate.t("LABEL.USER")} onclick={&handle_menu_click}></MenuItem>
<MenuItem class={if *active_menu == ViewType::SourceEditor { "active" } else {""}} icon="SourceEditor" name={ViewType::SourceEditor.to_string()} label={translate.t("LABEL.SOURCE_EDITOR")} onclick={&handle_menu_click}></MenuItem>
</CollapsePanel>
<CollapsePanel title={translate.t("LABEL.PLAYLIST")}>
<MenuItem class={if *active_menu == ViewType::PlaylistUpdate { "active" } else {""}} icon="Refresh" name={ViewType::PlaylistUpdate.to_string()} label={translate.t("LABEL.UPDATE")} onclick={&handle_menu_click}></MenuItem>
@@ -159,6 +160,7 @@ pub fn Sidebar(props: &SidebarProps) -> Html {
<span class="tp__app-sidebar__content-space"></span>
<IconButton class={if *active_menu == ViewType::Config { "active" } else {""}} icon="Config" name={ViewType::Config.to_string()} onclick={&handle_menu_click}></IconButton>
<IconButton class={if *active_menu == ViewType::Users { "active" } else {""}} icon="UserOutline" name={ViewType::Users.to_string()} onclick={&handle_menu_click}></IconButton>
<IconButton class={if *active_menu == ViewType::SourceEditor { "active" } else {""}} icon="SourceEditor" name={ViewType::SourceEditor.to_string()} onclick={&handle_menu_click}></IconButton>
<span class="tp__app-sidebar__content-space"></span>
<IconButton class={if *active_menu == ViewType::PlaylistUpdate { "active" } else {""}} icon="Refresh" name={ViewType::PlaylistUpdate.to_string()} onclick={&handle_menu_click}></IconButton>
<IconButton class={if *active_menu == ViewType::PlaylistEditor { "active" } else {""}} icon="PlayArrowOutline" name={ViewType::PlaylistEditor.to_string()} onclick={&handle_menu_click}></IconButton>
@@ -0,0 +1,77 @@
use std::fmt;
use serde::{Deserialize, Serialize};
// ----------------- Data Models -----------------
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub enum BlockType {
InputXtream,
InputM3u,
Target,
OutputM3u,
OutputXtream,
OutputHdhomerun,
OutputStrm,
}
// Define string constants
impl BlockType {
pub const INPUT_XTREAM: &'static str = "InputXtream";
pub const INPUT_M3U: &'static str = "InputM3u";
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_STRM: &'static str = "OutputStrm";
}
// Convert from String to BlockType
impl From<&str> for BlockType {
fn from(s: &str) -> Self {
match s {
BlockType::INPUT_XTREAM => BlockType::InputXtream,
BlockType::INPUT_M3U => BlockType::InputM3u,
BlockType::TARGET => BlockType::Target,
BlockType::OUTPUT_M3U => BlockType::OutputM3u,
BlockType::OUTPUT_XTREAM => BlockType::OutputXtream,
BlockType::OUTPUT_HDHOMERUN => BlockType::OutputHdhomerun,
BlockType::OUTPUT_STRM => BlockType::OutputStrm,
_ => BlockType::Target, // fallback
}
}
}
impl From<String> for BlockType {
fn from(s: String) -> Self {
BlockType::from(s.as_str())
}
}
// Display trait using constants
impl fmt::Display for BlockType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match self {
BlockType::InputXtream => Self::INPUT_XTREAM,
BlockType::InputM3u => Self::INPUT_M3U,
BlockType::Target => Self::TARGET,
BlockType::OutputM3u => Self::OUTPUT_M3U,
BlockType::OutputXtream => Self::OUTPUT_XTREAM,
BlockType::OutputHdhomerun => Self::OUTPUT_HDHOMERUN,
BlockType::OutputStrm => Self::OUTPUT_STRM,
};
write!(f, "{}", s)
}
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct Block {
pub id: usize,
pub block_type: BlockType,
pub position: (f32, f32),
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct Connection {
pub from: usize,
pub to: usize,
}
@@ -0,0 +1,7 @@
mod source_editor;
mod editor_model;
mod sidebar;
mod rules;
pub use source_editor::*;
pub(crate) use editor_model::*;
@@ -0,0 +1,82 @@
use crate::app::components::{Block, BlockType, Connection};
/// Determines whether two blocks can be connected based on explicit editor rules.
/// Allowed: Input → Target, Target → Output.
/// Target can have multiple Inputs.
/// Output can only have one Target.
/// Target can connect to:
/// - 1x OutputM3u
/// - 1x OutputXtream
/// - 1x OutputHdhomerun
/// - up to 4x OutputStrm
pub fn can_connect(from_block: &Block, to_block: &Block, connections: &[Connection], blocks: &[Block]) -> bool {
// 1️⃣ Prevent self-connection
if from_block.id == to_block.id {
return false;
}
// 2️⃣ Identify block categories
let is_input = matches!(from_block.block_type, BlockType::InputM3u | BlockType::InputXtream);
let is_target = matches!(from_block.block_type, BlockType::Target);
let to_is_target = matches!(to_block.block_type, BlockType::Target);
let to_is_output = matches!(
to_block.block_type,
BlockType::OutputM3u
| BlockType::OutputXtream
| BlockType::OutputHdhomerun
| BlockType::OutputStrm
);
// 3️⃣ Only allow Input → Target OR Target → Output
let valid_direction = (is_input && to_is_target) || (is_target && to_is_output);
if !valid_direction {
return false;
}
// 4️⃣ Prevent duplicate connection
if connections.iter().any(|c| c.from == from_block.id && c.to == to_block.id) {
return false;
}
// 5️⃣ Output can have only one incoming connection
if to_is_output {
let has_input_already = connections.iter().any(|c| c.to == to_block.id);
if has_input_already {
return false;
}
}
// 6️⃣ Per-target output type limits
if is_target && to_is_output {
let from_id = from_block.id;
// Count how many connections this Target already has to each output type
let mut count_m3u = 0;
let mut count_xtream = 0;
let mut count_hdhomerun = 0;
let mut count_strm = 0;
for conn in connections.iter().filter(|c| c.from == from_id) {
if let Some(out_block) = blocks.iter().find(|b| b.id == conn.to) {
match out_block.block_type {
BlockType::OutputM3u => count_m3u += 1,
BlockType::OutputXtream => count_xtream += 1,
BlockType::OutputHdhomerun => count_hdhomerun += 1,
BlockType::OutputStrm => count_strm += 1,
_ => {}
}
}
}
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::OutputStrm if count_strm >= 4 => return false,
_ => {}
}
}
// ✅ Passed all checks
true
}
@@ -0,0 +1,74 @@
use crate::app::components::{BlockType, CollapsePanel, IconButton};
use yew::prelude::*;
use yew_i18n::use_translation;
pub const BLOCK_TYPES_INPUT: [BlockType; 2] = [
BlockType::InputXtream,
BlockType::InputM3u
];
pub const BLOCK_TYPES_TARGET: [BlockType; 1] = [
BlockType::Target,
];
pub const BLOCK_TYPES_OUTPUT: [BlockType; 4] = [
BlockType::OutputM3u,
BlockType::OutputXtream,
BlockType::OutputHdhomerun,
BlockType::OutputStrm];
fn create_brick(t: &BlockType, on_drag_start: Callback<DragEvent>, label: String) -> Html {
html! {
<div class={format!("tp__source-editor__brick tp__source-editor__brick-{t}")}
draggable={"true"}
data-block-type={t.to_string()}
ondragstart={on_drag_start}>
{ label }
</div>
}
}
#[derive(Properties, PartialEq)]
pub struct SourceEditorSidebarProps {
#[prop_or_default]
pub delete_mode: bool,
#[prop_or_default]
pub on_toggle_delete: Callback<(String, MouseEvent)>,
#[prop_or_default]
pub on_drag_start: Callback<DragEvent>,
}
#[function_component]
pub fn SourceEditorSidebar(props: &SourceEditorSidebarProps) -> Html {
let translate = use_translation();
html! {
// Sidebar
<div class="tp__source-editor__sidebar">
<div class="tp__source-editor__sidebar-actions">
// Delete mode toggle button
<IconButton class={if props.delete_mode {"tp__source-editor__sidebar-actions-active"} else {""} } name="toggle_delete" icon="Delete" onclick={props.on_toggle_delete.clone()} />
</div>
<div class="tp__source-editor__sidebar-bricks">
<CollapsePanel title={translate.t("LABEL.INPUTS")}>
<div class="tp__source-editor__sidebar-bricks-group">
{for BLOCK_TYPES_INPUT.iter().map(|t| create_brick(t, props.on_drag_start.clone(), translate.t(&format!("SOURCE_EDITOR.BRICK_{t}"))))}
</div>
</CollapsePanel>
<CollapsePanel title={translate.t("LABEL.TARGETS")}>
<div class="tp__source-editor__sidebar-bricks-group">
{for BLOCK_TYPES_TARGET.iter().map(|t| create_brick(t, props.on_drag_start.clone(), translate.t(&format!("SOURCE_EDITOR.BRICK_{t}"))))}
</div>
</CollapsePanel>
<CollapsePanel title={translate.t("LABEL.OUTPUT")}>
<div class="tp__source-editor__sidebar-bricks-group">
{for BLOCK_TYPES_OUTPUT.iter().map(|t| create_brick(t, props.on_drag_start.clone(), translate.t(&format!("SOURCE_EDITOR.BRICK_{t}"))))}
</div>
</CollapsePanel>
</div>
</div>
}
}
@@ -0,0 +1,413 @@
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;
const PORT_INACTIVE: u32 = 0;
const PORT_VALID: u32 = 1;
const PORT_INVALID: u32 = 2;
// ----------------- 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 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>);
// Delete mode toggle
let delete_mode = use_state(|| false);
// ----------------- Drag Start from Sidebar -----------------
let on_drag_start = {
let sidebar_drag_offset = sidebar_drag_offset.clone();
Callback::from(move |e: DragEvent| {
if let Some(target) = e.target_dyn_into::<HtmlElement>() {
let block_type = target.get_attribute("data-block-type").unwrap_or_default();
e.data_transfer().unwrap().set_data("text/plain", &block_type).unwrap();
// Store mouse offset inside the element
let rect = target.get_bounding_client_rect();
let offset_x = e.client_x() as f32 - rect.left() as f32;
let offset_y = e.client_y() as f32 - rect.top() as f32;
sidebar_drag_offset.set((offset_x, offset_y));
}
}
)};
// ----------------- Drop on Canvas -----------------
let on_drop = {
let blocks = blocks.clone();
let next_id = next_id.clone();
let canvas_ref = canvas_ref.clone();
let sidebar_drag_offset = sidebar_drag_offset.clone();
Callback::from(move |e: DragEvent| {
e.prevent_default();
if let Some(canvas) = canvas_ref.cast::<HtmlElement>() {
if let Ok(data) = e.data_transfer().unwrap().get_data("text/plain") {
let rect = canvas.get_bounding_client_rect();
let mouse_x = e.client_x() as f32 - rect.left() as f32;
let mouse_y = e.client_y() as f32 - rect.top() as f32;
let (offset_x, offset_y) = *sidebar_drag_offset;
let block_type = BlockType::from(data.as_str());
let mut current_blocks = (*blocks).clone();
current_blocks.push(Block {
id: *next_id,
block_type,
position: (mouse_x - offset_x, mouse_y - offset_y),
});
blocks.set(current_blocks);
next_id.set(*next_id + 1);
}
}
})
};
let on_drag_over = Callback::from(|e: DragEvent| e.prevent_default());
// ----------------- Connection logic -----------------
let on_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;
pending_line.set(Some(((x, y), (x, y))));
}
})
};
let on_connection_drop = {
let pending_connection = pending_connection.clone();
let connections = connections.clone();
let pending_line = pending_line.clone();
let blocks = blocks.clone();
Callback::from(move |to_id: usize| {
if let Some(from_id) = *pending_connection {
if from_id != to_id {
let current_blocks = (*blocks).clone();
if let (Some(from_block), Some(to_block)) = (
current_blocks.iter().find(|b| b.id == from_id),
current_blocks.iter().find(|b| b.id == to_id),
) {
// ✅ Check connection rules before adding
if can_connect(from_block, to_block, &connections, &blocks) {
let mut current_connections = (*connections).clone();
current_connections.push(Connection { from: from_id, to: to_id });
connections.set(current_connections);
} else {
// ❌ (Optional) visual feedback
web_sys::console::log_1(
&format!(
"Connection from {:?} to {:?} not allowed",
from_block.block_type, to_block.block_type
)
.into(),
);
}
}
}
}
// 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;
if let Some(((from_x, from_y), _)) = *pending_line {
pending_line.set(Some(((from_x, from_y), (x, y))));
}
}
pending_connection.set(None);
pending_line.set(None);
})
};
// ----------------- Drag block logic -----------------
let on_block_mouse_down = {
let dragging_block = dragging_block.clone();
let drag_offset = drag_offset.clone();
let canvas_ref = canvas_ref.clone();
let blocks = blocks.clone();
Callback::from(move |(block_id, e): (usize, MouseEvent)| {
e.prevent_default();
if let Some(canvas) = canvas_ref.cast::<HtmlElement>() {
let rect = canvas.get_bounding_client_rect();
let mouse_x = e.client_x() as f32 - rect.left() as f32;
let mouse_y = e.client_y() as f32 - rect.top() as f32;
if let Some(block) = (*blocks).iter().find(|b| b.id == block_id) {
drag_offset.set((mouse_x - block.position.0, mouse_y - block.position.1));
dragging_block.set(Some(block_id));
}
}
})
};
// ----------------- Mouse move for both pending line and block drag -----------------
let on_canvas_mouse_move = {
let pending_line = pending_line.clone();
let dragging_block = dragging_block.clone();
let drag_offset = drag_offset.clone();
let blocks = blocks.clone();
let canvas_ref = canvas_ref.clone();
Callback::from(move |e: MouseEvent| {
if let Some(canvas) = canvas_ref.cast::<HtmlElement>() {
let rect = canvas.get_bounding_client_rect();
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)
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 dx = mouse_x - port_x;
let dy = mouse_y - port_y;
let dist = (dx * dx + dy * dy).sqrt();
if dist < 10.0 { // Snap distance threshold
snapped = (port_x, port_y);
}
}
pending_line.set(Some(((from_x, from_y), snapped)));
}
// 2️⃣ Update dragging block
if let Some(block_id) = *dragging_block {
let (offset_x, offset_y) = *drag_offset;
let mut current_blocks = (*blocks).clone();
if let Some(block) = current_blocks.iter_mut().find(|b| b.id == block_id) {
block.position = (mouse_x - offset_x, mouse_y - offset_y);
}
blocks.set(current_blocks);
}
}
})
};
let on_canvas_mouse_up = {
let dragging_block = dragging_block.clone();
Callback::from(move |_e: MouseEvent| {
dragging_block.set(None);
})
};
let on_canvas_right_click = {
let pending_connection = pending_connection.clone();
let pending_line = pending_line.clone();
Callback::from(move |e: MouseEvent| {
e.prevent_default(); // prevent default browser context menu
pending_connection.set(None);
pending_line.set(None);
})
};
// ----------------- Delete handlers -----------------
let toggle_delete_mode = {
let delete_mode = delete_mode.clone();
Callback::from(move |_| delete_mode.set(!*delete_mode))
};
let delete_block = {
let blocks = blocks.clone();
let connections = connections.clone();
Callback::from(move |block_id: usize| {
let mut current_blocks = (*blocks).clone();
current_blocks.retain(|b| b.id != block_id);
blocks.set(current_blocks);
let mut current_connections = (*connections).clone();
current_connections.retain(|c| c.from != block_id && c.to != block_id);
connections.set(current_connections);
})
};
let delete_connection = {
let connections = connections.clone();
Callback::from(move |(from, to): (usize, usize)| {
let mut current_connections = (*connections).clone();
current_connections.retain(|c| !(c.from == from && c.to == to));
connections.set(current_connections);
})
};
// ----------------- Render -----------------
html! {
<>
<span>{"WORK IN PROGRESS - NOT FINALIZED !!!"}</span>
<div class="tp__source-editor">
<SourceEditorSidebar
delete_mode={*delete_mode}
on_drag_start={on_drag_start.clone()}
on_toggle_delete={toggle_delete_mode.clone()}
/>
// Canvas
<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}>
// 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 to_x = to_block.position.0;
let to_y = to_block.position.1 + 25.0;
let dx = to_x - from_x;
let ctrl = dx * 0.5;
let d = format!(
"M {} {} C {} {}, {} {}, {} {}",
from_x, from_y,
from_x + ctrl, from_y,
to_x - ctrl, to_y,
to_x, to_y
);
Some(html! {
<g>
<path d={d} stroke="white" fill="transparent" stroke-width="2"/>
{ 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();
html! {
<circle cx={mid_x.to_string()} cy={mid_y.to_string()} r="6" fill="var(--source-editor-delete-color)" class="clickable"
onclick={
let from = c.from;
let to = c.to;
Callback::from(move |_| on_delete_connection.emit((from, to)))
}
/>
}
} else {
html!{}
} }
</g>
})
}) }
// Pending line (straight, yellow)
{ if let Some(((x1, y1), (x2, y2))) = *pending_line {
html! {
<line
x1={x1.to_string()} y1={y1.to_string()}
x2={x2.to_string()} y2={y2.to_string()}
stroke="yellow"
stroke-width="2"
stroke-dasharray="4 2" />
}
} else { 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",
_ => "",
};
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>
}
}) }
</div>
</div>
</>
}
}
+4
View File
@@ -11,6 +11,7 @@ const PLAYLIST_UPDATE: &str = "playlist_update";
const PLAYLIST_EDITOR: &str = "playlist_editor";
const PLAYLIST_EXPLORER: &str = "playlist_explorer";
const PLAYLIST_EPG: &str = "playlist_epg";
const SOURCE_EDITOR: &str = "source_editor";
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
@@ -20,6 +21,7 @@ pub enum ViewType {
Streams,
Users,
Config,
SourceEditor,
PlaylistUpdate,
PlaylistEditor,
PlaylistExplorer,
@@ -36,6 +38,7 @@ impl FromStr for ViewType {
STREAMS => Ok(ViewType::Streams),
USERS => Ok(ViewType::Users),
CONFIG => Ok(ViewType::Config),
SOURCE_EDITOR => Ok(ViewType::SourceEditor),
PLAYLIST_UPDATE => Ok(ViewType::PlaylistUpdate),
PLAYLIST_EDITOR => Ok(ViewType::PlaylistEditor),
PLAYLIST_EXPLORER => Ok(ViewType::PlaylistExplorer),
@@ -53,6 +56,7 @@ impl fmt::Display for ViewType {
ViewType::Streams => STREAMS,
ViewType::Users => USERS,
ViewType::Config => CONFIG,
ViewType::SourceEditor => SOURCE_EDITOR,
ViewType::PlaylistUpdate => PLAYLIST_UPDATE,
ViewType::PlaylistEditor => PLAYLIST_EDITOR,
ViewType::PlaylistExplorer => PLAYLIST_EXPLORER,
+3 -3
View File
@@ -33,12 +33,12 @@ where
.unwrap();
}
pub fn t_safe(i18n: &YewI18n, key: &str) -> String {
pub fn t_safe(i18n: &YewI18n, key: &str) -> Option<String> {
let result = i18n.t(key);
if result.starts_with("Unable to find the key") {
key.to_string()
None
} else {
result
Some(result)
}
}