Add search frontend code

This commit is contained in:
Greg Burri 2025-05-24 12:18:23 +02:00
parent 988849e598
commit 3e91f34303
22 changed files with 379 additions and 73 deletions

32
Cargo.lock generated
View file

@ -100,12 +100,12 @@ dependencies = [
[[package]] [[package]]
name = "anstyle-wincon" name = "anstyle-wincon"
version = "3.0.7" version = "3.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca3534e77181a9cc07539ad51f2141fe32f6c3ffd4df76db8ad92346b003ae4e" checksum = "6680de5231bd6ee4c6191b8a1325daa282b415391ec9d3a37bd34f2060dc73fa"
dependencies = [ dependencies = [
"anstyle", "anstyle",
"once_cell", "once_cell_polyfill",
"windows-sys 0.59.0", "windows-sys 0.59.0",
] ]
@ -499,9 +499,9 @@ dependencies = [
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.2.23" version = "1.2.24"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f4ac86a9e5bc1e2b3449ab9d7d3a6a405e3d1bb28d7b9be8614f55846ae3766" checksum = "16595d3be041c03b09d08d0858631facccee9221e579704070e6e9e4915d3bc7"
dependencies = [ dependencies = [
"shlex", "shlex",
] ]
@ -2011,12 +2011,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]] [[package]]
name = "onig" name = "once_cell_polyfill"
version = "6.4.0" version = "1.70.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c4b31c8722ad9171c6d77d3557db078cab2bd50afcc9d09c8b315c59df8ca4f" checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad"
[[package]]
name = "onig"
version = "6.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "336b9c63443aceef14bea841b899035ae3abe89b7c486aaf4c5bd8aafedac3f0"
dependencies = [ dependencies = [
"bitflags 1.3.2", "bitflags 2.9.1",
"libc", "libc",
"once_cell", "once_cell",
"onig_sys", "onig_sys",
@ -2024,9 +2030,9 @@ dependencies = [
[[package]] [[package]]
name = "onig_sys" name = "onig_sys"
version = "69.8.1" version = "69.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b829e3d7e9cc74c7e315ee8edb185bf4190da5acde74afd7fc59c35b1f086e7" checksum = "c7f86c6eef3d6df15f23bcfb6af487cbd2fed4e5581d58d5bf1f5f8b7f6727dc"
dependencies = [ dependencies = [
"cc", "cc",
"pkg-config", "pkg-config",
@ -2628,9 +2634,9 @@ dependencies = [
[[package]] [[package]]
name = "rustversion" name = "rustversion"
version = "1.0.20" version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2" checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d"
[[package]] [[package]]
name = "ryu" name = "ryu"

View file

@ -14,7 +14,7 @@
.svg-arrow { .svg-arrow {
display: block; display: block;
margin: consts.$margin calc(consts.$margin / 2); margin: var(--margin) calc(var(--margin) / 2);
fill: consts.$link-color; fill: consts.$link-color;
width: 0.8rem; width: 0.8rem;
height: 0.8rem; height: 0.8rem;
@ -34,7 +34,7 @@
} }
.year { .year {
margin: consts.$margin; margin: var(--margin);
} }
.month { .month {
@ -82,7 +82,7 @@
} }
.remove-scheduled-recipe { .remove-scheduled-recipe {
padding: 0px calc(consts.$margin / 2); padding: 0px calc(var(--margin) / 2);
} }
} }
} }

View file

@ -2,6 +2,25 @@
$dark-theme: false !default; $dark-theme: false !default;
$mobile-width: 1080px;
// Dynamic values depending of the screen width.
@media (min-width: calc($mobile-width + 1px)) {
:root {
--margin: 5px;
--title-font-size: 180%;
--logo-size: 50px;
}
}
@media (max-width: $mobile-width) {
:root {
--margin: 3px;
--title-font-size: 130%;
--logo-size: 40px;
}
}
$color-1: #B29B89; $color-1: #B29B89;
$color-2: #89B29B; $color-2: #89B29B;
$color-3: #9B89B2; $color-3: #9B89B2;
@ -9,21 +28,22 @@ $color-highlight: #cf2d2dff;
$text-color: color.adjust($color-1, $lightness: -30%); $text-color: color.adjust($color-1, $lightness: -30%);
$text-highlight: color.adjust($color-1, $lightness: +30%); $text-highlight: color.adjust($color-1, $lightness: +30%);
$text-dimished: color.adjust($color-1, $lightness: -15%, $saturation: -15%);
$link-color: color.adjust($color-3, $lightness: -25%); $link-color: color.adjust($color-3, $lightness: -25%);
$link-hover-color: color.adjust($color-3, $lightness: +20%); $link-hover-color: color.adjust($color-3, $lightness: +20%);
@if $dark-theme { @if $dark-theme {
$color-highlight: color.adjust($color-highlight, $lightness: +10%);
$text-color: color.adjust($color-1, $lightness: -10%); $text-color: color.adjust($color-1, $lightness: -10%);
$text-highlight: color.adjust($color-1, $lightness: +10%); $text-highlight: color.adjust($color-1, $lightness: +10%);
$color-highlight: color.adjust($color-highlight, $lightness: +10%); $text-dimished: color.adjust($color-1, $lightness: -20%, $saturation: -15%);
$link-color: color.adjust($color-3, $lightness: -5%); $link-color: color.adjust($color-3, $lightness: -5%);
$link-hover-color: color.adjust($color-3, $lightness: +10%); $link-hover-color: color.adjust($color-3, $lightness: +10%);
$color-1: color.adjust($color-1, $lightness: -47%); $color-1: color.adjust($color-1, $lightness: -47%);
$color-2: color.adjust($color-2, $lightness: -47%); $color-2: color.adjust($color-2, $lightness: -47%);
$color-3: color.adjust($color-2, $lightness: -47%); $color-3: color.adjust($color-3, $lightness: -50%);
} }
$margin: 5px;

View file

@ -4,6 +4,7 @@ $dark-theme: false !default;
@use 'constants' as consts with ($dark-theme: $dark-theme); @use 'constants' as consts with ($dark-theme: $dark-theme);
@use 'calendar'; @use 'calendar';
@use 'search';
@use 'toast'; @use 'toast';
@use 'modal-dialog'; @use 'modal-dialog';
@use 'mixins'; @use 'mixins';
@ -40,26 +41,26 @@ body {
} }
.footer-container { .footer-container {
margin: consts.$margin; margin: var(--margin);
align-self: center; align-self: center;
font-size: 0.7em; font-size: 0.7em;
} }
/// HEADER /// /// HEADER ///
.header-container { .header-container {
margin: calc(2 * consts.$margin); margin: calc(2 * var(--margin));
display: flex; display: flex;
align-items: flex-end; align-items: flex-end;
justify-content: space-between; justify-content: space-between;
.title { .title {
font-size: 180%; font-size: var(--title-font-size);
font-style: italic; font-style: italic;
.logo { .logo {
width: 50px; width: var(--logo-size);
height: 50px; height: var(--logo-size);
vertical-align: bottom; vertical-align: bottom;
margin: 0px 10px 0px 0px; margin: 0px 10px 0px 0px;
@ -68,16 +69,16 @@ body {
.header-menu { .header-menu {
.user-menu { .user-menu {
margin: consts.$margin; margin: var(--margin);
.user-edit-link { .user-edit-link {
margin-left: consts.$margin; margin-left: var(--margin);
} }
} }
#select-website-language { #select-website-language {
margin: consts.$margin; margin: var(--margin);
padding: consts.$margin; padding: var(--margin);
} }
} }
} }
@ -87,16 +88,16 @@ body {
flex-direction: row; flex-direction: row;
#recipes-list { #recipes-list {
margin: calc(2 * consts.$margin); margin: calc(2 * var(--margin));
ul { ul {
padding-left: 20px; padding-left: 20px;
li { li {
margin-bottom: calc(consts.$margin / 2); margin-bottom: calc(var(--margin) / 2);
a { a {
padding: calc(consts.$margin / 2); padding: calc(var(--margin) / 2);
} }
} }
} }
@ -130,7 +131,7 @@ body {
} }
.content { .content {
margin: calc(2 * consts.$margin); margin: calc(2 * var(--margin));
flex-grow: 1; flex-grow: 1;
@ -250,13 +251,11 @@ body {
} }
} }
// #dev-panel {
// }
form { form {
display: grid; display: grid;
// <label> <input>.
grid-template-columns: auto 1fr; grid-template-columns: auto 1fr;
gap: calc(consts.$margin / 2); gap: calc(var(--margin) / 2);
align-items: center; align-items: center;
} }
@ -279,14 +278,14 @@ body {
border: 0.1em solid consts.$color-3; border: 0.1em solid consts.$color-3;
border-radius: 0.5em; border-radius: 0.5em;
background-color: consts.$color-1; background-color: consts.$color-1;
margin: consts.$margin; margin: var(--margin);
padding: consts.$margin; padding: var(--margin);
} }
textarea, textarea,
input { input {
margin: consts.$margin; margin: var(--margin);
padding: calc(consts.$margin / 2) calc(2 * consts.$margin); padding: calc(var(--margin) / 2) calc(2 * var(--margin));
background-color: consts.$color-1; background-color: consts.$color-1;
border: solid 1px consts.$color-3; border: solid 1px consts.$color-3;
@ -300,8 +299,8 @@ input {
} }
select { select {
margin: consts.$margin; margin: var(--margin);
padding: calc(consts.$margin / 2) calc(2 * consts.$margin); padding: calc(var(--margin) / 2) calc(2 * var(--margin));
background-color: consts.$color-1; background-color: consts.$color-1;
border: solid 1px consts.$color-3; border: solid 1px consts.$color-3;
@ -316,8 +315,8 @@ select {
input[type="button"], input[type="button"],
input[type="submit"], input[type="submit"],
.button { .button {
margin: consts.$margin; margin: var(--margin);
padding: calc(consts.$margin / 2) calc(2 * consts.$margin); padding: calc(var(--margin) / 2) calc(2 * var(--margin));
border: 0.1em solid consts.$color-3; border: 0.1em solid consts.$color-3;
border-radius: 0.5em; border-radius: 0.5em;
@ -345,19 +344,19 @@ input[type="submit"],
width: 120px; width: 120px;
background-color: consts.$color-1; background-color: consts.$color-1;
text-align: center; text-align: center;
padding: consts.$margin 0; padding: var(--margin) 0;
border: 0.1em solid consts.$color-3; border: 0.1em solid consts.$color-3;
border-radius: 0.5em; border-radius: 0.5em;
position: absolute; position: absolute;
z-index: 1; z-index: 10;
cursor: default; cursor: default;
top: -(consts.$margin); top: -(var(--margin));
left: 100%; left: 100%;
margin-left: consts.$margin; margin-left: var(--margin);
} }
&:hover .tooltiptext { &:hover .tooltiptext {
@ -369,8 +368,8 @@ input[type="submit"],
position: absolute; position: absolute;
top: 50%; top: 50%;
right: 100%; right: 100%;
margin-top: -(consts.$margin); margin-top: -(var(--margin));
border-width: consts.$margin; border-width: var(--margin);
border-style: solid; border-style: solid;
border-color: transparent consts.$color-3 transparent transparent; border-color: transparent consts.$color-3 transparent transparent;
} }
@ -378,7 +377,7 @@ input[type="submit"],
/// Toggle theme (dark, light). /// Toggle theme (dark, light).
#toggle-theme { #toggle-theme {
margin: consts.$margin; margin: var(--margin);
position: relative; position: relative;
display: inline-block; display: inline-block;

View file

@ -35,6 +35,6 @@
color: consts.$text-color; color: consts.$text-color;
text-align: center; text-align: center;
padding: calc(2 * consts.$margin) calc(2 * consts.$margin); padding: calc(2 * var(--margin)) calc(2 * var(--margin));
box-shadow: -1px 1px 10px rgba(0, 0, 0, 0.3); box-shadow: -1px 1px 10px rgba(0, 0, 0, 0.3);
} }

View file

@ -3,7 +3,7 @@
#modal-dialog { #modal-dialog {
position: fixed; position: fixed;
z-index: 1; z-index: 5;
left: 50%; left: 50%;
width: 50%; width: 50%;
transform: translate(-50%, 0%); transform: translate(-50%, 0%);
@ -16,6 +16,6 @@
padding: 0; padding: 0;
>div { >div {
padding: calc(2 * consts.$margin) calc(2 * consts.$margin); padding: calc(2 * var(--margin)) calc(2 * var(--margin));
} }
} }

73
backend/scss/search.scss Normal file
View file

@ -0,0 +1,73 @@
@use 'sass:color';
@use 'constants' as consts;
#search {
position: relative;
display: inline-flex;
align-items: center;
input {
width: 100%;
line-height: 28px;
padding: 0 1rem;
padding-left: 2.5rem;
border-radius: 8px;
background-color: consts.$color-2;
}
input::placeholder {
color: consts.$text-dimished;
}
.icon {
position: absolute;
left: 1rem;
fill: consts.$color-3;
width: 1rem;
height: 1rem;
}
.results {
display: none;
z-index: 2;
position: absolute;
background-color: white;
vertical-align: top;
top: 100%;
border: solid 1px consts.$color-3;
background-color: consts.$color-1;
box-shadow: 0 0 5px color.adjust(consts.$color-3, $lightness: -20%);
.message {
display: hidden;
padding:
var(--margin) calc(2 * var(--margin)) var(--margin) calc(2 * var(--margin));
}
ul {
padding: 0px;
margin: 0px;
li {
padding:
var(--margin) calc(2 * var(--margin)) var(--margin) calc(2 * var(--margin));
list-style-type: none;
em {
font-weight: bold;
}
}
li:nth-child(odd) {
background: consts.$color-2;
}
}
}
}
#hidden-templates-search {
display: none;
}

