diff --git a/Cargo.lock b/Cargo.lock index c81b06a..c229fd0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -163,6 +163,8 @@ name = "app" version = "0.1.0" dependencies = [ "axum", + "base64 0.22.1", + "futures", "js-sys", "leptos", "leptos-use", diff --git a/search/app/Cargo.toml b/search/app/Cargo.toml index 612061c..c21ed43 100644 --- a/search/app/Cargo.toml +++ b/search/app/Cargo.toml @@ -34,8 +34,10 @@ tokio = { workspace = true, optional = true } search_engine = { path = "../../search_engine", optional = true, features = ["serde"] } axum = { workspace = true, optional = true, features = ["macros"] } leptos_axum = { version = "0.8", optional = true } +futures = { workspace = true, optional = true } +base64 = { version = "0.22", optional = true } [features] default = [] hydrate = ["leptos/hydrate"] -ssr = ["leptos/ssr", "leptos_meta/ssr", "leptos_router/ssr", "leptos-use/ssr", "dep:tokio", "dep:search_engine", "dep:axum", "dep:leptos_axum"] +ssr = ["leptos/ssr", "leptos_meta/ssr", "leptos_router/ssr", "leptos-use/ssr", "dep:tokio", "dep:search_engine", "dep:axum", "dep:leptos_axum", "dep:futures", "dep:base64"] diff --git a/search/app/src/components/image_details.rs b/search/app/src/components/image_details.rs index 60c30a3..0b1549a 100644 --- a/search/app/src/components/image_details.rs +++ b/search/app/src/components/image_details.rs @@ -1,6 +1,6 @@ use crate::{ components::{Lightbox, Results, Viewer}, - types::PostItem, + types::{ImageQuery, PostItem}, util::encode_path, }; @@ -68,6 +68,7 @@ fn crop_to_base64(img: &HtmlImageElement, region: Region) -> Option { pub fn ImageDetails() -> impl IntoView { let params = use_params_map(); let id = Memo::new(move |_| params.with(|p| p.get("id"))); + let img_query = expect_context::(); let details = OnceResource::new( @@ -262,11 +263,14 @@ pub fn ImageDetails() -> impl IntoView { sel_box.set(Some(lbox)); if let Some(img) = img_ref.get() - && let Some(q) = crop_to_base64(&img, region) + && let Some(b64) = crop_to_base64(&img, region) { + // Stash the cropped region in client memory and navigate + // with a short nonce, keeping the image out of the URL. + img_query.0.set(Some(b64)); let path = pathname.get_untracked(); nav_up( - &format!("{path}?mode=search_image&q={q}"), + &format!("{path}?mode=search_image&q={}", js_sys::Date::now() as u64), Default::default(), ); } diff --git a/search/app/src/components/results.rs b/search/app/src/components/results.rs index 73e3976..98bdf71 100644 --- a/search/app/src/components/results.rs +++ b/search/app/src/components/results.rs @@ -2,17 +2,16 @@ use std::iter::repeat_with; use crate::{ components::{lightbox::Lightbox, viewer::Viewer}, - types::{DoneEvent, ErrorEvent, Mode, PostItem}, + types::{ImageQuery, Mode, PostItem, SearchResponse}, util::encode_path, }; -use leptos::{prelude::*, reactive::send_wrapper_ext::SendOption}; +use leptos::{prelude::*, task::spawn_local}; use leptos_router::hooks::use_query_map; use leptos_use::{ UseElementSizeReturn, signal_debounced, use_element_size, use_intersection_observer, }; use search_types::Image; -use wasm_bindgen::{JsCast, prelude::Closure}; const COLUMN_WIDTH: u32 = 360; const COLUMN_PAD: u32 = 2; @@ -118,7 +117,10 @@ pub fn Results() -> impl IntoView { let viewer_open = Memo::new(move |_| viewer.get().is_some()); - let active: StoredValue> = StoredValue::new(SendOption::new_local(None)); + let img_query = expect_context::(); + // Monotonic id used to discard responses from superseded requests: a new + // search or the next page bumps it, and stale in-flight calls return early. + let req_id: StoredValue = StoredValue::new(0); Effect::new(move |_| { let _ = params.get(); @@ -132,7 +134,9 @@ pub fn Results() -> impl IntoView { offset.set(0); has_more.set(true); error.set(None); - load_page(0, params, columns, images, has_more, loading, error, active); + load_page( + 0, params, img_query, columns, images, has_more, loading, error, req_id, + ); }); let sentinel = NodeRef::::new(); @@ -150,7 +154,7 @@ pub fn Results() -> impl IntoView { offset.update(|old| *old += limit); let next = offset.get_untracked(); load_page( - next, params, columns, images, has_more, loading, error, active, + next, params, img_query, columns, images, has_more, loading, error, req_id, ); } }); @@ -292,144 +296,295 @@ pub fn Results() -> impl IntoView { } } -// ===================== wasm-only ===================== - -#[derive(Debug)] -struct ActiveSse { - es: web_sys::EventSource, - _closures: Vec>, - _err_closure: Closure, +/// Place a post into the shortest column, mirroring the masonry layout logic. +fn place_post(columns: RwSignal>, p: PostItem) { + columns.update(move |columns| { + let Some(cover) = p.images.first() else { + return; + }; + let Some(column) = columns.iter_mut().min_by_key(|c| c.height) else { + return; + }; + let height = + ((cover.height as f64 / cover.width as f64) * column.width as f64).round() as u32; + let w = column.width; + column.height += height + COLUMN_PAD; + column.items.push(Placed { item: Item::Post(p), w, h: height }); + }); } -impl Drop for ActiveSse { - fn drop(&mut self) { - self.es.close(); - } +/// Place an image into the shortest column and remember it for the viewer. +fn place_image(columns: RwSignal>, images: StoredValue>, im: Image) { + images.update_value(|v| v.push(im.clone())); + columns.update(|columns| { + let Some(column) = columns.iter_mut().min_by_key(|c| c.height) else { + return; + }; + let height = ((im.height as f64 / im.width as f64) * column.width as f64).round() as u32; + let w = column.width; + column.height += height + COLUMN_PAD; + column.items.push(Placed { item: Item::Image(im), w, h: height }); + }); } +/// Fetch one page of results through the REST search endpoints and merge it into +/// the columns. Superseded requests (a new search or an earlier page) are dropped +/// via `req_id` so their late responses can't corrupt the current view. #[allow(clippy::too_many_arguments)] fn load_page( offset_val: i64, params: Memo<(Mode, String, i64)>, + img_query: ImageQuery, columns: RwSignal>, images: StoredValue>, has_more: RwSignal, loading: RwSignal, error: RwSignal>, - active: StoredValue>, + req_id: StoredValue, ) { let (mode, q, limit) = params.get_untracked(); - active.set_value(SendOption::new_local(None)); + // Claim the newest request id; any in-flight call becomes stale. + let this_id = req_id.get_value() + 1; + req_id.set_value(this_id); - let url = format!( - "/api/search?mode={}&q={}&limit={}&offset={}", - mode.as_str(), - urlencoding::encode(&q), - limit, - offset_val, - ); + loading.set(true); + error.set(None); - let es = match web_sys::EventSource::new(&url) { - Ok(es) => es, - Err(_) => { - error.set(Some("Failed to open EventSource".into())); + spawn_local(async move { + let result = if mode == Mode::SearchImage { + match img_query.0.get_untracked() { + // The image bytes live only in client memory, so a reloaded or + // shared `search_image` URL has none. Degrade to an empty result + // instead of sending an empty body that would fail to decode. + None => { + if req_id.get_value() == this_id { + has_more.set(false); + loading.set(false); + } + return; + } + Some(b64) => search_by_image(b64, limit, offset_val).await, + } + } else { + search_query(mode.as_str().to_string(), q, limit, offset_val).await + }; + + // A newer request started while we were awaiting: discard this response. + if req_id.get_value() != this_id { return; } - }; - loading.set(true); - error.set(None); + match result { + Ok(resp) => { + for p in resp.posts { + place_post(columns, p); + } + for im in resp.images { + place_image(columns, images, im); + } + has_more.set(resp.has_more); + } + Err(e) => { + error.set(Some(e.to_string())); + has_more.set(false); + } + } + loading.set(false); + }); +} - let mut closures: Vec> = Vec::new(); +#[server] +async fn search_query( + mode: String, + q: String, + limit: i64, + offset: i64, +) -> Result { + use crate::state::AppState; + + use std::pin::Pin; + use std::sync::Arc; + + use axum::extract::State; + use futures::{Stream, StreamExt as _}; + use leptos_axum::extract_with_state; + use search_engine::{Engine, search_types}; + + let mode = Mode::parse(&mode); + let limit = limit.max(1); + let offset = offset.max(0); + + let state = expect_context::(); + let State(engine): State> = extract_with_state(&state).await?; + + // Return-type annotations pin `ServerFnError`'s generic parameter. + fn to_err(e: impl std::fmt::Display) -> ServerFnError { + ServerFnError::Response(e.to_string()) + } + fn to_args(e: impl std::fmt::Display) -> ServerFnError { + ServerFnError::Args(e.to_string()) + } - // post - let cb = Closure::wrap(Box::new(move |ev: web_sys::MessageEvent| { - if let Some(s) = ev.data().as_string() - && let Ok(p) = serde_json::from_str::(&s) - { - columns.update(move |columns| { - let Some(cover) = p.images.first() else { - return; - }; - let Some(column) = columns.iter_mut().min_by_key(|c| c.height) else { - return; - }; - let height = ((cover.height as f64 / cover.width as f64) * column.width as f64) - .round() as u32; - let w = column.width; - column.height += height + COLUMN_PAD; - column.items.push(Placed { item: Item::Post(p), w, h: height }); + // Post-oriented modes: newest (empty query), author, title, or random posts. + if q.is_empty() + || matches!(mode, Mode::Author | Mode::Title) + || (mode == Mode::Random && q != "images") + { + let mut posts: Pin + Send>> = if q.is_empty() { + Box::pin(engine.newest_posts(limit, offset).await.map_err(to_err)?) + } else { + match mode { + Mode::Author => Box::pin( + engine + .search_posts_by_author(q, limit, offset) + .await + .map_err(to_err)?, + ), + Mode::Title => Box::pin( + engine + .search_posts_by_title(q, limit, offset) + .await + .map_err(to_err)?, + ), + Mode::Random => Box::pin(engine.random_posts(limit).await.map_err(to_err)?), + _ => unreachable!(), + } + }; + + let mut items = Vec::new(); + let mut count = 0i64; + while let Some(post) = posts.next().await { + let Ok(images) = engine.list_post_images(post.id).await else { + continue; + }; + let Ok(videos) = engine.list_post_videos(post.id).await else { + continue; + }; + count += 1; + items.push(PostItem { + id: post.id, + title: post.title, + images, + videos, }); } - }) as Box); - es.add_event_listener_with_callback("post", cb.as_ref().unchecked_ref()) - .ok(); - closures.push(cb); - - // image - let cb = Closure::wrap(Box::new(move |ev: web_sys::MessageEvent| { - if let Some(s) = ev.data().as_string() - && let Ok(im) = serde_json::from_str::(&s) - { - images.update_value(|v| v.push(im.clone())); - columns.update(|columns| { - let Some(column) = columns.iter_mut().min_by_key(|c| c.height) else { - return; - }; - let height = - ((im.height as f64 / im.width as f64) * column.width as f64).round() as u32; - let w = column.width; - column.height += height + COLUMN_PAD; - column.items.push(Placed { item: Item::Image(im), w, h: height }); + return Ok(SearchResponse { + has_more: count >= limit, + posts: items, + images: vec![], + }); + } + + // Image-oriented modes. + let mut stream: Pin + Send>> = match mode { + Mode::Tag => Box::pin( + engine + .search_images_by_tag(q, limit, offset) + .await + .map_err(to_err)?, + ), + Mode::Clip => Box::pin( + engine + .search_clip_cached([q], limit, offset) + .await + .map_err(to_err)?, + ), + Mode::Random => Box::pin(engine.random_images(limit).await.map_err(to_err)?), + Mode::Similar => { + let id = q.parse::().map_err(to_args)?; + let details = engine.image_details(id).await.map_err(to_err)?; + let post_image_ids = match details.post.as_ref().map(|p| p.id) { + Some(post_id) => engine.list_post_images(post_id).await.ok().map(|imgs| { + imgs.into_iter() + .map(|i| i.id) + .collect::>() + }), + None => None, + }; + let mut stream = engine + .search_dinov3_by_id([id], limit, offset) + .await + .map_err(to_err)?; + let mut items = Vec::new(); + let mut count = 0i64; + while let Some(img) = stream.next().await { + count += 1; + if post_image_ids.as_ref().is_some_and(|ids| ids.contains(&img.id)) { + continue; + } + items.push(img); + } + return Ok(SearchResponse { + has_more: count >= limit, + posts: vec![], + images: items, }); } - }) as Box); - es.add_event_listener_with_callback("image", cb.as_ref().unchecked_ref()) - .ok(); - closures.push(cb); - - // done - let cb = Closure::wrap(Box::new(move |ev: web_sys::MessageEvent| { - let d: DoneEvent = ev - .data() - .as_string() - .and_then(|s| serde_json::from_str(&s).ok()) - .unwrap_or(DoneEvent { has_more: false }); - has_more.set(d.has_more); - loading.set(false); - active.set_value(SendOption::new_local(None)); - }) as Box); - es.add_event_listener_with_callback("done", cb.as_ref().unchecked_ref()) - .ok(); - closures.push(cb); - - // server-side error event - let cb = Closure::wrap(Box::new(move |ev: web_sys::MessageEvent| { - let msg = ev - .data() - .as_string() - .and_then(|s| serde_json::from_str::(&s).ok()) - .map(|e| e.message) - .unwrap_or_else(|| "stream error".into()); - error.set(Some(msg)); - loading.set(false); - has_more.set(false); - active.set_value(SendOption::new_local(None)); - }) as Box); - es.add_event_listener_with_callback("error", cb.as_ref().unchecked_ref()) - .ok(); - closures.push(cb); - - let onerror = Closure::wrap(Box::new(move |_ev: web_sys::Event| { - loading.set(false); - }) as Box); - es.set_onerror(Some(onerror.as_ref().unchecked_ref())); - - let new_sse = ActiveSse { - es, - _closures: closures, - _err_closure: onerror, + _ => return Ok(SearchResponse::default()), }; - active.set_value(SendOption::new_local(Some(new_sse))); + + let mut items = Vec::new(); + let mut count = 0i64; + while let Some(img) = stream.next().await { + count += 1; + items.push(img); + } + Ok(SearchResponse { + has_more: count >= limit, + posts: vec![], + images: items, + }) +} + +#[server] +async fn search_by_image( + image: String, + limit: i64, + offset: i64, +) -> Result { + use crate::state::AppState; + + use std::io::Cursor; + use std::sync::Arc; + + use axum::extract::State; + use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; + use futures::StreamExt as _; + use leptos_axum::extract_with_state; + use search_engine::Engine; + + fn to_err(e: impl std::fmt::Display) -> ServerFnError { + ServerFnError::Response(e.to_string()) + } + fn to_args(msg: String) -> ServerFnError { + ServerFnError::Args(msg) + } + + let limit = limit.max(1); + let offset = offset.max(0); + + let bytes = URL_SAFE_NO_PAD + .decode(image.as_bytes()) + .map_err(|e| to_args(format!("invalid image data: {e}")))?; + + let state = expect_context::(); + let State(engine): State> = extract_with_state(&state).await?; + + let mut stream = engine + .search_dinov3_cached([Cursor::new(bytes)], limit, offset) + .await + .map_err(to_err)?; + + let mut items = Vec::new(); + let mut count = 0i64; + while let Some(img) = stream.next().await { + count += 1; + items.push(img); + } + Ok(SearchResponse { + has_more: count >= limit, + posts: vec![], + images: items, + }) } diff --git a/search/app/src/components/search_bar.rs b/search/app/src/components/search_bar.rs index ef0c5d7..194d72a 100644 --- a/search/app/src/components/search_bar.rs +++ b/search/app/src/components/search_bar.rs @@ -1,5 +1,5 @@ use crate::components::BackToTop; -use crate::types::Mode; +use crate::types::{ImageQuery, Mode}; use js_sys::futures::JsFuture; use leptos::{prelude::*, task::spawn_local}; use leptos_router::{ @@ -69,6 +69,7 @@ pub fn SearchBar() -> impl IntoView { let q_input = RwSignal::new(init_q); let limit = RwSignal::new(init_limit); let file_name = RwSignal::new(String::new()); + let img_query = expect_context::(); let go = { let nav = use_navigate(); @@ -102,8 +103,11 @@ pub fn SearchBar() -> impl IntoView { let go = go.clone(); spawn_local(async move { if let Some(b64) = encode_file(file).await { - q_input.set(b64.clone()); - go(b64); + // Keep the (potentially large) image out of the URL: stash it + // in client memory and navigate with only a short nonce so the + // Results component re-runs its search against the stored bytes. + img_query.0.set(Some(b64)); + go(format!("{}", js_sys::Date::now() as u64)); } }); } diff --git a/search/app/src/lib.rs b/search/app/src/lib.rs index d03bad6..d4baec2 100644 --- a/search/app/src/lib.rs +++ b/search/app/src/lib.rs @@ -46,6 +46,7 @@ pub fn shell(options: LeptosOptions) -> impl IntoView { #[component] pub fn App() -> impl IntoView { provide_meta_context(); + provide_context(crate::types::ImageQuery(RwSignal::new(None))); view! { <Router> diff --git a/search/app/src/types.rs b/search/app/src/types.rs index 9d460f7..08a2f96 100644 --- a/search/app/src/types.rs +++ b/search/app/src/types.rs @@ -1,6 +1,15 @@ +use leptos::prelude::RwSignal; use search_types::{Image, Video}; use serde::{Deserialize, Serialize}; +/// Client-side holder for a pending image-search payload (URL-safe base64, no pad). +/// +/// The bytes are kept out of the URL to avoid the request-line length limit that +/// caused 400s; only a short nonce travels in the query string. Provided as a +/// context at the app root and read by the search entry points + `Results`. +#[derive(Clone, Copy)] +pub struct ImageQuery(pub RwSignal<Option<String>>); + #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[non_exhaustive] pub enum Mode { @@ -52,12 +61,13 @@ pub struct PostItem { pub videos: Vec<Video>, } -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct DoneEvent { +/// A single page of search results returned by the REST search endpoints. +/// +/// Exactly one of `posts` / `images` is populated depending on the search mode; +/// the other is empty. `has_more` drives infinite-scroll pagination. +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct SearchResponse { + pub posts: Vec<PostItem>, + pub images: Vec<Image>, pub has_more: bool, } - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct ErrorEvent { - pub message: String, -} diff --git a/search/server/src/api.rs b/search/server/src/api.rs deleted file mode 100644 index beffa45..0000000 --- a/search/server/src/api.rs +++ /dev/null @@ -1,219 +0,0 @@ -use std::{collections::HashSet, convert::Infallible, pin::Pin, sync::Arc, time::Duration}; - -use app::types::{DoneEvent, ErrorEvent, Mode, PostItem}; -use axum::{ - extract::{Query, State}, - response::sse::{Event, KeepAlive, Sse}, -}; -use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; -use futures::{Stream, StreamExt as _}; -use search_engine::{Engine, search_types}; -use serde::Deserialize; - -#[derive(Debug, Deserialize)] -pub struct SearchParams { - #[serde(default = "default_mode")] - mode: String, - #[serde(default)] - q: String, - #[serde(default = "default_limit")] - limit: i64, - #[serde(default)] - offset: i64, -} - -fn default_mode() -> String { - "tag".into() -} -fn default_limit() -> i64 { - 20 -} - -pub async fn search_sse( - State(engine): State<Arc<Engine>>, - Query(p): Query<SearchParams>, -) -> Sse<impl Stream<Item = Result<Event, Infallible>>> { - let mode = Mode::parse(&p.mode); - let limit = p.limit.max(1); - let offset = p.offset.max(0); - let q = p.q; - - let stream = async_stream::stream! { - let send_err = |msg: String| -> Event { - Event::default() - .event("error") - .json_data(ErrorEvent { message: msg }) - .unwrap() - }; - - match (mode, q.as_str()) { - (_, "") | (Mode::Author, _) | (Mode::Title, _) | (Mode::Random, _) - if mode != Mode::Random || q != "images" - => { - let res = match q.as_str() { - "" => - engine.newest_posts(limit, offset) - .await - .map(|s| Box::pin(s) as Pin<Box<dyn Stream<Item = search_types::Post> + Send>>), - _ => match mode { - Mode::Author => engine.search_posts_by_author(q, limit, offset).await - .map(|s| Box::pin(s) as _), - Mode::Title => engine.search_posts_by_title(q, limit, offset).await - .map(|s| Box::pin(s) as _), - Mode::Random => engine.random_posts(limit).await - .map(|s| Box::pin(s) as _), - _ => unreachable!() - } - }; - match res { - Ok(mut posts) => { - let mut count = 0i64; - - while let Some(post) = posts.next().await { - let images = match engine.list_post_images(post.id).await { - Ok(images) => images, - Err(_) => continue - }; - let videos = match engine.list_post_videos(post.id).await { - Ok(videos) => videos, - Err(_) => continue - }; - let item = PostItem { - id: post.id, - title: post.title, - images, - videos - }; - count += 1; - yield Ok(Event::default() - .event("post") - .json_data(item) - .unwrap()); - } - - yield Ok(Event::default() - .event("done") - .json_data(DoneEvent { has_more: count >= limit }) - .unwrap()); - } - Err(e) => yield Ok(send_err(e.to_string())), - } - } - (Mode::Tag | Mode::Clip, _) | (Mode::Random, "images") => { - let res = match mode { - Mode::Tag => - engine.search_images_by_tag(q, limit, offset) - .await - .map(|s| Box::pin(s) as Pin<Box<dyn Stream<Item = search_types::Image> + Send>>), - Mode::Clip => - engine.search_clip_cached([q], limit, offset) - .await - .map(|s| Box::pin(s) as _), - Mode::Random => - engine.random_images(limit) - .await - .map(|s| Box::pin(s) as _), - _ => unreachable!() - }; - match res { - Ok(mut images) => { - let mut count = 0i64; - - while let Some(img) = images.next().await { - count += 1; - yield Ok(Event::default() - .event("image") - .json_data(img) - .unwrap()); - } - - yield Ok(Event::default() - .event("done") - .json_data(DoneEvent { has_more: count >= limit }) - .unwrap()); - } - Err(e) => yield Ok(send_err(e.to_string())), - } - } - (Mode::Similar, _) => { - let prepared = async { - let id = q.parse::<i64>().map_err(|e| e.to_string())?; - let details = engine.image_details(id).await.map_err(|e| e.to_string())?; - - let post_image_ids = match details.post.as_ref().map(|p| p.id) { - Some(post_id) => engine - .list_post_images(post_id) - .await - .ok() - .map(|imgs| imgs.into_iter().map(|i| i.id).collect::<HashSet<_>>()), - None => None, - }; - - let images = engine - .search_dinov3_by_id([id], limit, offset) - .await - .map_err(|e| e.to_string())?; - - Ok::<_, String>((images, post_image_ids)) - } - .await; - - match prepared { - Err(e) => yield Ok(send_err(e)), - Ok((mut images, post_image_ids)) => { - let mut count = 0i64; - while let Some(img) = images.next().await { - count += 1; - if post_image_ids.as_ref().is_some_and(|ids| ids.contains(&img.id)) { - continue; - } - yield Ok(Event::default() - .event("image") - .json_data(img) - .unwrap()); - } - - yield Ok(Event::default() - .event("done") - .json_data(DoneEvent { has_more: count >= limit }) - .unwrap()); - } - } - } - (Mode::SearchImage, _) => { - let decoded = URL_SAFE_NO_PAD.decode(q.as_bytes()); - - match decoded { - Err(e) => yield Ok(send_err(format!("invalid image data: {e}"))), - Ok(bytes) => { - let cursor = std::io::Cursor::new(bytes); - match engine.search_dinov3_cached([cursor], limit, offset).await { - Err(e) => yield Ok(send_err(e.to_string())), - Ok(mut images) => { - let mut count = 0i64; - while let Some(img) = images.next().await { - count += 1; - yield Ok(Event::default() - .event("image") - .json_data(img) - .unwrap()); - } - yield Ok(Event::default() - .event("done") - .json_data(DoneEvent { has_more: count >= limit }) - .unwrap()); - } - } - } - } - } - _ => {} - } - }; - - Sse::new(stream).keep_alive( - KeepAlive::new() - .interval(Duration::from_secs(15)) - .text("ping"), - ) -} diff --git a/search/server/src/main.rs b/search/server/src/main.rs index a8ae981..fa7fa63 100644 --- a/search/server/src/main.rs +++ b/search/server/src/main.rs @@ -1,10 +1,9 @@ #![recursion_limit = "256"] -mod api; mod cli; use app::*; -use axum::{Router, routing::get}; +use axum::Router; use clap::Parser; use leptos::prelude::*; use leptos_axum::{LeptosRoutes, generate_route_list}; @@ -44,7 +43,9 @@ async fn main() -> anyhow::Result<()> { let routes = generate_route_list(App); - let mut app = Router::new().route("/api/search", get(api::search_sse)); + // Search is served by Leptos server functions (auto-registered under /api), + // so no manual routes are needed here beyond static assets + leptos routes. + let mut app = Router::new(); if let Some(prefix) = args.prefix { app = app .nest_service("/images", ServeDir::new(&prefix))