diff --git a/Cargo.lock b/Cargo.lock index 1bcb462..4b57218 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -97,6 +97,7 @@ dependencies = [ "handlebars", "http", "indexmap 2.0.0", + "log 0.4.20", "mime 0.3.17", "multer", "num-traits", @@ -1545,6 +1546,7 @@ dependencies = [ "serde", "serde_json", "shared", + "thiserror", "wasm-bindgen-test", "wasm-timer", "web-sys", @@ -3072,18 +3074,18 @@ checksum = "8eaa81235c7058867fa8c0e7314f33dcce9c215f535d1913822a2b3f5e289f3c" [[package]] name = "thiserror" -version = "1.0.47" +version = "1.0.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97a802ec30afc17eee47b2855fc72e0c4cd62be9b4efe6591edde0ec5bd68d8f" +checksum = "f9a7210f5c9a7156bb50aa36aed4c95afb51df0df00713949448cf9e97d382d2" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.47" +version = "1.0.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bb623b56e39ab7dcd4b1b98bb6c8f8d907ed255b18de254088016b27a8ee19b" +checksum = "266b2e40bc00e5a6c09c3584011e08b06f123c00362c92b975ba9843aaaa14b8" dependencies = [ "proc-macro2 1.0.66", "quote 1.0.33", diff --git a/web/Cargo.toml b/web/Cargo.toml index 3b6fcdc..2d69c03 100644 --- a/web/Cargo.toml +++ b/web/Cargo.toml @@ -29,6 +29,7 @@ wasm-timer = "0.2.5" css-inline = "0.8.5" chrono = "0.4.31" graphql_client = "0.13.0" +thiserror = "1.0.50" [package.metadata.wasm-pack.profile.release] wasm-opt = ['-Os'] diff --git a/web/Trunk.toml b/web/Trunk.toml index 55743ad..65d4360 100644 --- a/web/Trunk.toml +++ b/web/Trunk.toml @@ -9,3 +9,7 @@ port = 6758 [[proxy]] backend = "http://localhost:9345/" rewrite= "/api/" +[[proxy]] +backend="http://localhost:9345/graphiql" +[[proxy]] +backend="http://localhost:9345/graphql" diff --git a/web/graphql/front_page.graphql b/web/graphql/front_page.graphql index ac583c4..f43cdf8 100644 --- a/web/graphql/front_page.graphql +++ b/web/graphql/front_page.graphql @@ -1,6 +1,6 @@ -query FrontPageQuery($query: [String!], $first: [Int], $after: [String]) { +query FrontPageQuery($query: String!, $after: String $before: String, $first: Int, $last: Int) { count(query: $query) - search(query: $query, first: $first, after: $after) { + search(query: $query, after: $after, before: $before, first: $first, last: $last) { pageInfo { hasPreviousPage hasNextPage diff --git a/web/index.html b/web/index.html index 3b1a9ec..fb3a646 100644 --- a/web/index.html +++ b/web/index.html @@ -125,6 +125,19 @@ blockquote[type="cite"], background-color: red; display: none; } +.desktop-main-content { + display: grid; + grid-template-columns: 12rem 1fr; +} +.tags-menu { + padding: 1rem; +} +.tags-menu .menu-list a { + padding: 0.25em 0.5em; +} +.navbar { + border: none; +} diff --git a/web/src/graphql.rs b/web/src/graphql.rs index f5d621b..2b3b4ca 100644 --- a/web/src/graphql.rs +++ b/web/src/graphql.rs @@ -15,14 +15,14 @@ use serde::{de::DeserializeOwned, Deserialize, Serialize}; )] pub struct FrontPageQuery; -async fn send_graphql(body: Body) -> fetch::Result> +pub async fn send_graphql(body: Body) -> fetch::Result> where Body: Serialize, Resp: DeserializeOwned + 'static, { use web_sys::RequestMode; - Request::new("/graphql") + Request::new("/graphql/") .method(Method::Post) .header(Header::content_type("application/json")) .mode(RequestMode::Cors) diff --git a/web/src/lib.rs b/web/src/lib.rs index 2daead0..87c83ee 100644 --- a/web/src/lib.rs +++ b/web/src/lib.rs @@ -8,16 +8,33 @@ use std::{ }; use chrono::{DateTime, Duration, Local, Utc}; +use graphql_client::GraphQLQuery; use itertools::Itertools; use log::{debug, error, info, Level}; use notmuch::{Content, Part, Thread, ThreadNode, ThreadSet}; use seed::{prelude::*, *}; use serde::de::Deserialize; +use thiserror::Error; use wasm_timer::Instant; +use crate::graphql::{front_page_query::*, send_graphql}; + mod graphql; const SEARCH_RESULTS_PER_PAGE: usize = 20; +const USE_GRAPHQL: bool = true; + +#[derive(Error, Debug)] +enum UIError { + #[error("No error, this should never be presented to user")] + NoError, + #[error("failed to fetch {0}: {1:?}")] + FetchError(&'static str, FetchError), + #[error("{0} error decoding: {1:?}")] + FetchDecodeError(&'static str, Vec), + #[error("no data or errors for {0}")] + NoData(&'static str), +} // ------ ------ // Init @@ -36,6 +53,8 @@ fn init(url: Url, orders: &mut impl Orders) -> Model { context: Context::None, query: "".to_string(), refreshing_state: RefreshingState::None, + ui_error: UIError::NoError, + tags: None, } } @@ -51,29 +70,59 @@ fn on_url_changed(uc: subs::UrlChanged) -> Msg { ["t", tid] => Msg::ShowPrettyRequest(tid.to_string()), ["s", query] => { let query = Url::decode_uri_component(query).unwrap_or("".to_string()); - Msg::SearchRequest { - query, - page: 0, - results_per_page: SEARCH_RESULTS_PER_PAGE, + if USE_GRAPHQL { + Msg::FrontPageRequest { + query, + after: None, + before: None, + first: None, + last: None, + } + } else { + Msg::SearchRequest { + query, + page: 0, + results_per_page: SEARCH_RESULTS_PER_PAGE, + } } } ["s", query, page] => { let query = Url::decode_uri_component(query).unwrap_or("".to_string()); let page = page[1..].parse().unwrap_or(0); - Msg::SearchRequest { - query, - page, - results_per_page: SEARCH_RESULTS_PER_PAGE, + if USE_GRAPHQL { + Msg::FrontPageRequest { + query, + after: Some(page.to_string()), + before: None, + first: None, + last: None, + } + } else { + Msg::SearchRequest { + query, + page, + results_per_page: SEARCH_RESULTS_PER_PAGE, + } } } p => { if !p.is_empty() { info!("Unhandled path '{p:?}'"); } - Msg::SearchRequest { - query: "".to_string(), - page: 0, - results_per_page: SEARCH_RESULTS_PER_PAGE, + if USE_GRAPHQL { + Msg::FrontPageRequest { + query: "".to_string(), + after: None, + before: None, + first: None, + last: None, + } + } else { + Msg::SearchRequest { + query: "".to_string(), + page: 0, + results_per_page: SEARCH_RESULTS_PER_PAGE, + } } } } @@ -100,6 +149,12 @@ mod urls { enum Context { None, Search(shared::SearchResult), + SearchResult { + query: String, + results: Vec, + count: usize, + pager: FrontPageQuerySearchPageInfo, + }, Thread(ThreadSet), } @@ -108,6 +163,8 @@ struct Model { query: String, context: Context, refreshing_state: RefreshingState, + ui_error: UIError, + tags: Option>, } #[derive(Debug, PartialEq)] @@ -142,6 +199,17 @@ enum Msg { ShowPrettyResult(fetch::Result), NextPage, PreviousPage, + + FrontPageRequest { + query: String, + after: Option, + before: Option, + first: Option, + last: Option, + }, + FrontPageResult( + fetch::Result>, + ), } // `update` describes how to handle each `Msg`. @@ -213,6 +281,19 @@ fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders) { Context::Search(sr) => { orders.request_url(urls::search(&sr.query, sr.page + 1)); } + Context::SearchResult { query, pager, .. } => { + let query = query.to_string(); + let after = pager.end_cursor.clone(); + orders.perform_cmd(async move { + Msg::FrontPageRequest { + query, + after, + before: None, + first: Some(SEARCH_RESULTS_PER_PAGE as i64), + last: None, + } + }); + } Context::Thread(_) => (), // do nothing (yet?) Context::None => (), // do nothing (yet?) }; @@ -222,10 +303,75 @@ fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders) { Context::Search(sr) => { orders.request_url(urls::search(&sr.query, sr.page.saturating_sub(1))); } + Context::SearchResult { query, pager, .. } => { + let query = query.to_string(); + let before = pager.start_cursor.clone(); + orders.perform_cmd(async move { + Msg::FrontPageRequest { + query, + after: None, + before, + first: None, + last: Some(SEARCH_RESULTS_PER_PAGE as i64), + } + }); + } + Context::Thread(_) => (), // do nothing (yet?) Context::None => (), // do nothing (yet?) }; } + + Msg::FrontPageRequest { + query, + after, + before, + first, + last, + } => { + info!("making FrontPageRequest: {query} after:{after:?} before:{before:?} first:{first:?} last:{last:?}"); + model.query = query.clone(); + orders.skip().perform_cmd(async move { + Msg::FrontPageResult( + send_graphql(graphql::FrontPageQuery::build_query( + graphql::front_page_query::Variables { + query, + after, + before, + first, + last, + }, + )) + .await, + ) + }); + } + Msg::FrontPageResult(Err(e)) => error!("error FrontPageResult: {e:?}"), + Msg::FrontPageResult(Ok(graphql_client::Response { + data: None, + errors: None, + .. + })) => { + error!("FrontPageResult no data or errors, should not happen"); + } + Msg::FrontPageResult(Ok(graphql_client::Response { + data: None, + errors: Some(e), + .. + })) => { + error!("FrontPageResult error: {e:?}"); + } + Msg::FrontPageResult(Ok(graphql_client::Response { + data: Some(data), .. + })) => { + model.tags = Some(data.tags); + model.context = Context::SearchResult { + query: model.query.clone(), + results: data.search.nodes, + count: data.count as usize, + pager: data.search.page_info, + }; + } } } @@ -507,7 +653,49 @@ fn human_age(timestamp: i64) -> String { datetime } -fn view_mobile_search_results(query: &str, search_results: &shared::SearchResult) -> Node { +fn view_mobile_search_results( + query: &str, + results: &[FrontPageQuerySearchNodes], + count: usize, + pager: &FrontPageQuerySearchPageInfo, +) -> Node { + if query.is_empty() { + set_title("all mail"); + } else { + set_title(query); + } + let rows = results.iter().map(|r| { + let tid = r.thread.clone(); + let datetime = human_age(r.timestamp as i64); + a![ + C!["has-text-light"], + attrs! { + At::Href => urls::thread(&tid) + }, + div![ + C!["row"], + div![C!["subject"], &r.subject], + span![C!["from", "is-size-7"], pretty_authors(&r.authors)], + div![ + span![C!["is-size-7"], tags_chiclet(&r.tags, true)], + span![C!["is-size-7", "float-right", "date"], datetime] + ] + ] + ] + }); + div![ + C!["search-results"], + h1!["Search results"], + view_search_pager(count, pager), + rows, + view_search_pager(count, pager), + ] +} + +fn view_mobile_search_results_legacy( + query: &str, + search_results: &shared::SearchResult, +) -> Node { if query.is_empty() { set_title("all mail"); } else { @@ -550,13 +738,74 @@ fn view_mobile_search_results(query: &str, search_results: &shared::SearchResult div![ C!["search-results"], h1!["Search results"], - view_search_pager(first, summaries.len(), search_results.total), + view_search_pager_legacy(first, summaries.len(), search_results.total), rows, - view_search_pager(first, summaries.len(), search_results.total) + view_search_pager_legacy(first, summaries.len(), search_results.total) ] } -fn view_search_results(query: &str, search_results: &shared::SearchResult) -> Node { +fn view_search_results( + query: &str, + results: &[FrontPageQuerySearchNodes], + count: usize, + pager: &FrontPageQuerySearchPageInfo, +) -> Node { + info!("pager {pager:?}"); + if query.is_empty() { + set_title("all mail"); + } else { + set_title(query); + } + let rows = results.iter().map(|r| { + let tid = r.thread.clone(); + let datetime = human_age(r.timestamp as i64); + tr![ + td![ + C!["from"], + pretty_authors(&r.authors), + // TODO(wathiede): visualize message count if more than one message is in the + // thread + //IF!(r.total>1 => small![" ", r.total.to_string()]), + ], + td![ + C!["subject"], + tags_chiclet(&r.tags, false), + " ", + a![ + C!["has-text-light"], + attrs! { + At::Href => urls::thread(&tid) + }, + &r.subject, + ] + ], + td![C!["date"], datetime] + ] + }); + + div![ + view_search_pager(count, pager), + table![ + C![ + "table", + "index", + "is-fullwidth", + "is-hoverable", + "is-narrow", + "is-striped", + ], + thead![tr![ + th![C!["from"], "From"], + th![C!["subject"], "Subject"], + th![C!["date"], "Date"] + ]], + tbody![rows] + ], + view_search_pager(count, pager) + ] +} + +fn view_search_results_legacy(query: &str, search_results: &shared::SearchResult) -> Node { if query.is_empty() { set_title("all mail"); } else { @@ -589,7 +838,7 @@ fn view_search_results(query: &str, search_results: &shared::SearchResult) -> No }); let first = search_results.page * search_results.results_per_page; div![ - view_search_pager(first, summaries.len(), search_results.total), + view_search_pager_legacy(first, summaries.len(), search_results.total), table![ C![ "table", @@ -606,11 +855,51 @@ fn view_search_results(query: &str, search_results: &shared::SearchResult) -> No ]], tbody![rows] ], - view_search_pager(first, summaries.len(), search_results.total) + view_search_pager_legacy(first, summaries.len(), search_results.total) ] } -fn view_search_pager(start: usize, count: usize, total: usize) -> Node { +fn view_search_pager(count: usize, pager: &FrontPageQuerySearchPageInfo) -> Node { + let start = pager + .start_cursor + .as_ref() + .map(|i| i.parse().unwrap_or(0)) + .unwrap_or(0); + nav![ + C!["pagination"], + a![ + C![ + "pagination-previous", + "button", + //IF!(!pager.has_previous_page => "is-static"), + ], + IF!(!pager.has_previous_page => attrs!{ At::Disabled=>true }), + "<", + IF!(pager.has_previous_page => ev(Ev::Click, |_| Msg::PreviousPage)), + ], + a![ + C![ + "pagination-next", + "button", + //IF!(!pager.has_next_page => "is-static") + ], + IF!(!pager.has_next_page => attrs!{ At::Disabled=>true }), + ">", + IF!(pager.has_next_page => ev(Ev::Click, |_| Msg::NextPage)) + ], + ul![ + C!["pagination-list"], + li![format!( + "{} - {} of {}", + start, + count.min(start + SEARCH_RESULTS_PER_PAGE), + count + )], + ], + ] +} + +fn view_search_pager_legacy(start: usize, count: usize, total: usize) -> Node { let is_first = start <= 0; let is_last = (start + SEARCH_RESULTS_PER_PAGE) >= total; nav![ @@ -732,7 +1021,11 @@ fn view_header(query: &str, refresh_request: &RefreshingState) -> Node { At::Value => query, }, input_ev(Ev::Input, |q| Msg::SearchRequest { - query: Url::encode_uri_component(q), + query: Url::encode_uri_component(if q.is_empty() { + "*".to_string() + } else { + q + }), page: 0, results_per_page: SEARCH_RESULTS_PER_PAGE, }), @@ -762,17 +1055,42 @@ fn view_footer(render_time_ms: u128) -> Node { } fn view_desktop(model: &Model) -> Node { - // TODO(wathiede): add sidebar showing tags, use https://bulma.io/documentation/components/menu/#docsNav // Do two queries, one without `unread` so it loads fast, then a second with unread. let content = match &model.context { Context::None => div![h1!["Loading"]], Context::Thread(thread_set) => view_thread(thread_set), - Context::Search(search_results) => view_search_results(&model.query, search_results), + Context::Search(search_results) => view_search_results_legacy(&model.query, search_results), + Context::SearchResult { + query, + results, + count, + pager, + } => view_search_results(&query, results.as_slice(), *count, pager), }; div![ - view_header(&model.query, &model.refreshing_state), - section![C!["section"], content], - view_header(&model.query, &model.refreshing_state), + C!["desktop-main-content"], + aside![ + C!["tags-menu", "menu"], + p![C!["menu-label"], "Tags"], + ul![ + C!["menu-list"], + model.tags.as_ref().map(|tags| tags.iter().map(|t| li![a![ + attrs! { + At::Href => urls::search(&format!("tag:{}", t.name), 0) + }, + style! { + St::BackgroundColor => t.bg_color, + St::Color => t.fg_color, + }, + &t.name + ]])) + ] + ], + div![ + view_header(&model.query, &model.refreshing_state), + section![C!["section"], content], + view_header(&model.query, &model.refreshing_state), + ] ] } @@ -780,7 +1098,15 @@ fn view_mobile(model: &Model) -> Node { let content = match &model.context { Context::None => div![h1!["Loading"]], Context::Thread(thread_set) => view_thread(thread_set), - Context::Search(search_results) => view_mobile_search_results(&model.query, search_results), + Context::Search(search_results) => { + view_mobile_search_results_legacy(&model.query, search_results) + } + Context::SearchResult { + query, + results, + count, + pager, + } => view_mobile_search_results(&query, results.as_slice(), *count, pager), }; div![ view_header(&model.query, &model.refreshing_state),