TMP pile
This commit is contained in:
@@ -13,6 +13,7 @@ libservice = { workspace = true }
|
||||
|
||||
service-webpage = { workspace = true }
|
||||
service-assets = { workspace = true }
|
||||
service-pile = { workspace = true }
|
||||
|
||||
tracing = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use anyhow::{Context, Result};
|
||||
use libservice::{Service, ServiceConnectInfo, ToService};
|
||||
use service_assets::AssetService;
|
||||
use service_pile::PileService;
|
||||
use service_webpage::WebpageService;
|
||||
use std::sync::Arc;
|
||||
use tracing::{error, info};
|
||||
@@ -72,10 +73,15 @@ pub struct RouterState {}
|
||||
pub async fn make_service(_state: Option<Arc<RouterState>>) -> Result<impl ToService> {
|
||||
let service_webpage = WebpageService::new();
|
||||
let service_assets = AssetService::new();
|
||||
let service_pile = PileService::new()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!(e))
|
||||
.context("while initializing pile datasets")?;
|
||||
|
||||
Ok(Service::new()
|
||||
.merge(service_webpage)
|
||||
.nest("/assets", service_assets)
|
||||
.nest("/pile", service_pile)
|
||||
.to_service()
|
||||
.trace())
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ pub struct LoggingConfig {
|
||||
|
||||
// Libs
|
||||
pub libservice: LogLevel,
|
||||
pub servable: LogLevel,
|
||||
pub toolbox: LogLevel,
|
||||
|
||||
// Bins
|
||||
@@ -67,15 +68,22 @@ impl From<LoggingConfig> for EnvFilter {
|
||||
format!("axum={}", conf.silence),
|
||||
format!("selectors={}", conf.silence),
|
||||
format!("html5ever={}", conf.silence),
|
||||
format!("tantivy={}", conf.silence),
|
||||
format!("aws_smithy_runtime={}", conf.silence),
|
||||
format!("aws_smithy_http_client={}", conf.silence),
|
||||
format!("aws_sdk_s3={}", conf.silence),
|
||||
format!("aws_sigv4={}", conf.silence),
|
||||
//
|
||||
// Libs
|
||||
//
|
||||
format!("toolbox={}", conf.toolbox),
|
||||
format!("libservice={}", conf.libservice),
|
||||
format!("servable={}", conf.servable),
|
||||
//
|
||||
// Bins
|
||||
//
|
||||
format!("service_webpage={}", conf.service),
|
||||
format!("service_pile={}", conf.service),
|
||||
format!("webpage={}", conf.webpage),
|
||||
conf.other.to_string(),
|
||||
]
|
||||
@@ -183,6 +191,7 @@ impl LogFilterPreset {
|
||||
|
||||
// Libs
|
||||
libservice: LogLevel::Error,
|
||||
servable: LogLevel::Error,
|
||||
toolbox: LogLevel::Error,
|
||||
|
||||
// Bins
|
||||
@@ -196,6 +205,7 @@ impl LogFilterPreset {
|
||||
|
||||
// Libs
|
||||
libservice: LogLevel::Warn,
|
||||
servable: LogLevel::Warn,
|
||||
toolbox: LogLevel::Warn,
|
||||
|
||||
// Bins
|
||||
@@ -209,6 +219,7 @@ impl LogFilterPreset {
|
||||
|
||||
// Libs
|
||||
libservice: LogLevel::Info,
|
||||
servable: LogLevel::Info,
|
||||
toolbox: LogLevel::Info,
|
||||
|
||||
// Bins
|
||||
@@ -222,6 +233,7 @@ impl LogFilterPreset {
|
||||
|
||||
// Libs
|
||||
libservice: LogLevel::Debug,
|
||||
servable: LogLevel::Debug,
|
||||
toolbox: LogLevel::Debug,
|
||||
|
||||
// Bins
|
||||
@@ -235,6 +247,7 @@ impl LogFilterPreset {
|
||||
|
||||
// Libs
|
||||
libservice: LogLevel::Trace,
|
||||
servable: LogLevel::Trace,
|
||||
toolbox: LogLevel::Trace,
|
||||
|
||||
// Bins
|
||||
@@ -248,6 +261,7 @@ impl LogFilterPreset {
|
||||
|
||||
// Libs
|
||||
libservice: LogLevel::Trace,
|
||||
servable: LogLevel::Trace,
|
||||
toolbox: LogLevel::Trace,
|
||||
|
||||
// Bins
|
||||
@@ -261,6 +275,7 @@ impl LogFilterPreset {
|
||||
|
||||
// Libs
|
||||
libservice: LogLevel::Trace,
|
||||
servable: LogLevel::Trace,
|
||||
toolbox: LogLevel::Trace,
|
||||
|
||||
// Bins
|
||||
|
||||
28
crates/service/service-pile/Cargo.toml
Normal file
28
crates/service/service-pile/Cargo.toml
Normal file
@@ -0,0 +1,28 @@
|
||||
[package]
|
||||
name = "service-pile"
|
||||
version = { workspace = true }
|
||||
rust-version = { workspace = true }
|
||||
edition = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
libservice = { workspace = true }
|
||||
service-assets = { workspace = true }
|
||||
|
||||
pile-client = { workspace = true }
|
||||
|
||||
tracing = { workspace = true }
|
||||
grass = { workspace = true }
|
||||
axum = { workspace = true }
|
||||
maud = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
tower-http = { workspace = true }
|
||||
servable = { workspace = true }
|
||||
url = { workspace = true }
|
||||
mime = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true }
|
||||
183
crates/service/service-pile/css/main.scss
Normal file
183
crates/service/service-pile/css/main.scss
Normal file
@@ -0,0 +1,183 @@
|
||||
@import "text";
|
||||
|
||||
:root {
|
||||
// Misc colors
|
||||
--bgColor: #121212;
|
||||
--lightBgColor: #3a3f46;
|
||||
--fgColor: #ebebeb;
|
||||
--metaColor: #6199bb;
|
||||
--lightMetaColor: #638c86;
|
||||
--linkColor: #e4dab3;
|
||||
--codeBgColor: #292929;
|
||||
--codeFgColor: var(--fgColor);
|
||||
|
||||
// Main colors
|
||||
--grey: #696969;
|
||||
|
||||
// Accent colors, used only manally
|
||||
--green: #a2c579;
|
||||
--magenta: #ad79c5;
|
||||
--orange: #e86a33;
|
||||
--yellow: #e8bc00;
|
||||
--pink: #fa9f83;
|
||||
}
|
||||
|
||||
::selection,
|
||||
::-moz-selection {
|
||||
color: var(--bgColor);
|
||||
background: var(--metaColor);
|
||||
}
|
||||
|
||||
html {
|
||||
-webkit-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
font-size: 62.5%;
|
||||
scrollbar-color: var(--metaColor) var(--bgColor);
|
||||
scrollbar-width: auto;
|
||||
background: var(--bgColor);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Fira";
|
||||
font-size: 1.6rem;
|
||||
line-height: 1.35;
|
||||
max-width: 64rem;
|
||||
margin: auto;
|
||||
overflow-wrap: break-word;
|
||||
background: var(--bgColor);
|
||||
color: var(--fgColor);
|
||||
}
|
||||
|
||||
div.wrapper {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 650px) {
|
||||
.wrapper {
|
||||
margin: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
// Loading spinner (three dots)
|
||||
.htmx-indicator {
|
||||
display: none;
|
||||
}
|
||||
.htmx-request .htmx-indicator,
|
||||
.htmx-request.htmx-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
@keyframes dot-bounce {
|
||||
0%, 80%, 100% { opacity: 0.2; transform: scale(0.8); }
|
||||
40% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
.search-meta {
|
||||
font-size: 1.2rem;
|
||||
color: var(--grey);
|
||||
margin: 0 0 1.5em 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
#search-results {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.result-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5em;
|
||||
margin-top: 1.5em;
|
||||
}
|
||||
|
||||
.result-item {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
border: 1px solid var(--lightBgColor);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
.result-item-thumb {
|
||||
width: 64px;
|
||||
flex-shrink: 0;
|
||||
background: var(--lightBgColor);
|
||||
overflow: hidden;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.result-item-info {
|
||||
padding: 0.5em 0.8em;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 0.15em;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.result-item-key {
|
||||
font-family: monospace;
|
||||
font-size: 1.3rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
direction: rtl;
|
||||
text-align: left;
|
||||
color: var(--fgColor);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: var(--metaColor);
|
||||
}
|
||||
}
|
||||
|
||||
.result-item-link {
|
||||
font-family: monospace;
|
||||
font-size: 1.1rem;
|
||||
color: var(--linkColor);
|
||||
text-decoration: none;
|
||||
align-self: flex-start;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.result-sentinel {
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
#preview-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
border: 1px solid var(--lightBgColor);
|
||||
background: var(--bgColor);
|
||||
padding: 4px;
|
||||
border-radius: 3px;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.6);
|
||||
|
||||
img {
|
||||
display: block;
|
||||
max-width: 480px;
|
||||
max-height: 480px;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
29
crates/service/service-pile/css/text.scss
Normal file
29
crates/service/service-pile/css/text.scss
Normal file
@@ -0,0 +1,29 @@
|
||||
h1 {
|
||||
font-size: 3.5rem;
|
||||
margin-top: 1ex;
|
||||
margin-bottom: 1ex;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 2.5rem;
|
||||
margin-top: 1ex;
|
||||
margin-bottom: 0.5ex;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
border-radius: .3rem;
|
||||
padding: 0 .2ex 0 .2ex;
|
||||
color: var(--linkColor);
|
||||
transition: 150ms;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
background-color: var(--linkColor);
|
||||
color: var(--bgColor);
|
||||
transition: 150ms;
|
||||
}
|
||||
37
crates/service/service-pile/src/lib.rs
Normal file
37
crates/service/service-pile/src/lib.rs
Normal file
@@ -0,0 +1,37 @@
|
||||
use axum::Router;
|
||||
use libservice::ToService;
|
||||
use pile_client::PileClient;
|
||||
use std::sync::Arc;
|
||||
|
||||
mod pages;
|
||||
mod routes;
|
||||
|
||||
pub const PILE_PREFIX: &str = "/pile";
|
||||
pub const ASSET_PREFIX: &str = "/assets";
|
||||
|
||||
pub struct PileService {
|
||||
client: Arc<PileClient>,
|
||||
}
|
||||
|
||||
impl PileService {
|
||||
pub async fn new() -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let endpoint = std::env::var("PILE_ENDPOINT")?;
|
||||
let api_key = std::env::var("PILE_API_KEY").ok();
|
||||
let client = PileClient::new(&endpoint, api_key.as_deref())?;
|
||||
Ok(Self {
|
||||
client: Arc::new(client),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl ToService for PileService {
|
||||
#[inline]
|
||||
fn make_router(&self) -> Option<Router<()>> {
|
||||
Some(routes::router(self.client.clone()))
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn service_name(&self) -> Option<String> {
|
||||
Some("pile".to_owned())
|
||||
}
|
||||
}
|
||||
110
crates/service/service-pile/src/pages/index.rs
Normal file
110
crates/service/service-pile/src/pages/index.rs
Normal file
@@ -0,0 +1,110 @@
|
||||
use maud::{Markup, html};
|
||||
use servable::{HtmlPage, PageMetadata, RenderContext};
|
||||
use service_assets::assets::{CSS_FIRA, CSS_FONTAWESOME, HTMX};
|
||||
use std::{pin::Pin, sync::LazyLock};
|
||||
|
||||
use crate::{ASSET_PREFIX, PILE_PREFIX, routes::CSS_PILE};
|
||||
|
||||
pub static INDEX: LazyLock<HtmlPage> = LazyLock::new(|| {
|
||||
HtmlPage::default()
|
||||
.with_style_linked(CSS_PILE.route_at(PILE_PREFIX))
|
||||
.with_style_linked(CSS_FIRA.route_at(ASSET_PREFIX))
|
||||
.with_style_linked(CSS_FONTAWESOME.route_at(ASSET_PREFIX))
|
||||
.with_script_linked(HTMX.route_at(ASSET_PREFIX))
|
||||
.with_meta(PageMetadata {
|
||||
title: "Pile".into(),
|
||||
author: None,
|
||||
description: None,
|
||||
image: None,
|
||||
})
|
||||
.with_render(render)
|
||||
});
|
||||
|
||||
fn render<'a>(
|
||||
_page: &'a HtmlPage,
|
||||
_ctx: &'a RenderContext,
|
||||
) -> Pin<Box<dyn Future<Output = Markup> + Send + Sync + 'a>> {
|
||||
Box::pin(async {
|
||||
html! {
|
||||
div class="wrapper" style="margin-top:3ex;" {
|
||||
div {
|
||||
div style="
|
||||
text-align:center;
|
||||
padding-top:30px;
|
||||
padding-bottom:60px;
|
||||
" {
|
||||
h1 class="brand" {
|
||||
span class="fa fa-solid fa-book" aria-hidden="true" {}
|
||||
" Library search"
|
||||
}
|
||||
|
||||
div style="max-width:500px;margin:0 auto;padding:.4em 1em;" {
|
||||
form {
|
||||
input
|
||||
class="search-input"
|
||||
id="search"
|
||||
name="q"
|
||||
type="text"
|
||||
placeholder="Type to search..."
|
||||
style="
|
||||
-moz-box-sizing: border-box !important;
|
||||
box-sizing: border-box !important;
|
||||
outline: none;
|
||||
border: none;
|
||||
border-radius: 1px;
|
||||
margin-top: 5px;
|
||||
margin-bottom: 5px;
|
||||
padding: 10px 16px;
|
||||
font-size: 17px;
|
||||
width: 100%;
|
||||
box-shadow: 0 0 0 1px var(--color-border),0 0 0 1px var(--color-border);
|
||||
transition: box-shadow 150ms ease-in-out;
|
||||
"
|
||||
autofocus=""
|
||||
autocomplete="off"
|
||||
hx-get=(format!("{PILE_PREFIX}/search"))
|
||||
hx-trigger="load, keyup changed delay:100ms"
|
||||
hx-target="#search-results"
|
||||
hx-swap="outerHTML"
|
||||
hx-indicator="#search-spinner"
|
||||
{}
|
||||
}
|
||||
|
||||
div id="search-spinner" class="htmx-indicator dot-spinner" {
|
||||
span {}
|
||||
span {}
|
||||
span {}
|
||||
}
|
||||
|
||||
div id="search-results" {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div id="preview-overlay" {
|
||||
img id="preview-overlay-img" src="" alt="" {}
|
||||
}
|
||||
|
||||
script { (maud::PreEscaped("
|
||||
function showPreview(el) {
|
||||
var ov = document.getElementById('preview-overlay');
|
||||
var img = document.getElementById('preview-overlay-img');
|
||||
img.src = el.dataset.preview;
|
||||
var rect = el.getBoundingClientRect();
|
||||
var size = 480;
|
||||
var left = rect.right + 12;
|
||||
if (left + size > window.innerWidth) { left = rect.left - size - 12; }
|
||||
ov.style.left = left + 'px';
|
||||
ov.style.top = Math.max(8, rect.top - size / 2 + rect.height / 2) + 'px';
|
||||
ov.style.display = 'block';
|
||||
}
|
||||
function hidePreview() {
|
||||
var ov = document.getElementById('preview-overlay');
|
||||
ov.style.display = 'none';
|
||||
document.getElementById('preview-overlay-img').src = '';
|
||||
}
|
||||
")) }
|
||||
}
|
||||
})
|
||||
}
|
||||
37
crates/service/service-pile/src/pages/mod.rs
Normal file
37
crates/service/service-pile/src/pages/mod.rs
Normal file
@@ -0,0 +1,37 @@
|
||||
use maud::html;
|
||||
use reqwest::StatusCode;
|
||||
use servable::{HtmlPage, PageMetadata};
|
||||
use service_assets::assets::{CSS_FIRA, CSS_FONTAWESOME};
|
||||
use std::sync::LazyLock;
|
||||
|
||||
mod index;
|
||||
pub use index::INDEX;
|
||||
|
||||
use crate::{ASSET_PREFIX, PILE_PREFIX, routes::CSS_PILE};
|
||||
|
||||
pub static NOT_FOUND: LazyLock<HtmlPage> = LazyLock::new(|| {
|
||||
HtmlPage::default()
|
||||
.with_style_linked(CSS_PILE.route_at(PILE_PREFIX))
|
||||
.with_style_linked(CSS_FIRA.route_at(ASSET_PREFIX))
|
||||
.with_style_linked(CSS_FONTAWESOME.route_at(ASSET_PREFIX))
|
||||
.with_meta(PageMetadata {
|
||||
title: "Page not found".into(),
|
||||
author: None,
|
||||
description: None,
|
||||
image: None,
|
||||
})
|
||||
.with_render(move |_page, _ctx| {
|
||||
Box::pin(async {
|
||||
html! {
|
||||
div class="wrapper" {
|
||||
div style="display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh" {
|
||||
p style="font-weight:bold;font-size:50pt;margin:0;" { "404" }
|
||||
p style="font-size:13pt;margin:0;color:var(--grey);" { "(page not found)" }
|
||||
a style="font-size:12pt;margin:10pt;padding:5px;" href="/" {"<- Back to site"}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
.with_code(StatusCode::NOT_FOUND)
|
||||
});
|
||||
231
crates/service/service-pile/src/routes/mod.rs
Normal file
231
crates/service/service-pile/src/routes/mod.rs
Normal file
@@ -0,0 +1,231 @@
|
||||
use axum::Router;
|
||||
use axum::extract::{Query, State};
|
||||
use axum::routing::get;
|
||||
use maud::{Markup, html};
|
||||
use pile_client::PileClient;
|
||||
use servable::{CACHE_BUST_STR, ServableRouter, ServableWithRoute, StaticAsset};
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
use tower_http::compression::{CompressionLayer, DefaultPredicate};
|
||||
use tracing::warn;
|
||||
|
||||
use crate::PILE_PREFIX;
|
||||
use crate::pages;
|
||||
|
||||
const PAGE_SIZE: usize = 50;
|
||||
|
||||
pub(super) fn router(client: Arc<PileClient>) -> Router<()> {
|
||||
let compression: CompressionLayer = CompressionLayer::new()
|
||||
.br(true)
|
||||
.deflate(true)
|
||||
.gzip(true)
|
||||
.zstd(true)
|
||||
.compress_when(DefaultPredicate::new());
|
||||
|
||||
let search_router: Router<()> = Router::new()
|
||||
.route("/search", get(search_handler))
|
||||
.with_state(client.clone());
|
||||
|
||||
let api_router: Router<()> = client.dataset("books").proxy_router();
|
||||
|
||||
build_server()
|
||||
.into_router()
|
||||
.merge(search_router)
|
||||
.nest("/api", api_router)
|
||||
.layer(compression)
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct SearchQuery {
|
||||
#[serde(default)]
|
||||
q: String,
|
||||
#[serde(default)]
|
||||
page: usize,
|
||||
}
|
||||
|
||||
async fn search_handler(
|
||||
State(client): State<Arc<PileClient>>,
|
||||
Query(params): Query<SearchQuery>,
|
||||
) -> Markup {
|
||||
let start = Instant::now();
|
||||
let query = params.q.trim().to_lowercase();
|
||||
let page = params.page;
|
||||
let mut query_invalid = false;
|
||||
let mut list_error = false;
|
||||
|
||||
let mut all_keys: Vec<(String, String)> = Vec::new();
|
||||
let mut filtered_total = 0;
|
||||
let mut grand_total = 0;
|
||||
|
||||
match query.is_empty() {
|
||||
true => {
|
||||
match client
|
||||
.dataset("books")
|
||||
.list_items(page * PAGE_SIZE, PAGE_SIZE)
|
||||
.await
|
||||
{
|
||||
Err(error) => {
|
||||
list_error = true;
|
||||
warn!(message = "error while listing items", ?error);
|
||||
}
|
||||
|
||||
Ok(resp) => {
|
||||
all_keys = resp
|
||||
.items
|
||||
.into_iter()
|
||||
.map(|item| (item.source, item.key))
|
||||
.collect();
|
||||
filtered_total = resp.total;
|
||||
grand_total = resp.total;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false => match client.dataset("books").lookup(&query, Some(512)).await {
|
||||
Err(_error) => {
|
||||
query_invalid = true;
|
||||
}
|
||||
|
||||
Ok(resp) => {
|
||||
let mut results = resp.results;
|
||||
results.sort_unstable_by(|a, b| f32::total_cmp(&b.score, &a.score));
|
||||
|
||||
filtered_total = results.len();
|
||||
grand_total = results.len();
|
||||
all_keys = results.into_iter().map(|r| (r.source, r.key)).collect();
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// For empty query the server already paginated; for non-empty we slice locally.
|
||||
let page_items: Vec<&(String, String)> = if query.is_empty() {
|
||||
all_keys.iter().collect()
|
||||
} else {
|
||||
all_keys
|
||||
.iter()
|
||||
.skip(page * PAGE_SIZE)
|
||||
.take(PAGE_SIZE)
|
||||
.collect()
|
||||
};
|
||||
|
||||
let has_more = (page + 1) * PAGE_SIZE < filtered_total;
|
||||
let next_page = page + 1;
|
||||
|
||||
let encoded_q: String = url::form_urlencoded::byte_serialize(query.as_bytes()).collect();
|
||||
let next_url = format!("{PILE_PREFIX}/search?q={}&page={}", encoded_q, next_page);
|
||||
let elapsed_ms = start.elapsed().as_millis();
|
||||
|
||||
let mut msg = Vec::new();
|
||||
if query_invalid {
|
||||
msg.push("invalid query");
|
||||
}
|
||||
if list_error {
|
||||
msg.push("list error");
|
||||
}
|
||||
if filtered_total == 0 {
|
||||
msg.push("no results");
|
||||
}
|
||||
|
||||
if page == 0 {
|
||||
html! {
|
||||
div id="search-results" {
|
||||
p class="search-meta" {
|
||||
"Filtered " (filtered_total) "/" (grand_total) " items in " (elapsed_ms) "ms"
|
||||
@if !msg.is_empty() {
|
||||
span style="color:var(--orange)" {
|
||||
(format!("\u{00A0}\u{00A0}({})", msg.join(", ")))
|
||||
}
|
||||
}
|
||||
}
|
||||
div class="result-grid" {
|
||||
@for (source, key) in &page_items {
|
||||
(result_item(source, key))
|
||||
}
|
||||
@if has_more {
|
||||
div
|
||||
class="result-sentinel"
|
||||
hx-get=(next_url)
|
||||
hx-trigger="revealed"
|
||||
hx-swap="outerHTML"
|
||||
hx-target="this"
|
||||
{}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
@for (source, key) in &page_items {
|
||||
(result_item(source, key))
|
||||
}
|
||||
@if has_more {
|
||||
div
|
||||
class="result-sentinel"
|
||||
hx-get=(next_url)
|
||||
hx-trigger="revealed"
|
||||
hx-swap="outerHTML"
|
||||
hx-target="this"
|
||||
{}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn result_item(source: &str, key: &str) -> Markup {
|
||||
let enc_source: String = url::form_urlencoded::byte_serialize(source.as_bytes()).collect();
|
||||
let enc_key: String = url::form_urlencoded::byte_serialize(key.as_bytes()).collect();
|
||||
let enc_path: String =
|
||||
url::form_urlencoded::byte_serialize("$.pdf.pages[0]".as_bytes()).collect();
|
||||
let thumb_url =
|
||||
format!("{PILE_PREFIX}/api/field?source={enc_source}&key={enc_key}&path={enc_path}");
|
||||
let item_url =
|
||||
format!("{PILE_PREFIX}/api/item?source={enc_source}&key={enc_key}&download=false");
|
||||
html! {
|
||||
div class="result-item" {
|
||||
div class="result-item-thumb"
|
||||
data-preview=(thumb_url)
|
||||
onmouseenter="showPreview(this)"
|
||||
onmouseleave="hidePreview()"
|
||||
{
|
||||
img src=(thumb_url) alt="" onerror="this.style.visibility='hidden'" {}
|
||||
}
|
||||
div class="result-item-info" {
|
||||
span class="result-item-key"
|
||||
data-key=(key)
|
||||
onclick="navigator.clipboard.writeText(this.dataset.key)"
|
||||
title="Click to copy"
|
||||
{ (key) }
|
||||
a class="result-item-link" href=(item_url) target="_blank" { "item" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub static CSS_PILE: ServableWithRoute<StaticAsset> = ServableWithRoute::new(
|
||||
|| format!("/css/{}/main.css", *CACHE_BUST_STR),
|
||||
StaticAsset {
|
||||
bytes: grass::include!("crates/service/service-pile/css/main.scss").as_bytes(),
|
||||
mime: mime::TEXT_CSS,
|
||||
ttl: StaticAsset::DEFAULT_TTL,
|
||||
},
|
||||
);
|
||||
|
||||
fn build_server() -> ServableRouter {
|
||||
ServableRouter::new()
|
||||
.with_404(&pages::NOT_FOUND)
|
||||
.add_page("/", &pages::INDEX)
|
||||
//
|
||||
.add_page_with_route(&CSS_PILE)
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[expect(clippy::unwrap_used)]
|
||||
fn server_builds_without_panic() {
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap()
|
||||
.block_on(async {
|
||||
let _server = build_server();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user