Add proof-of-concept search UI page
Some checks failed
continuous-integration/drone the build failed

This commit is contained in:
Olivier 'reivilibre' 2022-03-25 23:39:27 +00:00
parent 9c7dfb93f1
commit d26b4271ce
11 changed files with 324 additions and 6 deletions

1
Cargo.lock generated
View File

@ -3435,6 +3435,7 @@ dependencies = [
"env_logger",
"itertools",
"log",
"quickpeep_index",
"ron",
"serde",
"sqlx",

View File

@ -18,3 +18,5 @@ env_logger = "0.9.0"
sqlx = { version = "0.5.11", features = ["sqlite", "runtime-tokio-rustls"] }
itertools = "0.10.3"
colour = "0.6.0"
quickpeep_index = { path = "../quickpeep_index" }

View File

@ -5,9 +5,12 @@ use axum::routing::{get, get_service, post};
use axum::Router;
use env_logger::Env;
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::IndexAccess;
use sqlx::sqlite::SqlitePoolOptions;
use std::path::PathBuf;
use std::sync::Arc;
use tower_http::services::ServeDir;
#[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 =
ron::de::from_bytes(&file_bytes).context("Failed to parse web config")?;
let web_config = WebConfig::load(&config_path)?;
let pool = SqlitePoolOptions::new()
.min_connections(1)
@ -58,11 +59,18 @@ async fn main() -> anyhow::Result<()> {
sqlx::migrate!().run(&pool).await?;
let backend = Arc::new(web_config.open_indexer_backend()?);
let index_access = IndexAccess { backend };
let app = Router::new()
.route("/seeds/", get(seed_collection_root))
.route("/seeds/", post(seed_collection_root_post))
.route("/", get(search_root))
.route("/search", get(search_search))
.layer(Extension(web_config))
.layer(Extension(pool))
.layer(Extension(index_access))
.nest(
"/static",
get_service(ServeDir::new("./quickpeep_static/dist")).handle_error(

View File

@ -1,6 +1,10 @@
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 std::path::PathBuf;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Deserialize)]
pub struct SeedCollectionConfig {
@ -15,4 +19,37 @@ pub struct SeedCollectionConfig {
pub struct WebConfig {
pub seed_collection: SeedCollectionConfig,
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!()
}
}
}
}

View File

@ -1,2 +1,10 @@
use quickpeep_index::backend::Backend;
use std::sync::Arc;
pub mod searcher;
pub mod seed_collector;
#[derive(Clone)]
pub struct IndexAccess {
pub backend: Arc<Box<dyn Backend>>,
}

View File

@ -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(),
}))
}

View 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>

View File

@ -5,7 +5,7 @@ pub mod tantivy;
/// Trait representing a search index backend;
/// 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 flush(&mut self) -> anyhow::Result<()>;

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@ -4,7 +4,8 @@
"license": "QuickPeep Licence",
"private": true,
"scripts": {
"build": "parcel build style/main.scss"
"build": "parcel build style/main.scss && cp assets/*.png dist/",
"watch": "parcel watch style/main.scss"
},
"dependencies": {
"@picocss/pico": "^1.5.0"

View File

@ -15,3 +15,116 @@
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;
}
}