Compare commits

..

No commits in common. "e570202ba228e89455acb13ad93c476b7c428728" and "530bd8e350f88c20978245ff8e8c077cf792eca7" have entirely different histories.

9 changed files with 229 additions and 290 deletions

View File

@ -3,7 +3,7 @@ SELECT
FROM FROM
post post
WHERE WHERE
($1::text IS NULL OR site = $1) site = $1
AND ( AND (
NOT $2 NOT $2
OR NOT is_read OR NOT is_read

View File

@ -1,5 +1,4 @@
SELECT SELECT
site,
date, date,
is_read, is_read,
title, title,
@ -9,7 +8,7 @@ FROM
post p post p
JOIN feed f ON p.site = f.slug JOIN feed f ON p.site = f.slug
WHERE WHERE
($1::text IS NULL OR site = $1) site = $1
AND ( AND (
NOT $2 NOT $2
OR NOT is_read OR NOT is_read

View File

@ -1,11 +1,9 @@
use async_graphql::{ use async_graphql::{
connection::{self, Connection, Edge, OpaqueCursor}, connection::Connection, Context, EmptySubscription, Enum, Error, FieldResult, Object, Schema,
Context, EmptySubscription, Enum, Error, FieldResult, InputObject, Object, Schema,
SimpleObject, Union, SimpleObject, Union,
}; };
use log::info; use log::info;
use notmuch::Notmuch; use notmuch::Notmuch;
use serde::{Deserialize, Serialize};
use sqlx::postgres::PgPool; use sqlx::postgres::PgPool;
use crate::{newsreader, nm}; use crate::{newsreader, nm};
@ -195,19 +193,13 @@ pub struct Email {
} }
#[derive(SimpleObject)] #[derive(SimpleObject)]
pub struct Tag { pub(crate) struct Tag {
pub name: String, pub name: String,
pub fg_color: String, pub fg_color: String,
pub bg_color: String, pub bg_color: String,
pub unread: usize, pub unread: usize,
} }
#[derive(Serialize, Deserialize, Debug, InputObject)]
struct SearchCursor {
newsreader_offset: i32,
notmuch_offset: i32,
}
pub struct QueryRoot; pub struct QueryRoot;
#[Object] #[Object]
impl QueryRoot { impl QueryRoot {
@ -215,9 +207,12 @@ impl QueryRoot {
let nm = ctx.data_unchecked::<Notmuch>(); let nm = ctx.data_unchecked::<Notmuch>();
let pool = ctx.data_unchecked::<PgPool>(); let pool = ctx.data_unchecked::<PgPool>();
let newsreader_query: newsreader::Query = query.parse()?; // TODO: make this search both copra and merge results
if newsreader::is_newsreader_search(&query) {
Ok(newsreader::count(pool, &newsreader_query).await? + nm::count(nm, &query).await?) Ok(newsreader::count(pool, &query).await?)
} else {
Ok(nm::count(nm, &query).await?)
}
} }
async fn search<'ctx>( async fn search<'ctx>(
@ -228,117 +223,17 @@ impl QueryRoot {
first: Option<i32>, first: Option<i32>,
last: Option<i32>, last: Option<i32>,
query: String, query: String,
) -> Result<Connection<OpaqueCursor<SearchCursor>, ThreadSummary>, Error> { ) -> Result<Connection<usize, ThreadSummary>, Error> {
// TODO: add keywords to limit search to one corpus, i.e. is:news or is:mail info!("search({after:?} {before:?} {first:?} {last:?} {query:?})");
info!("search({after:?} {before:?} {first:?} {last:?} {query:?})",);
let nm = ctx.data_unchecked::<Notmuch>(); let nm = ctx.data_unchecked::<Notmuch>();
let pool = ctx.data_unchecked::<PgPool>(); let pool = ctx.data_unchecked::<PgPool>();
enum ThreadSummaryCursor { // TODO: make this search both copra and merge results
Newsreader(i32, ThreadSummary), if newsreader::is_newsreader_search(&query) {
Notmuch(i32, ThreadSummary), Ok(newsreader::search(pool, after, before, first, last, query).await?)
} else {
Ok(nm::search(nm, after, before, first, last, query).await?)
} }
Ok(connection::query(
after,
before,
first,
last,
|after: Option<OpaqueCursor<SearchCursor>>,
before: Option<OpaqueCursor<SearchCursor>>,
first: Option<usize>,
last: Option<usize>| async move {
info!(
"search({:?} {:?} {first:?} {last:?} {query:?})",
after.as_ref().map(|v| &v.0),
before.as_ref().map(|v| &v.0)
);
let newsreader_after = after.as_ref().map(|sc| sc.newsreader_offset);
let notmuch_after = after.as_ref().map(|sc| sc.newsreader_offset);
let newsreader_before = before.as_ref().map(|sc| sc.newsreader_offset);
let notmuch_before = before.as_ref().map(|sc| sc.notmuch_offset);
let newsreader_query: newsreader::Query = query.parse()?;
let newsreader_results = newsreader::search(
pool,
newsreader_after,
newsreader_before,
first.map(|v| v as i32),
last.map(|v| v as i32),
&newsreader_query,
)
.await?
.into_iter()
.map(|(cur, ts)| ThreadSummaryCursor::Newsreader(cur, ts));
let notmuch_results = nm::search(
nm,
notmuch_after,
notmuch_before,
first.map(|v| v as i32),
last.map(|v| v as i32),
query,
)
.await?
.into_iter()
.map(|(cur, ts)| ThreadSummaryCursor::Notmuch(cur, ts));
let mut results: Vec<_> = newsreader_results.chain(notmuch_results).collect();
// The leading '-' is to reverse sort
results.sort_by_key(|item| match item {
ThreadSummaryCursor::Newsreader(_, ts) => -ts.timestamp,
ThreadSummaryCursor::Notmuch(_, ts) => -ts.timestamp,
});
let mut has_next_page = before.is_some();
if let Some(first) = first {
if results.len() > first {
has_next_page = true;
results.truncate(first);
}
}
let mut has_previous_page = after.is_some();
if let Some(last) = last {
if results.len() > last {
has_previous_page = true;
// TODO: find better way to do this.
results.reverse();
results.truncate(last);
results.reverse();
}
}
let mut connection = Connection::new(has_previous_page, has_next_page);
let mut newsreader_offset = 0;
let mut notmuch_offset = 0;
connection.edges.extend(results.into_iter().map(|item| {
let thread_summary;
match item {
ThreadSummaryCursor::Newsreader(offset, ts) => {
thread_summary = ts;
newsreader_offset = offset;
}
ThreadSummaryCursor::Notmuch(offset, ts) => {
thread_summary = ts;
notmuch_offset = offset;
}
}
info!(
"item: {} {}",
thread_summary.subject, thread_summary.timestamp
);
let cur = OpaqueCursor(SearchCursor {
newsreader_offset,
notmuch_offset,
});
Edge::new(cur, thread_summary)
}));
Ok::<_, async_graphql::Error>(connection)
},
)
.await?)
} }
async fn tags<'ctx>(&self, ctx: &Context<'ctx>) -> FieldResult<Vec<Tag>> { async fn tags<'ctx>(&self, ctx: &Context<'ctx>) -> FieldResult<Vec<Tag>> {

View File

@ -5,7 +5,7 @@ pub mod nm;
use css_inline::{CSSInliner, InlineError, InlineOptions}; use css_inline::{CSSInliner, InlineError, InlineOptions};
use linkify::{LinkFinder, LinkKind}; use linkify::{LinkFinder, LinkKind};
use log::error; use log::{error, info};
use lol_html::{element, errors::RewritingError, rewrite_str, RewriteStrSettings}; use lol_html::{element, errors::RewritingError, rewrite_str, RewriteStrSettings};
use maplit::{hashmap, hashset}; use maplit::{hashmap, hashset};
use thiserror::Error; use thiserror::Error;
@ -345,39 +345,3 @@ pub fn sanitize_html(
Ok(clean_html) Ok(clean_html)
} }
fn compute_offset_limit(
after: Option<i32>,
before: Option<i32>,
first: Option<i32>,
last: Option<i32>,
) -> (i32, i32) {
let default_page_size = 100;
match (after, before, first, last) {
// Reasonable defaults
(None, None, None, None) => (0, default_page_size),
(None, None, Some(first), None) => (0, first),
(Some(after), None, None, None) => (after, default_page_size),
(Some(after), None, Some(first), None) => (after, first),
(None, Some(before), None, None) => (0.max(before - default_page_size), default_page_size),
(None, Some(before), None, Some(last)) => (0.max(before - last), last),
(None, None, None, Some(_)) => {
panic!("specifying last and no before doesn't make sense")
}
(None, None, Some(_), Some(_)) => {
panic!("specifying first and last doesn't make sense")
}
(None, Some(_), Some(_), _) => {
panic!("specifying before and first doesn't make sense")
}
(Some(_), Some(_), _, _) => {
panic!("specifying after and before doesn't make sense")
}
(Some(_), None, None, Some(_)) => {
panic!("specifying after and last doesn't make sense")
}
(Some(_), None, Some(_), Some(_)) => {
panic!("specifying after, first and last doesn't make sense")
}
}
}

View File

@ -4,16 +4,14 @@ use std::{
str::FromStr, str::FromStr,
}; };
use log::info; use async_graphql::connection::{self, Connection, Edge};
use sqlx::postgres::PgPool; use sqlx::postgres::PgPool;
use url::Url; use url::Url;
const TAG_PREFIX: &'static str = "News/"; const TAG_PREFIX: &'static str = "News/";
const THREAD_PREFIX: &'static str = "news:"; const THREAD_PREFIX: &'static str = "news:";
const NON_EXISTENT_SITE_NAME: &'static str = "NO-SUCH-SITE";
use crate::{ use crate::{
compute_offset_limit,
error::ServerError, error::ServerError,
graphql::{Body, Email, Html, Message, Tag, Thread, ThreadSummary}, graphql::{Body, Email, Html, Message, Tag, Thread, ThreadSummary},
EscapeHtml, InlineStyle, SanitizeHtml, Transformer, EscapeHtml, InlineStyle, SanitizeHtml, Transformer,
@ -27,8 +25,10 @@ pub fn is_newsreader_thread(query: &str) -> bool {
query.starts_with(THREAD_PREFIX) query.starts_with(THREAD_PREFIX)
} }
pub async fn count(pool: &PgPool, query: &Query) -> Result<usize, ServerError> { pub async fn count(pool: &PgPool, query: &str) -> Result<usize, ServerError> {
let row = sqlx::query_file!("sql/count.sql", query.site, query.unread_only) let query: Query = query.parse()?;
let site = query.site.expect("search has no site");
let row = sqlx::query_file!("sql/count.sql", site, query.unread_only)
.fetch_one(pool) .fetch_one(pool)
.await?; .await?;
Ok(row.count.unwrap_or(0).try_into().unwrap_or(0)) Ok(row.count.unwrap_or(0).try_into().unwrap_or(0))
@ -36,57 +36,102 @@ pub async fn count(pool: &PgPool, query: &Query) -> Result<usize, ServerError> {
pub async fn search( pub async fn search(
pool: &PgPool, pool: &PgPool,
after: Option<i32>, after: Option<String>,
before: Option<i32>, before: Option<String>,
first: Option<i32>, first: Option<i32>,
last: Option<i32>, last: Option<i32>,
query: &Query, query: String,
) -> Result<Vec<(i32, ThreadSummary)>, async_graphql::Error> { ) -> Result<Connection<usize, ThreadSummary>, async_graphql::Error> {
info!("search({after:?} {before:?} {first:?} {last:?} {query:?}"); let query: Query = query.parse()?;
let (offset, limit) = compute_offset_limit(after, before, first, last); let site = query.site.expect("search has no site");
// The +1 is to see if there are more pages of data available. connection::query(
let limit = limit + 1; after,
info!("search offset {offset} limit {limit}"); before,
first,
let rows = sqlx::query_file!( last,
"sql/threads.sql", |after: Option<usize>, before: Option<usize>, first, last| async move {
query.site, let default_page_size = 100;
query.unread_only, let (offset, limit) = match (after, before, first, last) {
offset as i64, // Reasonable defaults
limit as i64 (None, None, None, None) => (0, default_page_size),
) (None, None, Some(first), None) => (0, first),
.fetch_all(pool) (Some(after), None, None, None) => (after, default_page_size),
.await?; (Some(after), None, Some(first), None) => (after, first),
(None, Some(before), None, None) => {
Ok(rows (before.saturating_sub(default_page_size), default_page_size)
.into_iter() }
.enumerate() (None, Some(before), None, Some(last)) => (before.saturating_sub(last), last),
.map(|(i, r)| { (None, None, None, Some(_)) => {
let site = r.site.unwrap_or("UNKOWN SITE".to_string()); panic!("specifying last and no before doesn't make sense")
let tags = if r.is_read.unwrap_or(false) { }
vec![site.clone()] (None, None, Some(_), Some(_)) => {
} else { panic!("specifying first and last doesn't make sense")
vec!["unread".to_string(), site.clone()] }
(None, Some(_), Some(_), _) => {
panic!("specifying before and first doesn't make sense")
}
(Some(_), Some(_), _, _) => {
panic!("specifying after and before doesn't make sense")
}
(Some(_), None, None, Some(_)) => {
panic!("specifying after and last doesn't make sense")
}
(Some(_), None, Some(_), Some(_)) => {
panic!("specifying after, first and last doesn't make sense")
}
}; };
( // The +1 is to see if there are more pages of data available.
i as i32 + offset, let limit = limit + 1;
ThreadSummary {
thread: format!("{THREAD_PREFIX}{}", r.uid), let rows = sqlx::query_file!(
timestamp: r "sql/threads.sql",
.date site,
.expect("post missing date") query.unread_only,
.assume_utc() offset as i64,
.unix_timestamp() as isize, limit as i64
date_relative: "TODO date_relative".to_string(),
matched: 0,
total: 1,
authors: r.name.unwrap_or_else(|| site.clone()),
subject: r.title.unwrap_or("NO TITLE".to_string()),
tags,
},
) )
}) .fetch_all(pool)
.collect()) .await?;
let mut slice = rows
.into_iter()
.map(|r| {
let tags = if r.is_read.unwrap_or(false) {
vec![site.clone()]
} else {
vec!["unread".to_string(), site.clone()]
};
ThreadSummary {
thread: format!("{THREAD_PREFIX}{}", r.uid),
timestamp: r
.date
.expect("post missing date")
.assume_utc()
.unix_timestamp() as isize,
date_relative: "TODO date_relative".to_string(),
matched: 0,
total: 1,
authors: r.name.unwrap_or_else(|| site.clone()),
subject: r.title.unwrap_or("NO TITLE".to_string()),
tags,
}
})
.collect::<Vec<_>>();
let has_more = slice.len() == limit;
let mut connection = Connection::new(offset > 0, has_more);
if has_more {
slice.pop();
};
connection.edges.extend(
slice
.into_iter()
.enumerate()
.map(|(idx, item)| Edge::new(offset + idx, item)),
);
Ok::<_, async_graphql::Error>(connection)
},
)
.await
} }
pub async fn tags(pool: &PgPool, _needs_unread: bool) -> Result<Vec<Tag>, ServerError> { pub async fn tags(pool: &PgPool, _needs_unread: bool) -> Result<Vec<Tag>, ServerError> {
@ -213,11 +258,11 @@ pub async fn thread(pool: &PgPool, thread_id: String) -> Result<Thread, ServerEr
} }
#[derive(Debug)] #[derive(Debug)]
pub struct Query { struct Query {
pub unread_only: bool, unread_only: bool,
pub site: Option<String>, site: Option<String>,
pub uid: Option<String>, uid: Option<String>,
pub remainder: Vec<String>, remainder: Vec<String>,
} }
impl FromStr for Query { impl FromStr for Query {
@ -233,10 +278,6 @@ impl FromStr for Query {
unread_only = true unread_only = true
} else if word.starts_with(&site_prefix) { } else if word.starts_with(&site_prefix) {
site = Some(word[site_prefix.len()..].to_string()) site = Some(word[site_prefix.len()..].to_string())
} else if word.starts_with("tag:") {
// Any tag that doesn't match site_prefix should explicitly set the site to something not in the
// database
site = Some(NON_EXISTENT_SITE_NAME.to_string());
} else if word.starts_with(THREAD_PREFIX) { } else if word.starts_with(THREAD_PREFIX) {
uid = Some(word[THREAD_PREFIX.len()..].to_string()) uid = Some(word[THREAD_PREFIX.len()..].to_string())
} else { } else {

View File

@ -5,13 +5,13 @@ use std::{
time::Instant, time::Instant,
}; };
use async_graphql::connection::{self, Connection, Edge};
use log::{error, info, warn}; use log::{error, info, warn};
use mailparse::{parse_mail, MailHeader, MailHeaderMap, ParsedMail}; use mailparse::{parse_mail, MailHeader, MailHeaderMap, ParsedMail};
use memmap::MmapOptions; use memmap::MmapOptions;
use notmuch::Notmuch; use notmuch::Notmuch;
use crate::{ use crate::{
compute_offset_limit,
error::ServerError, error::ServerError,
graphql::{ graphql::{
Attachment, Body, DispositionType, Email, Header, Html, Message, PlainText, Tag, Thread, Attachment, Body, DispositionType, Email, Header, Html, Message, PlainText, Tag, Thread,
@ -44,22 +44,41 @@ pub async fn count(nm: &Notmuch, query: &str) -> Result<usize, ServerError> {
pub async fn search( pub async fn search(
nm: &Notmuch, nm: &Notmuch,
after: Option<i32>, after: Option<String>,
before: Option<i32>, before: Option<String>,
first: Option<i32>, first: Option<i32>,
last: Option<i32>, last: Option<i32>,
query: String, query: String,
) -> Result<Vec<(i32, ThreadSummary)>, async_graphql::Error> { ) -> Result<Connection<usize, ThreadSummary>, async_graphql::Error> {
let (offset, limit) = compute_offset_limit(after, before, first, last); connection::query(
Ok(nm after,
.search(&query, offset as usize, limit as usize)? before,
.0 first,
.into_iter() last,
.enumerate() |after, before, first, last| async move {
.map(|(i, ts)| { let total = nm.count(&query)?;
( let (first, last) = if let (None, None) = (first, last) {
offset + i as i32, info!("neither first nor last set, defaulting first to 20");
ThreadSummary { (Some(20), None)
} else {
(first, last)
};
let mut start = after.map(|after| after + 1).unwrap_or(0);
let mut end = before.unwrap_or(total);
if let Some(first) = first {
end = (start + first).min(end);
}
if let Some(last) = last {
start = if last > end - start { end } else { end - last };
}
let count = end - start;
let slice: Vec<ThreadSummary> = nm
.search(&query, start, count)?
.0
.into_iter()
.map(|ts| ThreadSummary {
thread: format!("thread:{}", ts.thread), thread: format!("thread:{}", ts.thread),
timestamp: ts.timestamp, timestamp: ts.timestamp,
date_relative: ts.date_relative, date_relative: ts.date_relative,
@ -68,10 +87,20 @@ pub async fn search(
authors: ts.authors, authors: ts.authors,
subject: ts.subject, subject: ts.subject,
tags: ts.tags, tags: ts.tags,
}, })
) .collect();
})
.collect()) let mut connection = Connection::new(start > 0, end < total);
connection.edges.extend(
slice
.into_iter()
.enumerate()
.map(|(idx, item)| Edge::new(start + idx, item)),
);
Ok::<_, async_graphql::Error>(connection)
},
)
.await
} }
pub fn tags(nm: &Notmuch, needs_unread: bool) -> Result<Vec<Tag>, ServerError> { pub fn tags(nm: &Notmuch, needs_unread: bool) -> Result<Vec<Tag>, ServerError> {

View File

@ -22,6 +22,48 @@
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Poppins:wght@700&display=swap" rel="stylesheet">
<style>
:root {
--active-brightness: 0.85;
--border-radius: 5px;
--box-shadow: 2px 2px 10px;
--color-accent: #118bee15;
--color-bg: #fff;
--color-bg-secondary: #e9e9e9;
--color-link: #118bee;
--color-secondary: #920de9;
--color-secondary-accent: #920de90b;
--color-shadow: #f4f4f4;
--color-table: #118bee;
--color-text: #000;
--color-text-secondary: #999;
--color-scrollbar: #cacae8;
--font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
--hover-brightness: 1.2;
--justify-important: center;
--justify-normal: left;
--line-height: 1.5;
--width-card: 285px;
--width-card-medium: 460px;
--width-card-wide: 800px;
--width-content: 1080px;
}
@media (prefers-color-scheme: dark) {
:root[color-mode="user"] {
--color-accent: #0097fc4f;
--color-bg: #333;
--color-bg-secondary: #555;
--color-link: #0097fc;
--color-secondary: #e20de9;
--color-secondary-accent: #e20de94f;
--color-shadow: #bbbbbb20;
--color-table: #0097fc;
--color-text: #f7f7f7;
--color-text-secondary: #aaa;
}
}
</style>
</head> </head>
<body> <body>

View File

@ -305,11 +305,23 @@ fn search_toolbar(
show_bulk_edit: bool, show_bulk_edit: bool,
show_icon_text: bool, show_icon_text: bool,
) -> Node<Msg> { ) -> Node<Msg> {
let start = pager
.start_cursor
.as_ref()
.map(|i| i.parse().unwrap_or(0))
.unwrap_or(0)
+ 1;
let end = pager
.end_cursor
.as_ref()
.map(|i| i.parse().unwrap_or(count))
.unwrap_or(count)
+ 1;
nav![ nav![
C!["level", "is-mobile"], C!["level", "is-mobile"],
IF!(show_bulk_edit =>
div![ div![
C!["level-left"], C!["level-left"],
IF!(show_bulk_edit =>
div![ div![
C!["level-item"], C!["level-item"],
div![C!["buttons", "has-addons"], div![C!["buttons", "has-addons"],
@ -328,8 +340,7 @@ fn search_toolbar(
ev(Ev::Click, |_| Msg::SelectionMarkAsUnread) ev(Ev::Click, |_| Msg::SelectionMarkAsUnread)
] ]
] ]
]), ],
IF!(show_bulk_edit =>
div![ div![
C!["level-item"], C!["level-item"],
div![C!["buttons", "has-addons"], div![C!["buttons", "has-addons"],
@ -346,8 +357,8 @@ fn search_toolbar(
) )
], ],
], ],
]) ]
], ]),
div![ div![
C!["level-right"], C!["level-right"],
nav![ nav![
@ -372,7 +383,10 @@ fn search_toolbar(
">", ">",
IF!(pager.has_next_page => ev(Ev::Click, |_| Msg::NextPage)) IF!(pager.has_next_page => ev(Ev::Click, |_| Msg::NextPage))
], ],
ul![C!["pagination-list"], li![format!("{count} results")],], ul![
C!["pagination-list"],
li![format!("{} - {} of {}", start, end, count)],
],
] ]
] ]
] ]

View File

@ -1,44 +1,3 @@
:root {
--active-brightness: 0.85;
--border-radius: 5px;
--box-shadow: 2px 2px 10px;
--color-accent: #118bee15;
--color-bg: #fff;
--color-bg-secondary: #e9e9e9;
--color-link: #118bee;
--color-secondary: #920de9;
--color-secondary-accent: #920de90b;
--color-shadow: #f4f4f4;
--color-table: #118bee;
--color-text: #000;
--color-text-secondary: #999;
--color-scrollbar: #cacae8;
--font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
--hover-brightness: 1.2;
--justify-important: center;
--justify-normal: left;
--line-height: 1.5;
--width-card: 285px;
--width-card-medium: 460px;
--width-card-wide: 800px;
--width-content: 1080px;
}
@media (prefers-color-scheme: dark) {
:root[color-mode="user"] {
--color-accent: #0097fc4f;
--color-bg: #333;
--color-bg-secondary: #555;
--color-link: #0097fc;
--color-secondary: #e20de9;
--color-secondary-accent: #e20de94f;
--color-shadow: #bbbbbb20;
--color-table: #0097fc;
--color-text: #f7f7f7;
--color-text-secondary: #aaa;
}
}
.message { .message {
display: inline-block; display: inline-block;
padding: 0.5em; padding: 0.5em;
@ -209,10 +168,6 @@ input::placeholder,
padding: 1em; padding: 1em;
} }
.search-results>nav {
margin: 1.25rem;
}
.tablet .thread h3, .tablet .thread h3,
.mobile .thread h3 { .mobile .thread h3 {
overflow-wrap: break-word; overflow-wrap: break-word;