This commit is contained in:
euzu
2025-07-06 15:01:28 +02:00
parent 829a15f83e
commit fc0eaa72d7
25 changed files with 310 additions and 138 deletions
+1 -8
View File
@@ -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"
+5 -3
View File
@@ -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": "",
+4
View File
@@ -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"
}
]
+4 -5
View File
@@ -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;
}
+12 -9
View File
@@ -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;
+3 -1
View File
@@ -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";
@forward "components/chip";
@forward "components/popup_menu";
@@ -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;
}
}
}
@@ -16,6 +16,7 @@
}
label {
pointer-events: none;
color: inherit;
}
&:hover {
@@ -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;
}
}
+1
View File
@@ -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;
@@ -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: ":";
}
}
}
@@ -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: ":";
}
}
}
@@ -0,0 +1,5 @@
.tp__target-table {
.tp__icon-button {
width: 2rem;
}
}
@@ -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;
}
}
+4 -6
View File
@@ -19,16 +19,14 @@ pub fn CollapsePanel(props: &CollapsePanelProps) -> Html {
};
html! {
<div class="tp__collapse-panel">
<div class={classes!("tp__collapse-panel", if *expanded {""} else {"tp__collapsed"})}>
<div class="tp__collapse-panel__header" onclick={toggle}>
<span>{ props.title.clone() }</span>
<AppIcon name={ if *expanded { "ChevronUp" } else {"ChevronDown"} }/>
</div>
if *expanded {
<div class="tp__collapse-panel__body">
{ for props.children.iter() }
</div>
}
<div class="tp__collapse-panel__body">
{ for props.children.iter() }
</div>
</div>
}
}
+3 -1
View File
@@ -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<String>,
}
@@ -24,7 +26,7 @@ pub fn MenuItem(props: &MenuItemProps) -> Html {
};
html! {
<div class="tp__menu-item" onclick={ handle_click }>
<div class={classes!("tp__menu-item", props.style.to_string())} onclick={ handle_click }>
<AppIcon name={props.icon.clone()}></AppIcon>
<label>{props.label.clone()}</label>
</div>
+2
View File
@@ -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::*;
@@ -10,7 +10,7 @@ pub struct HdHomeRunOutputProps {
pub fn HdHomeRunOutput(props: &HdHomeRunOutputProps) -> Html {
html! {
<div class="tp__hdhomerun_output">
<div class="tp__hdhomerun_output tp__target_output__output">
</div>
}
}
@@ -26,12 +26,12 @@ pub fn M3uOutput(props: &M3uOutputProps) -> Html {
html! {
<div class="tp__m3u_output">
<div class="tp__m3u_output__section">
<span class="tp__m3u_output__label">{translator.t("LABEL.FILENAME")}</span>
<span>{props.output.filename.as_ref().map_or_else(|| String::new(), |f| f.to_string())}</span>
<div class="tp__m3u_output tp__target_output__output">
<div class="tp__target_output__output__section">
<span class="tp__target_output__output__label">{translator.t("LABEL.FILENAME")}</span>
{ props.output.filename.as_ref().map(|f| html! {<span>{ f }</span>}) }
</div>
<div class="tp__m3u_output__section">
<div class="tp__target_output__output__section">
<TagList tags={(*tags).clone()} />
</div>
</div>
@@ -10,7 +10,7 @@ pub struct StrmOutputProps {
pub fn StrmOutput(props: &StrmOutputProps) -> Html {
html! {
<div class="tp__strm_output">
<div class="tp__strm_output tp__target_output__output">
</div>
}
}
@@ -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! {
<div class="tp__xtream_output">
<div class="tp__xtream_output__section">
<span class="tp__xtream_output__label">{translator.t("LABEL.SKIP_DIRECT_SOURCE")}</span>
<div class="tp__xtream_output tp__target_output__output">
<div class="tp__target_output__output__section">
<span class="tp__target_output__output__label">{translator.t("LABEL.SKIP_DIRECT_SOURCE")}</span>
<TagList tags={(*tags_skip_direct_source).clone()} />
</div>
<div class="tp__xtream_output__section">
<span class="tp__xtream_output__label">{translator.t("LABEL.RESOLVE")}</span>
<span class="tp__xtream_output__label">
<TagList tags={(*tags_resolve).clone()} />
</span>
<div class="tp__target_output__output__section">
<span class="tp__target_output__output__label">{translator.t("LABEL.RESOLVE")}</span>
<TagList tags={(*tags_resolve).clone()} />
</div>
</div>
}
@@ -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::<PlaylistContext>();
let popup_anchor_ref = use_state(|| None::<web_sys::Element>);
let popup_is_open = use_state(|| false);
let selected_dto = use_state(|| None::<Rc<ConfigTargetDto>>);
let table_definition = use_state(|| None::<Rc<TableDefinition<ConfigTargetDto>>>);
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<ConfigTargetDto>, MouseEvent)| {
if let Some(target) = event.target_dyn_into::<web_sys::Element>() {
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::<usize, Html>::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<ConfigTargetDto>), Html>::from(
move |(_row, col, dto): (usize, usize, Rc<ConfigTargetDto>)| {
move |(row, col, dto): (usize, usize, Rc<ConfigTargetDto>)| {
match col {
0 => html! { String::new() },
0 => {
let popup_onclick = popup_onclick.clone();
html! {
<button class="tp__icon-button"
onclick={Callback::from(move |event: MouseEvent| popup_onclick.emit((dto.clone(), event)))}
data-row={row.to_string()}>
<AppIcon name="Popup"></AppIcon>
</button>
}
}
1 => html! { <ToggleSwitch readonly={true} value={&dto.enabled} /> },
2 => html! { &dto.name.to_string() },
3 => html! { <RevealContent>{"Hello"}</RevealContent> },
@@ -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::<ConfigTargetDto> {
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::<ConfigTargetDto> {
table_definition_state.set(Some(Rc::new(TableDefinition::<ConfigTargetDto> {
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! {
<div class="tp__target-table">
<Table::<ConfigTargetDto> definition={(*table_definition).clone()} />
{
if table_definition.is_some() {
html! {
<>
<Table::<ConfigTargetDto> definition={(*table_definition).as_ref().unwrap().clone()} />
<PopupMenu is_open={*popup_is_open} anchor_ref={(*popup_anchor_ref).clone()} on_close={handle_popup_close}>
<MenuItem icon="Edit" name="edit" label={translate.t("LABEL.EDIT")} onclick={&handle_menu_edit}></MenuItem>
<MenuItem style="tp__delete_action" icon="Delete" name="delete" label={translate.t("LABEL.DELETE")} onclick={&handle_menu_edit}></MenuItem>
</PopupMenu>
</>
}
} else {
html! {}
}
}
</div>
}
}
+98
View File
@@ -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<web_sys::Element>,
#[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::<HtmlElement>() {
if let Some(target) = event.target().and_then(|t| t.dyn_into::<HtmlElement>().ok()) {
if !popup.contains(Some(&target)) {
on_close.emit(());
}
}
}
}) as Box<dyn FnMut(_)>);
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! {
<div class="tp__popup-menu" ref={popup_ref} style={(*style).clone()}>
<ul>
{ for props.children.iter().map(|child| html! { <li>{child.clone()}</li> }) }
</ul>
</div>
}
}
+4 -4
View File
@@ -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();
@@ -1,4 +1,3 @@
use log::info;
use yew::prelude::*;
#[derive(Properties, Clone, PartialEq, Debug)]