From abb6bad41350c202a14dd4bf37b99623b7442a61 Mon Sep 17 00:00:00 2001 From: madeofpendletonwool Date: Fri, 20 Sep 2024 18:02:11 -0500 Subject: [PATCH 01/14] todos --- completed_todos.md | 1 + 1 file changed, 1 insertion(+) diff --git a/completed_todos.md b/completed_todos.md index 5626c94e..c4688684 100644 --- a/completed_todos.md +++ b/completed_todos.md @@ -54,6 +54,7 @@ Version 0.6.6 - [x] Added loading spinner when opening an episode to ensure you don't momentarily see the wrong episode - [x] Improve Filtering css so that things align correctly - [x] Made the button to add and remove podcasts more consistent (Sometimes it was just not registering) +- [] Update Rust dependancies CI/CD: From 673f8e298fe5aa8bbd82a39c0d54891fd219e756 Mon Sep 17 00:00:00 2001 From: madeofpendletonwool Date: Sun, 6 Oct 2024 19:54:26 -0500 Subject: [PATCH 02/14] Starting to add on load only what's needed --- web/Cargo.toml | 2 + web/src/components/episodes_layout.rs | 212 ++++------------------ web/src/components/gen_components.rs | 5 + web/src/components/home.rs | 218 ++++++++++++++--------- web/src/components/mod.rs | 1 + web/src/components/virtual_list.rs | 241 ++++++++++++++++++++++++++ 6 files changed, 416 insertions(+), 263 deletions(-) create mode 100644 web/src/components/virtual_list.rs diff --git a/web/Cargo.toml b/web/Cargo.toml index ef23da15..b2b8fe62 100644 --- a/web/Cargo.toml +++ b/web/Cargo.toml @@ -30,6 +30,8 @@ web-sys = { version = "0.3.69", features = [ "Performance", "PerformanceNavigation", "DragEvent", + "MutationObserver", + "MutationObserverInit", "DataTransfer", "TouchEvent", "TouchList", diff --git a/web/src/components/episodes_layout.rs b/web/src/components/episodes_layout.rs index 7524c53c..b68515ad 100644 --- a/web/src/components/episodes_layout.rs +++ b/web/src/components/episodes_layout.rs @@ -1,15 +1,17 @@ use super::app_drawer::App_drawer; -use super::gen_components::{ContextButton, LoadingModal, on_shownotes_click, EpisodeTrait, Search_nav, UseScrollToTop}; +use super::gen_components::{ + on_shownotes_click, ContextButton, EpisodeTrait, LoadingModal, Search_nav, UseScrollToTop, +}; use super::gen_funcs::{format_datetime, match_date_format, parse_date}; use crate::components::audio::{on_play_click, AudioPlayer}; use crate::components::click_events::create_on_title_click; use crate::components::context::{AppState, UIState}; use crate::components::gen_funcs::format_time; -use futures::future::join_all; use crate::components::gen_funcs::{ convert_time_to_seconds, sanitize_html_with_blank_target, truncate_description, }; use crate::components::podcast_layout::ClickedFeedURL; +use crate::components::virtual_list::{PodcastEpisodeVirtualList, PodcastEpisodeVirtualListProps}; use crate::requests::login_requests::use_check_authentication; use crate::requests::pod_req::{ call_add_category, call_add_podcast, call_adjust_skip_times, call_check_podcast, @@ -24,6 +26,7 @@ use crate::requests::pod_req::{ use crate::requests::search_pods::call_get_person_info; use crate::requests::search_pods::call_get_podcast_details_dynamic; use crate::requests::search_pods::call_get_podcast_episodes; +use futures::future::join_all; use htmlentity::entity::decode; use htmlentity::entity::ICodedDataTrait; use std::collections::HashMap; @@ -224,7 +227,6 @@ pub fn host_dropdown(HostDropdownProps { title, hosts }: &HostDropdownProps) -> let loading_modal_visible = use_state(|| false); let loading_name = use_state(|| String::new()); - html! {
@@ -2085,173 +2087,25 @@ pub fn episode_layout() -> Html { if let Some(results) = podcast_feed_results { let podcast_link_clone = clicked_podcast_info.clone().unwrap().podcast_url.clone(); let podcast_title = clicked_podcast_info.clone().unwrap().podcast_title.clone(); - html! { -
- { for results.episodes.iter().map(|episode| { - let history_clone = history.clone(); - let dispatch = _dispatch.clone(); - let search_dispatch = _search_dispatch.clone(); - let search_state_clone = search_state.clone(); // Clone search_state - - // Clone the variables outside the closure - let podcast_link_clone = podcast_link_clone.clone(); - let podcast_title = podcast_title.clone(); - let episode_url_clone = episode.enclosure_url.clone().unwrap_or_default(); - let episode_title_clone = episode.title.clone().unwrap_or_default(); - let episode_artwork_clone = episode.artwork.clone().unwrap_or_default(); - // let episode_duration_clone = episode.duration.clone().unwrap_or_default(); - let episode_duration_clone = episode.duration.clone().unwrap_or_default(); - let episode_duration_in_seconds = match convert_time_to_seconds(&episode_duration_clone) { - Ok(seconds) => seconds as i32, - Err(e) => { - eprintln!("Failed to convert time to seconds: {}", e); - 0 - } - }; - let episode_id_clone = episode.episode_id.unwrap_or(0); - let mut db_added = false; - if episode_id_clone == 0 { - - } else { - db_added = true; - } - let episode_id_shownotes = episode_id_clone.clone(); - let server_name_play = server_name.clone(); - let user_id_play = user_id.clone(); - let api_key_play = api_key.clone(); - - let is_expanded = search_state.expanded_descriptions.contains( - &episode.guid.clone().unwrap() - ); - - - let sanitized_description = sanitize_html_with_blank_target(&episode.description.clone().unwrap_or_default()); - - let (description, _is_truncated) = if is_expanded { - (sanitized_description, false) - } else { - truncate_description(sanitized_description, 300) - }; - - let search_state_toggle = search_state_clone.clone(); - let toggle_expanded = { - let search_dispatch_clone = search_dispatch.clone(); - let episode_guid = episode.guid.clone().unwrap(); - Callback::from(move |_: MouseEvent| { - let guid_clone = episode_guid.clone(); - let search_dispatch_call = search_dispatch_clone.clone(); - - if search_state_toggle.expanded_descriptions.contains(&guid_clone) { - search_dispatch_call.apply(AppStateMsg::CollapseEpisode(guid_clone)); - } else { - search_dispatch_call.apply(AppStateMsg::ExpandEpisode(guid_clone)); - } - - }) - }; - - - let state = state.clone(); - - let on_play_click = on_play_click( - episode_url_clone.clone(), - episode_title_clone.clone(), - episode_artwork_clone.clone(), - episode_duration_in_seconds, - episode_id_clone.clone(), - Some(0), - api_key_play.unwrap().unwrap(), - user_id_play.unwrap(), - server_name_play.unwrap(), - dispatch.clone(), - state.clone(), - None, - ); - let description_class = if is_expanded { - "desc-expanded".to_string() - } else { - "desc-collapsed".to_string() - }; - - let date_format = match_date_format(search_state_clone.date_format.as_deref()); - let datetime = parse_date(&episode.pub_date.clone().unwrap_or_default(), &search_state_clone.user_tz); - let format_release = format!("{}", format_datetime(&datetime, &search_state_clone.hour_preference, date_format)); - let boxed_episode = Box::new(episode.clone()) as Box; - let formatted_duration = format_time(episode_duration_in_seconds.into()); - - let episode_url_for_ep_item = episode_url_clone.clone(); - let shownotes_episode_url = episode_url_clone.clone(); - let should_show_buttons = !episode_url_for_ep_item.is_empty(); - html! { -
- {format!("Cover -
-

{ &episode.title.clone().unwrap_or_default() }

- //

{ &episode.description.clone().unwrap_or_default() }

- { - html! { - - } - } - - - { format_release } - - { - html! { - { format!("{}", formatted_duration) } - } - } -
- { - html! { -
// Add align-self: center; heren medium and larger screens - if should_show_buttons { - - { - if podcast_added { - let page_type = "episode_layout".to_string(); - - let context_button = html! { - - }; - - - context_button - - } else { - html! {} - } - } - } -
- } - } - - -
- } - })} -
+ html! { + } + } else { html! {
diff --git a/web/src/components/gen_components.rs b/web/src/components/gen_components.rs index defa90a1..0a1b153b 100644 --- a/web/src/components/gen_components.rs +++ b/web/src/components/gen_components.rs @@ -1,3 +1,5 @@ +use super::gen_funcs::{format_datetime, match_date_format, parse_date}; +use crate::components::audio::{on_play_click, AudioPlayer}; use crate::components::context::{AppState, UIState}; #[cfg(not(feature = "server_build"))] use crate::components::downloads_tauri::{ @@ -5,6 +7,9 @@ use crate::components::downloads_tauri::{ }; use crate::components::episodes_layout::SafeHtml; use crate::components::gen_funcs::format_time; +use crate::components::gen_funcs::{ + convert_time_to_seconds, sanitize_html_with_blank_target, truncate_description, +}; use crate::requests::pod_req::{ call_download_episode, call_mark_episode_completed, call_mark_episode_uncompleted, call_queue_episode, call_remove_downloaded_episode, call_remove_queued_episode, diff --git a/web/src/components/home.rs b/web/src/components/home.rs index 4cc1d25d..150e44ab 100644 --- a/web/src/components/home.rs +++ b/web/src/components/home.rs @@ -6,7 +6,7 @@ use crate::components::audio::on_play_click; use crate::components::audio::AudioPlayer; use crate::components::context::{AppState, ExpandedDescriptions, UIState}; use crate::components::gen_funcs::{ - format_datetime, parse_date, sanitize_html_with_blank_target, DateFormat, + format_datetime, match_date_format, parse_date, sanitize_html_with_blank_target, }; use crate::requests::pod_req; use crate::requests::pod_req::Episode as EpisodeData; @@ -18,9 +18,11 @@ use yewdux::prelude::*; // use crate::components::gen_funcs::check_auth; use crate::components::episodes_layout::UIStateMsg; use crate::requests::login_requests::use_check_authentication; +use gloo::events::EventListener; use wasm_bindgen::closure::Closure; use wasm_bindgen::JsCast; use web_sys::window; +use web_sys::{Element, HtmlElement}; use wasm_bindgen::prelude::*; @@ -182,13 +184,12 @@ pub fn home() -> Html { "You can add new podcasts by using the search bar above. Search for your favorite podcast and click the plus button to add it." ) } else { - episodes.into_iter().map(|episode| { - html! { - - } - }).collect::() + html! { + + } } } else { empty_message( @@ -224,16 +225,112 @@ pub fn home() -> Html { } } +#[derive(Properties, PartialEq)] +pub struct VirtualListProps { + pub episodes: Vec, + pub page_type: String, +} + +#[function_component(VirtualList)] +pub fn virtual_list(props: &VirtualListProps) -> Html { + let scroll_pos = use_state(|| 0.0); + let container_ref = use_node_ref(); + let container_height = use_state(|| 0.0); + let item_height = use_state(|| 234.0); // Default item height + + // Effect to set initial container height, item height, and listen for window resize + { + let container_height = container_height.clone(); + let item_height = item_height.clone(); + use_effect_with((), move |_| { + let window = window().expect("no global `window` exists"); + let window_clone = window.clone(); + let update_sizes = Callback::from(move |_| { + let height = window_clone.inner_height().unwrap().as_f64().unwrap(); + container_height.set(height - 100.0); // Adjust 100 based on your layout + + let width = window_clone.inner_width().unwrap().as_f64().unwrap(); + let new_item_height = if width <= 530.0 { + 122.0 + } else if width <= 768.0 { + 162.0 + } else { + 234.0 + }; + item_height.set(new_item_height); + }); + + // Set initial sizes + update_sizes.emit(()); + + // Listen for window resize + let listener = EventListener::new(&window, "resize", move |_| { + update_sizes.emit(()); + }); + + move || drop(listener) + }); + } + + // Effect for scroll handling + { + let scroll_pos = scroll_pos.clone(); + let container_ref = container_ref.clone(); + use_effect_with(container_ref.clone(), move |container_ref| { + let container = container_ref.cast::().unwrap(); + let listener = EventListener::new(&container, "scroll", move |event| { + let target = event.target().unwrap().unchecked_into::(); + scroll_pos.set(target.scroll_top() as f64); + }); + move || drop(listener) + }); + } + + let start_index = (*scroll_pos / *item_height).floor() as usize; + let end_index = (((*scroll_pos + *container_height) / *item_height).ceil() as usize) + .min(props.episodes.len()); + + let visible_episodes = (start_index..end_index) + .map(|index| { + let episode = props.episodes[index].clone(); + html! { + + } + }) + .collect::(); + + let total_height = props.episodes.len() as f64 * *item_height; + let offset_y = start_index as f64 * *item_height; + + html! { +
+
+
+ { visible_episodes } +
+
+
+ } +} + +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(js_namespace = window)] + fn toggleDescription(guid: &str, expanded: bool); +} #[derive(Properties, PartialEq, Clone)] pub struct EpisodeProps { - pub episode: EpisodeData, // Assuming EpisodeData contains all episode details - // Add callbacks for play and shownotes if they can't be internally handled + pub episode: EpisodeData, + pub page_type: String, // New prop to determine the context (e.g., "home", "saved") } #[function_component(Episode)] pub fn episode(props: &EpisodeProps) -> Html { let (state, dispatch) = use_store::(); - // let (post_state, _post_dispatch) = use_store::(); let (audio_state, audio_dispatch) = use_store::(); let (desc_state, desc_dispatch) = use_store::(); let api_key = state.auth_details.as_ref().map(|ud| ud.api_key.clone()); @@ -245,30 +342,6 @@ pub fn episode(props: &EpisodeProps) -> Html { let desc_expanded = desc_state.expanded_descriptions.contains(id_string); - let dispatch = dispatch.clone(); - - let episode_url_clone = props.episode.episodeurl.clone(); - let episode_title_clone = props.episode.episodetitle.clone(); - let episode_artwork_clone = props.episode.episodeartwork.clone(); - let episode_duration_clone = props.episode.episodeduration.clone(); - let episode_id_clone = props.episode.episodeid.clone(); - let episode_listened_clone = props.episode.listenduration.clone(); - - let sanitized_description = - sanitize_html_with_blank_target(&props.episode.episodedescription.clone()); - - // let (description, _is_truncated) = if desc_expanded { - // (sanitized_description, false) - // } else { - // truncate_description(sanitized_description, 300) - // }; - - #[wasm_bindgen] - extern "C" { - #[wasm_bindgen(js_namespace = window)] - fn toggleDescription(guid: &str, expanded: bool); - } - let toggle_expanded = { let desc_dispatch = desc_dispatch.clone(); let episode_guid = props.episode.episodeid.clone().to_string(); @@ -277,37 +350,26 @@ pub fn episode(props: &EpisodeProps) -> Html { let guid = episode_guid.clone(); desc_dispatch.reduce_mut(move |state| { if state.expanded_descriptions.contains(&guid) { - state.expanded_descriptions.remove(&guid); // Collapse the description - toggleDescription(&guid, false); // Call JavaScript function + state.expanded_descriptions.remove(&guid); + toggleDescription(&guid, false); } else { - state.expanded_descriptions.insert(guid.clone()); // Expand the description - toggleDescription(&guid, true); // Call JavaScript function + state.expanded_descriptions.insert(guid.clone()); + toggleDescription(&guid, true); } }); }) }; - let episode_url_for_closure = episode_url_clone.clone(); - let episode_title_for_closure = episode_title_clone.clone(); - let episode_artwork_for_closure = episode_artwork_clone.clone(); - let episode_duration_for_closure = episode_duration_clone.clone(); - let listener_duration_for_closure = episode_listened_clone.clone(); - let episode_id_for_closure = episode_id_clone.clone(); - let user_id_play = user_id.clone(); - let server_name_play = server_name.clone(); - let api_key_play = api_key.clone(); - let audio_dispatch = audio_dispatch.clone(); - let on_play_click = on_play_click( - episode_url_for_closure.clone(), - episode_title_for_closure.clone(), - episode_artwork_for_closure.clone(), - episode_duration_for_closure.clone(), - episode_id_for_closure.clone(), - listener_duration_for_closure.clone(), - api_key_play.unwrap().unwrap(), - user_id_play.unwrap(), - server_name_play.unwrap(), + props.episode.episodeurl.clone(), + props.episode.episodetitle.clone(), + props.episode.episodeartwork.clone(), + props.episode.episodeduration.clone(), + props.episode.episodeid.clone(), + props.episode.listenduration.clone(), + api_key.unwrap().unwrap(), + user_id.unwrap(), + server_name.unwrap(), audio_dispatch.clone(), audio_state.clone(), None, @@ -316,52 +378,40 @@ pub fn episode(props: &EpisodeProps) -> Html { let on_shownotes_click = on_shownotes_click( history_clone.clone(), dispatch.clone(), - Some(episode_id_for_closure.clone()), - Some(String::from("home")), - Some(String::from("home")), - Some(String::from("home")), + Some(props.episode.episodeid.clone()), + Some(props.page_type.clone()), + Some(props.page_type.clone()), + Some(props.page_type.clone()), true, ); - let date_format = match state.date_format.as_deref() { - Some("MDY") => DateFormat::MDY, - Some("DMY") => DateFormat::DMY, - Some("YMD") => DateFormat::YMD, - Some("JUL") => DateFormat::JUL, - Some("ISO") => DateFormat::ISO, - Some("USA") => DateFormat::USA, - Some("EUR") => DateFormat::EUR, - Some("JIS") => DateFormat::JIS, - _ => DateFormat::ISO, // default to ISO if the format is not recognized - }; - + let date_format = match_date_format(state.date_format.as_deref()); let datetime = parse_date(&props.episode.episodepubdate, &state.user_tz); - let episode_url_for_ep_item = episode_url_clone.clone(); - // let datetime = parse_date(&episode.EpisodePubDate, &state.user_tz, &state.date_format); let format_release = format!( "{}", format_datetime(&datetime, &state.hour_preference, date_format) ); - let check_episode_id = props.episode.episodeid.clone(); + let is_completed = state .completed_episodes .as_ref() .unwrap_or(&vec![]) - .contains(&check_episode_id); + .contains(&props.episode.episodeid); + let item = episode_item( Box::new(props.episode.clone()), - sanitized_description.clone(), + sanitize_html_with_blank_target(&props.episode.episodedescription), desc_expanded, &format_release, on_play_click, on_shownotes_click, toggle_expanded, - episode_duration_clone, - episode_listened_clone, - "home", + props.episode.episodeduration, + props.episode.listenduration, + &props.page_type, Callback::from(|_| {}), false, - episode_url_for_ep_item, + props.episode.episodeurl.clone(), is_completed, ); diff --git a/web/src/components/mod.rs b/web/src/components/mod.rs index 748ed072..800bfe02 100644 --- a/web/src/components/mod.rs +++ b/web/src/components/mod.rs @@ -9,6 +9,7 @@ pub(crate) mod saved; pub(crate) mod search; pub(crate) mod settings; pub(crate) mod user_stats; +pub(crate) mod virtual_list; mod audio; mod click_events; diff --git a/web/src/components/virtual_list.rs b/web/src/components/virtual_list.rs new file mode 100644 index 00000000..f1f18a0f --- /dev/null +++ b/web/src/components/virtual_list.rs @@ -0,0 +1,241 @@ +use super::gen_components::{on_shownotes_click, ContextButton, EpisodeTrait}; +use super::gen_funcs::{format_datetime, match_date_format, parse_date}; +use crate::components::audio::on_play_click; +use crate::components::context::{AppState, UIState}; +use crate::components::episodes_layout::{AppStateMsg, SafeHtml}; +use crate::components::gen_funcs::format_time; +use crate::components::gen_funcs::{ + convert_time_to_seconds, sanitize_html_with_blank_target, truncate_description, +}; +use crate::requests::search_pods::Episode; +use gloo::events::EventListener; +use std::rc::Rc; +use wasm_bindgen::JsCast; +use web_sys::{window, Element, HtmlElement, MouseEvent}; +use yew::prelude::*; +use yew::Properties; +use yew::{function_component, html, use_effect_with, use_node_ref, Callback, Html}; +use yew_router::history::BrowserHistory; +use yewdux::prelude::*; + +#[derive(Properties, PartialEq)] +pub struct PodcastEpisodeVirtualListProps { + pub episodes: Vec, + pub item_height: f64, + pub podcast_added: bool, + pub search_state: Rc, + pub search_ui_state: Rc, + pub dispatch: Dispatch, + pub search_dispatch: Dispatch, + pub history: BrowserHistory, + pub server_name: Option, + pub user_id: Option, + pub api_key: Option>, + pub podcast_link: String, + pub podcast_title: String, +} + +#[function_component(PodcastEpisodeVirtualList)] +pub fn podcast_episode_virtual_list(props: &PodcastEpisodeVirtualListProps) -> Html { + let scroll_pos = use_state(|| 0.0); + let container_ref = use_node_ref(); + let container_height = use_state(|| 0.0); + + // Effect to set initial container height and listen for window resize + { + let container_height = container_height.clone(); + use_effect_with((), move |_| { + let window = window().expect("no global `window` exists"); + let window_clone = window.clone(); + + let update_height = Callback::from(move |_| { + let height = window_clone.inner_height().unwrap().as_f64().unwrap(); + container_height.set(height - 100.0); // Adjust 100 based on your layout + }); + + update_height.emit(()); + + let listener = EventListener::new(&window, "resize", move |_| { + update_height.emit(()); + }); + + move || drop(listener) + }); + } + + // Effect for scroll handling + { + let scroll_pos = scroll_pos.clone(); + let container_ref = container_ref.clone(); + use_effect_with(container_ref.clone(), move |container_ref| { + let container = container_ref.cast::().unwrap(); + let listener = EventListener::new(&container, "scroll", move |event| { + let target = event.target().unwrap().unchecked_into::(); + scroll_pos.set(target.scroll_top() as f64); + }); + move || drop(listener) + }); + } + + let start_index = (*scroll_pos / props.item_height).floor() as usize; + let end_index = (((*scroll_pos + *container_height) / props.item_height).ceil() as usize) + .min(props.episodes.len()); + + let visible_episodes = (start_index..end_index) + .map(|index| { + let episode = &props.episodes[index]; + let history_clone = props.history.clone(); + let dispatch = props.dispatch.clone(); + let search_dispatch = props.search_dispatch.clone(); + let search_state_clone = props.search_state.clone(); + let search_ui_state_clone = props.search_ui_state.clone(); + + let episode_url_clone = episode.enclosure_url.clone().unwrap_or_default(); + let episode_title_clone = episode.title.clone().unwrap_or_default(); + let episode_artwork_clone = episode.artwork.clone().unwrap_or_default(); + let episode_duration_clone = episode.duration.clone().unwrap_or_default(); + let episode_duration_in_seconds = match convert_time_to_seconds(&episode_duration_clone) { + Ok(seconds) => seconds as i32, + Err(e) => { + eprintln!("Failed to convert time to seconds: {}", e); + 0 + } + }; + let episode_id_clone = episode.episode_id.unwrap_or(0); + let db_added = episode_id_clone != 0; + + let episode_id_shownotes = episode_id_clone.clone(); + let server_name_play = props.server_name.clone(); + let user_id_play = props.user_id; + let api_key_play = props.api_key.clone(); + + let is_expanded = search_state_clone.expanded_descriptions.contains(&episode.guid.clone().unwrap()); + + let sanitized_description = sanitize_html_with_blank_target(&episode.description.clone().unwrap_or_default()); + let (description, _is_truncated) = if is_expanded { + (sanitized_description, false) + } else { + truncate_description(sanitized_description, 300) + }; + + let search_state_toggle = search_state_clone.clone(); + let toggle_expanded = { + let search_dispatch_clone = search_dispatch.clone(); + let episode_guid = episode.guid.clone().unwrap(); + Callback::from(move |_: MouseEvent| { + let guid_clone = episode_guid.clone(); + let search_dispatch_call = search_dispatch_clone.clone(); + + if search_state_toggle.expanded_descriptions.contains(&guid_clone) { + search_dispatch_call.apply(AppStateMsg::CollapseEpisode(guid_clone)); + } else { + search_dispatch_call.apply(AppStateMsg::ExpandEpisode(guid_clone)); + } + }) + }; + + let on_play_click = on_play_click( + episode_url_clone.clone(), + episode_title_clone.clone(), + episode_artwork_clone.clone(), + episode_duration_in_seconds, + episode_id_clone.clone(), + Some(0), + api_key_play.unwrap().unwrap(), + user_id_play.unwrap(), + server_name_play.unwrap(), + dispatch.clone(), + search_ui_state_clone.clone(), + None, + ); + + let description_class = if is_expanded { + "desc-expanded".to_string() + } else { + "desc-collapsed".to_string() + }; + + let date_format = match_date_format(search_state_clone.date_format.as_deref()); + let datetime = parse_date(&episode.pub_date.clone().unwrap_or_default(), &search_state_clone.user_tz); + let format_release = format!("{}", format_datetime(&datetime, &search_state_clone.hour_preference, date_format)); + let boxed_episode = Box::new(episode.clone()) as Box; + let formatted_duration = format_time(episode_duration_in_seconds.into()); + + let episode_url_for_ep_item = episode_url_clone.clone(); + let shownotes_episode_url = episode_url_clone.clone(); + let should_show_buttons = !episode_url_for_ep_item.is_empty(); + + html! { +
+ {format!("Cover +
+

{ &episode.title.clone().unwrap_or_default() }

+ { + html! { + + } + } + + + { format_release } + + { + html! { + { format!("{}", formatted_duration) } + } + } +
+ { + html! { +
+ if should_show_buttons { + + { + if props.podcast_added { + let page_type = "episode_layout".to_string(); + html! { + + } + } else { + html! {} + } + } + } +
+ } + } +
+ } + }) + .collect::(); + + let total_height = props.episodes.len() as f64 * props.item_height; + let offset_y = start_index as f64 * props.item_height; + + html! { +
+
+
+ { visible_episodes } +
+
+
+ } +} From d7adfb0b21e20912ba64631ada1e2b2be44c02ba Mon Sep 17 00:00:00 2001 From: madeofpendletonwool Date: Tue, 8 Oct 2024 06:42:28 -0500 Subject: [PATCH 03/14] improved scrolling behaviour on home --- completed_todos.md | 2 +- web/src/components/gen_components.rs | 262 ++++++++++++++++++++++----- web/src/components/home.rs | 2 +- web/static/styles.css | 33 +++- 4 files changed, 251 insertions(+), 48 deletions(-) diff --git a/completed_todos.md b/completed_todos.md index de1d5f5a..61596121 100644 --- a/completed_todos.md +++ b/completed_todos.md @@ -57,8 +57,8 @@ pre-0.7.0: - [] Add loading spinner when adding podcast via people page - [] People page dropdowns on podcasts and episodes - alternative 3 per line view on podcasts - [] Android play/pause episode metadata -- [] On mobile get queue adjust working - [] Finalize loading states so you don't see login page when you are already authenticated +- [] Make virtual lines work for saved queue, downloads, local downloads, and history done but needs testing diff --git a/web/src/components/gen_components.rs b/web/src/components/gen_components.rs index 0a1b153b..2b2ec8f5 100644 --- a/web/src/components/gen_components.rs +++ b/web/src/components/gen_components.rs @@ -24,12 +24,14 @@ use crate::requests::pod_req::{ use crate::requests::search_pods::Episode as SearchNewEpisode; use crate::requests::search_pods::SearchEpisode; use crate::requests::search_pods::{call_get_podcast_info, test_connection, PeopleEpisode}; +use gloo_events::EventListener; use std::any::Any; use std::rc::Rc; use wasm_bindgen::closure::Closure; use wasm_bindgen::prelude::*; use wasm_bindgen::JsCast; use wasm_bindgen::JsValue; +use web_sys::HtmlElement; use web_sys::{console, window, HtmlInputElement, MouseEvent}; use yew::prelude::*; use yew::Callback; @@ -360,16 +362,50 @@ pub fn context_button(props: &ContextButtonProps) -> Html { .auth_details .as_ref() .map(|ud| ud.server_name.clone()); - let dropdown_ref = NodeRef::default(); + let dropdown_ref = use_node_ref(); + let button_ref = use_node_ref(); let toggle_dropdown = { let dropdown_open = dropdown_open.clone(); Callback::from(move |e: MouseEvent| { - e.stop_propagation(); // Stop the event from propagating further + e.stop_propagation(); dropdown_open.set(!*dropdown_open); }) }; + // Close dropdown when clicking outside + { + let dropdown_open = dropdown_open.clone(); + let dropdown_ref = dropdown_ref.clone(); + let button_ref = button_ref.clone(); + + use_effect_with((*dropdown_open, ()), move |_| { + let document = window().unwrap().document().unwrap(); + let dropdown_open = dropdown_open.clone(); + let dropdown_ref = dropdown_ref.clone(); + let button_ref = button_ref.clone(); + + let listener = EventListener::new(&document, "click", move |event| { + if *dropdown_open { + let target = event.target().unwrap().dyn_into::().unwrap(); + if let Some(dropdown_element) = dropdown_ref.cast::() { + if let Some(button_element) = button_ref.cast::() { + if !dropdown_element.contains(Some(&target)) + && !button_element.contains(Some(&target)) + { + dropdown_open.set(false); + } + } + } + } + }); + + move || { + drop(listener); + } + }); + } + { let dropdown_open = dropdown_open.clone(); // Clone for use in the effect hook let dropdown_ref = dropdown_ref.clone(); // Clone for use in the effect hook @@ -959,26 +995,41 @@ pub fn context_button(props: &ContextButtonProps) -> Html { }) }; + let close_dropdown = { + let dropdown_open = dropdown_open.clone(); + Callback::from(move |_| { + dropdown_open.set(false); + }) + }; + + let wrap_action = |action: Callback| { + let close = close_dropdown.clone(); + Callback::from(move |e: MouseEvent| { + action.emit(e); + close.emit(()); + }) + }; + #[cfg(feature = "server_build")] let download_button = html! { - + }; #[cfg(not(feature = "server_build"))] let download_button = html! { <> - - + + }; #[cfg(not(feature = "server_build"))] let local_download_options = html! { <> - - - - + + + + }; @@ -988,30 +1039,33 @@ pub fn context_button(props: &ContextButtonProps) -> Html { let action_buttons = match props.page_type.as_str() { "saved" => html! { <> - - + + { + // Handle download_button as VNode download_button.clone() } - + }, "queue" => html! { <> - - + + { download_button.clone() } - + }, "downloads" => html! { <> - - - - + + + + }, "local_downloads" => html! { @@ -1021,42 +1075,36 @@ pub fn context_button(props: &ContextButtonProps) -> Html { _ => html! { // Default set of buttons for other page types <> - - + + { download_button.clone() } - + }, }; html! { - <> -
+
- // Dropdown Content - { - if *dropdown_open { - html! { - - } - } else { - html! {} - } + if *dropdown_open { + }
- } } @@ -1350,6 +1398,61 @@ pub fn episode_item( }); let checkbox_ep = episode.get_episode_id(Some(0)); let should_show_buttons = !ep_url.is_empty(); + let container_height = { + if let Some(window) = window() { + if let Ok(width) = window.inner_width() { + if let Some(width) = width.as_f64() { + if width <= 530.0 { + "122px" + } else if width <= 768.0 { + "162px" + } else { + "221px" + } + } else { + "221px" // Default if we can't get the width as f64 + } + } else { + "221px" // Default if we can't get inner_width + } + } else { + "221px" // Default if we can't get window + } + }; + + // let container_ref = use_node_ref(); + // let touch_timer: Rc>> = Rc::new(RefCell::new(None)); + // let context_button_ref = use_node_ref(); + + // let on_touch_start = { + // let touch_timer = touch_timer.clone(); + // let context_button_ref = context_button_ref.clone(); + // Callback::from(move |e: TouchEvent| { + // e.prevent_default(); + // let context_button = context_button_ref.cast::().unwrap(); + // let timer = web_sys::window() + // .unwrap() + // .set_timeout_with_callback_and_timeout_and_arguments_0( + // &Closure::wrap(Box::new(move || { + // context_button.click(); + // }) as Box) + // .into_js_value(), + // 500, // 500ms for long press + // ) + // .unwrap(); + // *touch_timer.borrow_mut() = Some(timer); + // }) + // }; + + // let on_touch_end = { + // let touch_timer = touch_timer.clone(); + // Callback::from(move |_: TouchEvent| { + // if let Some(timer) = *touch_timer.borrow() { + // web_sys::window().unwrap().clear_timeout_with_handle(timer); + // *touch_timer.borrow_mut() = None; + // } + // }) + // }; #[wasm_bindgen] extern "C" { @@ -1362,8 +1465,13 @@ pub fn episode_item( "desc-collapsed".to_string() }; html! { -
-
+
+
{if is_delete_mode { html! {
-

- { episode.get_episode_title() } -

- { +

+ { episode.get_episode_title() } +

+ { if completed.clone() { html! { {"check_circle"} @@ -1465,6 +1573,70 @@ pub fn episode_item( } } +// #[derive(Properties, PartialEq)] +// pub struct EpisodeTitleProps { +// pub title: String, +// pub max_lines: usize, +// } + +// #[function_component(EpisodeTitle)] +// pub fn episode_title(props: &EpisodeTitleProps) -> Html { +// let title_ref = use_node_ref(); +// let font_size = use_state(|| 16.0); // Starting font size + +// { +// let title_ref = title_ref.clone(); +// let font_size = font_size.clone(); +// let title = props.title.clone(); +// let max_lines = props.max_lines; + +// use_effect_with(title, move |_| { +// if let Some(title_element) = title_ref.cast::() { +// let mut current_font_size = *font_size; +// title_element +// .style() +// .set_property("font-size", &format!("{}px", current_font_size)) +// .unwrap(); + +// while title_element.scroll_height() > title_element.client_height() +// && current_font_size > 10.0 +// { +// current_font_size -= 0.5; +// title_element +// .style() +// .set_property("font-size", &format!("{}px", current_font_size)) +// .unwrap(); +// } + +// if title_element.scroll_height() > title_element.client_height() { +// let mut truncated_text = title_element.inner_text(); +// while title_element.scroll_height() > title_element.client_height() +// && !truncated_text.is_empty() +// { +// truncated_text.pop(); +// if truncated_text.ends_with(' ') { +// truncated_text.pop(); +// } +// truncated_text.push_str("..."); +// title_element.set_inner_text(&truncated_text); +// } +// } + +// font_size.set(current_font_size); +// } +// }); +// } + +// html! { +//
+// {props.title.clone()} +//
+// } +// } + pub fn download_episode_item( episode: Box, description: String, diff --git a/web/src/components/home.rs b/web/src/components/home.rs index 150e44ab..1b1dad3e 100644 --- a/web/src/components/home.rs +++ b/web/src/components/home.rs @@ -255,7 +255,7 @@ pub fn virtual_list(props: &VirtualListProps) -> Html { } else if width <= 768.0 { 162.0 } else { - 234.0 + 221.0 }; item_height.set(new_item_height); }); diff --git a/web/static/styles.css b/web/static/styles.css index 8260420f..78b1f486 100644 --- a/web/static/styles.css +++ b/web/static/styles.css @@ -69,6 +69,13 @@ body { background-size: cover; } +.line-clamp-2 { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + .search-page-container { max-width: 1200px; /* Set the maximum width you desire */ @@ -1436,7 +1443,7 @@ body { } .item_container-text.episode-title { - font-size: 1.25rem; + font-size: 1rem; /* Base font size for larger screens */ } @@ -2277,6 +2284,30 @@ button.item-container-action-button.item-container-action-button { /* Bottom padding as requested */ } +.context-button-wrapper { + position: relative; +} + +.dropdown-content-class { + position: absolute; + top: 100%; + right: 0; + margin-top: 0.5rem; + background-color: #fff; /* Adjust as needed */ + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); + z-index: 1000; +} + +/* Ensure the dropdown is on top of other elements */ +.item-container { + overflow: visible !important; +} + +/* If needed, adjust the container of the ContextButton */ +.button-container { + position: static !important; +} + .episode-action-buttons .no-media-warning { display: flex; justify-content: center; From 0b5d28d08483ba455ff64e4d73b34542d9972ae0 Mon Sep 17 00:00:00 2001 From: madeofpendletonwool Date: Wed, 9 Oct 2024 10:42:24 -0500 Subject: [PATCH 04/14] All context options except local download now dynamically update --- database_functions/functions.py | 26 ++- web/src/components/context.rs | 4 + web/src/components/gen_components.rs | 273 +++++++++++++++++++-------- web/src/components/home.rs | 22 ++- web/src/requests/pod_req.rs | 3 + 5 files changed, 242 insertions(+), 86 deletions(-) diff --git a/database_functions/functions.py b/database_functions/functions.py index 1b7c3fd1..e686e006 100644 --- a/database_functions/functions.py +++ b/database_functions/functions.py @@ -139,7 +139,7 @@ def add_news_feed_if_not_added(database_type, cnx): cursor.execute("UPDATE AppSettings SET NewsFeedSubscribed = 1") cnx.commit() - except (psycopg.ProgrammingError,create_database_connection mysql.connector.ProgrammingError) as e: + except (psycopg.ProgrammingError, mysql.connector.ProgrammingError) as e: print(f"Error in add_news_feed_if_not_added: {e}") cnx.rollback() finally: @@ -735,10 +735,16 @@ def return_episodes(database_type, cnx, user_id): query = ( 'SELECT "Podcasts".PodcastName, "Episodes".EpisodeTitle, "Episodes".EpisodePubDate, ' '"Episodes".EpisodeDescription, "Episodes".EpisodeArtwork, "Episodes".EpisodeURL, "Episodes".EpisodeDuration, ' - '"UserEpisodeHistory".ListenDuration, "Episodes".EpisodeID, "Episodes".Completed ' + '"UserEpisodeHistory".ListenDuration, "Episodes".EpisodeID, "Episodes".Completed, ' + 'CASE WHEN "SavedEpisodes".EpisodeID IS NOT NULL THEN TRUE ELSE FALSE END AS Saved, ' + 'CASE WHEN "EpisodeQueue".EpisodeID IS NOT NULL THEN TRUE ELSE FALSE END AS Queued, ' + 'CASE WHEN "DownloadedEpisodes".EpisodeID IS NOT NULL THEN TRUE ELSE FALSE END AS Downloaded ' 'FROM "Episodes" ' 'INNER JOIN "Podcasts" ON "Episodes".PodcastID = "Podcasts".PodcastID ' 'LEFT JOIN "UserEpisodeHistory" ON "Episodes".EpisodeID = "UserEpisodeHistory".EpisodeID AND "UserEpisodeHistory".UserID = %s ' + 'LEFT JOIN "SavedEpisodes" ON "Episodes".EpisodeID = "SavedEpisodes".EpisodeID AND "SavedEpisodes".UserID = %s ' + 'LEFT JOIN "EpisodeQueue" ON "Episodes".EpisodeID = "EpisodeQueue".EpisodeID AND "EpisodeQueue".UserID = %s ' + 'LEFT JOIN "DownloadedEpisodes" ON "Episodes".EpisodeID = "DownloadedEpisodes".EpisodeID AND "DownloadedEpisodes".UserID = %s ' 'WHERE "Episodes".EpisodePubDate >= NOW() - INTERVAL \'30 days\' ' 'AND "Podcasts".UserID = %s ' 'ORDER BY "Episodes".EpisodePubDate DESC' @@ -747,31 +753,35 @@ def return_episodes(database_type, cnx, user_id): query = ( "SELECT Podcasts.PodcastName, Episodes.EpisodeTitle, Episodes.EpisodePubDate, " "Episodes.EpisodeDescription, Episodes.EpisodeArtwork, Episodes.EpisodeURL, Episodes.EpisodeDuration, " - "UserEpisodeHistory.ListenDuration, Episodes.EpisodeID, Episodes.Completed " + "UserEpisodeHistory.ListenDuration, Episodes.EpisodeID, Episodes.Completed, " + "CASE WHEN SavedEpisodes.EpisodeID IS NOT NULL THEN TRUE ELSE FALSE END AS Saved, " + "CASE WHEN EpisodeQueue.EpisodeID IS NOT NULL THEN TRUE ELSE FALSE END AS Queued, " + "CASE WHEN DownloadedEpisodes.EpisodeID IS NOT NULL THEN TRUE ELSE FALSE END AS Downloaded " "FROM Episodes " "INNER JOIN Podcasts ON Episodes.PodcastID = Podcasts.PodcastID " "LEFT JOIN UserEpisodeHistory ON Episodes.EpisodeID = UserEpisodeHistory.EpisodeID AND UserEpisodeHistory.UserID = %s " + "LEFT JOIN SavedEpisodes ON Episodes.EpisodeID = SavedEpisodes.EpisodeID AND SavedEpisodes.UserID = %s " + "LEFT JOIN EpisodeQueue ON Episodes.EpisodeID = EpisodeQueue.EpisodeID AND EpisodeQueue.UserID = %s " + "LEFT JOIN DownloadedEpisodes ON Episodes.EpisodeID = DownloadedEpisodes.EpisodeID AND DownloadedEpisodes.UserID = %s " "WHERE Episodes.EpisodePubDate >= DATE_SUB(NOW(), INTERVAL 30 DAY) " "AND Podcasts.UserID = %s " "ORDER BY Episodes.EpisodePubDate DESC" ) - cursor.execute(query, (user_id, user_id)) + cursor.execute(query, (user_id, user_id, user_id, user_id, user_id)) rows = cursor.fetchall() - cursor.close() if not rows: return [] if database_type != "postgresql": - # Convert column names to lowercase for MySQL and ensure `Completed` is a boolean - rows = [{k.lower(): (bool(v) if k.lower() == 'completed' else v) for k, v in row.items()} for row in rows] + # Convert column names to lowercase for MySQL and ensure boolean fields are actual booleans + rows = [{k.lower(): (bool(v) if k.lower() in ['completed', 'saved', 'queued', 'downloaded'] else v) for k, v in row.items()} for row in rows] return rows - def return_podcast_episodes(database_type, cnx, user_id, podcast_id): if database_type == "postgresql": cnx.row_factory = dict_row diff --git a/web/src/components/context.rs b/web/src/components/context.rs index 4849dc17..9b0007dd 100644 --- a/web/src/components/context.rs +++ b/web/src/components/context.rs @@ -115,6 +115,10 @@ pub struct AppState { pub date_format: Option, pub podcast_added: Option, pub completed_episodes: Option>, + pub saved_episode_ids: Option>, + pub queued_episode_ids: Option>, + pub downloaded_episode_ids: Option>, + pub locally_downloaded_episodes: Option>, pub podcast_layout: Option, } diff --git a/web/src/components/gen_components.rs b/web/src/components/gen_components.rs index 2b2ec8f5..6cbd8f36 100644 --- a/web/src/components/gen_components.rs +++ b/web/src/components/gen_components.rs @@ -454,9 +454,11 @@ pub fn context_button(props: &ContextButtonProps) -> Html { }); } + let check_episode_id = props.episode.get_episode_id(Some(0)); + let queue_api_key = api_key.clone(); let queue_server_name = server_name.clone(); - let queue_post = audio_dispatch.clone(); + let queue_post = post_dispatch.clone(); // let server_name = server_name.clone(); let on_add_to_queue = { let episode = props.episode.clone(); @@ -464,6 +466,7 @@ pub fn context_button(props: &ContextButtonProps) -> Html { let server_name_copy = queue_server_name.clone(); let api_key_copy = queue_api_key.clone(); let queue_post = queue_post.clone(); + let episode_clone = episode.clone(); let request = QueuePodcastRequest { episode_id: episode.get_episode_id(Some(0)), user_id: user_id.unwrap(), // replace with the actual user ID @@ -477,7 +480,10 @@ pub fn context_button(props: &ContextButtonProps) -> Html { { Ok(success_message) => { queue_post.reduce_mut(|state| { - state.info_message = Option::from(format!("{}", success_message)) + state.info_message = Option::from(format!("{}", success_message)); + if let Some(ref mut queued_episodes) = state.queued_episode_ids { + queued_episodes.push(episode_clone.get_episode_id(Some(0))); + } }); } Err(e) => { @@ -531,6 +537,9 @@ pub fn context_button(props: &ContextButtonProps) -> Html { .episodes .retain(|ep| ep.get_episode_id(Some(0)) != episode_id); } + if let Some(ref mut queued_episode_ids) = state.queued_episode_ids { + queued_episode_ids.retain(|&id| id != episode_id); + } // Optionally, you can update the info_message with success message state.info_message = Some(format!("{}", success_message).to_string()); }); @@ -548,15 +557,39 @@ pub fn context_button(props: &ContextButtonProps) -> Html { }) }; + let is_queued = post_state + .queued_episode_ids + .as_ref() + .unwrap_or(&vec![]) + .contains(&check_episode_id.clone()); + + let on_toggle_queue = { + let on_add_to_queue = on_add_to_queue.clone(); + let on_remove_queued_episode = on_remove_queued_episode.clone(); + // let is_queued = post_state + // .queued_episode_ids + // .as_ref() + // .unwrap_or(&vec![]) + // .contains(&props.episode.get_episode_id(Some(0))); + Callback::from(move |_| { + if is_queued { + on_remove_queued_episode.emit(()); + } else { + on_add_to_queue.emit(()); + } + }) + }; + let saved_api_key = api_key.clone(); let saved_server_name = server_name.clone(); - let save_post = audio_dispatch.clone(); + let save_post = post_dispatch.clone(); let on_save_episode = { let episode = props.episode.clone(); Callback::from(move |_| { let server_name_copy = saved_server_name.clone(); let api_key_copy = saved_api_key.clone(); let post_state = save_post.clone(); + let episode_clone = episode.clone(); let request = SavePodcastRequest { episode_id: episode.get_episode_id(Some(0)), // changed from episode_title user_id: user_id.unwrap(), // replace with the actual user ID @@ -569,7 +602,10 @@ pub fn context_button(props: &ContextButtonProps) -> Html { match call_save_episode(&server_name.unwrap(), &api_key.flatten(), &request).await { Ok(success_message) => { post_state.reduce_mut(|state| { - state.info_message = Option::from(format!("{}", success_message)) + state.info_message = Option::from(format!("{}", success_message)); + if let Some(ref mut saved_episodes) = state.saved_episode_ids { + saved_episodes.push(episode_clone.get_episode_id(Some(0))); + } }); } Err(e) => { @@ -616,6 +652,9 @@ pub fn context_button(props: &ContextButtonProps) -> Html { .episodes .retain(|ep| ep.get_episode_id(Some(0)) != episode_id); } + if let Some(ref mut saved_episode_ids) = state.saved_episode_ids { + saved_episode_ids.retain(|&id| id != episode_id); + } // Optionally, you can update the info_message with success message state.info_message = Some(format!("{}", success_message).to_string()); }); @@ -633,15 +672,39 @@ pub fn context_button(props: &ContextButtonProps) -> Html { }) }; + let is_saved = post_state + .saved_episode_ids + .as_ref() + .unwrap_or(&vec![]) + .contains(&check_episode_id.clone()); + + let on_toggle_save = { + let on_save_episode = on_save_episode.clone(); + let on_remove_saved_episode = on_remove_saved_episode.clone(); + // let is_saved = post_state + // .saved_episode_ids + // .as_ref() + // .unwrap_or(&vec![]) + // .contains(&props.episode.get_episode_id(Some(0))); + Callback::from(move |_| { + if is_saved { + on_remove_saved_episode.emit(()); + } else { + on_save_episode.emit(()); + } + }) + }; + let download_api_key = api_key.clone(); let download_server_name = server_name.clone(); - let download_post = audio_dispatch.clone(); + let download_post = post_dispatch.clone(); let on_download_episode = { let episode = props.episode.clone(); Callback::from(move |_| { let post_state = download_post.clone(); let server_name_copy = download_server_name.clone(); let api_key_copy = download_api_key.clone(); + let episode_clone = episode.clone(); let request = DownloadEpisodeRequest { episode_id: episode.get_episode_id(Some(0)), user_id: user_id.unwrap(), // replace with the actual user ID @@ -656,7 +719,11 @@ pub fn context_button(props: &ContextButtonProps) -> Html { { Ok(success_message) => { post_state.reduce_mut(|state| { - state.info_message = Option::from(format!("{}", success_message)) + state.info_message = Option::from(format!("{}", success_message)); + if let Some(ref mut downloaded_episodes) = state.downloaded_episode_ids + { + downloaded_episodes.push(episode_clone.get_episode_id(Some(0))); + } }); } Err(e) => { @@ -671,6 +738,89 @@ pub fn context_button(props: &ContextButtonProps) -> Html { // dropdown_open.set(false); }) }; + + let remove_download_api_key = api_key.clone(); + let remove_download_server_name = server_name.clone(); + let remove_download_post = audio_dispatch.clone(); + let dispatch_clone = post_dispatch.clone(); + let on_remove_downloaded_episode = { + let episode = props.episode.clone(); + let episode_id = props.episode.get_episode_id(Some(0)); + Callback::from(move |_| { + let post_dispatch = dispatch_clone.clone(); + let post_state = remove_download_post.clone(); + let server_name_copy = remove_download_server_name.clone(); + let api_key_copy = remove_download_api_key.clone(); + let request = DownloadEpisodeRequest { + episode_id: episode.get_episode_id(Some(0)), + user_id: user_id.unwrap(), // replace with the actual user ID + }; + let server_name = server_name_copy; // replace with the actual server name + let api_key = api_key_copy; // replace with the actual API key + let future = async move { + // let _ = call_download_episode(&server_name.unwrap(), &api_key.flatten(), &request).await; + // post_state.reduce_mut(|state| state.info_message = Option::from(format!("Episode now downloading!"))); + match call_remove_downloaded_episode( + &server_name.unwrap(), + &api_key.flatten(), + &request, + ) + .await + { + Ok(success_message) => { + // queue_post.reduce_mut(|state| state.info_message = Option::from(format!("{}", success_message))); + post_dispatch.reduce_mut(|state| { + // Here, you should remove the episode from the downloaded_episodes + if let Some(ref mut downloaded_episodes) = state.downloaded_episodes { + downloaded_episodes + .episodes + .retain(|ep| ep.get_episode_id(Some(0)) != episode_id); + } + if let Some(ref mut downloaded_episode_ids) = + state.downloaded_episode_ids + { + downloaded_episode_ids.retain(|&id| id != episode_id); + } + // Optionally, you can update the info_message with success message + state.info_message = Some(format!("{}", success_message).to_string()); + }); + } + Err(e) => { + post_state.reduce_mut(|state| { + state.error_message = Option::from(format!("{}", e)) + }); + // Handle error, e.g., display the error message + } + } + }; + wasm_bindgen_futures::spawn_local(future); + // dropdown_open.set(false); + }) + }; + + let is_downloaded = post_state + .downloaded_episode_ids + .as_ref() + .unwrap_or(&vec![]) + .contains(&check_episode_id.clone()); + + let on_toggle_download = { + let on_download = on_download_episode.clone(); + let on_remove_download = on_remove_downloaded_episode.clone(); + // let is_queued = post_state + // .queued_episode_ids + // .as_ref() + // .unwrap_or(&vec![]) + // .contains(&props.episode.get_episode_id(Some(0))); + Callback::from(move |_| { + if is_downloaded { + on_remove_download.emit(()); + } else { + on_download.emit(()); + } + }) + }; + #[cfg(not(feature = "server_build"))] let on_local_episode_download = { let episode = props.episode.clone(); @@ -804,60 +954,6 @@ pub fn context_button(props: &ContextButtonProps) -> Html { }) }; - let remove_download_api_key = api_key.clone(); - let remove_download_server_name = server_name.clone(); - let remove_download_post = audio_dispatch.clone(); - let dispatch_clone = post_dispatch.clone(); - let on_remove_downloaded_episode = { - let episode = props.episode.clone(); - let episode_id = props.episode.get_episode_id(Some(0)); - Callback::from(move |_| { - let post_dispatch = dispatch_clone.clone(); - let post_state = remove_download_post.clone(); - let server_name_copy = remove_download_server_name.clone(); - let api_key_copy = remove_download_api_key.clone(); - let request = DownloadEpisodeRequest { - episode_id: episode.get_episode_id(Some(0)), - user_id: user_id.unwrap(), // replace with the actual user ID - }; - let server_name = server_name_copy; // replace with the actual server name - let api_key = api_key_copy; // replace with the actual API key - let future = async move { - // let _ = call_download_episode(&server_name.unwrap(), &api_key.flatten(), &request).await; - // post_state.reduce_mut(|state| state.info_message = Option::from(format!("Episode now downloading!"))); - match call_remove_downloaded_episode( - &server_name.unwrap(), - &api_key.flatten(), - &request, - ) - .await - { - Ok(success_message) => { - // queue_post.reduce_mut(|state| state.info_message = Option::from(format!("{}", success_message))); - post_dispatch.reduce_mut(|state| { - // Here, you should remove the episode from the downloaded_episodes - if let Some(ref mut downloaded_episodes) = state.downloaded_episodes { - downloaded_episodes - .episodes - .retain(|ep| ep.get_episode_id(Some(0)) != episode_id); - } - // Optionally, you can update the info_message with success message - state.info_message = Some(format!("{}", success_message).to_string()); - }); - } - Err(e) => { - post_state.reduce_mut(|state| { - state.error_message = Option::from(format!("{}", e)) - }); - // Handle error, e.g., display the error message - } - } - }; - wasm_bindgen_futures::spawn_local(future); - // dropdown_open.set(false); - }) - }; - let uncomplete_api_key = api_key.clone(); let uncomplete_server_name = server_name.clone(); let uncomplete_download_post = audio_dispatch.clone(); @@ -974,7 +1070,6 @@ pub fn context_button(props: &ContextButtonProps) -> Html { }) }; - let check_episode_id = props.episode.get_episode_id(Some(0)); let is_completed = post_state .completed_episodes .as_ref() @@ -1012,13 +1107,17 @@ pub fn context_button(props: &ContextButtonProps) -> Html { #[cfg(feature = "server_build")] let download_button = html! { - + }; #[cfg(not(feature = "server_build"))] let download_button = html! { <> - + }; @@ -1026,9 +1125,15 @@ pub fn context_button(props: &ContextButtonProps) -> Html { #[cfg(not(feature = "server_build"))] let local_download_options = html! { <> - - - + + + }; @@ -1039,8 +1144,12 @@ pub fn context_button(props: &ContextButtonProps) -> Html { let action_buttons = match props.page_type.as_str() { "saved" => html! { <> - - + + { // Handle download_button as VNode download_button.clone() @@ -1052,8 +1161,12 @@ pub fn context_button(props: &ContextButtonProps) -> Html { }, "queue" => html! { <> - - + + { download_button.clone() } @@ -1062,9 +1175,15 @@ pub fn context_button(props: &ContextButtonProps) -> Html { }, "downloads" => html! { <> - - - + + + }, @@ -1075,8 +1194,12 @@ pub fn context_button(props: &ContextButtonProps) -> Html { _ => html! { // Default set of buttons for other page types <> - - + + { download_button.clone() } diff --git a/web/src/components/home.rs b/web/src/components/home.rs index 1b1dad3e..9a0c6218 100644 --- a/web/src/components/home.rs +++ b/web/src/components/home.rs @@ -125,7 +125,6 @@ pub fn home() -> Html { (api_key.clone(), user_id.clone(), server_name.clone()) { let dispatch = effect_dispatch.clone(); - wasm_bindgen_futures::spawn_local(async move { match pod_req::call_get_recent_eps(&server_name, &api_key, &user_id).await { Ok(fetched_episodes) => { @@ -134,18 +133,35 @@ pub fn home() -> Html { .filter(|ep| ep.completed) .map(|ep| ep.episodeid) .collect(); - + let saved_episode_ids: Vec = fetched_episodes + .iter() + .filter(|ep| ep.saved) + .map(|ep| ep.episodeid) + .collect(); + let queued_episode_ids: Vec = fetched_episodes + .iter() + .filter(|ep| ep.queued) + .map(|ep| ep.episodeid) + .collect(); + let downloaded_episode_ids: Vec = fetched_episodes + .iter() + .filter(|ep| ep.downloaded) + .map(|ep| ep.episodeid) + .collect(); dispatch.reduce_mut(move |state| { state.server_feed_results = Some(RecentEps { episodes: Some(fetched_episodes), }); state.completed_episodes = Some(completed_episode_ids); + state.saved_episode_ids = Some(saved_episode_ids); + state.queued_episode_ids = Some(queued_episode_ids); + state.downloaded_episode_ids = Some(downloaded_episode_ids); }); loading_ep.set(false); } Err(e) => { error_clone.set(Some(e.to_string())); - loading_ep.set(false); // Set loading to false here + loading_ep.set(false); } } }); diff --git a/web/src/requests/pod_req.rs b/web/src/requests/pod_req.rs index 44f10f3b..f9140a80 100644 --- a/web/src/requests/pod_req.rs +++ b/web/src/requests/pod_req.rs @@ -59,6 +59,9 @@ pub struct Episode { pub listenduration: Option, pub episodeid: i32, pub completed: bool, + pub saved: bool, + pub queued: bool, + pub downloaded: bool, } #[derive(Deserialize, Debug, PartialEq, Clone)] From f678a75559ac4471ea488be0be24945fd4ff6865 Mon Sep 17 00:00:00 2001 From: madeofpendletonwool Date: Wed, 9 Oct 2024 11:47:23 -0500 Subject: [PATCH 05/14] Added basic android and ios metadata --- completed_todos.md | 5 +- dockerfile | 2 +- dockerfile-arm | 2 +- web/Cargo.toml | 5 +- web/src/components/audio.rs | 133 +++++++++++++++++++++++++++++++++++- 5 files changed, 141 insertions(+), 6 deletions(-) diff --git a/completed_todos.md b/completed_todos.md index 61596121..1f238f80 100644 --- a/completed_todos.md +++ b/completed_todos.md @@ -10,6 +10,7 @@ Next Minor Version: - [] Push completion status to Nextcloud/gpodder - [] Test with LXC containers +- [] Dynamically adjusting local download buttons - [] Adjust download checkboxes to look nicer - [] Change download multiple buttons to be on same line as header - [] Full Show deletion with checkbox on download page @@ -53,12 +54,13 @@ pre-0.7.0: - [] People Table with background jobs to update people found in podcasts - [] Subscribe to people -- [] Dynamically adjusting Download, Queue, and Saved Episodes so that every page can add or remove from these lists - [] Add loading spinner when adding podcast via people page - [] People page dropdowns on podcasts and episodes - alternative 3 per line view on podcasts - [] Android play/pause episode metadata +- [] Stop issues with timeouts on occation with mobile apps - [] Finalize loading states so you don't see login page when you are already authenticated - [] Make virtual lines work for saved queue, downloads, local downloads, and history +- [] Dynamically adjusting buttons on episode page done but needs testing @@ -85,6 +87,7 @@ Version 0.7.0 - [x] Added Valkey to make many processes faster - [x] Using valkey to ensure stateless opml imports +- [x] Dynamically adjusting Download, Queue, and Saved Episodes so that every page can add or remove from these lists - [x] Fixed issue where some episodes weren't adding when refreshing due to redirects - [x] Some pods not loading in from opml import - better opml validation. Say number importing. - OPML imports moved to backend to get pod values, also reporting function created to update status - [x] Update queue slider to be centered diff --git a/dockerfile b/dockerfile index 282f5b7b..5276ecbc 100644 --- a/dockerfile +++ b/dockerfile @@ -25,7 +25,7 @@ COPY ./web /app WORKDIR /app # Build the Yew application in release mode -RUN trunk build --features server_build --release +RUN RUSTFLAGS="--cfg=web_sys_unstable_apis" trunk build --features server_build --release # Final stage for setting up runtime environment FROM alpine:3.19 diff --git a/dockerfile-arm b/dockerfile-arm index f9b2b3e0..da0dbd73 100644 --- a/dockerfile-arm +++ b/dockerfile-arm @@ -25,7 +25,7 @@ COPY . /app WORKDIR /app/web # Build the Yew application in release mode -RUN trunk build --features server_build --release +RUN RUSTFLAGS="--cfg=web_sys_unstable_apis" trunk build --features server_build --release # Final stage for setting up runtime environment FROM alpine:3.19 diff --git a/web/Cargo.toml b/web/Cargo.toml index b2b8fe62..8513719e 100644 --- a/web/Cargo.toml +++ b/web/Cargo.toml @@ -10,7 +10,7 @@ edition = "2021" #yew = { git = "https://github.com/yewstack/yew/", features = ["csr"] } yew = { version = "0.21.0", features = ["csr"] } #yew = { "0.21.0", features = ["csr"] } -web-sys = { version = "0.3.69", features = [ +web-sys = { version = "0.3.70", features = [ "CssStyleDeclaration", "DomTokenList", "HtmlSelectElement", @@ -38,6 +38,9 @@ web-sys = { version = "0.3.69", features = [ "Touch", "Clipboard", "Navigator", + "MediaMetadata", + "MediaSession", + "MediaSessionAction", "Permissions", ] } log = "0.4.22" diff --git a/web/src/components/audio.rs b/web/src/components/audio.rs index a820cdf8..693fbc2a 100644 --- a/web/src/components/audio.rs +++ b/web/src/components/audio.rs @@ -14,17 +14,19 @@ use crate::requests::pod_req::{ QueuePodcastRequest, RecordListenDurationRequest, }; use gloo_timers::callback::Interval; +use js_sys::Array; use std::cell::Cell; #[cfg(not(feature = "server_build"))] use std::path::Path; use std::rc::Rc; use std::string::String; use wasm_bindgen::closure::Closure; +use wasm_bindgen::prelude::wasm_bindgen; use wasm_bindgen::JsCast; use wasm_bindgen::JsValue; use wasm_bindgen_futures::spawn_local; use web_sys::HtmlElement; -use web_sys::{window, HtmlAudioElement, HtmlInputElement}; +use web_sys::{window, HtmlAudioElement, HtmlInputElement, Navigator}; use yew::prelude::*; use yew::{function_component, html, Callback, Html}; use yew_router::history::{BrowserHistory, History}; @@ -568,6 +570,134 @@ pub fn audio_player(props: &AudioPlayerProps) -> Html { } }); + // // Try importing each type individually + // // #[cfg(feature = "MediaMetadata")] + // use web_sys::MediaMetadata; + + // // #[cfg(feature = "MediaSession")] + // use web_sys::MediaSession; + + // // #[cfg(feature = "MediaSessionAction")] + // use web_sys::MediaSessionAction; + + // #[wasm_bindgen(start)] + // pub fn run() { + // // This will help us verify that the unstable APIs flag is recognized + // #[cfg(web_sys_unstable_apis)] + // web_sys::console::log_1(&"Unstable APIs are enabled".into()); + + // #[cfg(not(web_sys_unstable_apis))] + // web_sys::console::log_1(&"Unstable APIs are NOT enabled".into()); + + // // Check which types are available + // #[cfg(feature = "MediaMetadata")] + // web_sys::console::log_1(&"MediaMetadata is available".into()); + + // #[cfg(feature = "MediaSession")] + // web_sys::console::log_1(&"MediaSession is available".into()); + + // #[cfg(feature = "MediaSessionAction")] + // web_sys::console::log_1(&"MediaSessionAction is available".into()); + + // // Try to use Navigator, which should definitely be available + // if let Some(window) = web_sys::window() { + // let navigator: Navigator = window.navigator(); + // web_sys::console::log_1(&"Navigator is available".into()); + // } + // } + + // Add this near the top of the component, after other use_effect calls + { + let audio_state = audio_state.clone(); + let audio_dispatch = _audio_dispatch.clone(); + + use_effect_with(audio_state.clone(), move |_| { + if let Some(window) = web_sys::window() { + let navigator: Navigator = window.navigator(); + + // Set up media session + if let Ok(media_session) = + js_sys::Reflect::get(&navigator, &JsValue::from_str("mediaSession")) + { + let media_session: web_sys::MediaSession = media_session.dyn_into().unwrap(); + + // Update metadata + if let Some(audio_props) = &audio_state.currently_playing { + let metadata = web_sys::MediaMetadata::new().unwrap(); + metadata.set_title(&audio_props.title); + + // Create a JavaScript array for the artwork + let artwork_array = Array::new(); + let artwork_object = js_sys::Object::new(); + js_sys::Reflect::set( + &artwork_object, + &"src".into(), + &audio_props.artwork_url.clone().into(), + ) + .unwrap(); + js_sys::Reflect::set(&artwork_object, &"sizes".into(), &"512x512".into()) + .unwrap(); + js_sys::Reflect::set(&artwork_object, &"type".into(), &"image/jpeg".into()) + .unwrap(); + artwork_array.push(&artwork_object); + + // Set the artwork using the JavaScript array + metadata.set_artwork(&artwork_array.into()); + + media_session.set_metadata(Some(&metadata)); + } + let audio_dispatch_play = audio_dispatch.clone(); + // Set up action handlers + let play_pause_callback = Closure::wrap(Box::new(move || { + audio_dispatch_play.reduce_mut(UIState::toggle_playback); + }) + as Box); + media_session.set_action_handler( + web_sys::MediaSessionAction::Play, + Some(play_pause_callback.as_ref().unchecked_ref()), + ); + media_session.set_action_handler( + web_sys::MediaSessionAction::Pause, + Some(play_pause_callback.as_ref().unchecked_ref()), + ); + play_pause_callback.forget(); + let audio_state_back = audio_state.clone(); + let audio_dispatch_back = audio_dispatch.clone(); + let seek_backward_callback = Closure::wrap(Box::new(move || { + if let Some(audio_element) = audio_state_back.audio_element.as_ref() { + let new_time = audio_element.current_time() - 15.0; + audio_element.set_current_time(new_time); + audio_dispatch_back + .reduce_mut(|state| state.update_current_time(new_time)); + } + }) + as Box); + media_session.set_action_handler( + web_sys::MediaSessionAction::Seekbackward, + Some(seek_backward_callback.as_ref().unchecked_ref()), + ); + seek_backward_callback.forget(); + + let seek_forward_callback = Closure::wrap(Box::new(move || { + if let Some(audio_element) = audio_state.audio_element.as_ref() { + let new_time = audio_element.current_time() + 15.0; + audio_element.set_current_time(new_time); + audio_dispatch.reduce_mut(|state| state.update_current_time(new_time)); + } + }) + as Box); + media_session.set_action_handler( + web_sys::MediaSessionAction::Seekforward, + Some(seek_forward_callback.as_ref().unchecked_ref()), + ); + seek_forward_callback.forget(); + } + } + + || () + }); + } + // Toggle playback let toggle_playback = { let dispatch = _audio_dispatch.clone(); @@ -1421,7 +1551,6 @@ pub fn on_play_click_offline( }) } - pub fn on_play_click_shared( episode_url: String, episode_title: String, From 13c672615b6c6373bdee01845929c38166278012 Mon Sep 17 00:00:00 2001 From: madeofpendletonwool Date: Wed, 9 Oct 2024 12:03:42 -0500 Subject: [PATCH 06/14] Fix mouseevent error --- web/src/components/gen_components.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/components/gen_components.rs b/web/src/components/gen_components.rs index 6cbd8f36..65c80d13 100644 --- a/web/src/components/gen_components.rs +++ b/web/src/components/gen_components.rs @@ -921,7 +921,7 @@ pub fn context_button(props: &ContextButtonProps) -> Html { let episode = props.episode.clone(); let download_local_post = audio_dispatch.clone(); - Callback::from(move |_| { + Callback::from(move |_: MouseEvent| { let post_state = download_local_post.clone(); let episode_id = episode.get_episode_id(Some(0)); From 4b027bfff01665de49bb2b13bfc407bb988accdf Mon Sep 17 00:00:00 2001 From: madeofpendletonwool Date: Wed, 9 Oct 2024 13:54:24 -0500 Subject: [PATCH 07/14] Add rustflags to tauri conf --- web/src-tauri/tauri.conf.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/src-tauri/tauri.conf.json b/web/src-tauri/tauri.conf.json index 7be7c721..f951aea7 100644 --- a/web/src-tauri/tauri.conf.json +++ b/web/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "build": { - "beforeBuildCommand": "trunk build", - "beforeDevCommand": "trunk serve", + "beforeBuildCommand": "RUSTFLAGS='--cfg=web_sys_unstable_apis' trunk build", + "beforeDevCommand": "RUSTFLAGS='--cfg=web_sys_unstable_apis' trunk serve", "devUrl": "http://localhost:8080", "frontendDist": "../dist" }, @@ -63,4 +63,4 @@ "timestampUrl": "" } } -} \ No newline at end of file +} From 5c0fbc5dc9daecfd4d089a4c9d044dae477e5c1b Mon Sep 17 00:00:00 2001 From: madeofpendletonwool Date: Fri, 11 Oct 2024 07:57:43 -0500 Subject: [PATCH 08/14] Fixed login state so it now shows loading pre-auth --- completed_todos.md | 17 ++++++++--------- web/Trunk.toml | 16 ++++++++++++++++ web/src/.cargo/config.toml | 2 ++ web/src/components/login.rs | 37 ++++++++++++++++++++++++++++++------- 4 files changed, 56 insertions(+), 16 deletions(-) create mode 100644 web/Trunk.toml create mode 100644 web/src/.cargo/config.toml diff --git a/completed_todos.md b/completed_todos.md index 1f238f80..d1d131c4 100644 --- a/completed_todos.md +++ b/completed_todos.md @@ -56,10 +56,10 @@ pre-0.7.0: - [] Subscribe to people - [] Add loading spinner when adding podcast via people page - [] People page dropdowns on podcasts and episodes - alternative 3 per line view on podcasts -- [] Android play/pause episode metadata - [] Stop issues with timeouts on occation with mobile apps -- [] Finalize loading states so you don't see login page when you are already authenticated - [] Make virtual lines work for saved queue, downloads, local downloads, and history +- [] Finalize virtual lines so it works like home on episode layout +- [] On very small screens you no longer get the mini version without the context button - [] Dynamically adjusting buttons on episode page done but needs testing @@ -86,7 +86,9 @@ Version 0.7.0 - [x] aur client - [x] Added Valkey to make many processes faster +- [x] Finalize loading states so you don't see login page when you are already authenticated - [x] Using valkey to ensure stateless opml imports +- [x] Android play/pause episode metadata - [x] Dynamically adjusting Download, Queue, and Saved Episodes so that every page can add or remove from these lists - [x] Fixed issue where some episodes weren't adding when refreshing due to redirects - [x] Some pods not loading in from opml import - better opml validation. Say number importing. - OPML imports moved to backend to get pod values, also reporting function created to update status @@ -114,14 +116,11 @@ Version 0.6.6 - [x] Upgraded pulldown-cmark library - [x] Upgraded python mysql-connection library to 9 - [x] Upgraded chrono-tz rust library - -CI/CD: - -- [] mac version attached like this: +- [x] mac version attached like this: dmg.Pinepods_0.6.5_aarch64.dmg - Also second mac archive build failed -- [] Fix the archived builds for linux. Which are huge because we include a ton of appimage info -- [] Add in x64 mac releases -- [] Build in arm cross compile into ubuntu build +- [x] Fix the archived builds for linux. Which are huge because we include a ton of appimage info +- [x] Add in x64 mac releases +- [x] Build in arm cross compile into ubuntu build Version 0.6.5 diff --git a/web/Trunk.toml b/web/Trunk.toml new file mode 100644 index 00000000..d497551e --- /dev/null +++ b/web/Trunk.toml @@ -0,0 +1,16 @@ +[build] +target = "index.html" +dist = "dist" +release = true + +# [watch] +# ignore = ["dist/**"] + +# [serve] +# Add any serve-specific configurations here if needed + +[clean] +dist = "dist" + +# Specify the required Trunk version if needed +# trunk-version = "^0.19.0" diff --git a/web/src/.cargo/config.toml b/web/src/.cargo/config.toml new file mode 100644 index 00000000..84671750 --- /dev/null +++ b/web/src/.cargo/config.toml @@ -0,0 +1,2 @@ +[build] +rustflags = ["--cfg=web_sys_unstable_apis"] diff --git a/web/src/components/login.rs b/web/src/components/login.rs index b5e9b7da..4a558467 100644 --- a/web/src/components/login.rs +++ b/web/src/components/login.rs @@ -54,6 +54,7 @@ pub fn login() -> Html { let temp_user_id = use_state(|| 0); let temp_server_name = use_state(|| "".to_string()); let info_message = _state.info_message.clone(); + let loading = use_state(|| true); // Define the initial state let page_state = use_state(|| PageState::Default); let self_service_enabled = use_state(|| false); // State to store self-service status @@ -112,13 +113,19 @@ pub fn login() -> Html { }); } let effect_displatch = dispatch.clone(); + let effect_loading = loading.clone(); // User Auto Login with saved state use_effect_with((), { - // let error_clone_use = error_message_clone.clone(); + web_sys::console::log_1(&"start of effect".into()); let history = history.clone(); move |_| { + web_sys::console::log_1(&"moving in".into()); + effect_loading.set(true); + web_sys::console::log_1(&"moving in2".into()); if let Some(window) = web_sys::window() { + web_sys::console::log_1(&"moving in3".into()); if let Ok(local_storage) = window.local_storage() { + web_sys::console::log_1(&"moving in4".into()); if let Some(storage) = local_storage { if let Ok(Some(stored_theme)) = storage.get_item("selected_theme") { // Set the theme using your existing theme change function @@ -126,8 +133,9 @@ pub fn login() -> Html { &stored_theme, ); } - + web_sys::console::log_1(&"pre user".into()); if let Ok(Some(user_state)) = storage.get_item("userState") { + web_sys::console::log_1(&"post user".into()); let app_state_result = AppState::deserialize(&user_state); if let Ok(Some(auth_state)) = storage.get_item("userAuthState") { @@ -245,21 +253,23 @@ pub fn login() -> Html { } }); let redirect_route = requested_route.unwrap_or_else(|| "/home".to_string()); + effect_loading + .set(false); history.push( &redirect_route, ); // Redirect to the requested or home page } Err(_) => { - // API key is not valid, redirect to login + effect_loading + .set(false); history.push("/"); } } }, ); } else { - console::log_1( - &"Auth details are None".into(), - ); + // API key is not valid, redirect to login + effect_loading.set(false); } } } @@ -271,9 +281,12 @@ pub fn login() -> Html { &format!("Error deserializing auth state: {:?}", e) .into(), ); + effect_loading.set(false); } } } + } else { + effect_loading.set(false); } } } @@ -542,7 +555,6 @@ pub fn login() -> Html { e.stop_propagation(); }); - let on_fullname_change = { let fullname = fullname.clone(); Callback::from(move |e: InputEvent| { @@ -1291,6 +1303,16 @@ pub fn login() -> Html { html! { <> + if *loading { +
+
+
+
+
+
+
+
+ } else {
{ match *page_state { @@ -1385,6 +1407,7 @@ pub fn login() -> Html {
+ } } From 001e315d37b53ec426e7fcc4876edba8d2ffa27e Mon Sep 17 00:00:00 2001 From: madeofpendletonwool Date: Sat, 12 Oct 2024 09:27:19 -0500 Subject: [PATCH 09/14] Testing lto --- web/Cargo.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/web/Cargo.toml b/web/Cargo.toml index 8513719e..c9d5351f 100644 --- a/web/Cargo.toml +++ b/web/Cargo.toml @@ -87,3 +87,6 @@ tauri-sys = { git = "https://github.com/madeofpendletonwool/tauri-sys", version [features] default = [] server_build = [] + +# [profile.release] +# lto = true From 6f668f2567b44a4da6cae60c9eb442a9e85599e2 Mon Sep 17 00:00:00 2001 From: madeofpendletonwool Date: Thu, 17 Oct 2024 20:48:22 -0500 Subject: [PATCH 10/14] Adding start of people subscriptions --- Backend/pinepods_backend/src/main.rs | 123 ++++++++---- clients/clientapi.py | 71 +++++++ completed_todos.md | 30 +-- database_functions/functions.py | 113 ++++++++++- dockerfile | 10 +- dockerfile-arm | 12 +- startup/setupdatabase.py | 16 ++ startup/setuppostgresdatabase.py | 17 ++ web/src/components/episode.rs | 87 ++++----- web/src/components/episodes_layout.rs | 263 ++------------------------ web/src/components/mod.rs | 1 + web/src/components/podcasts.rs | 2 +- web/src/components/shared_episode.rs | 51 +++-- web/src/requests/mod.rs | 3 +- web/src/requests/pod_req.rs | 1 + 15 files changed, 429 insertions(+), 371 deletions(-) diff --git a/Backend/pinepods_backend/src/main.rs b/Backend/pinepods_backend/src/main.rs index 52f4cc20..c7b2f0e4 100644 --- a/Backend/pinepods_backend/src/main.rs +++ b/Backend/pinepods_backend/src/main.rs @@ -12,13 +12,17 @@ use actix_cors::Cors; struct SearchQuery { query: Option, index: Option, - search_type: Option, // Added for specifying search type + search_type: Option, +} + +#[derive(Deserialize)] +struct PodcastQuery { + id: String, } async fn search_handler(query: web::Query) -> impl Responder { println!("search_handler called"); - // Check if the query parameters are empty and return 200 OK immediately if they are if query.query.is_none() && query.index.is_none() { println!("Empty query and index - returning 200 OK"); return HttpResponse::Ok().body("Test connection successful"); @@ -40,20 +44,10 @@ async fn search_handler(query: web::Query) -> impl Responder { client.get(&itunes_search_url).send().await } else { - // Determine the correct Podcast Index API endpoint based on search_type - let api_key = match env::var("API_KEY") { - Ok(key) => key, - Err(_) => { - println!("API_KEY not set in the environment"); - return HttpResponse::InternalServerError().body("API_KEY not set"); - } - }; - let api_secret = match env::var("API_SECRET") { - Ok(secret) => secret, - Err(_) => { - println!("API_SECRET not set in the environment"); - return HttpResponse::InternalServerError().body("API_SECRET not set"); - } + // Podcast Index API search + let (api_key, api_secret) = match get_api_credentials() { + Ok(creds) => creds, + Err(response) => return response, }; let encoded_search_term = urlencoding::encode(&search_term); @@ -72,33 +66,87 @@ async fn search_handler(query: web::Query) -> impl Responder { println!("Using Podcast Index search URL: {}", podcast_search_url); - let epoch_time = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs().to_string(); - let data_to_hash = format!("{}{}{}", api_key, api_secret, epoch_time); - - let mut hasher = Sha1::new(); - hasher.update(data_to_hash.as_bytes()); - let sha_1 = format!("{:x}", hasher.finalize()); - - let mut headers = HeaderMap::new(); - headers.insert("X-Auth-Date", HeaderValue::from_str(&epoch_time).unwrap_or_else(|e| { - error!("Failed to insert X-Auth-Date header: {:?}", e); - std::process::exit(1); - })); - headers.insert("X-Auth-Key", HeaderValue::from_str(&api_key).unwrap_or_else(|e| { - error!("Failed to insert X-Auth-Key header: {:?}", e); - std::process::exit(1); - })); - headers.insert("Authorization", HeaderValue::from_str(&sha_1).unwrap_or_else(|e| { - error!("Failed to insert Authorization header: {:?}", e); - std::process::exit(1); - })); - headers.insert(USER_AGENT, HeaderValue::from_static("MyPodcastApp/1.0")); // Use your custom User-Agent here + let headers = match create_auth_headers(&api_key, &api_secret) { + Ok(h) => h, + Err(response) => return response, + }; println!("Final Podcast Index URL: {}", podcast_search_url); client.get(&podcast_search_url).headers(headers).send().await }; + handle_response(response).await +} + +async fn podcast_handler(query: web::Query) -> impl Responder { + println!("podcast_handler called"); + + let podcast_id = &query.id; + let client = reqwest::Client::new(); + + let (api_key, api_secret) = match get_api_credentials() { + Ok(creds) => creds, + Err(response) => return response, + }; + + let podcast_url = format!("https://api.podcastindex.org/api/1.0/podcasts/byfeedid?id={}", podcast_id); + println!("Using Podcast Index URL: {}", podcast_url); + + let headers = match create_auth_headers(&api_key, &api_secret) { + Ok(h) => h, + Err(response) => return response, + }; + + let response = client.get(&podcast_url).headers(headers).send().await; + handle_response(response).await +} + +fn get_api_credentials() -> Result<(String, String), HttpResponse> { + let api_key = match env::var("API_KEY") { + Ok(key) => key, + Err(_) => { + println!("API_KEY not set in the environment"); + return Err(HttpResponse::InternalServerError().body("API_KEY not set")); + } + }; + let api_secret = match env::var("API_SECRET") { + Ok(secret) => secret, + Err(_) => { + println!("API_SECRET not set in the environment"); + return Err(HttpResponse::InternalServerError().body("API_SECRET not set")); + } + }; + Ok((api_key, api_secret)) +} + +fn create_auth_headers(api_key: &str, api_secret: &str) -> Result { + let epoch_time = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs().to_string(); + let data_to_hash = format!("{}{}{}", api_key, api_secret, epoch_time); + + let mut hasher = Sha1::new(); + hasher.update(data_to_hash.as_bytes()); + let sha_1 = format!("{:x}", hasher.finalize()); + + let mut headers = HeaderMap::new(); + headers.insert("X-Auth-Date", HeaderValue::from_str(&epoch_time).unwrap_or_else(|e| { + error!("Failed to insert X-Auth-Date header: {:?}", e); + std::process::exit(1); + })); + headers.insert("X-Auth-Key", HeaderValue::from_str(api_key).unwrap_or_else(|e| { + error!("Failed to insert X-Auth-Key header: {:?}", e); + std::process::exit(1); + })); + headers.insert("Authorization", HeaderValue::from_str(&sha_1).unwrap_or_else(|e| { + error!("Failed to insert Authorization header: {:?}", e); + std::process::exit(1); + })); + headers.insert(USER_AGENT, HeaderValue::from_static("PodPeopleDB/1.0")); + + Ok(headers) +} + +async fn handle_response(response: Result) -> HttpResponse { match response { Ok(resp) => { if resp.status().is_success() { @@ -137,6 +185,7 @@ async fn main() -> std::io::Result<()> { App::new() .wrap(Cors::default().allow_any_origin().allow_any_method().allow_any_header()) .route("/api/search", web::get().to(search_handler)) + .route("/api/podcast", web::get().to(podcast_handler)) }) .bind("0.0.0.0:5000")? .run() diff --git a/clients/clientapi.py b/clients/clientapi.py index 4df88c34..f072e7d6 100644 --- a/clients/clientapi.py +++ b/clients/clientapi.py @@ -3692,6 +3692,77 @@ async def queue_bump(data: QueueBump, cnx=Depends(get_database_connection), raise HTTPException(status_code=403, detail="You can only bump the queue for yourself!") +class PersonSubscribeRequest(BaseModel): + person_name: str + podcast_id: int + +@app.post("/api/data/person/subscribe/{user_id}/{person_id}") +async def api_subscribe_to_person( + user_id: int, + person_id: int, + request: PersonSubscribeRequest, + cnx=Depends(get_database_connection), + api_key: str = Depends(get_api_key_from_header) +): + is_valid_key = database_functions.functions.verify_api_key(cnx, database_type, api_key) + if not is_valid_key: + raise HTTPException(status_code=403, detail="Invalid or unauthorized API key") + is_web_key = api_key == base_webkey.web_key + key_id = database_functions.functions.id_from_api_key(cnx, database_type, api_key) + if key_id == user_id or is_web_key: + success = database_functions.functions.subscribe_to_person(cnx, database_type, user_id, person_id, request.person_name, request.podcast_id) + if success: + return {"message": "Successfully subscribed to person"} + else: + raise HTTPException(status_code=400, detail="Failed to subscribe to person") + else: + raise HTTPException(status_code=403, detail="You can only subscribe for yourself!") + +class UnsubscribeRequest(BaseModel): + person_name: str + +@app.delete("/api/data/person/unsubscribe/{user_id}/{person_id}") +async def api_unsubscribe_from_person( + user_id: int, + person_id: int, + request: UnsubscribeRequest, + cnx=Depends(get_database_connection), + api_key: str = Depends(get_api_key_from_header) +): + is_valid_key = database_functions.functions.verify_api_key(cnx, database_type, api_key) + if not is_valid_key: + raise HTTPException(status_code=403, detail="Invalid or unauthorized API key") + is_web_key = api_key == base_webkey.web_key + key_id = database_functions.functions.id_from_api_key(cnx, database_type, api_key) + if key_id == user_id or is_web_key: + success = database_functions.functions.unsubscribe_from_person(cnx, database_type, user_id, person_id, request.person_name) + if success: + return {"message": "Successfully unsubscribed from person"} + else: + raise HTTPException(status_code=400, detail="Failed to unsubscribe from person") + else: + raise HTTPException(status_code=403, detail="You can only unsubscribe for yourself!") + +@app.get("/api/data/person/subscriptions/{user_id}") +async def api_get_person_subscriptions( + user_id: int, + cnx=Depends(get_database_connection), + api_key: str = Depends(get_api_key_from_header) +): + is_valid_key = database_functions.functions.verify_api_key(cnx, database_type, api_key) + if not is_valid_key: + raise HTTPException(status_code=403, detail="Invalid or unauthorized API key") + + is_web_key = api_key == base_webkey.web_key + key_id = database_functions.functions.id_from_api_key(cnx, database_type, api_key) + + if key_id == user_id or is_web_key: + subscriptions = database_functions.functions.get_person_subscriptions(cnx, database_type, user_id) + return {"subscriptions": subscriptions} + else: + raise HTTPException(status_code=403, detail="You can only view your own subscriptions!") + + @app.get("/api/data/stream/{episode_id}") async def stream_episode( episode_id: int, diff --git a/completed_todos.md b/completed_todos.md index b18c9889..349ac262 100644 --- a/completed_todos.md +++ b/completed_todos.md @@ -50,18 +50,6 @@ Mobile: - [] On mobile nextcloud doesn't redirect back after adding - [] mobile version giving one of two share links wrong. Provides the current server - which on mobile is wrong -pre-0.7.0: - -- [] People Table with background jobs to update people found in podcasts -- [] Subscribe to people -- [] Add loading spinner when adding podcast via people page -- [] People page dropdowns on podcasts and episodes - alternative 3 per line view on podcasts -- [] Stop issues with timeouts on occation with mobile apps -- [] Make virtual lines work for saved queue, downloads, local downloads, and history -- [] Finalize virtual lines so it works like home on episode layout -- [] On very small screens you no longer get the mini version without the context button -- [] Dynamically adjusting buttons on episode page - done but needs testing - [] Fix issues with refreshing @@ -78,6 +66,14 @@ done but needs testing select category no longer pulls in categories - [] client local download function broken. Need android compiling alternative to reqwest + +People stuff left: +- [] Flesh out podpeopledb +- [] Add check for person associated with other added pods after subbing to a person +- [] Add call to pod people db to see if a person exists associated with a given podcast - Get pod people id if so +- [] Pre-emtively 'cache' podcasts that a host is a part of +- [] Call for hosts for any podcasts, even ones without pod 2.0 data from the pod people db + Version 0.7.0 - [x] Android App @@ -86,6 +82,16 @@ Version 0.7.0 - [x] aur client - [x] Added Valkey to make many processes faster +- [] People Table with background jobs to update people found in podcasts +- [] Subscribe to people +- [] Add loading spinner when adding podcast via people page +- [] People page dropdowns on podcasts and episodes - alternative 3 per line view on podcasts +- [] Stop issues with timeouts on occation with mobile apps +- [] Make virtual lines work for saved queue, downloads, local downloads, and history +- [] Finalize virtual lines so it works like home on episode layout +- [] On very small screens you no longer get the mini version without the context button +- [] Dynamically adjusting buttons on episode page +- [] PodcastPeople DB up and running and can be contributed to - [x] Finalize loading states so you don't see login page when you are already authenticated - [x] Using valkey to ensure stateless opml imports - [x] Android play/pause episode metadata diff --git a/database_functions/functions.py b/database_functions/functions.py index e686e006..3b5dc1de 100644 --- a/database_functions/functions.py +++ b/database_functions/functions.py @@ -22,6 +22,7 @@ import requests from requests.auth import HTTPBasicAuth from urllib.parse import urlparse, urlunparse +from typing import List # # Get the application root directory from the environment variable # app_root = os.environ.get('APP_ROOT') @@ -2931,7 +2932,6 @@ def enable_disable_self_service(cnx, database_type): def verify_api_key(cnx, database_type, passed_key): - print(f'heres your key {passed_key} (length: {len(passed_key)})') cursor = cnx.cursor() if database_type == "postgresql": query = 'SELECT * FROM "APIKeys" WHERE APIKey = %s' @@ -5443,6 +5443,117 @@ def queue_bump(database_type, cnx, ep_url, title, user_id): +def subscribe_to_person(cnx, database_type, user_id: int, person_id: int, person_name: str, podcast_id: int) -> bool: + cursor = cnx.cursor() + try: + if database_type == "postgresql": + # Check if a person with the same PeopleDBID (if not 0) or Name (if PeopleDBID is 0) exists + if person_id != 0: + query = """ + SELECT PersonID, AssociatedPodcasts FROM "People" + WHERE UserID = %s AND PeopleDBID = %s + """ + cursor.execute(query, (user_id, person_id)) + else: + query = """ + SELECT PersonID, AssociatedPodcasts FROM "People" + WHERE UserID = %s AND Name = %s AND PeopleDBID = 0 + """ + cursor.execute(query, (user_id, person_name)) + + existing_person = cursor.fetchone() + + if existing_person: + # Person exists, update AssociatedPodcasts + person_id, associated_podcasts = existing_person + podcast_list = associated_podcasts.split(',') if associated_podcasts else [] + if str(podcast_id) not in podcast_list: + podcast_list.append(str(podcast_id)) + new_associated_podcasts = ','.join(podcast_list) + update_query = """ + UPDATE "People" SET AssociatedPodcasts = %s + WHERE PersonID = %s + """ + cursor.execute(update_query, (new_associated_podcasts, person_id)) + else: + # Person doesn't exist, insert new record + insert_query = """ + INSERT INTO "People" (UserID, PeopleDBID, Name, AssociatedPodcasts) + VALUES (%s, %s, %s, %s) + """ + cursor.execute(insert_query, (user_id, person_id, person_name, str(podcast_id))) + + else: # MySQL or MariaDB + # Similar logic for MySQL/MariaDB + pass + + cnx.commit() + return True + except Exception as e: + print(f"Error subscribing to person: {e}") + cnx.rollback() + return False + finally: + cursor.close() + +def unsubscribe_from_person(cnx, database_type, user_id: int, person_id: int, person_name: str) -> bool: + cursor = cnx.cursor() + try: + if database_type == "postgresql": + if person_id != 0: + query = 'DELETE FROM "People" WHERE UserID = %s AND PeopleDBID = %s' + cursor.execute(query, (user_id, person_id)) + else: + query = 'DELETE FROM "People" WHERE UserID = %s AND Name = %s AND PeopleDBID = 0' + cursor.execute(query, (user_id, person_name)) + else: # MySQL or MariaDB + if person_id != 0: + query = "DELETE FROM People WHERE UserID = %s AND PeopleDBID = %s" + cursor.execute(query, (user_id, person_id)) + else: + query = "DELETE FROM People WHERE UserID = %s AND Name = %s AND PeopleDBID = 0" + cursor.execute(query, (user_id, person_name)) + cnx.commit() + return True + except Exception as e: + print(f"Error unsubscribing from person: {e}") + cnx.rollback() + return False + finally: + cursor.close() + +def get_person_subscriptions(cnx, database_type, user_id: int) -> List[dict]: + try: + if database_type == "postgresql": + cursor = cnx.cursor(row_factory=dict_row) + query = 'SELECT * FROM "People" WHERE UserID = %s' + else: # MySQL or MariaDB + cursor = cnx.cursor(dictionary=True) + query = "SELECT * FROM People WHERE UserID = %s" + + cursor.execute(query, (user_id,)) + result = cursor.fetchall() + + # Convert result to list of dicts and ensure correct data types + formatted_result = [] + for row in result: + formatted_row = { + 'personid': int(row['personid']), + 'name': row['name'], + 'peopledbid': int(row['peopledbid']) if row['peopledbid'] is not None else None, + 'associatedpodcasts': row['associatedpodcasts'], + 'userid': int(row['userid']) + } + formatted_result.append(formatted_row) + + return formatted_result + except Exception as e: + print(f"Error getting person subscriptions: {e}") + return [] + finally: + cursor.close() + + def backup_user(database_type, cnx, user_id): if database_type == "postgresql": diff --git a/dockerfile b/dockerfile index 5276ecbc..a410c940 100644 --- a/dockerfile +++ b/dockerfile @@ -20,10 +20,16 @@ RUN apk add trunk@edge RUN rustup target add wasm32-unknown-unknown && \ cargo install wasm-bindgen-cli -# Add your application files to the builder stage -COPY ./web /app +# Add application files to the builder stage +COPY ./web/Cargo.lock ./web/Cargo.toml ./web/dev-info.md ./web/index.html ./web/tailwind.config.js ./web/Trunk.toml /app/ +COPY ./web/dist /app/dist +COPY ./web/src /app/src +COPY ./web/static /app/static +COPY ./web/target /app/target + WORKDIR /app + # Build the Yew application in release mode RUN RUSTFLAGS="--cfg=web_sys_unstable_apis" trunk build --features server_build --release diff --git a/dockerfile-arm b/dockerfile-arm index da0dbd73..44691154 100644 --- a/dockerfile-arm +++ b/dockerfile-arm @@ -20,9 +20,15 @@ RUN apk update # Install the desired package from the edge community repository RUN apk add trunk@edge -# Add your application files to the builder stage -COPY . /app -WORKDIR /app/web +# Add application files to the builder stage +COPY ./web/Cargo.lock ./web/Cargo.toml ./web/dev-info.md ./web/index.html ./web/tailwind.config.js ./web/Trunk.toml /app/ +COPY ./web/dist /app/dist +COPY ./web/src /app/src +COPY ./web/static /app/static +COPY ./web/target /app/target + +WORKDIR /app + # Build the Yew application in release mode RUN RUSTFLAGS="--cfg=web_sys_unstable_apis" trunk build --features server_build --release diff --git a/startup/setupdatabase.py b/startup/setupdatabase.py index 145c23fd..fb39dc4a 100644 --- a/startup/setupdatabase.py +++ b/startup/setupdatabase.py @@ -404,6 +404,22 @@ def create_index_if_not_exists(cursor, index_name, table_name, column_name): create_index_if_not_exists(cursor, "idx_episodes_episodepubdate", "Episodes", "EpisodePubDate") + try: + cursor.execute(""" + CREATE TABLE IF NOT EXISTS People ( + PersonID INT AUTO_INCREMENT PRIMARY KEY, + Name TEXT, + PeopleDBID INT, + AssociatedPodcasts TEXT, + UserID INT, + FOREIGN KEY (UserID) REFERENCES Users(UserID) + ); + """) + cnx.commit() + except Exception as e: + print(f"Error creating SharedEpisodes table: {e}") + + try: cursor.execute(""" CREATE TABLE IF NOT EXISTS SharedEpisodes ( diff --git a/startup/setuppostgresdatabase.py b/startup/setuppostgresdatabase.py index 4bfe8786..da480b90 100644 --- a/startup/setuppostgresdatabase.py +++ b/startup/setuppostgresdatabase.py @@ -414,6 +414,23 @@ def create_index_if_not_exists(cursor, index_name, table_name, column_name): create_index_if_not_exists(cursor, "idx_episodes_podcastid", "Episodes", "PodcastID") create_index_if_not_exists(cursor, "idx_episodes_episodepubdate", "Episodes", "EpisodePubDate") + try: + cursor.execute(""" + CREATE TABLE IF NOT EXISTS "People" ( + PersonID SERIAL PRIMARY KEY, + Name TEXT, + PeopleDBID INT, + AssociatedPodcasts TEXT, + UserID INT, + FOREIGN KEY (UserID) REFERENCES "Users"(UserID) + ); + + """) + cnx.commit() + except Exception as e: + print(f"Error creating People table: {e}") + + try: cursor.execute(""" CREATE TABLE IF NOT EXISTS "SharedEpisodes" ( diff --git a/web/src/components/episode.rs b/web/src/components/episode.rs index 94e49c6b..ddeb8238 100644 --- a/web/src/components/episode.rs +++ b/web/src/components/episode.rs @@ -4,20 +4,20 @@ use crate::components::audio::on_play_click; use crate::components::audio::AudioPlayer; use crate::components::click_events::create_on_title_click; use crate::components::context::{AppState, UIState}; -use crate::components::episodes_layout::SafeHtml; -use crate::components::episodes_layout::{HostDropdown, UIStateMsg}; +use crate::components::episodes_layout::{SafeHtml, UIStateMsg}; use crate::components::gen_funcs::{ convert_time_to_seconds, format_datetime, format_time, match_date_format, parse_date, sanitize_html_with_blank_target, }; +use crate::components::host_component::HostDropdown; use crate::requests::login_requests::use_check_authentication; use crate::requests::pod_req; use crate::requests::pod_req::{ - call_download_episode, call_fetch_podcasting_2_data, call_get_episode_id, - call_mark_episode_completed, call_create_share_link, call_mark_episode_uncompleted, call_queue_episode, - call_save_episode, DownloadEpisodeRequest, EpisodeInfo, EpisodeMetadataResponse, - EpisodeRequest, FetchPodcasting2DataRequest, MarkEpisodeCompletedRequest, QueuePodcastRequest, - SavePodcastRequest, + call_create_share_link, call_download_episode, call_fetch_podcasting_2_data, + call_get_episode_id, call_mark_episode_completed, call_mark_episode_uncompleted, + call_queue_episode, call_save_episode, DownloadEpisodeRequest, EpisodeInfo, + EpisodeMetadataResponse, EpisodeRequest, FetchPodcasting2DataRequest, + MarkEpisodeCompletedRequest, QueuePodcastRequest, SavePodcastRequest, }; use crate::requests::search_pods::call_parse_podcast_url; use wasm_bindgen::closure::Closure; @@ -32,7 +32,9 @@ use yewdux::prelude::*; fn get_current_url() -> String { let window = window().expect("no global `window` exists"); let location = window.location(); - location.href().unwrap_or_else(|_| "Unable to retrieve URL".to_string()) + location + .href() + .unwrap_or_else(|_| "Unable to retrieve URL".to_string()) } #[function_component(Episode)] @@ -664,10 +666,6 @@ pub fn epsiode() -> Html { // } // }); - - - - { let state = state.clone(); let completion_status = completion_status.clone(); @@ -729,7 +727,9 @@ pub fn epsiode() -> Html { wasm_bindgen_futures::spawn_local(async move { let api_key_copy = api_key.clone(); - if let (Some(_api_key), Some(server_name)) = (api_key.as_ref(), server_name.as_ref()) { + if let (Some(_api_key), Some(server_name)) = + (api_key.as_ref(), server_name.as_ref()) + { match call_create_share_link( &server_name, &api_key_copy.unwrap().unwrap(), @@ -743,7 +743,9 @@ pub fn epsiode() -> Html { page_state_copy.set(PageState::Shown); // Show the modal } Err(e) => { - web_sys::console::log_1(&format!("Error creating share link: {}", e).into()); + web_sys::console::log_1( + &format!("Error creating share link: {}", e).into(), + ); } } } @@ -752,38 +754,37 @@ pub fn epsiode() -> Html { }; // Define the modal for showing the shareable link - let share_url_modal = - html! { -