View file

@ -3,7 +3,7 @@
#toasts { #toasts {
position: fixed; position: fixed;
z-index: 1; z-index: 6;
left: 50%; left: 50%;
top: 15px; top: 15px;
transform: translate(-50%, 0%); transform: translate(-50%, 0%);
@ -18,7 +18,7 @@
@include mixins.popup; @include mixins.popup;
margin: consts.$margin; margin: var(--margin);
.content { .content {
display: inline-block; display: inline-block;

View file

@ -96,7 +96,13 @@ CREATE TABLE [Recipe] (
CREATE VIRTUAL TABLE [RecipeTitle] USING FTS5( CREATE VIRTUAL TABLE [RecipeTitle] USING FTS5(
[title], [title],
CONTENT = [Recipe], CONTENT = [Recipe],
CONTENT_ROWID = [id] CONTENT_ROWID = [id],
PREFIX = 2,
PREFIX = 3,
PREFIX = 4,
PREFIX = 5,
PREFIX = 6,
PREFIX = 7,
); );
CREATE TRIGGER [Recipe_trigger_insert] AFTER INSERT ON [Recipe] BEGIN CREATE TRIGGER [Recipe_trigger_insert] AFTER INSERT ON [Recipe] BEGIN

View file

@ -1,5 +1,3 @@
use std::u32;
use chrono::prelude::*; use chrono::prelude::*;
use common::web_api::Difficulty; use common::web_api::Difficulty;
use itertools::Itertools; use itertools::Itertools;
@ -287,18 +285,21 @@ FROM [Recipe] WHERE [id] = $1
&self, &self,
lang: &str, lang: &str,
term: &str, term: &str,
max_nb_results: u32,
) -> Result<Vec<model::RecipeSearchResult>> { ) -> Result<Vec<model::RecipeSearchResult>> {
sqlx::query_as( sqlx::query_as(
r#" r#"
SELECT [id], [recipe].[title], highlight([RecipeTitle], 0, '<em>', '</em>') [title_highlighted] SELECT [id], [recipe].[title], highlight([RecipeTitle], 0, '<em>', '</em>') [title_highlighted]
FROM [RecipeTitle] FROM [RecipeTitle]
INNER JOIN [Recipe] ON [Recipe].[id] = [RecipeTitle].[rowid] INNER JOIN [Recipe] ON [Recipe].[id] = [RecipeTitle].[rowid]
WHERE [Recipe].[lang] = $1 AND [RecipeTitle] MATCH $2 WHERE [Recipe].[is_public] AND [Recipe].[lang] = $1 AND [RecipeTitle] MATCH $2
ORDER BY RANK, [Recipe].[title] ORDER BY RANK, [Recipe].[title]
LIMIT $3
"#, "#,
) )
.bind(lang) .bind(lang)
.bind(term) .bind(term)
.bind(max_nb_results)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(DBError::from) .map_err(DBError::from)
@ -964,6 +965,8 @@ pub fn normalize_tag(tag: &str) -> String {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::u32;
use super::*; use super::*;
use chrono::Days; use chrono::Days;
@ -1011,7 +1014,7 @@ mod tests {
let id3 = add_recipe(&connection, user_id, "AAA ZZZ").await?; let id3 = add_recipe(&connection, user_id, "AAA ZZZ").await?;
{ {
let recipes = connection.search_recipes("en", "yyy").await?; let recipes = connection.search_recipes("en", "yyy", u32::MAX).await?;
assert_eq!(recipes.len(), 2); assert_eq!(recipes.len(), 2);
@ -1031,7 +1034,9 @@ mod tests {
} }
{ {
let recipes = connection.search_recipes("en", "aaa OR zzz").await?; let recipes = connection
.search_recipes("en", "aaa OR zzz", u32::MAX)
.await?;
assert_eq!(recipes.len(), 3); assert_eq!(recipes.len(), 3);

View file

@ -6,6 +6,7 @@ use axum::{
}; };
use axum_extra::extract::Query; use axum_extra::extract::Query;
use common::web_api; use common::web_api;
use itertools::Itertools;
use serde::Deserialize; use serde::Deserialize;
use tracing::warn; use tracing::warn;
@ -19,13 +20,35 @@ pub async fn search_by_title(
Extension(context): Extension<Context>, Extension(context): Extension<Context>,
Query(params): Query<web_api::SearchByTitleParams>, Query(params): Query<web_api::SearchByTitleParams>,
) -> Result<impl IntoResponse> { ) -> Result<impl IntoResponse> {
let trimmed_search_term = params.search_term.trim();
Ok(ron_response_ok( Ok(ron_response_ok(
connection if trimmed_search_term.len() < common::consts::MIN_SEARCH_STRING_LENGTH {
.search_recipes(context.tr.current_lang_code(), &params.search_term) // Avoid searching 1 or 2 characters.
.await? vec![]
.into_iter() } else {
.map(web_api::RecipeSearchResult::from) let mut forced_prefix_words = trimmed_search_term
.collect::<Vec<_>>(), .split(' ')
.filter(|word| word.len() >= common::consts::MIN_SEARCH_STRING_LENGTH)
.join("* OR ");
forced_prefix_words.push('*');
match connection
.search_recipes(
context.tr.current_lang_code(),
&forced_prefix_words,
params.max_nb_results,
)
.await
{
Ok(result) => result
.into_iter()
.map(web_api::RecipeSearchResult::from)
.collect::<Vec<_>>(),
Err(_) => vec![],
}
},
)) ))
} }

View file

@ -5,6 +5,8 @@
{% include "title.html" %} {% include "title.html" %}
<span class="header-menu"> <span class="header-menu">
{% include "search.html" %}
<span class="user-menu"> <span class="user-menu">
{% match context.user %} {% match context.user %}
{% when Some with (user) %} {% when Some with (user) %}

View file

@ -0,0 +1,17 @@
<span id="search">
<svg class="icon" aria-hidden="true" viewBox="0 0 24 24"><g><path d="M21.53 20.47l-3.66-3.66C19.195 15.24 20 13.214 20 11c0-4.97-4.03-9-9-9s-9 4.03-9 9 4.03 9 9 9c2.215 0 4.24-.804 5.808-2.13l3.66 3.66c.147.146.34.22.53.22s.385-.073.53-.22c.295-.293.295-.767.002-1.06zM3.5 11c0-4.135 3.365-7.5 7.5-7.5s7.5 3.365 7.5 7.5-3.365 7.5-7.5 7.5-7.5-3.365-7.5-7.5z"></path></g></svg>
<input type="search" placeholder="{{ context.tr.t(Sentence::SearchPlaceholder) }}" />
<div class="results">
<div class="message">
<span class="to-small">{{ context.tr.t(Sentence::SearchTooFewCharacters) }}</span>
<span class="no-results">{{ context.tr.t(Sentence::SearchNoResults) }}</span>
</div>
<ul>
</ul>
</div>
</span>
<div id="hidden-templates-search">
<li class="result-item"><a></a></li>
</div>

View file

@ -22,6 +22,8 @@
(TemplateError, "Template error"), (TemplateError, "Template error"),
(SearchPlaceholder, "Search by title"), (SearchPlaceholder, "Search by title"),
(SearchTooFewCharacters, "Too few characters"),
(SearchNoResults, "No results"),
(SignInMenu, "Sign in"), (SignInMenu, "Sign in"),
(SignInTitle, "Sign in"), (SignInTitle, "Sign in"),

View file

@ -22,6 +22,8 @@
(TemplateError, "Erreur du moteur de modèles (Template error)"), (TemplateError, "Erreur du moteur de modèles (Template error)"),
(SearchPlaceholder, "Recherche par titre"), (SearchPlaceholder, "Recherche par titre"),
(SearchTooFewCharacters, "Trop peu de caractères"),
(SearchNoResults, "Pas de résultats"),
(SignInMenu, "Se connecter"), (SignInMenu, "Se connecter"),
(SignInTitle, "Se connecter"), (SignInTitle, "Se connecter"),

View file

@ -9,3 +9,5 @@ pub const GET_PARAMETER_USER_MESSAGE_LEVEL: &str = "user_message_icon";
// It should be "application/ron" but firefox can't display the response propertly (base64) // It should be "application/ron" but firefox can't display the response propertly (base64)
// in the dev tool. // in the dev tool.
pub const MIME_TYPE_RON: HeaderValue = HeaderValue::from_static("text/ron"); pub const MIME_TYPE_RON: HeaderValue = HeaderValue::from_static("text/ron");
pub const MIN_SEARCH_STRING_LENGTH: usize = 3;

View file

@ -24,6 +24,8 @@ pub enum Sentence {
// Search // Search
SearchPlaceholder, SearchPlaceholder,
SearchTooFewCharacters,
SearchNoResults,
// Sign in page. // Sign in page.
SignInMenu, SignInMenu,

View file

@ -28,6 +28,7 @@ pub struct RecipesListFragmentsParams {
#[derive(Serialize, Deserialize, Clone)] #[derive(Serialize, Deserialize, Clone)]
pub struct SearchByTitleParams { pub struct SearchByTitleParams {
pub search_term: String, pub search_term: String,
pub max_nb_results: u32,
} }
#[derive(Serialize, Deserialize, Clone)] #[derive(Serialize, Deserialize, Clone)]

View file

@ -45,6 +45,7 @@ web-sys = { version = "0.3", features = [
"HtmlElement", "HtmlElement",
"MediaQueryList", "MediaQueryList",
"HtmlDivElement", "HtmlDivElement",
"HtmlSpanElement",
"HtmlLabelElement", "HtmlLabelElement",
"HtmlInputElement", "HtmlInputElement",
"HtmlImageElement", "HtmlImageElement",

View file

@ -14,6 +14,7 @@ mod pages;
mod recipe_scheduler; mod recipe_scheduler;
mod request; mod request;
mod ron_request; mod ron_request;
mod search;
mod shopping_list; mod shopping_list;
mod toast; mod toast;
mod utils; mod utils;
@ -140,6 +141,9 @@ pub fn main() -> Result<(), JsValue> {
}) })
.forget(); .forget();
// Search box.
search::setup();
Ok(()) Ok(())
} }

View file

@ -1,9 +1,8 @@
use std::{cell::RefCell, rc, sync::Mutex}; use std::{cell::RefCell, rc, sync::Mutex};
use common::{web_api, utils::substitute}; use common::{utils::substitute, web_api};
use gloo::{ use gloo::{
events::{EventListener, EventListenerOptions}, events::{EventListener, EventListenerOptions},
net::http::Request,
utils::{document, window}, utils::{document, window},
}; };
use wasm_bindgen::prelude::*; use wasm_bindgen::prelude::*;
@ -15,7 +14,6 @@ use web_sys::{
use crate::{ use crate::{
modal_dialog, request, ron_request, modal_dialog, request, ron_request,
toast::{self, Level},
utils::{SelectorExt, by_id, get_current_lang, selector, selector_and_clone}, utils::{SelectorExt, by_id, get_current_lang, selector, selector_and_clone},
}; };

143
frontend/src/search.rs Normal file
View file

@ -0,0 +1,143 @@
use common::web_api;
use futures::{
StreamExt,
channel::mpsc::{UnboundedReceiver, UnboundedSender, unbounded},
};
use gloo::{events::EventListener, utils::document};
use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::spawn_local;
use web_sys::{Element, HtmlDivElement, HtmlInputElement, HtmlSpanElement, KeyboardEvent};
use crate::{
ron_request,
utils::{SelectorExt, get_current_lang, selector, selector_and_clone},
};
const MAX_NB_SEARCH_RESULT: u32 = 10;
pub fn setup() {
// Here we check if the search input exists.
// Otherwise the search elements are considered as not present.
let input_search = match document().query_selector("#search input") {
Ok(Some(e)) => e.dyn_into::<HtmlInputElement>().unwrap(),
_ => return,
};
let (sender, mut receiver): (UnboundedSender<String>, UnboundedReceiver<String>) = unbounded();
spawn_local(async move {
while let Some(search_term) = receiver.next().await {
perform_search(search_term).await;
}
});
EventListener::new(&input_search, "input", move |event| {
let input_search: HtmlInputElement = event.target().unwrap().dyn_into().unwrap();
let search_term = input_search.value().trim().to_string();
sender.unbounded_send(search_term).unwrap();
})
.forget();
EventListener::new(&input_search, "keydown", move |event| {
let key_event: &KeyboardEvent = event.dyn_ref().unwrap();
if key_event.key() == "Escape" {
let input_search: HtmlInputElement = key_event.target().unwrap().dyn_into().unwrap();
input_search.set_value("");
selector::<HtmlDivElement>("#search .results")
.style()
.set_property("display", "none")
.unwrap();
}
})
.forget();
}
async fn perform_search(search_term: String) {
let results_element: HtmlDivElement = selector("#search .results");
if search_term.is_empty() {
results_element
.style()
.set_property("display", "none")
.unwrap();
return;
}
results_element
.selector::<Element>("ul")
.set_text_content(None);
if search_term.len() < common::consts::MIN_SEARCH_STRING_LENGTH {
display_message(Message::TooFewCharacters);
} else {
display_message(Message::None);
let search_result: Vec<web_api::RecipeSearchResult> = ron_request::get_with_params(
"/ron-api/recipe/search",
web_api::SearchByTitleParams {
search_term,
max_nb_results: MAX_NB_SEARCH_RESULT,
},
)
.await
.unwrap();
if search_result.is_empty() {
display_message(Message::NoResults);
} else {
let ul_element = results_element.selector::<Element>("ul");
for recipe in search_result {
let li_element: Element = selector_and_clone("#hidden-templates-search li");
let a_element = li_element.selector::<Element>("a");
a_element
.set_attribute(
"href",
&format!("/{}/recipe/view/{}", get_current_lang(), recipe.recipe_id),
)
.unwrap();
a_element.set_inner_html(&recipe.title_highlighted);
ul_element.append_child(&li_element).unwrap();
}
}
}
results_element
.style()
.set_property("display", "block")
.unwrap();
}
enum Message {
None,
TooFewCharacters,
NoResults,
}
fn display_message(message: Message) {
let message_element: HtmlDivElement = selector("#search .message");
for m in message_element.selector_all::<HtmlSpanElement>("span") {
m.style().set_property("display", "none").unwrap();
}
message_element
.style()
.set_property("display", "block")
.unwrap();
match message {
Message::TooFewCharacters => message_element
.selector::<HtmlSpanElement>(".to-small")
.style()
.set_property("display", "inline")
.unwrap(),
Message::NoResults => message_element
.selector::<HtmlSpanElement>(".no-results")
.style()
.set_property("display", "inline")
.unwrap(),
Message::None => message_element
.style()
.set_property("display", "none")
.unwrap(),
}
}