This commit is contained in:
2026-03-23 21:54:11 -07:00
parent 7cb1dfa2d1
commit 8bcd6ada5d
12 changed files with 1131 additions and 1 deletions

View 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())
}
}

View 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 = '';
}
")) }
}
})
}

View 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)
});

View 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();
});
}