diff --git a/webui/Cargo.toml b/webui/Cargo.toml index 70659bc49..da08fc805 100644 --- a/webui/Cargo.toml +++ b/webui/Cargo.toml @@ -28,14 +28,7 @@ wasm-bindgen-futures = "0" [dependencies.web-sys] version = "0.3" -features = [ - "Document", - "Element", - "Node", - "Window", - "HtmlCollection", - "HtmlInputElement", -] +features = ["Document", "Element", "Node", "Window", "HtmlCollection", "HtmlInputElement", "DomRect"] [dev-dependencies] js-sys = "0.3" diff --git a/webui/public/assets/i18n/en.json b/webui/public/assets/i18n/en.json index 6d9875c7c..f0b9a1ed0 100644 --- a/webui/public/assets/i18n/en.json +++ b/webui/public/assets/i18n/en.json @@ -92,8 +92,8 @@ "XTREAM_CODES": "xtream codes", "XC": "xc", "M3U": "m3u", - "XTREAM": "Xtream", - "HDHOMERUN": "HdHomerun", + "XTREAM": "xtream", + "HDHOMERUN": "hdHomerun", "STRM": "strm", "PLAYLIST_BROWSER": "Playlist Browser", "RATE_LIMIT": "Rate Limit", @@ -144,7 +144,9 @@ "DELAY": "Delay", "FILENAME": "Filename", "INCLUDE_TYPE_IN_URL": "Include type in url", - "MASK_REDIRECT_URL": "Mask redirect url" + "MASK_REDIRECT_URL": "Mask redirect url", + "EDIT": "Edit", + "DELETE": "Delete" }, "TABLE": { "EMPTY": "", diff --git a/webui/public/assets/icons.json b/webui/public/assets/icons.json index ee6cc5a9b..1aa0210db 100644 --- a/webui/public/assets/icons.json +++ b/webui/public/assets/icons.json @@ -346,5 +346,9 @@ { "keys": ["Ellipsis", "Show"], "path": "M 4.5000013,9 C 3.1249997,9 2,10.125024 2,11.500015 c 0,1.374992 1.1249997,2.499969 2.5000013,2.499969 1.3750012,0 2.500001,-1.124977 2.500001,-2.499969 C 7.0000023,10.125024 5.8750025,9 4.5000013,9 M 19.499999,9 c -1.375001,0 -2.500001,1.125024 -2.500001,2.500015 0,1.374992 1.125,2.499969 2.500001,2.499969 C 20.875,13.999984 22,12.875007 22,11.500015 22,10.125024 20.875,9 19.499999,9 M 12,9 c -1.375001,0 -2.5000012,1.125024 -2.5000012,2.500015 0,1.374992 1.1250002,2.499969 2.5000012,2.499969 1.375001,0 2.500001,-1.124977 2.500001,-2.499969 C 14.500001,10.125024 13.375001,9 12,9" + }, + { + "keys": ["Hamburger", "Menu", "Popup", "Bars"], + "path": "M 2,18.333002 H 21.9995 V 16.110835 H 2 Z M 2,12.777585 H 21.9995 V 10.555417 H 2 Z M 2,5 V 7.2221668 H 21.9995 V 5 Z" } ] \ No newline at end of file diff --git a/webui/scss/_default.scss b/webui/scss/_default.scss index 4ff356e61..4f015564f 100644 --- a/webui/scss/_default.scss +++ b/webui/scss/_default.scss @@ -63,8 +63,7 @@ dialog { padding: 0 !important; } -.tp__rotated_text { - writing-mode: vertical-rl; // or vertical-lr - text-orientation: upright; - font-size: 1rem; -} +.tp__delete_action { + color: var(--attention-color) !important; + fill: var(--attention-color) !important; +} \ No newline at end of file diff --git a/webui/scss/_theme.scss b/webui/scss/_theme.scss index 8e28ac50d..caee6a3c2 100644 --- a/webui/scss/_theme.scss +++ b/webui/scss/_theme.scss @@ -74,6 +74,18 @@ --tag-inactive-background-color: #3a3a3a; + --popup-background-color: #18181b; + --popup-shadow: 0 3px 8px #06070a; + --popup-border-color: #2f2f31; + --popup-color: #fff; + //--popup-hover-background-color: #5599f0; + //--popup-hover-color: #fff; + + --attention-color: #f67070; + --warn-color: #b88e22; + + + --hint-color: #888888; --label-color: #73d6fe; @@ -96,15 +108,6 @@ --toolbar-button-hover-color: #ffffff; --toolbar-button-hover-background-color: #004db4; - --popup-background-color: #282A36; - --popup-shadow-color: #06070a; - --popup-border-color: #646464; - --popup-color: #fff; - --popup-hover-background-color: #5599f0; - --popup-hover-color: #fff; - - --attention-color: red; - --warn-color: orange; --ok-color: #21e821; --card-background-color-darker: #1c2026; diff --git a/webui/scss/app/_component.scss b/webui/scss/app/_component.scss index 81bf045cf..c5ece47bd 100644 --- a/webui/scss/app/_component.scss +++ b/webui/scss/app/_component.scss @@ -24,5 +24,7 @@ @forward "components/playlist/target/m3u_output"; @forward "components/playlist/target/strm_output"; @forward "components/playlist/target/hdhomerun_output"; +@forward "components/playlist/target/target_table"; @forward "components/tag_list"; -@forward "components/chip"; \ No newline at end of file +@forward "components/chip"; +@forward "components/popup_menu"; diff --git a/webui/scss/app/components/_collapse_panel.scss b/webui/scss/app/components/_collapse_panel.scss index cf611cf3c..3723746c9 100644 --- a/webui/scss/app/components/_collapse_panel.scss +++ b/webui/scss/app/components/_collapse_panel.scss @@ -25,4 +25,15 @@ width: 1.2rem; } } + + &__body { + max-height: 5000px; + // transition: max-height 0.5s ease; + // max-height: 5000px; + } + &.tp__collapsed { + .tp__collapse-panel__body { + max-height: 0; + } + } } \ No newline at end of file diff --git a/webui/scss/app/components/_menu_item.scss b/webui/scss/app/components/_menu_item.scss index 7bf584313..850091352 100644 --- a/webui/scss/app/components/_menu_item.scss +++ b/webui/scss/app/components/_menu_item.scss @@ -16,6 +16,7 @@ } label { pointer-events: none; + color: inherit; } &:hover { diff --git a/webui/scss/app/components/_popup_menu.scss b/webui/scss/app/components/_popup_menu.scss new file mode 100644 index 000000000..9603e6482 --- /dev/null +++ b/webui/scss/app/components/_popup_menu.scss @@ -0,0 +1,22 @@ +.tp__popup-menu { + display: flex; + flex-flow: column; + padding: 8px; + border: 1px solid var(--border-color); + border-radius: 4px; + background-color: var(--popup-background-color); + color: var(--popup-color); + box-shadow: var(--popup-shadow); + z-index: 9999; + + ul { + margin: 0; + padding: 0; + list-style: none; + background: transparent; + } + + li { + background: transparent; + } +} diff --git a/webui/scss/app/components/_table.scss b/webui/scss/app/components/_table.scss index aa331f6a2..bd28ab45a 100644 --- a/webui/scss/app/components/_table.scss +++ b/webui/scss/app/components/_table.scss @@ -7,6 +7,7 @@ border-radius: var(--border-radius); border: 1px solid var(--border-color); overflow: hidden; + fill: var(--modest-text-color); &__container { display: flex; diff --git a/webui/scss/app/components/playlist/target/_m3u_output.scss b/webui/scss/app/components/playlist/target/_m3u_output.scss index 07f12ad18..6a18ed87a 100644 --- a/webui/scss/app/components/playlist/target/_m3u_output.scss +++ b/webui/scss/app/components/playlist/target/_m3u_output.scss @@ -1,29 +1,3 @@ .tp__m3u_output { - display: flex; - flex-flow: column; - gap: var(--gap-small); - - &__section { - display: flex; - flex-flow: column; - gap: var(--gap-small); - border: 1px solid var(--border-color); - border-radius: var(--border-radius); - padding: var(--padding-micro) var(--padding-mini); - background-color: var(--table-card-background-color); - - .tp__tag_list { - flex-flow: row nowrap; - } - } - - &__label { - font-size: 1.1rem; - white-space: nowrap; - - &:after { - content: ":"; - } - } } \ No newline at end of file diff --git a/webui/scss/app/components/playlist/target/_target_output.scss b/webui/scss/app/components/playlist/target/_target_output.scss index 16e563c68..7c16fa3fa 100644 --- a/webui/scss/app/components/playlist/target/_target_output.scss +++ b/webui/scss/app/components/playlist/target/_target_output.scss @@ -2,4 +2,36 @@ display: flex; flex-flow: column; gap: var(--gap-small); +} + +.tp__target_output__output { + display: flex; + justify-content: center; + flex-flow: column; + gap: var(--gap-small); + + &__section { + display: flex; + flex-flow: column; + gap: var(--gap-small); + justify-content: center; + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + padding: var(--padding-micro) var(--padding-mini); + background-color: var(--table-card-background-color); + + .tp__tag_list { + flex-flow: row nowrap; + } + } + + &__label { + font-size: 1.1rem; + white-space: nowrap; + vertical-align: middle; + + &:after { + content: ":"; + } + } } \ No newline at end of file diff --git a/webui/scss/app/components/playlist/target/_target_table.scss b/webui/scss/app/components/playlist/target/_target_table.scss new file mode 100644 index 000000000..c3dca7a55 --- /dev/null +++ b/webui/scss/app/components/playlist/target/_target_table.scss @@ -0,0 +1,5 @@ +.tp__target-table { + .tp__icon-button { + width: 2rem; + } +} \ No newline at end of file diff --git a/webui/scss/app/components/playlist/target/_xtream_output.scss b/webui/scss/app/components/playlist/target/_xtream_output.scss index 041d790a1..7fad82bbe 100644 --- a/webui/scss/app/components/playlist/target/_xtream_output.scss +++ b/webui/scss/app/components/playlist/target/_xtream_output.scss @@ -1,25 +1,3 @@ .tp__xtream_output { - display: flex; - flex-flow: column; - gap: var(--gap-small); - - &__section { - display: flex; - flex-flow: column; - gap: var(--gap-small); - border: 1px solid var(--border-color); - border-radius: var(--border-radius); - padding: var(--padding-micro) var(--padding-mini); - background-color: var(--table-card-background-color); - - .tp__tag_list { - flex-flow: row nowrap; - } - } - - &__label { - font-size: 1.1rem; - white-space: nowrap; - } } \ No newline at end of file diff --git a/webui/src/app/components/collapse_panel.rs b/webui/src/app/components/collapse_panel.rs index e42b9d596..c8071d7f7 100644 --- a/webui/src/app/components/collapse_panel.rs +++ b/webui/src/app/components/collapse_panel.rs @@ -19,16 +19,14 @@ pub fn CollapsePanel(props: &CollapsePanelProps) -> Html { }; html! { -
+
{ props.title.clone() }
- if *expanded { -
- { for props.children.iter() } -
- } +
+ { for props.children.iter() } +
} } \ No newline at end of file diff --git a/webui/src/app/components/menu_item.rs b/webui/src/app/components/menu_item.rs index fb605cad0..81026ca8c 100644 --- a/webui/src/app/components/menu_item.rs +++ b/webui/src/app/components/menu_item.rs @@ -8,6 +8,8 @@ pub struct MenuItemProps { pub label: String, pub icon: String, #[prop_or_default] + pub style: String, + #[prop_or_default] pub onclick: Callback, } @@ -24,7 +26,7 @@ pub fn MenuItem(props: &MenuItemProps) -> Html { }; html! { -
+
diff --git a/webui/src/app/components/mod.rs b/webui/src/app/components/mod.rs index 723da9686..f2b6c8f2c 100644 --- a/webui/src/app/components/mod.rs +++ b/webui/src/app/components/mod.rs @@ -19,6 +19,7 @@ mod toggle_switch; mod reveal_content; mod tag_list; mod chip; +mod popup_menu; pub use self::svg_icon::*; // pub use self::input::*; @@ -42,3 +43,4 @@ pub use self::table::*; pub use self::toggle_switch::*; use self::chip::*; use self::tag_list::*; +use self::popup_menu::*; diff --git a/webui/src/app/components/playlist/target/hdhomerun_output.rs b/webui/src/app/components/playlist/target/hdhomerun_output.rs index 960184528..0171ecfee 100644 --- a/webui/src/app/components/playlist/target/hdhomerun_output.rs +++ b/webui/src/app/components/playlist/target/hdhomerun_output.rs @@ -10,7 +10,7 @@ pub struct HdHomeRunOutputProps { pub fn HdHomeRunOutput(props: &HdHomeRunOutputProps) -> Html { html! { -
+
} } \ No newline at end of file diff --git a/webui/src/app/components/playlist/target/m3u_output.rs b/webui/src/app/components/playlist/target/m3u_output.rs index 6dc179e81..cca577cac 100644 --- a/webui/src/app/components/playlist/target/m3u_output.rs +++ b/webui/src/app/components/playlist/target/m3u_output.rs @@ -26,12 +26,12 @@ pub fn M3uOutput(props: &M3uOutputProps) -> Html { html! { -
-
- {translator.t("LABEL.FILENAME")} - {props.output.filename.as_ref().map_or_else(|| String::new(), |f| f.to_string())} +
+
+ {translator.t("LABEL.FILENAME")} + { props.output.filename.as_ref().map(|f| html! {{ f }}) }
-
+
diff --git a/webui/src/app/components/playlist/target/strm_output.rs b/webui/src/app/components/playlist/target/strm_output.rs index 81cc4b199..87914e70b 100644 --- a/webui/src/app/components/playlist/target/strm_output.rs +++ b/webui/src/app/components/playlist/target/strm_output.rs @@ -10,7 +10,7 @@ pub struct StrmOutputProps { pub fn StrmOutput(props: &StrmOutputProps) -> Html { html! { -
+
} } \ No newline at end of file diff --git a/webui/src/app/components/playlist/target/xtream_output.rs b/webui/src/app/components/playlist/target/xtream_output.rs index fa7186b35..b08a21268 100644 --- a/webui/src/app/components/playlist/target/xtream_output.rs +++ b/webui/src/app/components/playlist/target/xtream_output.rs @@ -2,7 +2,7 @@ use std::rc::Rc; use yew::prelude::*; use yew_i18n::use_translation; use shared::model::XtreamTargetOutputDto; -use crate::app::components::chip::{convert_bool_to_chip_style, Chip, Tag}; +use crate::app::components::chip::{convert_bool_to_chip_style, Tag}; use crate::app::components::tag_list::{TagList}; #[derive(Properties, PartialEq, Clone)] @@ -39,16 +39,14 @@ pub fn XtreamOutput(props: &XtreamOutputProps) -> Html { }; html! { -
-
- {translator.t("LABEL.SKIP_DIRECT_SOURCE")} +
+
+ {translator.t("LABEL.SKIP_DIRECT_SOURCE")}
-
- {translator.t("LABEL.RESOLVE")} - - - +
+ {translator.t("LABEL.RESOLVE")} +
} diff --git a/webui/src/app/components/playlist/target_table.rs b/webui/src/app/components/playlist/target_table.rs index 9c8108a2a..f67e62f9b 100644 --- a/webui/src/app/components/playlist/target_table.rs +++ b/webui/src/app/components/playlist/target_table.rs @@ -1,40 +1,66 @@ +use crate::app::components::popup_menu::PopupMenu; +use crate::app::components::reveal_content::RevealContent; +use crate::app::components::{AppIcon, Table, TableDefinition, TargetOutput, ToggleSwitch}; +use crate::hooks::use_service_context; +use shared::model::ConfigTargetDto; use std::future; use std::rc::Rc; use log::info; use yew::prelude::*; use yew::suspense::use_future; use yew_i18n::use_translation; -use shared::model::{ConfigTargetDto}; -use crate::app::components::{PlaylistContext, Table, TableDefinition, TargetOutput, ToggleSwitch}; -use crate::app::components::reveal_content::RevealContent; -use crate::hooks::use_service_context; +use crate::app::components::menu_item::MenuItem; const HEADERS: [&str; 11] = [ -"TABLE.EMPTY", -"TABLE.ENABLED", -"TABLE.NAME", -"TABLE.OPTIONS", -"TABLE.SORT", -"TABLE.FILTER", -"TABLE.OUTPUT", -"TABLE.RENAME", -"TABLE.MAPPING", -"TABLE.PROCESSING_ORDER", -"TABLE.WATCH", + "TABLE.EMPTY", + "TABLE.ENABLED", + "TABLE.NAME", + "TABLE.OPTIONS", + "TABLE.SORT", + "TABLE.FILTER", + "TABLE.OUTPUT", + "TABLE.RENAME", + "TABLE.MAPPING", + "TABLE.PROCESSING_ORDER", + "TABLE.WATCH", ]; #[function_component] pub fn TargetTable() -> Html { let translate = use_translation(); let services = use_service_context(); - let playlist_ctx = use_context::(); + let popup_anchor_ref = use_state(|| None::); + let popup_is_open = use_state(|| false); + let selected_dto = use_state(|| None::>); + let table_definition = use_state(|| None::>>); + + let handle_popup_close = { + let set_is_open = popup_is_open.clone(); + Callback::from(move |()| { + set_is_open.set(false); + }) + }; + + let handle_popup_onclick = { + let set_selected_dto = selected_dto.clone(); + let set_anchor_ref = popup_anchor_ref.clone(); + let set_is_open = popup_is_open.clone(); + Callback::from(move |(dto, event): (Rc, MouseEvent)| { + if let Some(target) = event.target_dyn_into::() { + set_selected_dto.set(Some(dto.clone())); + set_anchor_ref.set(Some(target)); + set_is_open.set(true); + } + }) + }; let render_header_cell = { + let translator = translate.clone(); Callback::::from(move |col| { html! { { if col < HEADERS.len() { - translate.t(HEADERS[col]) + translator.t(HEADERS[col]) } else { String::new() } @@ -44,10 +70,20 @@ pub fn TargetTable() -> Html { }; let render_data_cell = { + let popup_onclick = handle_popup_onclick.clone(); Callback::<(usize, usize, Rc), Html>::from( - move |(_row, col, dto): (usize, usize, Rc)| { + move |(row, col, dto): (usize, usize, Rc)| { match col { - 0 => html! { String::new() }, + 0 => { + let popup_onclick = popup_onclick.clone(); + html! { + + } + } 1 => html! { }, 2 => html! { &dto.name.to_string() }, 3 => html! { {"Hello"} }, @@ -60,24 +96,13 @@ pub fn TargetTable() -> Html { 10 => html! { dto.watch.as_ref().map_or_else(String::new, |w| format!("{w:?}"))}, _ => html! {""}, } - }) - }; - - let table_definition = { - let render_header_cell_cb = render_header_cell.clone(); - let render_data_cell_cb = render_data_cell.clone(); - use_state(|| Rc::new(TableDefinition:: { - items: Rc::new(Vec::new()), - num_cols: 11, - render_header_cell: render_header_cell_cb, - render_data_cell: render_data_cell_cb, - })) + }) }; { // first register for config update let services_ctx = services.clone(); - let table_definition_state = table_definition.clone(); + let table_definition_state = table_definition.clone(); let render_header_cell_cb = render_header_cell.clone(); let render_data_cell_cb = render_data_cell.clone(); let _ = use_future(|| async move { @@ -87,18 +112,17 @@ pub fn TargetTable() -> Html { let render_data_cell_cb = render_data_cell_cb.clone(); if let Some(app_cfg) = cfg.clone() { let mut targets = vec![]; - info!("{:?}", &app_cfg.sources.sources); for source in &app_cfg.sources.sources { for target in &source.targets { targets.push(Rc::new(target.clone())); } } - table_definition_state.set(Rc::new(TableDefinition:: { + table_definition_state.set(Some(Rc::new(TableDefinition:: { items: Rc::new(targets), num_cols: 11, render_header_cell: render_header_cell_cb, render_data_cell: render_data_cell_cb, - })); + }))); }; future::ready(()) } @@ -113,9 +137,33 @@ pub fn TargetTable() -> Html { }); } + + let handle_menu_edit = { + let popup_is_open_state = popup_is_open.clone(); + Callback::from(move |name:String| { + info!("Menu selected {name}"); + popup_is_open_state.set(false); + }) + }; + html! {
- definition={(*table_definition).clone()} /> + { + if table_definition.is_some() { + html! { + <> + definition={(*table_definition).as_ref().unwrap().clone()} /> + + + + + + } + } else { + html! {} + } + } +
} } diff --git a/webui/src/app/components/popup_menu.rs b/webui/src/app/components/popup_menu.rs new file mode 100644 index 000000000..85c0ed042 --- /dev/null +++ b/webui/src/app/components/popup_menu.rs @@ -0,0 +1,98 @@ +use wasm_bindgen::prelude::Closure; +use wasm_bindgen::JsCast; +use web_sys::{window, HtmlElement, MouseEvent}; +use yew::prelude::*; + +#[derive(Properties, PartialEq, Clone)] +pub struct PopupMenuProps { + pub is_open: bool, + pub anchor_ref: Option, + #[prop_or_default] + pub on_close: Callback<()>, + pub children: Children, +} + +#[function_component] +pub fn PopupMenu(props: &PopupMenuProps) -> Html { + let popup_ref = use_node_ref(); + + // Calculate popup position relative to anchor and keep inside viewport + let style = { + let is_open = props.is_open; + let anchor_ref = props.anchor_ref.clone(); + use_memo((is_open, anchor_ref.clone()), move |(is_open, anchor_ref)| { + if !*is_open || anchor_ref.is_none() { + return "display: none;".to_string(); + } + let anchor_ref = anchor_ref.as_ref().unwrap().clone(); + + let rect = anchor_ref.get_bounding_client_rect(); + let window = window().expect("no global window"); + let inner_width = window.inner_width().unwrap().as_f64().unwrap(); + let inner_height = window.inner_height().unwrap().as_f64().unwrap(); + + // Basic positioning below the anchor element + let mut top = rect.bottom(); + let mut left = rect.left(); + + // Clamp popup within viewport width (assuming popup width ~200px) + if left + 200.0 > inner_width { + left = inner_width - 200.0; + } + if top + 150.0 > inner_height { + // show above if no space below (assuming popup height ~150px) + top = rect.top() - 150.0; + } + + format!( + "position: fixed; top: {}px; left: {}px;", + top, left + ) + }) + }; + + // Close popup when clicking outside of it + { + let popup_ref = popup_ref.clone(); + let on_close = props.on_close.clone(); + use_effect_with(props.is_open, move |is_open| { + let handler = if *is_open { + let handler = Closure::wrap(Box::new(move |event: MouseEvent| { + if let Some(popup) = popup_ref.cast::() { + if let Some(target) = event.target().and_then(|t| t.dyn_into::().ok()) { + if !popup.contains(Some(&target)) { + on_close.emit(()); + } + } + } + }) as Box); + + window() + .unwrap() + .add_event_listener_with_callback("mousedown", handler.as_ref().unchecked_ref()) + .unwrap(); + Some(handler) + } else { + None + }; + + // Cleanup-Funktion + move || { + if let Some(handler) = handler { + window() + .unwrap() + .remove_event_listener_with_callback("mousedown", handler.as_ref().unchecked_ref()) + .unwrap(); + } + } + }); + } + + html! { +
+
    + { for props.children.iter().map(|child| html! {
  • {child.clone()}
  • }) } +
