diff --git a/Cargo.lock b/Cargo.lock index 1dfcf26..970f026 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -454,6 +454,7 @@ dependencies = [ "strum", "strum_macros", "unic-langid", + "web-sys", ] [[package]] diff --git a/app/Cargo.toml b/app/Cargo.toml index 1cf8cce..18ac1ed 100644 --- a/app/Cargo.toml +++ b/app/Cargo.toml @@ -26,4 +26,12 @@ enum-map = "1" qrcode = "0.12" image = "0.23" -shared = { path = "../shared" } \ No newline at end of file +shared = { path = "../shared" } + +[dependencies.web-sys] +version = "0.3" +features = [ + "IntersectionObserver", + "IntersectionObserverInit", + "IntersectionObserverEntry", +] \ No newline at end of file diff --git a/app/src/i18n.rs b/app/src/i18n.rs index 402343a..8569307 100644 --- a/app/src/i18n.rs +++ b/app/src/i18n.rs @@ -42,7 +42,7 @@ impl I18n { /// Get a localized string. Optionally with parameters provided in `args`. pub fn translate(&self, key: impl AsRef, args: Option<&FluentArgs>) -> String { - log!(key.as_ref()); + // log!(key.as_ref()); let msg = self .ftl_bundle .get_message(key.as_ref()) diff --git a/app/src/lib.rs b/app/src/lib.rs index 8f28c7f..108344c 100644 --- a/app/src/lib.rs +++ b/app/src/lib.rs @@ -26,8 +26,8 @@ fn init(url: Url, orders: &mut impl Orders) -> Model { Model { index: 0, - location: Location::new(url.clone()), - page: Page::init(url, orders, lang.clone()), + location: Location::new(url), + page: Page::NotFound, i18n: lang, user: Loadable::Data(None), login_form: LoginForm::default(), diff --git a/app/src/pages/list_links.rs b/app/src/pages/list_links.rs index 43add76..a8aedb8 100644 --- a/app/src/pages/list_links.rs +++ b/app/src/pages/list_links.rs @@ -9,6 +9,7 @@ use seed::{ a, attrs, div, h1, img, input, log, nodes, prelude::*, raw, section, span, table, td, th, tr, Url, C, IF, }; +use web_sys::{IntersectionObserver, IntersectionObserverEntry, IntersectionObserverInit}; use shared::{ apirequests::general::Ordering, @@ -25,6 +26,7 @@ use crate::{get_host, i18n::I18n, unwrap_or_return}; pub fn init(mut url: Url, orders: &mut impl Orders, i18n: I18n) -> Model { // fetch the links to fill the list. orders.send_msg(Msg::Query(QueryMsg::Fetch)); + orders.perform_cmd(cmds::timeout(50, || Msg::SetupObserver)); // if the url contains create_link set the edit_link variable. // This variable then opens the create link dialog. let dialog = match url.next_path_part() { @@ -37,24 +39,35 @@ pub fn init(mut url: Url, orders: &mut impl Orders, i18n: I18n) -> Model { Model { links: Vec::new(), // will contain the links to display - i18n, // to translate + load_more: ElRef::new(), // will contain a reference to the load more button to be able to load more links after scrolling to the bottom. + i18n, // to translate formconfig: LinkRequestForm::default(), // when requesting links the form is stored here - inputs: EnumMap::default(), // the input fields for the searches + inputs: EnumMap::default(), // the input fields for the searches dialog, handle_render: None, handle_timeout: None, + observer: None, // load more on scroll - this is used to see if the load-more button is completely visible + observer_callback: None, + observer_entries: None, + everything_loaded: false, } } #[derive(Debug)] pub struct Model { links: Vec>, // will contain the links to display - i18n: I18n, // to translate - formconfig: LinkRequestForm, // when requesting links the form is stored here + load_more: ElRef, + i18n: I18n, // to translate + formconfig: LinkRequestForm, // when requesting links the form is stored here inputs: EnumMap, // the input fields for the searches - dialog: Dialog, // User interaction - there can only ever be one dialog open. + dialog: Dialog, // User interaction - there can only ever be one dialog open. handle_render: Option, // Rendering qr-codes takes time... it is aborted when this handle is dropped and replaced. handle_timeout: Option, // Rendering qr-codes takes time... it is aborted when this handle is dropped and replaced. + + observer: Option, + observer_callback: Option)>>, + observer_entries: Option>, + everything_loaded: bool, } impl Model { @@ -133,9 +146,11 @@ struct FilterInput { /// A message can either edit or query. (or set a dialog) #[derive(Clone)] pub enum Msg { - Query(QueryMsg), // Messages related to querying links - Edit(EditMsg), // Messages related to editing links - ClearAll, // Clear all messages + Query(QueryMsg), // Messages related to querying links + Edit(EditMsg), // Messages related to editing links + ClearAll, // Clear all messages + SetupObserver, // Make an observer for endles scroll + Observed(Vec), SetMessage(String), // Set a message to the user } @@ -143,8 +158,10 @@ pub enum Msg { #[derive(Clone)] pub enum QueryMsg { Fetch, + FetchAdditional, OrderBy(LinkOverviewColumns), Received(Vec), + ReceivedAdditional(Vec), CodeFilterChanged(String), DescriptionFilterChanged(String), TargetFilterChanged(String), @@ -181,10 +198,54 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders) { Msg::Query(msg) => process_query_messages(msg, model, orders), Msg::Edit(msg) => process_edit_messages(msg, model, orders), Msg::ClearAll => clear_all(model), + Msg::Observed(entries) => { + if let Some(entry) = entries.first() { + log!(entry); + if entry.is_intersecting() { + orders.send_msg(Msg::Query(QueryMsg::FetchAdditional)); + } + } + } Msg::SetMessage(msg) => { clear_all(model); model.dialog = Dialog::Message(Status::Error(Message { message: msg })); } + Msg::SetupObserver => { + orders.skip(); + + // ---- observer callback ---- + let sender = orders.msg_sender(); + let callback = move |entries: Vec| { + let entries = entries + .into_iter() + .map(IntersectionObserverEntry::from) + .collect(); + sender(Some(Msg::Observed(entries))); + }; + let callback = Closure::wrap(Box::new(callback) as Box)>); + + // ---- observer options ---- + let mut options = IntersectionObserverInit::new(); + options.threshold(&JsValue::from(1)); + // ---- observer ---- + log!("Trying to register observer"); + if let Ok(observer) = + IntersectionObserver::new_with_options(callback.as_ref().unchecked_ref(), &options) + { + if let Some(element) = model.load_more.get() { + log!("element registered! ", element); + observer.observe(&element); + + // Note: Drop `observer` is not enough. We have to call `observer.disconnect()`. + model.observer = Some(observer); + model.observer_callback = Some(callback); + } else { + log!("element not yet registered! "); + }; + } else { + log!("Failed to get observer!") + }; + } } } @@ -193,7 +254,11 @@ pub fn process_query_messages(msg: QueryMsg, model: &mut Model, orders: &mut imp match msg { QueryMsg::Fetch => { orders.skip(); // No need to rerender - load_links(model, orders) + initial_load(model, orders) + } + QueryMsg::FetchAdditional => { + orders.skip(); // No need to rerender + consecutive_load(model, orders) } // Default to ascending ordering but if the links are already sorted according to this collumn toggle between ascending and descending ordering. QueryMsg::OrderBy(column) => { @@ -247,6 +312,20 @@ pub fn process_query_messages(msg: QueryMsg, model: &mut Model, orders: &mut imp }) .collect(); } + QueryMsg::ReceivedAdditional(response) => { + if response.len() < model.formconfig.amount { + log!("There are no more links! "); + model.everything_loaded = true + }; + let mut new_links = response + .into_iter() + .map(|l| { + let cache = generate_qr_from_code(&l.link.code); + Cached { data: l, cache } + }) + .collect(); + model.links.append(&mut new_links); + } QueryMsg::CodeFilterChanged(s) => { log!("Filter is: ", &s); let sanit = s.chars().filter(|x| x.is_alphanumeric()).collect(); @@ -274,9 +353,19 @@ pub fn process_query_messages(msg: QueryMsg, model: &mut Model, orders: &mut imp } } +fn initial_load(model: &Model, orders: &mut impl Orders) { + let mut data = model.formconfig.clone(); + data.offset = 0; + load_links(orders, data); +} +fn consecutive_load(model: &Model, orders: &mut impl Orders) { + let mut data = model.formconfig.clone(); + data.offset = model.links.len(); + load_links(orders, data); +} + /// Perform a request to the server to load the links from the server. -fn load_links(model: &Model, orders: &mut impl Orders) { - let data = model.formconfig.clone(); +fn load_links(orders: &mut impl Orders, data: LinkRequestForm) { orders.perform_cmd(async { let data = data; // create a request @@ -302,7 +391,11 @@ fn load_links(model: &Model, orders: &mut impl Orders) { Msg::SetMessage("Invalid response".to_string()) ); // The message that is sent by perform_cmd after this async block is completed - Msg::Query(QueryMsg::Received(links)) + match data.offset.cmp(&0) { + std::cmp::Ordering::Less => unreachable!(), + std::cmp::Ordering::Equal => Msg::Query(QueryMsg::Received(links)), + std::cmp::Ordering::Greater => Msg::Query(QueryMsg::ReceivedAdditional(links)), + } }); } @@ -530,6 +623,17 @@ pub fn view(model: &Model, logged_in_user: &User) -> Node { // Add all the content lines model.links.iter().map(|l| { view_link(l, logged_in_user) }) ], + if not(model.everything_loaded) { + a![ + C!["loadmore", "button"], + el_ref(&model.load_more), + ev(Ev::Click, move |_| Msg::Query(QueryMsg::FetchAdditional)), + img![C!["reloadicon"], attrs!(At::Src => "/static/reload.svg")], + t("load-more-links") + ] + } else { + a![C!["loadmore", "button"], t("no-more-links")] + } ] } diff --git a/locales/de/main.ftl b/locales/de/main.ftl index f9ca170..554b566 100644 --- a/locales/de/main.ftl +++ b/locales/de/main.ftl @@ -12,6 +12,8 @@ language = Sprache: not-found = Dieser Link existiert nicht, oder wurde gelöscht. +load-more-links = Lade mehr Links +no-more-links = Es gibt keine weiteren Links edit-link-headline = Zu editierender Link: {$linktitle} edit-link = Link Editieren create-link = Link Erstellen diff --git a/locales/en/main.ftl b/locales/en/main.ftl index a743297..f06d850 100644 --- a/locales/en/main.ftl +++ b/locales/en/main.ftl @@ -12,6 +12,8 @@ language = Language: not-found = This Link has not been found or has been deleted +load-more-links = load more links +no-more-links = there are no additional links available edit-link-headline = Edit link: {$linktitle} edit-link = Edit link create-link = Create link diff --git a/pslink/src/models.rs b/pslink/src/models.rs index 076a77d..ab41e5c 100644 --- a/pslink/src/models.rs +++ b/pslink/src/models.rs @@ -131,7 +131,7 @@ impl UserDbOperations for User { #[instrument()] async fn toggle_admin(self, server_config: &ServerConfig) -> Result<(), ServerError> { let new_role = match self.role { - r @ Role::NotAuthenticated | r @ Role::Disabled => r, + r @ (Role::NotAuthenticated | Role::Disabled) => r, Role::Regular => Role::Admin, Role::Admin => Role::Regular, }; diff --git a/pslink/src/queries.rs b/pslink/src/queries.rs index 613f8a5..77467bc 100644 --- a/pslink/src/queries.rs +++ b/pslink/src/queries.rs @@ -113,6 +113,7 @@ pub async fn list_all_allowed( querystring.push_str(&generate_order_sql(&order)); } querystring.push_str(&format!("\n LIMIT {}", parameters.amount)); + querystring.push_str(&format!("\n OFFSET {}", parameters.offset)); info!("{}", querystring); let links = sqlx::query(&querystring) diff --git a/pslink/static/admin.css b/pslink/static/admin.css index bda99bc..88199d4 100644 --- a/pslink/static/admin.css +++ b/pslink/static/admin.css @@ -8,21 +8,23 @@ form { } .center { - position: absolute; + position: fixed; width: 600px; max-width: 100%; - height: 400px; + height: 500px; max-height: 100%; top: 50%; left: 50%; margin-left: -300px; - margin-top: -200px; + margin-top: -250px; display: flex; flex-direction: column; justify-content: center; align-items: center; padding: 30px; color: #333; + overflow-y: scroll; + scrollbar-width: none; } .center input { @@ -229,4 +231,14 @@ img.trashicon { padding: 5px; margin: 3px; border-radius: 50%; +} + +a.loadmore { + display: flex; + color: darkgray; + margin: auto; + justify-content: space-evenly; + align-items: center; + border: none; + border-radius: 15px; } \ No newline at end of file diff --git a/pslink/static/reload.svg b/pslink/static/reload.svg new file mode 100644 index 0000000..c96afff --- /dev/null +++ b/pslink/static/reload.svg @@ -0,0 +1,3 @@ + + + diff --git a/shared/src/apirequests/links.rs b/shared/src/apirequests/links.rs index acbacc0..75d03a2 100644 --- a/shared/src/apirequests/links.rs +++ b/shared/src/apirequests/links.rs @@ -12,6 +12,7 @@ use super::general::{EditMode, Filter, Operation, Ordering}; pub struct LinkRequestForm { pub filter: EnumMap, pub order: Option>, + pub offset: usize, pub amount: usize, } @@ -20,7 +21,8 @@ impl Default for LinkRequestForm { Self { filter: EnumMap::default(), order: None, - amount: 20, + offset: 0, + amount: 60, } } }