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),