Add proof-of-concept search UI page
Some checks failed
continuous-integration/drone the build failed
Some checks failed
continuous-integration/drone the build failed
This commit is contained in:
parent
9c7dfb93f1
commit
d26b4271ce
1
Cargo.lock
generated
1
Cargo.lock
generated
@ -3435,6 +3435,7 @@ dependencies = [
|
|||||||
"env_logger",
|
"env_logger",
|
||||||
"itertools",
|
"itertools",
|
||||||
"log",
|
"log",
|
||||||
|
"quickpeep_index",
|
||||||
"ron",
|
"ron",
|
||||||
"serde",
|
"serde",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
|
@ -18,3 +18,5 @@ env_logger = "0.9.0"
|
|||||||
sqlx = { version = "0.5.11", features = ["sqlite", "runtime-tokio-rustls"] }
|
sqlx = { version = "0.5.11", features = ["sqlite", "runtime-tokio-rustls"] }
|
||||||
itertools = "0.10.3"
|
itertools = "0.10.3"
|
||||||
colour = "0.6.0"
|
colour = "0.6.0"
|
||||||
|
|
||||||
|
quickpeep_index = { path = "../quickpeep_index" }
|
||||||
|
@ -5,9 +5,12 @@ use axum::routing::{get, get_service, post};
|
|||||||
use axum::Router;
|
use axum::Router;
|
||||||
use env_logger::Env;
|
use env_logger::Env;
|
||||||
use quickpeep::config::WebConfig;
|
use quickpeep::config::WebConfig;
|
||||||
|
use quickpeep::web::searcher::{search_root, search_search};
|
||||||
use quickpeep::web::seed_collector::{seed_collection_root, seed_collection_root_post};
|
use quickpeep::web::seed_collector::{seed_collection_root, seed_collection_root_post};
|
||||||
|
use quickpeep::web::IndexAccess;
|
||||||
use sqlx::sqlite::SqlitePoolOptions;
|
use sqlx::sqlite::SqlitePoolOptions;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
use tower_http::services::ServeDir;
|
use tower_http::services::ServeDir;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
@ -27,9 +30,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let file_bytes = std::fs::read(&config_path).context("Failed to read web config file")?;
|
let web_config = WebConfig::load(&config_path)?;
|
||||||
let web_config: WebConfig =
|
|
||||||
ron::de::from_bytes(&file_bytes).context("Failed to parse web config")?;
|
|
||||||
|
|
||||||
let pool = SqlitePoolOptions::new()
|
let pool = SqlitePoolOptions::new()
|
||||||
.min_connections(1)
|
.min_connections(1)
|
||||||
@ -58,11 +59,18 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
sqlx::migrate!().run(&pool).await?;
|
sqlx::migrate!().run(&pool).await?;
|
||||||
|
|
||||||
|
let backend = Arc::new(web_config.open_indexer_backend()?);
|
||||||
|
|
||||||
|
let index_access = IndexAccess { backend };
|
||||||
|
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route("/seeds/", get(seed_collection_root))
|
.route("/seeds/", get(seed_collection_root))
|
||||||
.route("/seeds/", post(seed_collection_root_post))
|
.route("/seeds/", post(seed_collection_root_post))
|
||||||
|
.route("/", get(search_root))
|
||||||
|
.route("/search", get(search_search))
|
||||||
.layer(Extension(web_config))
|
.layer(Extension(web_config))
|
||||||
.layer(Extension(pool))
|
.layer(Extension(pool))
|
||||||
|
.layer(Extension(index_access))
|
||||||
.nest(
|
.nest(
|
||||||
"/static",
|
"/static",
|
||||||
get_service(ServeDir::new("./quickpeep_static/dist")).handle_error(
|
get_service(ServeDir::new("./quickpeep_static/dist")).handle_error(
|
||||||
|
@ -1,6 +1,10 @@
|
|||||||
use crate::web::seed_collector::SeedTagColumn;
|
use crate::web::seed_collector::SeedTagColumn;
|
||||||
|
use anyhow::Context;
|
||||||
|
use quickpeep_index::backend::tantivy::TantivyBackend;
|
||||||
|
use quickpeep_index::backend::Backend;
|
||||||
|
use quickpeep_index::config::BackendConfig;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::path::PathBuf;
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
pub struct SeedCollectionConfig {
|
pub struct SeedCollectionConfig {
|
||||||
@ -15,4 +19,37 @@ pub struct SeedCollectionConfig {
|
|||||||
pub struct WebConfig {
|
pub struct WebConfig {
|
||||||
pub seed_collection: SeedCollectionConfig,
|
pub seed_collection: SeedCollectionConfig,
|
||||||
pub sqlite_db_path: PathBuf,
|
pub sqlite_db_path: PathBuf,
|
||||||
|
pub index: BackendConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WebConfig {
|
||||||
|
/// Loads a config at the specified path.
|
||||||
|
/// Will resolve all the paths in the IndexerConfig for you.
|
||||||
|
pub fn load(path: &Path) -> anyhow::Result<WebConfig> {
|
||||||
|
let config_dir = path.parent().context("Can't get parent of config file.")?;
|
||||||
|
let file_bytes = std::fs::read(path).context("Failed to read web config file")?;
|
||||||
|
let mut web_config: WebConfig =
|
||||||
|
ron::de::from_bytes(&file_bytes).context("Failed to parse web config")?;
|
||||||
|
|
||||||
|
match &mut web_config.index {
|
||||||
|
BackendConfig::Tantivy(tantivy) => {
|
||||||
|
tantivy.index_dir = config_dir.join(&tantivy.index_dir);
|
||||||
|
}
|
||||||
|
BackendConfig::Meili(_) => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(web_config)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn open_indexer_backend(&self) -> anyhow::Result<Box<dyn Backend>> {
|
||||||
|
// TODO deduplicate with the indexer crate
|
||||||
|
match &self.index {
|
||||||
|
BackendConfig::Tantivy(tantivy) => {
|
||||||
|
Ok(Box::new(TantivyBackend::open(&tantivy.index_dir)?))
|
||||||
|
}
|
||||||
|
BackendConfig::Meili(_) => {
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,2 +1,10 @@
|
|||||||
|
use quickpeep_index::backend::Backend;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
pub mod searcher;
|
pub mod searcher;
|
||||||
pub mod seed_collector;
|
pub mod seed_collector;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct IndexAccess {
|
||||||
|
pub backend: Arc<Box<dyn Backend>>,
|
||||||
|
}
|
||||||
|
@ -1 +1,83 @@
|
|||||||
|
use crate::config::WebConfig;
|
||||||
|
use crate::web::IndexAccess;
|
||||||
|
use crate::webutil::{internal_error, TemplatedHtml};
|
||||||
|
use askama::Template;
|
||||||
|
use axum::extract::{Extension, Query};
|
||||||
|
use axum::response::IntoResponse;
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
#[derive(Clone, Template)]
|
||||||
|
#[template(path = "search.html.askama")]
|
||||||
|
pub struct SearchTemplate {
|
||||||
|
pub search_term: String,
|
||||||
|
pub results: Vec<SearchResult>,
|
||||||
|
pub contact: Vec<(String, String)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct SearchResult {
|
||||||
|
pub favicon_url: String,
|
||||||
|
pub url: String,
|
||||||
|
pub title: String,
|
||||||
|
pub excerpt: Vec<ExcerptFragment>,
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct ExcerptFragment {
|
||||||
|
pub text: String,
|
||||||
|
pub mark: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn search_root(Extension(web_config): Extension<WebConfig>) -> impl IntoResponse {
|
||||||
|
let seed_config = &web_config.seed_collection;
|
||||||
|
TemplatedHtml(SearchTemplate {
|
||||||
|
search_term: String::with_capacity(0),
|
||||||
|
results: vec![],
|
||||||
|
contact: seed_config.contact.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug, Clone)]
|
||||||
|
pub struct QueryParameters {
|
||||||
|
/// The search query
|
||||||
|
q: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn search_search(
|
||||||
|
web_config: Extension<WebConfig>,
|
||||||
|
index_access: Extension<IndexAccess>,
|
||||||
|
params: Query<QueryParameters>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
search_search_inner(web_config, index_access, params)
|
||||||
|
.await
|
||||||
|
.map_err(internal_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn search_search_inner(
|
||||||
|
Extension(web_config): Extension<WebConfig>,
|
||||||
|
Extension(index_access): Extension<IndexAccess>,
|
||||||
|
Query(params): Query<QueryParameters>,
|
||||||
|
) -> anyhow::Result<impl IntoResponse> {
|
||||||
|
let seed_config = &web_config.seed_collection;
|
||||||
|
|
||||||
|
let raw_results = index_access.backend.query(params.q.clone())?;
|
||||||
|
|
||||||
|
let mut results = Vec::with_capacity(raw_results.len());
|
||||||
|
|
||||||
|
for search_doc in raw_results {
|
||||||
|
results.push(SearchResult {
|
||||||
|
favicon_url: "".to_string(),
|
||||||
|
url: search_doc.url,
|
||||||
|
title: search_doc.title,
|
||||||
|
excerpt: vec![], // TODO
|
||||||
|
tags: vec!["Software".to_owned(), "Blog".to_owned()], // TODO
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(TemplatedHtml(SearchTemplate {
|
||||||
|
search_term: params.q.clone(),
|
||||||
|
results,
|
||||||
|
contact: seed_config.contact.clone(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
66
quickpeep/templates/search.html.askama
Normal file
66
quickpeep/templates/search.html.askama
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{{ search_term }} — QuickPeep</title>
|
||||||
|
<link rel="stylesheet" type="text/css" href="/static/main.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="container_overall">
|
||||||
|
<div class="left_side_container">
|
||||||
|
<header>
|
||||||
|
<form method="GET" action="search">
|
||||||
|
<fieldset class="horizontal">
|
||||||
|
<img src="/static/quickpeep_logo_sml.png" class="bar_logo">
|
||||||
|
<input type="search" id="search" name="q" placeholder="..." value="{{ search_term }}" class="grow">
|
||||||
|
|
||||||
|
<input type="submit" value="Search" class="shrink">
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</header><!-- ./ Header -->
|
||||||
|
|
||||||
|
<!-- Main -->
|
||||||
|
<main class="search">
|
||||||
|
<ul class="search_results">
|
||||||
|
{%- for result in results %}
|
||||||
|
<li>
|
||||||
|
<img src="{{ result.favicon_url }}">
|
||||||
|
<div class="result_title"><a href="{{ result.url }}" rel="nofollow noreferrer">{{ result.title }}</a></div>
|
||||||
|
<div class="result_excerpt">
|
||||||
|
{%- for excerpt_chunk in result.excerpt -%}
|
||||||
|
{%- if excerpt_chunk.mark %}
|
||||||
|
<b>{{ excerpt_chunk.text }}</b>
|
||||||
|
{%- else %}
|
||||||
|
{{ excerpt_chunk.text }}
|
||||||
|
{%- endif -%}
|
||||||
|
{%- endfor -%}
|
||||||
|
</div>
|
||||||
|
<ul class="result_tags">
|
||||||
|
{%- for tag in result.tags -%}
|
||||||
|
<li>{{ tag }}</li>
|
||||||
|
{%- endfor -%}
|
||||||
|
</ul>
|
||||||
|
<div class="result_url">{{ result.url }}</div>
|
||||||
|
</li>
|
||||||
|
{%- endfor %}
|
||||||
|
</ul>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="right_side_container">
|
||||||
|
<!-- Preview pane -->
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<footer class="container">
|
||||||
|
{% for (method, url) in contact %}
|
||||||
|
<a href="{{ url }}">{{ method }}</a> •
|
||||||
|
{% endfor %}
|
||||||
|
<a href="/">Return to QuickPeep Root</a>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
@ -5,7 +5,7 @@ pub mod tantivy;
|
|||||||
|
|
||||||
/// Trait representing a search index backend;
|
/// Trait representing a search index backend;
|
||||||
/// either Tantivy (embedded) or Meilisearch (via HTTP API).
|
/// either Tantivy (embedded) or Meilisearch (via HTTP API).
|
||||||
pub trait Backend {
|
pub trait Backend: Send + Sync {
|
||||||
fn add_document(&mut self, document: BackendIndependentDocument) -> anyhow::Result<()>;
|
fn add_document(&mut self, document: BackendIndependentDocument) -> anyhow::Result<()>;
|
||||||
|
|
||||||
fn flush(&mut self) -> anyhow::Result<()>;
|
fn flush(&mut self) -> anyhow::Result<()>;
|
||||||
|
BIN
quickpeep_static/assets/quickpeep_logo_sml.png
Normal file
BIN
quickpeep_static/assets/quickpeep_logo_sml.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 25 KiB |
@ -4,7 +4,8 @@
|
|||||||
"license": "QuickPeep Licence",
|
"license": "QuickPeep Licence",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "parcel build style/main.scss"
|
"build": "parcel build style/main.scss && cp assets/*.png dist/",
|
||||||
|
"watch": "parcel watch style/main.scss"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@picocss/pico": "^1.5.0"
|
"@picocss/pico": "^1.5.0"
|
||||||
|
@ -15,3 +15,116 @@
|
|||||||
background-color: #11391F;
|
background-color: #11391F;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@media only screen and (max-width: 960px) {
|
||||||
|
.left_side_container {
|
||||||
|
@extends(.container);
|
||||||
|
}
|
||||||
|
.right_side_container {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media only screen and (min-width: 961px) {
|
||||||
|
.container_overall {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left_side_container {
|
||||||
|
width: 66%;
|
||||||
|
padding: 1em;
|
||||||
|
}
|
||||||
|
.right_side_container {
|
||||||
|
width: 33%;
|
||||||
|
padding: 1em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
fieldset.horizontal {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grow {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shrink {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
main.search {
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar_logo {
|
||||||
|
height: 60px;
|
||||||
|
align-self: center;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: var(--spacing);
|
||||||
|
margin-right: var(--spacing);
|
||||||
|
}
|
||||||
|
|
||||||
|
ul.search_results {
|
||||||
|
> li {
|
||||||
|
list-style-type: none;
|
||||||
|
|
||||||
|
margin-left: 32px;
|
||||||
|
|
||||||
|
|
||||||
|
position: relative;
|
||||||
|
> img {
|
||||||
|
display: block;
|
||||||
|
position: absolute;
|
||||||
|
left: -50px;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
padding-bottom: 0.8em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.result_title a {
|
||||||
|
color: palegoldenrod;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result_excerpt {
|
||||||
|
font-size: .8em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result_url {
|
||||||
|
font-size: .7em;
|
||||||
|
opacity: 0.7;
|
||||||
|
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result_tags {
|
||||||
|
padding-left: 0;
|
||||||
|
font-size: .7em;
|
||||||
|
> li {
|
||||||
|
list-style-type: none;
|
||||||
|
display: inline-block;
|
||||||
|
//background-color: palegreen;
|
||||||
|
color: palegreen;
|
||||||
|
//padding: 0.2em;
|
||||||
|
//border-radius: 8px;
|
||||||
|
|
||||||
|
//text-decoration: underline;
|
||||||
|
//text-decoration-style: double;
|
||||||
|
//text-decoration-thickness: 5px;
|
||||||
|
|
||||||
|
border-bottom: 1px solid palegreen;
|
||||||
|
opacity: .7;
|
||||||
|
|
||||||
|
margin-right: 0.4em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user