From dcba8072165d724bdaac62d5e61aa02d32dd5ddd Mon Sep 17 00:00:00 2001 From: euzu Date: Fri, 31 Oct 2025 09:43:34 +0100 Subject: [PATCH 1/4] source editor --- frontend/public/assets/i18n/en.json | 3 +- frontend/scss/_default.scss | 6 + frontend/scss/app/_component.scss | 1 + .../scss/app/components/_source_editor.scss | 37 +++++ frontend/src/app/components/config/mod.rs | 2 + .../app/components/config/source_editor.rs | 140 ++++++++++++++++++ .../app/components/dashboard/streams_table.rs | 2 +- frontend/src/app/components/home.rs | 5 +- frontend/src/app/components/sidebar.rs | 2 + frontend/src/model/view_type.rs | 4 + frontend/src/utils/mod.rs | 6 +- 11 files changed, 202 insertions(+), 6 deletions(-) create mode 100644 frontend/scss/app/components/_source_editor.scss create mode 100644 frontend/src/app/components/config/source_editor.rs diff --git a/frontend/public/assets/i18n/en.json b/frontend/public/assets/i18n/en.json index 0f6569639..f0862cba6 100644 --- a/frontend/public/assets/i18n/en.json +++ b/frontend/public/assets/i18n/en.json @@ -300,7 +300,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" diff --git a/frontend/scss/_default.scss b/frontend/scss/_default.scss index b8a850043..3807c9cf1 100644 --- a/frontend/scss/_default.scss +++ b/frontend/scss/_default.scss @@ -92,6 +92,12 @@ li { align-items: center; } +.tp__full-height { + box-sizing: border-box; + height: 100%; + align-items: center; +} + .disabled { pointer-events: none; color: var(--hint-color); diff --git a/frontend/scss/app/_component.scss b/frontend/scss/app/_component.scss index e60e68382..a165d3497 100644 --- a/frontend/scss/app/_component.scss +++ b/frontend/scss/app/_component.scss @@ -75,3 +75,4 @@ @forward "components/websocket_status"; @forward "components/playlist/epg_view"; @forward "components/floating_background"; +@forward "components/source_editor"; diff --git a/frontend/scss/app/components/_source_editor.scss b/frontend/scss/app/components/_source_editor.scss new file mode 100644 index 000000000..028d34f6e --- /dev/null +++ b/frontend/scss/app/components/_source_editor.scss @@ -0,0 +1,37 @@ +.tp__source-editor { + display: flex; + flex-flow: row nowrap; + box-sizing: border-box; + width: 100%; + height: 100%; + + &__sidebar { + display: flex; + flex-flow: column; + box-sizing: border-box; + height: 100%; + gap: var(--gap-small); + } + + &__canvas { + box-sizing: border-box; + width: 100%; + height: 100%; + } + + &__brick { + border: 1px solid var(--border-color); + cursor: grab; + padding: var(--padding-small) var(--padding-default); + } + + &__block { + width:100px; + height:50px; + border:1px solid var(--border-color); + display:flex; + align-items:center; + justify-content:center; + background-color:var(--card-background-color); + } +} \ No newline at end of file diff --git a/frontend/src/app/components/config/mod.rs b/frontend/src/app/components/config/mod.rs index 0c5778e08..9242630ee 100644 --- a/frontend/src/app/components/config/mod.rs +++ b/frontend/src/app/components/config/mod.rs @@ -16,6 +16,7 @@ mod config_page; mod hdhomerun_device_view; mod config_update; mod log_config_view; +mod source_editor; pub use config_view::*; pub use main_config_view::*; @@ -29,4 +30,5 @@ pub use proxy_config_view::*; pub use ipcheck_config_view::*; pub use video_config_view::*; pub use log_config_view::*; +pub use source_editor::*; pub use macros::*; \ No newline at end of file diff --git a/frontend/src/app/components/config/source_editor.rs b/frontend/src/app/components/config/source_editor.rs new file mode 100644 index 000000000..85e0253c4 --- /dev/null +++ b/frontend/src/app/components/config/source_editor.rs @@ -0,0 +1,140 @@ +use log::error; +use yew::prelude::*; +use serde::{Serialize, Deserialize}; +use wasm_bindgen::JsCast; +use web_sys::{HtmlElement}; +// ----------------- Data Models ----------------- + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub enum BlockType { + Task, + Decision, + ApiCall, +} + +#[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, +} + +#[function_component] +pub fn SourceEditor() -> Html { + let blocks = use_state(|| Vec::::new()); + let connections = use_state(|| Vec::::new()); + let next_id = use_state(|| 1usize); + + // ----------------- Drag Start ----------------- + let on_drag_start = { + Callback::from(|e: DragEvent| { + if let Some(target) = e.target_dyn_into::() { + let block_type = target.get_attribute("data-block-type").unwrap_or_default(); + e.data_transfer().unwrap().set_data("text/plain", &block_type).unwrap(); + } + }) + }; + + // ----------------- Drop on Canvas ----------------- + let on_drop = { + let blocks = blocks.clone(); + let next_id = next_id.clone(); + Callback::from(move |e: DragEvent| { + e.prevent_default(); + if let Some(current_target) = e.current_target() { + if let Ok(canvas) = current_target.dyn_into::() { + if let Some(data) = e.data_transfer().unwrap().get_data("text/plain").ok() { + error!("data transfer {data}"); + let x = e.client_x() as f32 - canvas.offset_left() as f32; + let y = e.client_y() as f32 - canvas.offset_top() as f32; + let block_type = match data.as_str() { + "Task" => BlockType::Task, + "Decision" => BlockType::Decision, + "ApiCall" => BlockType::ApiCall, + _ => BlockType::Task, + }; + + let mut current_blocks = (*blocks).clone(); + current_blocks.push(Block { + id: *next_id, + block_type, + position: (x, y), + }); + blocks.set(current_blocks); + next_id.set(*next_id + 1); + } + } + } + }) + }; + + // ----------------- Drag Over Canvas ----------------- + let on_drag_over = Callback::from(|e: DragEvent| e.prevent_default()); + + html! { +
+ // Sidebar with block palette +
+
+ { "Task" } +
+
+ { "Decision" } +
+
+ { "ApiCall" } +
+
+ + // Canvas area +
+ // Draw connections (lines) + + { for (*connections).iter().map(|c| { + let from_block = (*blocks).iter().find(|b| b.id == c.from).unwrap(); + let to_block = (*blocks).iter().find(|b| b.id == c.to).unwrap(); + html! { + + } + }) } + + + // Render blocks + { for (*blocks).iter().map(|b| { + let style = format!( "position:absolute; left:{}px; top:{}px;", b.position.0, b.position.1); + html! { +
{ format!("{:?}", b.block_type) }
+ } + }) } +
+
+ } +} diff --git a/frontend/src/app/components/dashboard/streams_table.rs b/frontend/src/app/components/dashboard/streams_table.rs index deaf9b9ce..a6b425f5e 100644 --- a/frontend/src/app/components/dashboard/streams_table.rs +++ b/frontend/src/app/components/dashboard/streams_table.rs @@ -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! { }, "USER_AGENT" => html! { dto.user_agent.as_str() }, diff --git a/frontend/src/app/components/home.rs b/frontend/src/app/components/home.rs index 45e6b19c2..560479260 100644 --- a/frontend/src/app/components/home.rs +++ b/frontend/src/app/components/home.rs @@ -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, SourceEditor}; use crate::app::components::loading_indicator::{BusyIndicator}; use crate::provider::DialogProvider; use crate::services::{ToastCloseMode, ToastOptions}; @@ -181,6 +181,9 @@ pub fn Home() -> Html { + + + diff --git a/frontend/src/app/components/sidebar.rs b/frontend/src/app/components/sidebar.rs index 7354f3f27..482b14812 100644 --- a/frontend/src/app/components/sidebar.rs +++ b/frontend/src/app/components/sidebar.rs @@ -139,6 +139,7 @@ pub fn Sidebar(props: &SidebarProps) -> Html { + @@ -159,6 +160,7 @@ pub fn Sidebar(props: &SidebarProps) -> Html { + diff --git a/frontend/src/model/view_type.rs b/frontend/src/model/view_type.rs index e61044d87..cbd5397a1 100644 --- a/frontend/src/model/view_type.rs +++ b/frontend/src/model/view_type.rs @@ -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, diff --git a/frontend/src/utils/mod.rs b/frontend/src/utils/mod.rs index a7d40e5cd..7c0e55ef9 100644 --- a/frontend/src/utils/mod.rs +++ b/frontend/src/utils/mod.rs @@ -33,12 +33,12 @@ where .unwrap(); } -pub fn t_safe(i18n: &YewI18n, key: &str) -> String { +pub fn t_safe(i18n: &YewI18n, key: &str) -> Option { let result = i18n.t(key); if result.starts_with("Unable to find the key") { - key.to_string() + None } else { - result + Some(result) } } \ No newline at end of file From 19eb962a2d62baad1b607d52e3e9d8eebe720ec4 Mon Sep 17 00:00:00 2001 From: euzu Date: Fri, 31 Oct 2025 15:58:49 +0100 Subject: [PATCH 2/4] Source editor --- backend/src/repository/xtream_repository.rs | 2 - frontend/public/assets/i18n/en.json | 9 + frontend/public/assets/icons.json | 4 + frontend/scss/_default.scss | 7 + frontend/scss/_theme.scss | 54 +++ frontend/scss/app/_app.scss | 1 + .../scss/app/components/_source_editor.scss | 249 ++++++++++- frontend/src/app/components/config/mod.rs | 2 - .../app/components/config/source_editor.rs | 140 ------ frontend/src/app/components/home.rs | 4 +- frontend/src/app/components/mod.rs | 4 +- .../components/source_editor/editor_model.rs | 77 ++++ .../src/app/components/source_editor/mod.rs | 7 + .../src/app/components/source_editor/rules.rs | 82 ++++ .../app/components/source_editor/sidebar.rs | 74 ++++ .../components/source_editor/source_editor.rs | 399 ++++++++++++++++++ 16 files changed, 957 insertions(+), 158 deletions(-) delete mode 100644 frontend/src/app/components/config/source_editor.rs create mode 100644 frontend/src/app/components/source_editor/editor_model.rs create mode 100644 frontend/src/app/components/source_editor/mod.rs create mode 100644 frontend/src/app/components/source_editor/rules.rs create mode 100644 frontend/src/app/components/source_editor/sidebar.rs create mode 100644 frontend/src/app/components/source_editor/source_editor.rs diff --git a/backend/src/repository/xtream_repository.rs b/backend/src/repository/xtream_repository.rs index 9fafbdd8c..549c3e7e2 100644 --- a/backend/src/repository/xtream_repository.rs +++ b/backend/src/repository/xtream_repository.rs @@ -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) } _ => { diff --git a/frontend/public/assets/i18n/en.json b/frontend/public/assets/i18n/en.json index f0862cba6..b65302fce 100644 --- a/frontend/public/assets/i18n/en.json +++ b/frontend/public/assets/i18n/en.json @@ -407,6 +407,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", diff --git a/frontend/public/assets/icons.json b/frontend/public/assets/icons.json index 83f7d9da7..0e5a97b52 100644 --- a/frontend/public/assets/icons.json +++ b/frontend/public/assets/icons.json @@ -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" } ] \ No newline at end of file diff --git a/frontend/scss/_default.scss b/frontend/scss/_default.scss index 3807c9cf1..5aa45f8d8 100644 --- a/frontend/scss/_default.scss +++ b/frontend/scss/_default.scss @@ -101,4 +101,11 @@ li { .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 */ } \ No newline at end of file diff --git a/frontend/scss/_theme.scss b/frontend/scss/_theme.scss index a3a68eb07..96b1ae8fb 100644 --- a/frontend/scss/_theme.scss +++ b/frontend/scss/_theme.scss @@ -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; + } diff --git a/frontend/scss/app/_app.scss b/frontend/scss/app/_app.scss index 46f6b9a16..563711d6b 100644 --- a/frontend/scss/app/_app.scss +++ b/frontend/scss/app/_app.scss @@ -23,6 +23,7 @@ padding: 0 var(--padding-larger); align-items: center; overflow: hidden; + height: 100vh; } } } \ No newline at end of file diff --git a/frontend/scss/app/components/_source_editor.scss b/frontend/scss/app/components/_source_editor.scss index 028d34f6e..905c780e2 100644 --- a/frontend/scss/app/components/_source_editor.scss +++ b/frontend/scss/app/components/_source_editor.scss @@ -4,6 +4,7 @@ box-sizing: border-box; width: 100%; height: 100%; + gap: var(--gap-default); &__sidebar { display: flex; @@ -11,27 +12,253 @@ 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%; } - &__brick { - border: 1px solid var(--border-color); - cursor: grab; - padding: var(--padding-small) var(--padding-default); + &__connections { + position: absolute; + box-sizing: border-box; + width:100%; + height:100%; + border: 1px solid var(--card-border-color); } &__block { - width:100px; - height:50px; - border:1px solid var(--border-color); - display:flex; - align-items:center; - justify-content:center; - background-color:var(--card-background-color); + 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; } \ No newline at end of file diff --git a/frontend/src/app/components/config/mod.rs b/frontend/src/app/components/config/mod.rs index 9242630ee..0c5778e08 100644 --- a/frontend/src/app/components/config/mod.rs +++ b/frontend/src/app/components/config/mod.rs @@ -16,7 +16,6 @@ mod config_page; mod hdhomerun_device_view; mod config_update; mod log_config_view; -mod source_editor; pub use config_view::*; pub use main_config_view::*; @@ -30,5 +29,4 @@ pub use proxy_config_view::*; pub use ipcheck_config_view::*; pub use video_config_view::*; pub use log_config_view::*; -pub use source_editor::*; pub use macros::*; \ No newline at end of file diff --git a/frontend/src/app/components/config/source_editor.rs b/frontend/src/app/components/config/source_editor.rs deleted file mode 100644 index 85e0253c4..000000000 --- a/frontend/src/app/components/config/source_editor.rs +++ /dev/null @@ -1,140 +0,0 @@ -use log::error; -use yew::prelude::*; -use serde::{Serialize, Deserialize}; -use wasm_bindgen::JsCast; -use web_sys::{HtmlElement}; -// ----------------- Data Models ----------------- - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -pub enum BlockType { - Task, - Decision, - ApiCall, -} - -#[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, -} - -#[function_component] -pub fn SourceEditor() -> Html { - let blocks = use_state(|| Vec::::new()); - let connections = use_state(|| Vec::::new()); - let next_id = use_state(|| 1usize); - - // ----------------- Drag Start ----------------- - let on_drag_start = { - Callback::from(|e: DragEvent| { - if let Some(target) = e.target_dyn_into::() { - let block_type = target.get_attribute("data-block-type").unwrap_or_default(); - e.data_transfer().unwrap().set_data("text/plain", &block_type).unwrap(); - } - }) - }; - - // ----------------- Drop on Canvas ----------------- - let on_drop = { - let blocks = blocks.clone(); - let next_id = next_id.clone(); - Callback::from(move |e: DragEvent| { - e.prevent_default(); - if let Some(current_target) = e.current_target() { - if let Ok(canvas) = current_target.dyn_into::() { - if let Some(data) = e.data_transfer().unwrap().get_data("text/plain").ok() { - error!("data transfer {data}"); - let x = e.client_x() as f32 - canvas.offset_left() as f32; - let y = e.client_y() as f32 - canvas.offset_top() as f32; - let block_type = match data.as_str() { - "Task" => BlockType::Task, - "Decision" => BlockType::Decision, - "ApiCall" => BlockType::ApiCall, - _ => BlockType::Task, - }; - - let mut current_blocks = (*blocks).clone(); - current_blocks.push(Block { - id: *next_id, - block_type, - position: (x, y), - }); - blocks.set(current_blocks); - next_id.set(*next_id + 1); - } - } - } - }) - }; - - // ----------------- Drag Over Canvas ----------------- - let on_drag_over = Callback::from(|e: DragEvent| e.prevent_default()); - - html! { -
- // Sidebar with block palette -
-
- { "Task" } -
-
- { "Decision" } -
-
- { "ApiCall" } -
-
- - // Canvas area -
- // Draw connections (lines) - - { for (*connections).iter().map(|c| { - let from_block = (*blocks).iter().find(|b| b.id == c.from).unwrap(); - let to_block = (*blocks).iter().find(|b| b.id == c.to).unwrap(); - html! { - - } - }) } - - - // Render blocks - { for (*blocks).iter().map(|b| { - let style = format!( "position:absolute; left:{}px; top:{}px;", b.position.0, b.position.1); - html! { -
{ format!("{:?}", b.block_type) }
- } - }) } -
-
- } -} diff --git a/frontend/src/app/components/home.rs b/frontend/src/app/components/home.rs index 560479260..1f67d8847 100644 --- a/frontend/src/app/components/home.rs +++ b/frontend/src/app/components/home.rs @@ -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, SourceEditor}; +use crate::app::components::config::{ConfigView}; use crate::app::components::loading_indicator::{BusyIndicator}; use crate::provider::DialogProvider; use crate::services::{ToastCloseMode, ToastOptions}; diff --git a/frontend/src/app/components/mod.rs b/frontend/src/app/components/mod.rs index e215fffff..b3feb966c 100644 --- a/frontend/src/app/components/mod.rs +++ b/frontend/src/app/components/mod.rs @@ -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::*; diff --git a/frontend/src/app/components/source_editor/editor_model.rs b/frontend/src/app/components/source_editor/editor_model.rs new file mode 100644 index 000000000..5d8045ee9 --- /dev/null +++ b/frontend/src/app/components/source_editor/editor_model.rs @@ -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 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, +} diff --git a/frontend/src/app/components/source_editor/mod.rs b/frontend/src/app/components/source_editor/mod.rs new file mode 100644 index 000000000..04b5944ac --- /dev/null +++ b/frontend/src/app/components/source_editor/mod.rs @@ -0,0 +1,7 @@ +mod source_editor; +mod editor_model; +mod sidebar; +mod rules; + +pub use source_editor::*; +pub(crate) use editor_model::*; \ No newline at end of file diff --git a/frontend/src/app/components/source_editor/rules.rs b/frontend/src/app/components/source_editor/rules.rs new file mode 100644 index 000000000..594dfe66d --- /dev/null +++ b/frontend/src/app/components/source_editor/rules.rs @@ -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 +} \ No newline at end of file diff --git a/frontend/src/app/components/source_editor/sidebar.rs b/frontend/src/app/components/source_editor/sidebar.rs new file mode 100644 index 000000000..3984bc3db --- /dev/null +++ b/frontend/src/app/components/source_editor/sidebar.rs @@ -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, label: String) -> Html { + html! { +
+ { label } +
+ } +} + +#[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, +} + + +#[function_component] +pub fn SourceEditorSidebar(props: &SourceEditorSidebarProps) -> Html { + let translate = use_translation(); + + html! { + // Sidebar +
+
+ // Delete mode toggle button + +
+
+ +
+ {for BLOCK_TYPES_INPUT.iter().map(|t| create_brick(&t, props.on_drag_start.clone(), translate.t(&format!("SOURCE_EDITOR.BRICK_{t}"))))} +
+
+ +
+ {for BLOCK_TYPES_TARGET.iter().map(|t| create_brick(&t, props.on_drag_start.clone(), translate.t(&format!("SOURCE_EDITOR.BRICK_{t}"))))} +
+
+ +
+ {for BLOCK_TYPES_OUTPUT.iter().map(|t| create_brick(&t, props.on_drag_start.clone(), translate.t(&format!("SOURCE_EDITOR.BRICK_{t}"))))} +
+
+
+
+ } +} diff --git a/frontend/src/app/components/source_editor/source_editor.rs b/frontend/src/app/components/source_editor/source_editor.rs new file mode 100644 index 000000000..3423c53b4 --- /dev/null +++ b/frontend/src/app/components/source_editor/source_editor.rs @@ -0,0 +1,399 @@ +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::::new()); + let connections = use_state(|| Vec::::new()); + let next_id = use_state(|| 1usize); + + // Dragging state + let dragging_block = use_state(|| None as Option); + let 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); + + // Delete mode toggle + let delete_mode = use_state(|| false); + + // ----------------- Drag Start from Sidebar ----------------- + let on_drag_start = Callback::from(|e: DragEvent| { + if let Some(target) = e.target_dyn_into::() { + let block_type = target.get_attribute("data-block-type").unwrap_or_default(); + e.data_transfer().unwrap().set_data("text/plain", &block_type).unwrap(); + } + }); + + // ----------------- Drop on Canvas ----------------- + let on_drop = { + let blocks = blocks.clone(); + let next_id = next_id.clone(); + let canvas_ref = canvas_ref.clone(); + Callback::from(move |e: DragEvent| { + e.prevent_default(); + if let Some(canvas) = canvas_ref.cast::() { + if let Some(data) = e.data_transfer().unwrap().get_data("text/plain").ok() { + let rect = canvas.get_bounding_client_rect(); + let x = e.client_x() as f32 - rect.left() as f32; + let y = e.client_y() as f32 - rect.top() as f32; + + let block_type = BlockType::from(data.as_str()); + + let mut current_blocks = (*blocks).clone(); + current_blocks.push(Block { + id: *next_id, + block_type, + position: (x, 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::() { + 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::() { + 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! { +
+ + + // Canvas +
+ + // SVG for 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! { + + + { 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! { + + } + } else { + html!{} + } } + + }) + }) } + + // Pending line (straight, yellow) + { if let Some(((x1, y1), (x2, y2))) = *pending_line { + html! { + + } + } else { html!{} } } + + + // 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! { +
+ // Block handle (drag) +
+
+ + // Delete button for block + { + html_if!(delete_mode, { +
+ }) + } + + {html_if!(is_target || is_output, { + // Left port +
+ })} + // Block label +
+ { translate.t(&format!("SOURCE_EDITOR.BRICK_{}", b.block_type)) } +
+ + {html_if!(is_target || is_input, { + // Right port +
+ })} +
+ } + }) } +
+
+ } +} From f0b0701f3cfd281e5f11185a989ec089f3787b9d Mon Sep 17 00:00:00 2001 From: euzu Date: Fri, 31 Oct 2025 16:08:09 +0100 Subject: [PATCH 3/4] Source editor added sidebar drag offset --- .../components/source_editor/source_editor.rs | 30 ++++++++++++++----- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/frontend/src/app/components/source_editor/source_editor.rs b/frontend/src/app/components/source_editor/source_editor.rs index 3423c53b4..1f61ec062 100644 --- a/frontend/src/app/components/source_editor/source_editor.rs +++ b/frontend/src/app/components/source_editor/source_editor.rs @@ -22,6 +22,7 @@ pub fn SourceEditor() -> Html { // Dragging state let dragging_block = use_state(|| None as Option); 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))>); @@ -31,25 +32,35 @@ pub fn SourceEditor() -> Html { let delete_mode = use_state(|| false); // ----------------- Drag Start from Sidebar ----------------- - let on_drag_start = Callback::from(|e: DragEvent| { - if let Some(target) = e.target_dyn_into::() { - let block_type = target.get_attribute("data-block-type").unwrap_or_default(); - e.data_transfer().unwrap().set_data("text/plain", &block_type).unwrap(); + 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::() { + 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::() { if let Some(data) = e.data_transfer().unwrap().get_data("text/plain").ok() { let rect = canvas.get_bounding_client_rect(); - let x = e.client_x() as f32 - rect.left() as f32; - let y = e.client_y() as f32 - rect.top() as f32; + 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()); @@ -57,7 +68,7 @@ pub fn SourceEditor() -> Html { current_blocks.push(Block { id: *next_id, block_type, - position: (x, y), + position: (mouse_x - offset_x, mouse_y - offset_y), }); blocks.set(current_blocks); next_id.set(*next_id + 1); @@ -239,6 +250,8 @@ pub fn SourceEditor() -> Html { // ----------------- Render ----------------- html! { + <> + {"WORK IN PROGRESS - NOT FINALIZED !!!"}
Html { }) }
+ } } From e4c93c2c71fa0c97e1ca7dd66fe0a8e3e14805e1 Mon Sep 17 00:00:00 2001 From: euzu Date: Fri, 31 Oct 2025 16:17:29 +0100 Subject: [PATCH 4/4] clippy fixes --- frontend/src/app/components/source_editor/sidebar.rs | 6 +++--- frontend/src/app/components/source_editor/source_editor.rs | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/frontend/src/app/components/source_editor/sidebar.rs b/frontend/src/app/components/source_editor/sidebar.rs index 3984bc3db..d132e4177 100644 --- a/frontend/src/app/components/source_editor/sidebar.rs +++ b/frontend/src/app/components/source_editor/sidebar.rs @@ -55,17 +55,17 @@ pub fn SourceEditorSidebar(props: &SourceEditorSidebarProps) -> Html {
- {for BLOCK_TYPES_INPUT.iter().map(|t| create_brick(&t, props.on_drag_start.clone(), translate.t(&format!("SOURCE_EDITOR.BRICK_{t}"))))} + {for BLOCK_TYPES_INPUT.iter().map(|t| create_brick(t, props.on_drag_start.clone(), translate.t(&format!("SOURCE_EDITOR.BRICK_{t}"))))}
- {for BLOCK_TYPES_TARGET.iter().map(|t| create_brick(&t, props.on_drag_start.clone(), translate.t(&format!("SOURCE_EDITOR.BRICK_{t}"))))} + {for BLOCK_TYPES_TARGET.iter().map(|t| create_brick(t, props.on_drag_start.clone(), translate.t(&format!("SOURCE_EDITOR.BRICK_{t}"))))}
- {for BLOCK_TYPES_OUTPUT.iter().map(|t| create_brick(&t, props.on_drag_start.clone(), translate.t(&format!("SOURCE_EDITOR.BRICK_{t}"))))} + {for BLOCK_TYPES_OUTPUT.iter().map(|t| create_brick(t, props.on_drag_start.clone(), translate.t(&format!("SOURCE_EDITOR.BRICK_{t}"))))}
diff --git a/frontend/src/app/components/source_editor/source_editor.rs b/frontend/src/app/components/source_editor/source_editor.rs index 1f61ec062..0a52bc0f2 100644 --- a/frontend/src/app/components/source_editor/source_editor.rs +++ b/frontend/src/app/components/source_editor/source_editor.rs @@ -15,8 +15,8 @@ const PORT_INVALID: u32 = 2; pub fn SourceEditor() -> Html { let translate = use_translation(); let canvas_ref = use_node_ref(); - let blocks = use_state(|| Vec::::new()); - let connections = use_state(|| Vec::::new()); + let blocks = use_state(Vec::::new); + let connections = use_state(Vec::::new); let next_id = use_state(|| 1usize); // Dragging state @@ -56,7 +56,7 @@ pub fn SourceEditor() -> Html { Callback::from(move |e: DragEvent| { e.prevent_default(); if let Some(canvas) = canvas_ref.cast::() { - if let Some(data) = e.data_transfer().unwrap().get_data("text/plain").ok() { + 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;