+
+ } +} \ No newline at end of file diff --git a/webui/src/app/components/tag_list.rs b/webui/src/app/components/tag_list.rs index 483441e77..0b5a29bce 100644 --- a/webui/src/app/components/tag_list.rs +++ b/webui/src/app/components/tag_list.rs @@ -46,12 +46,12 @@ pub fn tag_list(props: &TagListProps) -> Html { }; let on_keypress = { - let new_tag = new_tag.clone(); - let tag_state = tag_state.clone(); - let on_change = on_change.clone(); + // TODO + // let new_tag = new_tag.clone(); + // let tag_state = tag_state.clone(); + // let on_change = on_change.clone(); Callback::from(move |e: KeyboardEvent| { if e.key() == "Enter" { - // TODO // let val = (*new_tag).trim().to_string(); // if !val.is_empty() && !tag_state.contains(&val) { // let mut updated = (*tag_state).clone(); diff --git a/webui/src/app/components/toggle_switch.rs b/webui/src/app/components/toggle_switch.rs index 985f588e9..d100a5dfe 100644 --- a/webui/src/app/components/toggle_switch.rs +++ b/webui/src/app/components/toggle_switch.rs @@ -1,4 +1,3 @@ -use log::info; use yew::prelude::*; #[derive(Properties, Clone, PartialEq, Debug)]