Compare commits

...

7 Commits

11 changed files with 640 additions and 573 deletions

View File

@ -9,6 +9,8 @@
integrity="sha512-SzlrxWUlpfuzQ+pcUCosxcglQRNAq/DZjVsC0lE40xsADsfeQoEypE+enwcOiGjk/bSuGGKHEyjSoQ1zVisanQ==" integrity="sha512-SzlrxWUlpfuzQ+pcUCosxcglQRNAq/DZjVsC0lE40xsADsfeQoEypE+enwcOiGjk/bSuGGKHEyjSoQ1zVisanQ=="
crossorigin="anonymous" referrerpolicy="no-referrer" /> crossorigin="anonymous" referrerpolicy="no-referrer" />
<link rel="icon" href="https://static.xinu.tv/favicon/letterbox.svg" /> <link rel="icon" href="https://static.xinu.tv/favicon/letterbox.svg" />
<!-- Pretty checkboxes -->
<link data-trunk rel="css" href="static/main.css" />
<style> <style>
.message { .message {
display: inline-block; display: inline-block;
@ -63,6 +65,14 @@
width: 100%; width: 100%;
} }
.index .edit {
width: 1.5em;
}
.index .unread {
font-weight: bold;
}
.index .from { .index .from {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@ -76,16 +86,6 @@
white-space: nowrap; white-space: nowrap;
} }
.index .subject:hover .mark-read-button {
display: inline-flex;
}
.index .subject .mark-read-button {
display: none;
font-size: .6rem;
float: right;
}
.index .date { .index .date {
width: 10em; width: 10em;
white-space: nowrap; white-space: nowrap;
@ -152,9 +152,20 @@
} }
.search-results .row { .search-results .row {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
border-bottom: 1px #444 solid; border-bottom: 1px #444 solid;
padding-bottom: .5em; padding-bottom: .5em;
padding-top: .5em; padding-top: .5em;
width: 100%;
}
.search-results .row .checkbox {}
.search-results .row .summary {
min-width: 0;
} }
.search-results .row .subject { .search-results .row .subject {
@ -181,13 +192,13 @@
/* Hide quoted emails */ /* Hide quoted emails */
/* /*
div[name="quote"], div[name="quote"],
blockquote[type="cite"], blockquote[type="cite"],
.gmail_quote { .gmail_quote {
background-color: red; background-color: red;
display: none; display: none;
} }
*/ */
.desktop .main-content { .desktop .main-content {
display: grid; display: grid;

View File

@ -1,22 +1,10 @@
use gloo_net::{http::Request, Error}; use gloo_net::{http::Request, Error};
use log::info; use log::info;
use notmuch::ThreadSet;
use seed::Url;
const BASE_URL: &str = "/api"; const BASE_URL: &str = "/api";
pub fn refresh() -> String { pub fn refresh() -> String {
format!("{BASE_URL}/refresh") format!("{BASE_URL}/refresh")
} }
pub fn search(query: &str, page: usize, results_per_page: usize) -> String {
let query = Url::encode_uri_component(query);
format!("{BASE_URL}/search/{query}?page={page}&results_per_page={results_per_page}")
}
pub fn show_pretty(tid: &str) -> String {
format!("{BASE_URL}/show/{tid}/pretty")
}
pub fn original(message_id: &str) -> String {
format!("{BASE_URL}/original/{message_id}")
}
pub mod urls { pub mod urls {
use seed::Url; use seed::Url;
pub fn search(query: &str, page: usize) -> Url { pub fn search(query: &str, page: usize) -> Url {
@ -32,24 +20,8 @@ pub mod urls {
} }
} }
pub async fn search_request(
query: &str,
page: usize,
results_per_page: usize,
) -> Result<shared::SearchResult, Error> {
Request::get(&search(query, page, results_per_page))
.send()
.await?
.json()
.await
}
pub async fn refresh_request() -> Result<(), Error> { pub async fn refresh_request() -> Result<(), Error> {
let t = Request::get(&refresh()).send().await?.text().await?; let t = Request::get(&refresh()).send().await?.text().await?;
info!("refresh {t}"); info!("refresh {t}");
Ok(()) Ok(())
} }
pub async fn show_pretty_request(tid: &str) -> Result<ThreadSet, Error> {
Request::get(&show_pretty(tid)).send().await?.json().await
}

View File

@ -1,2 +1 @@
pub const SEARCH_RESULTS_PER_PAGE: usize = 20; pub const SEARCH_RESULTS_PER_PAGE: usize = 20;
pub const USE_GRAPHQL: bool = true;

View File

@ -1,13 +1,14 @@
use std::collections::HashSet;
use graphql_client::GraphQLQuery; use graphql_client::GraphQLQuery;
use log::{debug, error, info}; use log::{error, info};
use notmuch::ThreadSet;
use seed::{app::subs, prelude::*, *}; use seed::{app::subs, prelude::*, *};
use thiserror::Error; use thiserror::Error;
use crate::{ use crate::{
api, api,
api::urls, api::urls,
consts::{SEARCH_RESULTS_PER_PAGE, USE_GRAPHQL}, consts::SEARCH_RESULTS_PER_PAGE,
graphql, graphql,
graphql::{front_page_query::*, send_graphql, show_thread_query::*}, graphql::{front_page_query::*, send_graphql, show_thread_query::*},
}; };
@ -43,70 +44,40 @@ fn on_url_changed(uc: subs::UrlChanged) -> Msg {
); );
let hpp = url.remaining_hash_path_parts(); let hpp = url.remaining_hash_path_parts();
match hpp.as_slice() { match hpp.as_slice() {
["t", tid] => { ["t", tid] => Msg::ShowThreadRequest {
if USE_GRAPHQL { thread_id: tid.to_string(),
Msg::ShowThreadRequest { },
thread_id: tid.to_string(),
}
} else {
Msg::ShowPrettyRequest(tid.to_string())
}
}
["s", query] => { ["s", query] => {
let query = Url::decode_uri_component(query).unwrap_or("".to_string()); let query = Url::decode_uri_component(query).unwrap_or("".to_string());
if USE_GRAPHQL { Msg::FrontPageRequest {
Msg::FrontPageRequest { query,
query, after: None,
after: None, before: None,
before: None, first: None,
first: None, last: None,
last: None,
}
} else {
Msg::SearchRequest {
query,
page: 0,
results_per_page: SEARCH_RESULTS_PER_PAGE,
}
} }
} }
["s", query, page] => { ["s", query, page] => {
let query = Url::decode_uri_component(query).unwrap_or("".to_string()); let query = Url::decode_uri_component(query).unwrap_or("".to_string());
let page = page[1..].parse().unwrap_or(0); let page = page[1..].parse().unwrap_or(0);
if USE_GRAPHQL { Msg::FrontPageRequest {
Msg::FrontPageRequest { query,
query, after: Some(page.to_string()),
after: Some(page.to_string()), before: None,
before: None, first: None,
first: None, last: None,
last: None,
}
} else {
Msg::SearchRequest {
query,
page,
results_per_page: SEARCH_RESULTS_PER_PAGE,
}
} }
} }
p => { p => {
if !p.is_empty() { if !p.is_empty() {
info!("Unhandled path '{p:?}'"); info!("Unhandled path '{p:?}'");
} }
if USE_GRAPHQL { Msg::FrontPageRequest {
Msg::FrontPageRequest { query: "".to_string(),
query: "".to_string(), after: None,
after: None, before: None,
before: None, first: None,
first: None, last: None,
last: None,
}
} else {
Msg::SearchRequest {
query: "".to_string(),
page: 0,
results_per_page: SEARCH_RESULTS_PER_PAGE,
}
} }
} }
} }
@ -133,42 +104,8 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
} }
Msg::OnResize => (), Msg::OnResize => (),
Msg::SearchRequest {
query,
page,
results_per_page,
} => {
info!("searching for '{query}' pg {page} # / pg {results_per_page}");
model.query = query.clone();
orders.skip().perform_cmd(async move {
Msg::SearchResult(api::search_request(&query, page, results_per_page).await)
});
}
Msg::SearchResult(Ok(response_data)) => {
debug!("fetch ok {:#?}", response_data);
model.context = Context::Search(response_data);
}
Msg::SearchResult(Err(fetch_error)) => {
error!("fetch failed {:?}", fetch_error);
}
Msg::ShowPrettyRequest(tid) => {
orders.skip().perform_cmd(async move {
Msg::ShowPrettyResult(api::show_pretty_request(&tid).await)
});
}
Msg::ShowPrettyResult(Ok(response_data)) => {
debug!("fetch ok {:#?}", response_data);
model.context = Context::Thread(response_data);
}
Msg::ShowPrettyResult(Err(fetch_error)) => {
error!("fetch failed {:?}", fetch_error);
}
Msg::NextPage => { Msg::NextPage => {
match &model.context { match &model.context {
Context::Search(sr) => {
orders.request_url(urls::search(&sr.query, sr.page + 1));
}
Context::SearchResult { query, pager, .. } => { Context::SearchResult { query, pager, .. } => {
let query = query.to_string(); let query = query.to_string();
let after = pager.end_cursor.clone(); let after = pager.end_cursor.clone();
@ -182,16 +119,12 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
} }
}); });
} }
Context::Thread(_) => (), // do nothing (yet?) Context::ThreadResult { .. } => (), // do nothing (yet?)
Context::ThreadResult(_) => (), // do nothing (yet?) Context::None => (), // do nothing (yet?)
Context::None => (), // do nothing (yet?)
}; };
} }
Msg::PreviousPage => { Msg::PreviousPage => {
match &model.context { match &model.context {
Context::Search(sr) => {
orders.request_url(urls::search(&sr.query, sr.page.saturating_sub(1)));
}
Context::SearchResult { query, pager, .. } => { Context::SearchResult { query, pager, .. } => {
let query = query.to_string(); let query = query.to_string();
let before = pager.start_cursor.clone(); let before = pager.start_cursor.clone();
@ -206,9 +139,8 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
}); });
} }
Context::Thread(_) => (), // do nothing (yet?) Context::ThreadResult { .. } => (), // do nothing (yet?)
Context::ThreadResult(_) => (), // do nothing (yet?) Context::None => (), // do nothing (yet?)
Context::None => (), // do nothing (yet?)
}; };
} }
@ -294,6 +226,7 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
results: data.search.nodes, results: data.search.nodes,
count: data.count as usize, count: data.count as usize,
pager: data.search.page_info, pager: data.search.page_info,
selected_threads: HashSet::new(),
}; };
} }
@ -321,11 +254,89 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
}) })
.collect(), .collect(),
); );
model.context = Context::ThreadResult(data.thread); let mut open_messages: HashSet<_> = data
.thread
.messages
.iter()
.filter(|msg| msg.tags.iter().any(|t| t == "unread"))
.map(|msg| msg.id.clone())
.collect();
if open_messages.is_empty() {
open_messages = data
.thread
.messages
.iter()
.map(|msg| msg.id.clone())
.collect();
}
model.context = Context::ThreadResult {
thread: data.thread,
open_messages,
};
} }
Msg::ShowThreadResult(bad) => { Msg::ShowThreadResult(bad) => {
error!("show_thread_query error: {bad:#?}"); error!("show_thread_query error: {bad:#?}");
} }
Msg::SelectionMarkAsRead => {
if let Context::SearchResult {
selected_threads, ..
} = &mut model.context
{
let threads = selected_threads
.iter()
.map(|tid| format!("thread:{tid}"))
.collect::<Vec<_>>()
.join(" ");
orders
.skip()
.perform_cmd(async move { Msg::SetUnread(threads, false) });
}
}
Msg::SelectionMarkAsUnread => {
if let Context::SearchResult {
selected_threads, ..
} = &mut model.context
{
let threads = selected_threads
.iter()
.map(|tid| format!("thread:{tid}"))
.collect::<Vec<_>>()
.join(" ");
orders
.skip()
.perform_cmd(async move { Msg::SetUnread(threads, true) });
}
}
Msg::SelectionAddThread(tid) => {
if let Context::SearchResult {
selected_threads, ..
} = &mut model.context
{
selected_threads.insert(tid);
info!("selected threads {selected_threads:?}");
}
}
Msg::SelectionRemoveThread(tid) => {
if let Context::SearchResult {
selected_threads, ..
} = &mut model.context
{
selected_threads.remove(&tid);
info!("selected threads {selected_threads:?}");
}
}
Msg::MessageCollapse(id) => {
if let Context::ThreadResult { open_messages, .. } = &mut model.context {
open_messages.remove(&id);
info!("open_messages threads {open_messages:?}");
}
}
Msg::MessageExpand(id) => {
if let Context::ThreadResult { open_messages, .. } = &mut model.context {
open_messages.insert(id);
info!("open_messages threads {open_messages:?}");
}
}
} }
} }
// `Model` describes our app state. // `Model` describes our app state.
@ -352,15 +363,17 @@ pub enum UIError {
pub enum Context { pub enum Context {
None, None,
Search(shared::SearchResult),
SearchResult { SearchResult {
query: String, query: String,
results: Vec<FrontPageQuerySearchNodes>, results: Vec<FrontPageQuerySearchNodes>,
count: usize, count: usize,
pager: FrontPageQuerySearchPageInfo, pager: FrontPageQuerySearchPageInfo,
selected_threads: HashSet<String>,
},
ThreadResult {
thread: ShowThreadQueryThread,
open_messages: HashSet<String>,
}, },
Thread(ThreadSet),
ThreadResult(ShowThreadQueryThread),
} }
pub struct Tag { pub struct Tag {
@ -386,14 +399,6 @@ pub enum Msg {
// Tell the server to update state // Tell the server to update state
RefreshStart, RefreshStart,
RefreshDone(Option<gloo_net::Error>), RefreshDone(Option<gloo_net::Error>),
SearchRequest {
query: String,
page: usize,
results_per_page: usize,
},
SearchResult(Result<shared::SearchResult, gloo_net::Error>),
ShowPrettyRequest(String),
ShowPrettyResult(Result<ThreadSet, gloo_net::Error>),
NextPage, NextPage,
PreviousPage, PreviousPage,
UpdateQuery(String), UpdateQuery(String),
@ -417,4 +422,12 @@ pub enum Msg {
ShowThreadResult( ShowThreadResult(
Result<graphql_client::Response<graphql::show_thread_query::ResponseData>, gloo_net::Error>, Result<graphql_client::Response<graphql::show_thread_query::ResponseData>, gloo_net::Error>,
), ),
SelectionMarkAsRead,
SelectionMarkAsUnread,
SelectionAddThread(String),
SelectionRemoveThread(String),
MessageCollapse(String),
MessageExpand(String),
} }

View File

@ -4,7 +4,7 @@ use seed_hooks::{state_access::CloneState, topo, use_state};
use crate::{ use crate::{
api::urls, api::urls,
state::{Context, Model, Msg, Tag}, state::{Context, Model, Msg, Tag},
view::{self, legacy, view_header, view_search_results}, view::{self, view_header, view_search_results},
}; };
#[topo::nested] #[topo::nested]
@ -12,15 +12,17 @@ pub(super) fn view(model: &Model) -> Node<Msg> {
// Do two queries, one without `unread` so it loads fast, then a second with unread. // Do two queries, one without `unread` so it loads fast, then a second with unread.
let content = match &model.context { let content = match &model.context {
Context::None => div![h1!["Loading"]], Context::None => div![h1!["Loading"]],
Context::Thread(thread_set) => legacy::thread(thread_set), Context::ThreadResult {
Context::ThreadResult(thread) => view::thread(thread), thread,
Context::Search(search_results) => legacy::search_results(&model.query, search_results), open_messages,
} => view::thread(thread, open_messages),
Context::SearchResult { Context::SearchResult {
query, query,
results, results,
count, count,
pager, pager,
} => view_search_results(&query, results.as_slice(), *count, pager), selected_threads,
} => view_search_results(&query, results.as_slice(), *count, pager, selected_threads),
}; };
fn view_tag_li(display_name: &str, indent: usize, t: &Tag, search_unread: bool) -> Node<Msg> { fn view_tag_li(display_name: &str, indent: usize, t: &Tag, search_unread: bool) -> Node<Msg> {
let href = if search_unread { let href = if search_unread {

View File

@ -1,270 +0,0 @@
use notmuch::{Content, Part, ThreadNode, ThreadSet};
use seed::{prelude::*, *};
use crate::{
api,
api::urls,
consts::SEARCH_RESULTS_PER_PAGE,
state::Msg,
view::{human_age, pretty_authors, set_title, tags_chiclet},
};
pub(super) fn search_results(query: &str, search_results: &shared::SearchResult) -> Node<Msg> {
if query.is_empty() {
set_title("all mail");
} else {
set_title(query);
}
let summaries = &search_results.summary.0;
let rows = summaries.iter().map(|r| {
let tid = r.thread.clone();
let datetime = human_age(r.timestamp as i64);
tr![
td![
C!["from"],
pretty_authors(&r.authors),
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]
]
});
let first = search_results.page * search_results.results_per_page;
div![
search_pager(first, summaries.len(), search_results.total),
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]
],
search_pager(first, summaries.len(), search_results.total)
]
}
fn search_pager(start: usize, count: usize, total: usize) -> Node<Msg> {
let is_first = start <= 0;
let is_last = (start + SEARCH_RESULTS_PER_PAGE) >= total;
nav![
C!["pagination"],
a![
C!["pagination-previous", "button",],
IF!(is_first => attrs!{ At::Disabled=>true }),
"<",
ev(Ev::Click, |_| Msg::PreviousPage)
],
a![
C!["pagination-next", "button", IF!(is_last => "is-static")],
IF!(is_last => attrs!{ At::Disabled=>true }),
">",
ev(Ev::Click, |_| Msg::NextPage)
],
ul![
C!["pagination-list"],
li![format!("{} - {} of {}", start, start + count, total)],
],
]
}
pub(super) fn thread(thread_set: &ThreadSet) -> Node<Msg> {
assert_eq!(thread_set.0.len(), 1);
let thread = &thread_set.0[0];
assert_eq!(thread.0.len(), 1);
let thread_node = &thread.0[0];
let subject = first_subject(&thread_node).unwrap_or("<No subject>".to_string());
set_title(&subject);
div![
C!["container"],
h1![C!["title"], subject],
view_message(&thread_node),
a![
attrs! {At::Href=>api::original(&thread_node.0.as_ref().expect("message missing").id)},
"Original"
],
]
}
pub(super) fn mobile_search_results(
query: &str,
search_results: &shared::SearchResult,
) -> Node<Msg> {
if query.is_empty() {
set_title("all mail");
} else {
set_title(query);
}
let summaries = &search_results.summary.0;
let rows = summaries.iter().map(|r| {
/*
let tid = r.thread.clone();
tr![
td![
C!["from"],
pretty_authors(&r.authors),
IF!(r.total>1 => small![" ", r.total.to_string()]),
],
td![C!["subject"], tags_chiclet(&r.tags), " ", &r.subject],
td![C!["date"], &r.date_relative],
ev(Ev::Click, move |_| Msg::ShowPrettyRequest(tid)),
]
*/
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]
]
]
]
});
let first = search_results.page * search_results.results_per_page;
div![
C!["search-results"],
search_pager(first, summaries.len(), search_results.total),
rows,
search_pager(first, summaries.len(), search_results.total)
]
}
fn view_message(thread: &ThreadNode) -> Node<Msg> {
let message = thread.0.as_ref().expect("ThreadNode missing Message");
let children = &thread.1;
div![
C!["message"],
/* TODO(wathiede): collect all the tags and show them here. */
/* TODO(wathiede): collect all the attachments from all the subparts */
div![C!["header"], "From: ", &message.headers.from],
div![C!["header"], "Date: ", &message.headers.date],
div![C!["header"], "To: ", &message.headers.to],
div![
C!["body"],
match &message.body {
Some(body) => view_body(body.as_slice()),
None => div!["<no body>"],
},
],
children.iter().map(view_message)
]
}
fn view_body(body: &[Part]) -> Node<Msg> {
div![body.iter().map(view_part)]
}
fn view_text_plain(content: &Option<Content>) -> Node<Msg> {
match &content {
Some(Content::String(content)) => p![C!["view-part-text-plain"], content],
_ => div![
C!["error"],
format!("Unhandled content enum for text/plain"),
],
}
}
fn view_part(part: &Part) -> Node<Msg> {
match part.content_type.as_str() {
"text/plain" => view_text_plain(&part.content),
"text/html" => {
if let Some(Content::String(html)) = &part.content {
/* Build problems w/ css_inline. TODO(wathiede): move to server
let inliner = css_inline::CSSInliner::options()
.load_remote_stylesheets(false)
.remove_style_tags(true)
.build();
let inlined = inliner.inline(html).expect("failed to inline CSS");
*/
return div![C!["view-part-text-html"], raw![&html]];
} else {
div![
C!["error"],
format!("Unhandled content enum for multipart/mixed"),
]
}
}
// https://en.wikipedia.org/wiki/MIME#alternative
// RFC1341 states: In general, user agents that compose multipart/alternative entities
// should place the body parts in increasing order of preference, that is, with the
// preferred format last.
"multipart/alternative" => {
if let Some(Content::Multipart(parts)) = &part.content {
for part in parts.iter().rev() {
if part.content_type == "text/html" {
if let Some(Content::String(html)) = &part.content {
/*
let inliner = css_inline::CSSInliner::options()
.load_remote_stylesheets(false)
.remove_style_tags(true)
.build();
let inlined = inliner.inline(html).expect("failed to inline CSS");
*/
return div![Node::from_html(None, &html)];
}
}
if part.content_type == "text/plain" {
return view_text_plain(&part.content);
}
}
div!["No known multipart/alternative parts"]
} else {
div![
C!["error"],
format!("multipart/alternative with non-multipart content"),
]
}
}
"multipart/mixed" => match &part.content {
Some(Content::Multipart(parts)) => div![parts.iter().map(view_part)],
_ => div![
C!["error"],
format!("Unhandled content enum for multipart/mixed"),
],
},
_ => div![
C!["error"],
format!("Unhandled content type: {}", part.content_type)
],
}
}
fn first_subject(thread: &ThreadNode) -> Option<String> {
if let Some(msg) = &thread.0 {
return Some(msg.headers.subject.clone());
} else {
for tn in &thread.1 {
if let Some(s) = first_subject(&tn) {
return Some(s);
}
}
}
None
}

View File

@ -1,29 +1,28 @@
use std::collections::HashSet;
use seed::{prelude::*, *}; use seed::{prelude::*, *};
use crate::{ use crate::{
api::urls, api::urls,
graphql::front_page_query::*, graphql::front_page_query::*,
state::{Context, Model, Msg}, state::{Context, Model, Msg},
view::{ view::{self, human_age, pretty_authors, search_toolbar, set_title, tags_chiclet, view_header},
self, human_age, legacy, pretty_authors, set_title, tags_chiclet, view_header,
view_search_pager,
},
}; };
pub(super) fn view(model: &Model) -> Node<Msg> { pub(super) fn view(model: &Model) -> Node<Msg> {
let content = match &model.context { let content = match &model.context {
Context::None => div![h1!["Loading"]], Context::None => div![h1!["Loading"]],
Context::Thread(thread_set) => legacy::thread(thread_set), Context::ThreadResult {
Context::ThreadResult(thread) => view::thread(thread), thread,
Context::Search(search_results) => { open_messages,
legacy::mobile_search_results(&model.query, search_results) } => view::thread(thread, open_messages),
}
Context::SearchResult { Context::SearchResult {
query, query,
results, results,
count, count,
pager, pager,
} => search_results(&query, results.as_slice(), *count, pager), selected_threads,
} => search_results(&query, results.as_slice(), *count, pager, selected_threads),
}; };
div![ div![
view_header(&model.query, &model.refreshing_state), view_header(&model.query, &model.refreshing_state),
@ -37,6 +36,7 @@ fn search_results(
results: &[FrontPageQuerySearchNodes], results: &[FrontPageQuerySearchNodes],
count: usize, count: usize,
pager: &FrontPageQuerySearchPageInfo, pager: &FrontPageQuerySearchPageInfo,
selected_threads: &HashSet<String>,
) -> Node<Msg> { ) -> Node<Msg> {
if query.is_empty() { if query.is_empty() {
set_title("all mail"); set_title("all mail");
@ -45,27 +45,59 @@ fn search_results(
} }
let rows = results.iter().map(|r| { let rows = results.iter().map(|r| {
let tid = r.thread.clone(); let tid = r.thread.clone();
let check_tid = r.thread.clone();
let datetime = human_age(r.timestamp as i64); let datetime = human_age(r.timestamp as i64);
a![ let unread_idx = r.tags.iter().position(|e| e == &"unread");
C!["has-text-light"], let mut tags = r.tags.clone();
attrs! { if let Some(idx) = unread_idx {
At::Href => urls::thread(&tid) tags.remove(idx);
}, };
div![ div![
C!["row"], C!["row"],
label![
C!["b-checkbox", "checkbox", "is-large"],
input![attrs! {
At::Type=>"checkbox",
At::Checked=>selected_threads.contains(&tid).as_at_value(),
}],
span![C!["check"]],
ev(Ev::Input, move |e| {
if let Some(input) = e
.target()
.as_ref()
.expect("failed to get reference to target")
.dyn_ref::<web_sys::HtmlInputElement>()
{
if input.checked() {
Msg::SelectionAddThread(check_tid)
} else {
Msg::SelectionRemoveThread(check_tid)
}
} else {
Msg::Noop
}
}),
],
a![
C!["has-text-light", "summary"],
IF!(unread_idx.is_some() => C!["unread"]),
attrs! {
At::Href => urls::thread(&tid)
},
div![C!["subject"], &r.subject], div![C!["subject"], &r.subject],
span![C!["from", "is-size-7"], pretty_authors(&r.authors)], span![C!["from", "is-size-7"], pretty_authors(&r.authors)],
div![ div![
span![C!["is-size-7"], tags_chiclet(&r.tags, true)], span![C!["is-size-7"], tags_chiclet(&tags, true)],
span![C!["is-size-7", "float-right", "date"], datetime] span![C!["is-size-7", "float-right", "date"], datetime]
] ]
] ]
] ]
}); });
let show_bulk_edit = !selected_threads.is_empty();
div![ div![
C!["search-results"], C!["search-results"],
view_search_pager(count, pager), search_toolbar(count, pager, show_bulk_edit),
rows, div![C!["index"], rows],
view_search_pager(count, pager), search_toolbar(count, pager, show_bulk_edit),
] ]
} }

View File

@ -5,23 +5,19 @@ use std::{
use chrono::{DateTime, Datelike, Duration, Local, Utc}; use chrono::{DateTime, Datelike, Duration, Local, Utc};
use itertools::Itertools; use itertools::Itertools;
use log::{error, info}; use log::error;
use seed::{prelude::*, *}; use seed::{prelude::*, *};
use seed_hooks::{ use seed_hooks::{state_access::CloneState, topo, use_state};
state_access::{CloneState, StateAccess},
topo, use_state,
};
use wasm_timer::Instant; use wasm_timer::Instant;
use crate::{ use crate::{
api::urls, api::urls,
consts::{SEARCH_RESULTS_PER_PAGE, USE_GRAPHQL}, consts::SEARCH_RESULTS_PER_PAGE,
graphql::{front_page_query::*, show_thread_query::*}, graphql::{front_page_query::*, show_thread_query::*},
state::{Model, Msg, RefreshingState}, state::{Model, Msg, RefreshingState},
}; };
mod desktop; mod desktop;
mod legacy;
mod mobile; mod mobile;
mod tablet; mod tablet;
@ -47,10 +43,12 @@ fn tags_chiclet(tags: &[String], is_mobile: bool) -> impl Iterator<Item = Node<M
"replied" => span![classes, style, i![C!["fa-solid", "fa-reply"]]], "replied" => span![classes, style, i![C!["fa-solid", "fa-reply"]]],
_ => span![classes, style, &tag], _ => span![classes, style, &tag],
}, },
ev(Ev::Click, move |_| Msg::SearchRequest { ev(Ev::Click, move |_| Msg::FrontPageRequest {
query: format!("tag:{tag}"), query: format!("tag:{tag}"),
page: 0, after: None,
results_per_page: SEARCH_RESULTS_PER_PAGE, before: None,
first: None,
last: None,
}) })
] ]
}) })
@ -111,17 +109,50 @@ fn view_search_results(
results: &[FrontPageQuerySearchNodes], results: &[FrontPageQuerySearchNodes],
count: usize, count: usize,
pager: &FrontPageQuerySearchPageInfo, pager: &FrontPageQuerySearchPageInfo,
selected_threads: &HashSet<String>,
) -> Node<Msg> { ) -> Node<Msg> {
info!("pager {pager:?}");
if query.is_empty() { if query.is_empty() {
set_title("all mail"); set_title("all mail");
} else { } else {
set_title(query); set_title(query);
} }
let show_bulk_edit = !selected_threads.is_empty();
let rows = results.iter().map(|r| { let rows = results.iter().map(|r| {
let tid = r.thread.clone(); let tid = r.thread.clone();
let check_tid = r.thread.clone();
let datetime = human_age(r.timestamp as i64); let datetime = human_age(r.timestamp as i64);
let unread_idx = r.tags.iter().position(|e| e == &"unread");
let mut tags = r.tags.clone();
if let Some(idx) = unread_idx {
tags.remove(idx);
};
tr![ tr![
IF!(unread_idx.is_some() => C!["unread"]),
td![label![
C!["checkbox"],
input![
attrs! {
At::Type=>"checkbox",
At::Checked=>selected_threads.contains(&tid).as_at_value(),
},
ev(Ev::Input, move |e| {
if let Some(input) = e
.target()
.as_ref()
.expect("failed to get reference to target")
.dyn_ref::<web_sys::HtmlInputElement>()
{
if input.checked() {
Msg::SelectionAddThread(check_tid)
} else {
Msg::SelectionRemoveThread(check_tid)
}
} else {
Msg::Noop
}
}),
]
]],
td![ td![
C!["from"], C!["from"],
pretty_authors(&r.authors), pretty_authors(&r.authors),
@ -129,7 +160,7 @@ fn view_search_results(
], ],
td![ td![
C!["subject"], C!["subject"],
tags_chiclet(&r.tags, false), tags_chiclet(&tags, false),
" ", " ",
a![ a![
C!["has-text-light"], C!["has-text-light"],
@ -137,16 +168,6 @@ fn view_search_results(
At::Href => urls::thread(&tid) At::Href => urls::thread(&tid)
}, },
&r.subject, &r.subject,
button![
C!["mark-read-button", "button", "is-dark", "is-small"],
"Read",
ev(Ev::Click, move |e| {
info!("click happens");
e.stop_propagation();
e.prevent_default();
Msg::SetUnread(format!("thread:{tid}"), false)
}),
]
] ]
], ],
td![C!["date"], datetime] td![C!["date"], datetime]
@ -154,7 +175,7 @@ fn view_search_results(
}); });
div![ div![
view_search_pager(count, pager), search_toolbar(count, pager, show_bulk_edit),
table![ table![
C![ C![
"table", "table",
@ -165,53 +186,83 @@ fn view_search_results(
"is-striped", "is-striped",
], ],
thead![tr![ thead![tr![
th![C!["edit"], ""],
th![C!["from"], "From"], th![C!["from"], "From"],
th![C!["subject"], "Subject"], th![C!["subject"], "Subject"],
th![C!["date"], "Date"] th![C!["date"], "Date"]
]], ]],
tbody![rows] tbody![rows]
], ],
view_search_pager(count, pager) search_toolbar(count, pager, show_bulk_edit)
] ]
} }
fn view_search_pager(count: usize, pager: &FrontPageQuerySearchPageInfo) -> Node<Msg> { fn search_toolbar(
count: usize,
pager: &FrontPageQuerySearchPageInfo,
show_bulk_edit: bool,
) -> Node<Msg> {
let start = pager let start = pager
.start_cursor .start_cursor
.as_ref() .as_ref()
.map(|i| i.parse().unwrap_or(0)) .map(|i| i.parse().unwrap_or(0))
.unwrap_or(0); .unwrap_or(0);
nav![ nav![
C!["pagination"], C!["level"],
a![ div![
C![ C!["level-left"],
"pagination-previous", IF!(show_bulk_edit =>
"button", span![
//IF!(!pager.has_previous_page => "is-static"), C!["level-item", "buttons"],
], button![
IF!(!pager.has_previous_page => attrs!{ At::Disabled=>true }), C!["button"],
"<", attrs!{At::Title => "Mark as read"},
IF!(pager.has_previous_page => ev(Ev::Click, |_| Msg::PreviousPage)), span![C!["icon", "is-small"], i![C!["far", "fa-envelope-open"]]],
], ev(Ev::Click, |_| Msg::SelectionMarkAsRead),
a![ ],
C![ button![
"pagination-next", C!["button"],
"button", attrs!{At::Title => "Mark as unread"},
//IF!(!pager.has_next_page => "is-static") span![C!["icon", "is-small"], i![C!["far", "fa-envelope"]]],
], ev(Ev::Click, |_| Msg::SelectionMarkAsUnread),
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
)],
], ],
div![
C!["level-right"],
nav![
C!["level-item", "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
)],
],
]
]
] ]
} }
@ -276,16 +327,12 @@ fn raw_text_message(contents: &str) -> Node<Msg> {
} }
fn has_unread(tags: &[String]) -> bool { fn has_unread(tags: &[String]) -> bool {
for t in tags { tags.contains(&String::from("unread"))
if t == "unread" {
return true;
}
}
false
} }
fn read_message_render(msg: &ShowThreadQueryThreadMessages, open: StateAccess<bool>) -> Node<Msg> { fn read_message_render(msg: &ShowThreadQueryThreadMessages, open: bool) -> Node<Msg> {
let id = msg.id.clone(); let id = msg.id.clone();
let expand_id = msg.id.clone();
let is_unread = has_unread(&msg.tags); let is_unread = has_unread(&msg.tags);
div![ div![
C!["message"], C!["message"],
@ -297,7 +344,10 @@ fn read_message_render(msg: &ShowThreadQueryThreadMessages, open: StateAccess<bo
style! { style! {
St::Color => "gold" St::Color => "gold"
}, },
C![if is_unread { "fa-regular" } else { "fa-solid" }, "fa-star"], C![
if is_unread { "fa-regular" } else { "fa-solid" },
"fa-envelope"
],
ev(Ev::Click, move |e| { ev(Ev::Click, move |e| {
e.stop_propagation(); e.stop_propagation();
Msg::SetUnread(format!("id:{id}"), !is_unread) Msg::SetUnread(format!("id:{id}"), !is_unread)
@ -313,16 +363,18 @@ fn read_message_render(msg: &ShowThreadQueryThreadMessages, open: StateAccess<bo
// TODO(wathiede): add first line of message body // TODO(wathiede): add first line of message body
], ],
ev(Ev::Click, move |e| { ev(Ev::Click, move |e| {
open.set(!open.get());
e.stop_propagation(); e.stop_propagation();
if open {
Msg::MessageCollapse(expand_id)
} else {
Msg::MessageExpand(expand_id)
}
}), }),
] ]
} }
fn unread_message_render( fn unread_message_render(msg: &ShowThreadQueryThreadMessages, open: bool) -> Node<Msg> {
msg: &ShowThreadQueryThreadMessages,
open: StateAccess<bool>,
) -> Node<Msg> {
let id = msg.id.clone(); let id = msg.id.clone();
let expand_id = msg.id.clone();
let is_unread = has_unread(&msg.tags); let is_unread = has_unread(&msg.tags);
div![ div![
C!["message"], C!["message"],
@ -334,7 +386,10 @@ fn unread_message_render(
style! { style! {
St::Color => "gold" St::Color => "gold"
}, },
C![if is_unread { "fa-regular" } else { "fa-solid" }, "fa-star"], C![
if is_unread { "fa-regular" } else { "fa-solid" },
"fa-envelope"
],
ev(Ev::Click, move |e| { ev(Ev::Click, move |e| {
e.stop_propagation(); e.stop_propagation();
Msg::SetUnread(format!("id:{id}"), !is_unread) Msg::SetUnread(format!("id:{id}"), !is_unread)
@ -353,8 +408,12 @@ fn unread_message_render(
IF!(!msg.cc.is_empty() => span!["CC: ", view_addresses(&msg.cc)]) IF!(!msg.cc.is_empty() => span!["CC: ", view_addresses(&msg.cc)])
], ],
ev(Ev::Click, move |e| { ev(Ev::Click, move |e| {
open.set(!open.get());
e.stop_propagation(); e.stop_propagation();
if open {
Msg::MessageCollapse(expand_id)
} else {
Msg::MessageExpand(expand_id)
}
}), }),
], ],
div![ div![
@ -397,7 +456,7 @@ fn unread_message_render(
} }
#[topo::nested] #[topo::nested]
fn thread(thread: &ShowThreadQueryThread) -> Node<Msg> { fn thread(thread: &ShowThreadQueryThread, open_messages: &HashSet<String>) -> Node<Msg> {
// TODO(wathiede): show per-message subject if it changes significantly from top-level subject // TODO(wathiede): show per-message subject if it changes significantly from top-level subject
set_title(&thread.subject); set_title(&thread.subject);
let mut tags: Vec<_> = thread let mut tags: Vec<_> = thread
@ -412,40 +471,39 @@ fn thread(thread: &ShowThreadQueryThread) -> Node<Msg> {
tags.sort(); tags.sort();
let messages = thread.messages.iter().map(|msg| { let messages = thread.messages.iter().map(|msg| {
let is_unread = has_unread(&msg.tags); let is_unread = has_unread(&msg.tags);
let open = use_state(|| is_unread); let open = open_messages.contains(&msg.id);
//info!("open {} {}", open.get(), msg.id); if open {
if open.get() {
unread_message_render(&msg, open) unread_message_render(&msg, open)
} else { } else {
read_message_render(&msg, open) read_message_render(&msg, open)
} }
}); });
let any_unread = thread.messages.iter().any(|msg| has_unread(&msg.tags)); let read_thread_id = thread.thread_id.clone();
let thread_id = thread.thread_id.clone(); let unread_thread_id = thread.thread_id.clone();
div![ div![
C!["thread"], C!["thread"],
h1![ h3![C!["is-size-5"], &thread.subject,],
C!["title"], tags_chiclet(&tags, false),
span![ span![
C!["read-status"], C!["level-item", "buttons"],
i![ button![
style! { C!["button"],
St::Color => "gold" attrs! {At::Title => "Mark as read"},
}, span![C!["icon", "is-small"], i![C!["far", "fa-envelope-open"]]],
C![ ev(Ev::Click, move |_| Msg::SetUnread(
if any_unread { "fa-regular" } else { "fa-solid" }, format!("thread:{read_thread_id}"),
"fa-star" false
], )),
ev(Ev::Click, move |_| Msg::SetUnread( ],
format!("thread:{}", thread_id), button![
!any_unread C!["button"],
)), attrs! {At::Title => "Mark as unread"},
], span![C!["icon", "is-small"], i![C!["far", "fa-envelope"]]],
" ", ev(Ev::Click, move |_| Msg::SetUnread(
format!("thread:{unread_thread_id}"),
true
)),
], ],
&thread.subject,
" ",
tags_chiclet(&tags, false)
], ],
messages, messages,
/* TODO(wathiede): plumb in orignal id /* TODO(wathiede): plumb in orignal id
@ -526,30 +584,10 @@ fn view_header(query: &str, refresh_request: &RefreshingState) -> Node<Msg> {
At::AutoFocus => true.as_at_value(); At::AutoFocus => true.as_at_value();
At::Value => query, At::Value => query,
}, },
input_ev(Ev::Input, |q| if USE_GRAPHQL { input_ev(Ev::Input, |q| Msg::UpdateQuery(q)),
Msg::UpdateQuery(q)
} else {
Msg::SearchRequest {
query: Url::encode_uri_component(if q.is_empty() {
"*".to_string()
} else {
q
}),
page: 0,
results_per_page: SEARCH_RESULTS_PER_PAGE,
}
}),
// Send search on enter. // Send search on enter.
keyboard_ev(Ev::KeyUp, move |e| if e.key_code() == 0x0d { keyboard_ev(Ev::KeyUp, move |e| if e.key_code() == 0x0d {
if USE_GRAPHQL { Msg::SearchQuery(query)
Msg::SearchQuery(query)
} else {
Msg::SearchRequest {
query: Url::encode_uri_component(query),
page: 0,
results_per_page: SEARCH_RESULTS_PER_PAGE,
}
}
} else { } else {
Msg::Noop Msg::Noop
}), }),
@ -571,14 +609,13 @@ fn view_footer(render_time_ms: u128) -> Node<Msg> {
// `view` describes what to display. // `view` describes what to display.
pub fn view(model: &Model) -> Node<Msg> { pub fn view(model: &Model) -> Node<Msg> {
let start = Instant::now(); let start = Instant::now();
info!("refreshing {:?}", model.refreshing_state);
let win = seed::window(); let win = seed::window();
let w = win let w = win
.inner_width() .inner_width()
.expect("window width") .expect("window width")
.as_f64() .as_f64()
.expect("window width f64"); .expect("window width f64");
let h = win let _h = win
.inner_height() .inner_height()
.expect("window height") .expect("window height")
.as_f64() .as_f64()

View File

@ -9,15 +9,17 @@ pub(super) fn view(model: &Model) -> Node<Msg> {
// Do two queries, one without `unread` so it loads fast, then a second with unread. // Do two queries, one without `unread` so it loads fast, then a second with unread.
let content = match &model.context { let content = match &model.context {
Context::None => div![h1!["Loading"]], Context::None => div![h1!["Loading"]],
Context::Thread(_) => unimplemented!("tablet legacy thread view"), Context::ThreadResult {
Context::ThreadResult(thread) => view::thread(thread), thread,
Context::Search(_) => unimplemented!("tablet legacy search results view"), open_messages,
} => view::thread(thread, open_messages),
Context::SearchResult { Context::SearchResult {
query, query,
results, results,
count, count,
pager, pager,
} => view_search_results(&query, results.as_slice(), *count, pager), selected_threads,
} => view_search_results(&query, results.as_slice(), *count, pager, selected_threads),
}; };
div![ div![
C!["main-content"], C!["main-content"],

268
web/static/main.css Normal file
View File

@ -0,0 +1,268 @@
/* Bulma Utilities */
.b-checkbox.checkbox {
-webkit-touch-callout: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
/* Box-shadow on hover */
.b-checkbox.checkbox {
outline: none;
display: inline-flex;
align-items: center;
}
.b-checkbox.checkbox:not(.button) {
margin-right: 0.5em;
}
.b-checkbox.checkbox:not(.button) + .checkbox:last-child {
margin-right: 0;
}
.b-checkbox.checkbox input[type=checkbox] {
position: absolute;
left: 0;
opacity: 0;
outline: none;
z-index: -1;
}
.b-checkbox.checkbox input[type=checkbox] + .check {
width: 1.25em;
height: 1.25em;
flex-shrink: 0;
border-radius: 4px;
border: 2px solid #7a7a7a;
transition: background 150ms ease-out;
background: transparent;
}
.b-checkbox.checkbox input[type=checkbox]:checked + .check {
background: #00d1b2 url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1'%3E%3Cpath style='fill:%23fff' d='M 0.04038059,0.6267767 0.14644661,0.52071068 0.42928932,0.80355339 0.3232233,0.90961941 z M 0.21715729,0.80355339 0.85355339,0.16715729 0.95961941,0.2732233 0.3232233,0.90961941 z'%3E%3C/path%3E%3C/svg%3E") no-repeat center center;
border-color: #00d1b2;
}
.b-checkbox.checkbox input[type=checkbox]:checked + .check.is-white {
background: white url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1'%3E%3Cpath style='fill:%230a0a0a' d='M 0.04038059,0.6267767 0.14644661,0.52071068 0.42928932,0.80355339 0.3232233,0.90961941 z M 0.21715729,0.80355339 0.85355339,0.16715729 0.95961941,0.2732233 0.3232233,0.90961941 z'%3E%3C/path%3E%3C/svg%3E") no-repeat center center;
border-color: white;
}
.b-checkbox.checkbox input[type=checkbox]:checked + .check.is-black {
background: #0a0a0a url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1'%3E%3Cpath style='fill:white' d='M 0.04038059,0.6267767 0.14644661,0.52071068 0.42928932,0.80355339 0.3232233,0.90961941 z M 0.21715729,0.80355339 0.85355339,0.16715729 0.95961941,0.2732233 0.3232233,0.90961941 z'%3E%3C/path%3E%3C/svg%3E") no-repeat center center;
border-color: #0a0a0a;
}
.b-checkbox.checkbox input[type=checkbox]:checked + .check.is-light {
background: whitesmoke url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1'%3E%3Cpath style='fill:rgba(0, 0, 0, 0.7)' d='M 0.04038059,0.6267767 0.14644661,0.52071068 0.42928932,0.80355339 0.3232233,0.90961941 z M 0.21715729,0.80355339 0.85355339,0.16715729 0.95961941,0.2732233 0.3232233,0.90961941 z'%3E%3C/path%3E%3C/svg%3E") no-repeat center center;
border-color: whitesmoke;
}
.b-checkbox.checkbox input[type=checkbox]:checked + .check.is-dark {
background: #363636 url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1'%3E%3Cpath style='fill:%23fff' d='M 0.04038059,0.6267767 0.14644661,0.52071068 0.42928932,0.80355339 0.3232233,0.90961941 z M 0.21715729,0.80355339 0.85355339,0.16715729 0.95961941,0.2732233 0.3232233,0.90961941 z'%3E%3C/path%3E%3C/svg%3E") no-repeat center center;
border-color: #363636;
}
.b-checkbox.checkbox input[type=checkbox]:checked + .check.is-primary {
background: #00d1b2 url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1'%3E%3Cpath style='fill:%23fff' d='M 0.04038059,0.6267767 0.14644661,0.52071068 0.42928932,0.80355339 0.3232233,0.90961941 z M 0.21715729,0.80355339 0.85355339,0.16715729 0.95961941,0.2732233 0.3232233,0.90961941 z'%3E%3C/path%3E%3C/svg%3E") no-repeat center center;
border-color: #00d1b2;
}
.b-checkbox.checkbox input[type=checkbox]:checked + .check.is-link {
background: #485fc7 url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1'%3E%3Cpath style='fill:%23fff' d='M 0.04038059,0.6267767 0.14644661,0.52071068 0.42928932,0.80355339 0.3232233,0.90961941 z M 0.21715729,0.80355339 0.85355339,0.16715729 0.95961941,0.2732233 0.3232233,0.90961941 z'%3E%3C/path%3E%3C/svg%3E") no-repeat center center;
border-color: #485fc7;
}
.b-checkbox.checkbox input[type=checkbox]:checked + .check.is-info {
background: #3e8ed0 url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1'%3E%3Cpath style='fill:%23fff' d='M 0.04038059,0.6267767 0.14644661,0.52071068 0.42928932,0.80355339 0.3232233,0.90961941 z M 0.21715729,0.80355339 0.85355339,0.16715729 0.95961941,0.2732233 0.3232233,0.90961941 z'%3E%3C/path%3E%3C/svg%3E") no-repeat center center;
border-color: #3e8ed0;
}
.b-checkbox.checkbox input[type=checkbox]:checked + .check.is-success {
background: #48c78e url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1'%3E%3Cpath style='fill:%23fff' d='M 0.04038059,0.6267767 0.14644661,0.52071068 0.42928932,0.80355339 0.3232233,0.90961941 z M 0.21715729,0.80355339 0.85355339,0.16715729 0.95961941,0.2732233 0.3232233,0.90961941 z'%3E%3C/path%3E%3C/svg%3E") no-repeat center center;
border-color: #48c78e;
}
.b-checkbox.checkbox input[type=checkbox]:checked + .check.is-warning {
background: #ffe08a url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1'%3E%3Cpath style='fill:rgba(0, 0, 0, 0.7)' d='M 0.04038059,0.6267767 0.14644661,0.52071068 0.42928932,0.80355339 0.3232233,0.90961941 z M 0.21715729,0.80355339 0.85355339,0.16715729 0.95961941,0.2732233 0.3232233,0.90961941 z'%3E%3C/path%3E%3C/svg%3E") no-repeat center center;
border-color: #ffe08a;
}
.b-checkbox.checkbox input[type=checkbox]:checked + .check.is-danger {
background: #f14668 url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1'%3E%3Cpath style='fill:%23fff' d='M 0.04038059,0.6267767 0.14644661,0.52071068 0.42928932,0.80355339 0.3232233,0.90961941 z M 0.21715729,0.80355339 0.85355339,0.16715729 0.95961941,0.2732233 0.3232233,0.90961941 z'%3E%3C/path%3E%3C/svg%3E") no-repeat center center;
border-color: #f14668;
}
.b-checkbox.checkbox input[type=checkbox]:indeterminate + .check, .b-checkbox.checkbox input[type=checkbox].is-indeterminate + .check {
background: #00d1b2 url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1'%3E%3Crect style='fill:%23fff' width='0.7' height='0.2' x='.15' y='.4'%3E%3C/rect%3E%3C/svg%3E") no-repeat center center;
border-color: #00d1b2;
}
.b-checkbox.checkbox input[type=checkbox]:indeterminate + .check.is-white, .b-checkbox.checkbox input[type=checkbox].is-indeterminate + .check.is-white {
background: white url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1'%3E%3Crect style='fill:%230a0a0a' width='0.7' height='0.2' x='.15' y='.4'%3E%3C/rect%3E%3C/svg%3E") no-repeat center center;
border-color: white;
}
.b-checkbox.checkbox input[type=checkbox]:indeterminate + .check.is-black, .b-checkbox.checkbox input[type=checkbox].is-indeterminate + .check.is-black {
background: #0a0a0a url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1'%3E%3Crect style='fill:white' width='0.7' height='0.2' x='.15' y='.4'%3E%3C/rect%3E%3C/svg%3E") no-repeat center center;
border-color: #0a0a0a;
}
.b-checkbox.checkbox input[type=checkbox]:indeterminate + .check.is-light, .b-checkbox.checkbox input[type=checkbox].is-indeterminate + .check.is-light {
background: whitesmoke url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1'%3E%3Crect style='fill:rgba(0, 0, 0, 0.7)' width='0.7' height='0.2' x='.15' y='.4'%3E%3C/rect%3E%3C/svg%3E") no-repeat center center;
border-color: whitesmoke;
}
.b-checkbox.checkbox input[type=checkbox]:indeterminate + .check.is-dark, .b-checkbox.checkbox input[type=checkbox].is-indeterminate + .check.is-dark {
background: #363636 url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1'%3E%3Crect style='fill:%23fff' width='0.7' height='0.2' x='.15' y='.4'%3E%3C/rect%3E%3C/svg%3E") no-repeat center center;
border-color: #363636;
}
.b-checkbox.checkbox input[type=checkbox]:indeterminate + .check.is-primary, .b-checkbox.checkbox input[type=checkbox].is-indeterminate + .check.is-primary {
background: #00d1b2 url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1'%3E%3Crect style='fill:%23fff' width='0.7' height='0.2' x='.15' y='.4'%3E%3C/rect%3E%3C/svg%3E") no-repeat center center;
border-color: #00d1b2;
}
.b-checkbox.checkbox input[type=checkbox]:indeterminate + .check.is-link, .b-checkbox.checkbox input[type=checkbox].is-indeterminate + .check.is-link {
background: #485fc7 url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1'%3E%3Crect style='fill:%23fff' width='0.7' height='0.2' x='.15' y='.4'%3E%3C/rect%3E%3C/svg%3E") no-repeat center center;
border-color: #485fc7;
}
.b-checkbox.checkbox input[type=checkbox]:indeterminate + .check.is-info, .b-checkbox.checkbox input[type=checkbox].is-indeterminate + .check.is-info {
background: #3e8ed0 url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1'%3E%3Crect style='fill:%23fff' width='0.7' height='0.2' x='.15' y='.4'%3E%3C/rect%3E%3C/svg%3E") no-repeat center center;
border-color: #3e8ed0;
}
.b-checkbox.checkbox input[type=checkbox]:indeterminate + .check.is-success, .b-checkbox.checkbox input[type=checkbox].is-indeterminate + .check.is-success {
background: #48c78e url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1'%3E%3Crect style='fill:%23fff' width='0.7' height='0.2' x='.15' y='.4'%3E%3C/rect%3E%3C/svg%3E") no-repeat center center;
border-color: #48c78e;
}
.b-checkbox.checkbox input[type=checkbox]:indeterminate + .check.is-warning, .b-checkbox.checkbox input[type=checkbox].is-indeterminate + .check.is-warning {
background: #ffe08a url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1'%3E%3Crect style='fill:rgba(0, 0, 0, 0.7)' width='0.7' height='0.2' x='.15' y='.4'%3E%3C/rect%3E%3C/svg%3E") no-repeat center center;
border-color: #ffe08a;
}
.b-checkbox.checkbox input[type=checkbox]:indeterminate + .check.is-danger, .b-checkbox.checkbox input[type=checkbox].is-indeterminate + .check.is-danger {
background: #f14668 url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1'%3E%3Crect style='fill:%23fff' width='0.7' height='0.2' x='.15' y='.4'%3E%3C/rect%3E%3C/svg%3E") no-repeat center center;
border-color: #f14668;
}
.b-checkbox.checkbox input[type=checkbox]:focus + .check {
box-shadow: 0 0 0.5em rgba(122, 122, 122, 0.8);
}
.b-checkbox.checkbox input[type=checkbox]:focus:checked + .check {
box-shadow: 0 0 0.5em rgba(0, 209, 178, 0.8);
}
.b-checkbox.checkbox input[type=checkbox]:focus:checked + .check.is-white {
box-shadow: 0 0 0.5em rgba(255, 255, 255, 0.8);
}
.b-checkbox.checkbox input[type=checkbox]:focus:checked + .check.is-black {
box-shadow: 0 0 0.5em rgba(10, 10, 10, 0.8);
}
.b-checkbox.checkbox input[type=checkbox]:focus:checked + .check.is-light {
box-shadow: 0 0 0.5em rgba(245, 245, 245, 0.8);
}
.b-checkbox.checkbox input[type=checkbox]:focus:checked + .check.is-dark {
box-shadow: 0 0 0.5em rgba(54, 54, 54, 0.8);
}
.b-checkbox.checkbox input[type=checkbox]:focus:checked + .check.is-primary {
box-shadow: 0 0 0.5em rgba(0, 209, 178, 0.8);
}
.b-checkbox.checkbox input[type=checkbox]:focus:checked + .check.is-link {
box-shadow: 0 0 0.5em rgba(72, 95, 199, 0.8);
}
.b-checkbox.checkbox input[type=checkbox]:focus:checked + .check.is-info {
box-shadow: 0 0 0.5em rgba(62, 142, 208, 0.8);
}
.b-checkbox.checkbox input[type=checkbox]:focus:checked + .check.is-success {
box-shadow: 0 0 0.5em rgba(72, 199, 142, 0.8);
}
.b-checkbox.checkbox input[type=checkbox]:focus:checked + .check.is-warning {
box-shadow: 0 0 0.5em rgba(255, 224, 138, 0.8);
}
.b-checkbox.checkbox input[type=checkbox]:focus:checked + .check.is-danger {
box-shadow: 0 0 0.5em rgba(241, 70, 104, 0.8);
}
.b-checkbox.checkbox .control-label {
padding-left: calc(0.75em - 1px);
}
.b-checkbox.checkbox.button {
display: flex;
}
.b-checkbox.checkbox[disabled] {
opacity: 0.5;
}
.b-checkbox.checkbox:hover input[type=checkbox]:not(:disabled) + .check {
border-color: #00d1b2;
}
.b-checkbox.checkbox:hover input[type=checkbox]:not(:disabled) + .check.is-white {
border-color: white;
}
.b-checkbox.checkbox:hover input[type=checkbox]:not(:disabled) + .check.is-black {
border-color: #0a0a0a;
}
.b-checkbox.checkbox:hover input[type=checkbox]:not(:disabled) + .check.is-light {
border-color: whitesmoke;
}
.b-checkbox.checkbox:hover input[type=checkbox]:not(:disabled) + .check.is-dark {
border-color: #363636;
}
.b-checkbox.checkbox:hover input[type=checkbox]:not(:disabled) + .check.is-primary {
border-color: #00d1b2;
}
.b-checkbox.checkbox:hover input[type=checkbox]:not(:disabled) + .check.is-link {
border-color: #485fc7;
}
.b-checkbox.checkbox:hover input[type=checkbox]:not(:disabled) + .check.is-info {
border-color: #3e8ed0;
}
.b-checkbox.checkbox:hover input[type=checkbox]:not(:disabled) + .check.is-success {
border-color: #48c78e;
}
.b-checkbox.checkbox:hover input[type=checkbox]:not(:disabled) + .check.is-warning {
border-color: #ffe08a;
}
.b-checkbox.checkbox:hover input[type=checkbox]:not(:disabled) + .check.is-danger {
border-color: #f14668;
}
.b-checkbox.checkbox.is-small {
border-radius: 2px;
font-size: 0.75rem;
}
.b-checkbox.checkbox.is-medium {
font-size: 1.25rem;
}
.b-checkbox.checkbox.is-large {
font-size: 1.5rem;
}

1
web/static/main.min.css vendored Normal file

File diff suppressed because one or more lines are too long