This commit is contained in:
Adrian Woźniak 2024-10-30 13:15:01 +01:00
parent 427a6cb597
commit d8830fbbc0
21 changed files with 508 additions and 1127 deletions

22
Cargo.lock generated
View File

@ -1143,6 +1143,7 @@ dependencies = [
"tempfile",
"tracing",
"tracing-subscriber",
"tracing-test",
"uuid",
]
@ -4943,6 +4944,27 @@ dependencies = [
"tracing-log",
]
[[package]]
name = "tracing-test"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "557b891436fe0d5e0e363427fc7f217abf9ccd510d5136549847bdcbcd011d68"
dependencies = [
"tracing-core",
"tracing-subscriber",
"tracing-test-macro",
]
[[package]]
name = "tracing-test-macro"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04659ddb06c87d233c566112c1c9c5b9e98256d9af50ec3bc9c8327f873a7568"
dependencies = [
"quote",
"syn 2.0.82",
]
[[package]]
name = "typed-arena"
version = "2.0.2"

View File

@ -29,3 +29,6 @@ tantivy = "0.22.0"
tempfile = "3.13.0"
[build-dependencies]
[dev-dependencies]
tracing-test = "0.2.5"

File diff suppressed because it is too large Load Diff

View File

@ -1,11 +1,16 @@
use std::path::Path;
use std::sync::{Arc, Mutex};
use actix::prelude::*;
use tantivy::collector::TopDocs;
use tantivy::query::QueryParser;
use tantivy::{doc, Index, IndexWriter, ReloadPolicy, Searcher};
use tantivy::{doc, Index, IndexReader, IndexWriter, ReloadPolicy};
use tantivy::{schema::*, TantivyError};
const ID: &'static str = "id";
const TITLE: &'static str = "title";
const SUMMARY: &'static str = "summary";
#[derive(Debug, Clone, derive_more::Deref)]
pub struct Search(pub Addr<SearchEngine>);
@ -29,46 +34,43 @@ impl From<crate::entities::recipies::Model> for RecipeRecord {
pub struct Inner {
writer: IndexWriter,
schema: Schema,
searcher: Searcher,
index: Index,
reader: IndexReader,
}
pub struct SearchEngine(Arc<Mutex<Inner>>);
impl SearchEngine {
pub fn build() -> Result<Self, tantivy::TantivyError> {
let index_path = std::path::Path::new("./indices");
pub fn build(index_path: &Path) -> Result<Self, tantivy::TantivyError> {
std::fs::create_dir_all(&index_path).expect("Failed to create indices directory");
let mut schema_builder = Schema::builder();
schema_builder.add_u64_field("id", INDEXED);
schema_builder.add_text_field("title", TEXT);
schema_builder.add_text_field("summary", TEXT);
schema_builder.add_u64_field(ID, STORED);
schema_builder.add_text_field(TITLE, TEXT | STORED);
schema_builder.add_text_field(SUMMARY, TEXT);
let schema = schema_builder.build();
let index = Index::create_in_dir(&index_path, schema.clone())
.or_else(|_| Index::open_in_dir(&index_path))
.expect("Failed to construct indices directory");
let index_writer: IndexWriter = index.writer(50_000_000)?;
let writer: IndexWriter = index.writer(50_000_000)?;
let reader = index
.reader_builder()
.reload_policy(ReloadPolicy::OnCommitWithDelay)
.try_into()
.unwrap();
let searcher = reader.searcher();
.try_into()?;
Ok(Self(Arc::new(Mutex::new(Inner {
writer: index_writer,
writer,
schema,
searcher,
index,
reader,
}))))
}
}
impl actix::Actor for SearchEngine {
type Context = actix::Context<Self>;
type Context = actix::SyncContext<Self>;
}
#[derive(Debug, Message)]
@ -78,32 +80,30 @@ pub struct CreateRecipe {
}
impl Handler<CreateRecipe> for SearchEngine {
type Result = actix::ResponseActFuture<Self, Result<u64, TantivyError>>;
type Result = Result<u64, TantivyError>;
fn handle(&mut self, msg: CreateRecipe, _ctx: &mut Self::Context) -> Self::Result {
let inner = self.0.clone();
Box::pin(
async move {
let mut shared = inner.lock().unwrap();
let id = shared.schema.get_field("id").unwrap();
let title = shared.schema.get_field("summary").unwrap();
let summary = shared.schema.get_field("summary").unwrap();
let msg = msg.record;
let n = shared.writer.add_document(doc! {
id => msg.id,
title => msg.title,
summary => msg.summary.unwrap_or_default(),
})?;
shared.writer.commit()?;
Ok(n)
}
.into_actor(self),
)
create(self.0.clone(), msg)
}
}
fn create(inner: Arc<Mutex<Inner>>, msg: CreateRecipe) -> Result<u64, TantivyError> {
let mut shared = inner.lock().unwrap();
let id = shared.schema.get_field(ID).unwrap();
let title = shared.schema.get_field(TITLE).unwrap();
let summary = shared.schema.get_field(SUMMARY).unwrap();
let msg = msg.record;
let n = shared.writer.add_document(doc! {
id => msg.id,
title => msg.title,
summary => msg.summary.unwrap_or_default(),
})?;
tracing::debug!("attemp commit");
shared.writer.commit()?;
Ok(n)
}
#[derive(Debug, Message)]
#[rtype(result = "Result<Vec<u64>,TantivyError>")]
@ -112,64 +112,62 @@ pub struct Find {
}
impl Handler<Find> for SearchEngine {
type Result = actix::ResponseActFuture<Self, Result<Vec<u64>, TantivyError>>;
type Result = Result<Vec<u64>, TantivyError>;
fn handle(&mut self, msg: Find, _ctx: &mut Self::Context) -> Self::Result {
let inner = self.0.clone();
Box::pin(
async move {
let shared = inner.lock().unwrap();
let id = shared.schema.get_field("id").unwrap();
let title = shared.schema.get_field("summary").unwrap();
let summary = shared.schema.get_field("summary").unwrap();
let query_parser = QueryParser::for_index(&shared.index, vec![title, summary]);
let query = msg
.query
.split_whitespace()
.map(|piece| {
piece
.chars()
.filter(|c| c.is_alphabetic())
.collect::<String>()
})
.filter(|s| !s.trim().is_empty())
.collect::<Vec<_>>()
.join(" OR ");
tracing::debug!("Query is: {query:?}");
let query = query_parser.parse_query(&query).expect("invalid query");
let rows = shared.searcher.search(&query, &TopDocs::with_limit(100))?;
let ids = rows
.into_iter()
.filter_map(|row| {
tracing::debug!("tantivy row: {row:?}");
let doc: Option<TantivyDocument> = shared.searcher.doc(row.1).ok();
doc
})
.fold(Vec::with_capacity(1_000), |agg, doc| {
let json = doc.to_json(&shared.schema);
tracing::debug!("tantivy doc: {doc:?} {json:?}");
doc.get_all(id)
.filter_map(|id| {
tracing::debug!("tantivy id: {id:?}");
id.as_u64()
})
.fold(agg, |mut agg, id| {
agg.push(id);
agg
})
});
Ok(ids)
}
.into_actor(self),
)
find(self.0.clone(), msg)
}
}
fn find(inner: Arc<Mutex<Inner>>, msg: Find) -> Result<Vec<u64>, TantivyError> {
let shared = inner.lock().unwrap();
let id = shared.schema.get_field(ID).unwrap();
let title = shared.schema.get_field(TITLE).unwrap();
let summary = shared.schema.get_field(SUMMARY).unwrap();
let query_parser = QueryParser::for_index(&shared.index, vec![id, title, summary]);
let query = msg
.query
.split_whitespace()
.map(|piece| {
piece
.chars()
.filter(|c| c.is_alphabetic())
.collect::<String>()
})
.filter(|s| !s.trim().is_empty())
.map(|s| format!("(title:{s} OR summary:{s})", s = s.trim()))
.collect::<Vec<_>>()
.join(" OR ");
tracing::debug!("Query is: {query:?}");
let query = query_parser.parse_query(&query).expect("invalid query");
shared.reader.reload()?;
let searcher = shared.reader.searcher();
let rows = searcher.search(&query, &TopDocs::with_limit(10))?;
let ids = rows
.into_iter()
.filter_map(|(_score, addr)| {
let doc: Option<TantivyDocument> = searcher.doc(addr).ok();
doc
})
.fold(Vec::with_capacity(100), |mut agg, doc| {
let Some(id) = doc.into_iter().next() else {
return agg;
};
let value = id.value();
let Some(value) = value.as_u64() else {
return agg;
};
agg.push(value);
agg
});
Ok(ids)
}
#[derive(Debug, Message)]
#[rtype(result = "Result<(), TantivyError>")]
pub struct Refresh {
@ -177,33 +175,88 @@ pub struct Refresh {
}
impl Handler<Refresh> for SearchEngine {
type Result = actix::ResponseActFuture<Self, Result<(), TantivyError>>;
type Result = Result<(), TantivyError>;
fn handle(&mut self, msg: Refresh, _ctx: &mut Self::Context) -> Self::Result {
let inner = self.0.clone();
Box::pin(
async move {
let mut shared = inner.lock().unwrap();
let id = shared.schema.get_field("id").unwrap();
let title = shared.schema.get_field("summary").unwrap();
let summary = shared.schema.get_field("summary").unwrap();
shared.writer.delete_all_documents()?;
for msg in msg.records {
tracing::debug!("creating search index for {msg:?}");
let _n = shared.writer.add_document(doc! {
id => msg.id,
title => msg.title,
summary => msg.summary.unwrap_or_default(),
})?;
}
shared.writer.commit()?;
Ok(())
}
.into_actor(self),
)
refresh(self.0.clone(), msg)
}
}
fn refresh(inner: Arc<Mutex<Inner>>, msg: Refresh) -> Result<(), TantivyError> {
let mut shared = inner.lock().unwrap();
let id = shared.schema.get_field(ID).unwrap();
let title = shared.schema.get_field(TITLE).unwrap();
let summary = shared.schema.get_field(SUMMARY).unwrap();
shared.writer.delete_all_documents()?;
for msg in msg.records {
tracing::debug!("creating search index for {msg:?}");
let n = shared.writer.add_document(doc! {
id => msg.id,
title => msg.title,
summary => msg.summary.unwrap_or_default(),
})?;
tracing::debug!("add_document {n}");
}
shared.writer.commit()?;
Ok(())
}
#[cfg(test)]
mod tests {
use tempfile::tempdir;
use tracing_test::traced_test;
use super::*;
#[traced_test]
#[test]
fn find_doc() {
let dir = tempdir().unwrap();
let s = SearchEngine::build(dir.path()).unwrap();
refresh(
s.0.clone(),
Refresh {
records: vec![
RecipeRecord {
id: 1,
title: "Crab".into(),
summary: Some("One".into()),
},
RecipeRecord {
id: 1,
title: "Cream".into(),
summary: Some("One".into()),
},
RecipeRecord {
id: 1,
title: "Wine".into(),
summary: Some("One".into()),
},
],
},
)
.unwrap();
let v = find(
s.0.clone(),
Find {
query: "Crab".into(),
},
)
.unwrap();
assert_eq!(v.len(), 1);
let v = find(
s.0.clone(),
Find {
query: "Wine".into(),
},
)
.unwrap();
assert_eq!(v.len(), 1);
}
}

View File

@ -1,4 +1,4 @@
use actix::Actor;
use actix::SyncArbiter;
use actix_files::Files;
use actix_identity::IdentityMiddleware;
use actix_session::{storage::RedisSessionStore, SessionMiddleware};
@ -84,9 +84,9 @@ async fn main() {
let redis_store = RedisSessionStore::new(redis_url.as_str()).await.unwrap();
let search = {
let search_addr = crate::actors::search::SearchEngine::build()
.unwrap()
.start();
let search_addr = SyncArbiter::start(4, move || {
crate::actors::search::SearchEngine::build(&std::path::Path::new("./indices")).unwrap()
});
use crate::actors::search::*;
use sea_orm::prelude::*;
let records = entities::prelude::Recipies::find()

View File

@ -1,6 +1,8 @@
use crate::actors::search::{Find, Search};
use crate::types::*;
use crate::{entities, filters};
use actix_identity::Identity;
use actix_web::http::header::{CONTENT_LENGTH, CONTENT_TYPE};
use actix_web::web::{Data, Form, Path};
use actix_web::HttpMessage;
use actix_web::{get, post, HttpRequest, HttpResponse, Responder};
@ -9,11 +11,11 @@ use sea_orm::{prelude::*, QuerySelect};
use serde::Deserialize;
#[derive(Debug, Template, derive_more::Deref)]
#[template(path = "recipe_card.html")]
#[template(path = "recipe_card.jinja")]
struct RecipeCard(entities::recipies::Model);
#[derive(Debug, Template)]
#[template(path = "index.html")]
#[template(path = "index.jinja")]
struct IndexTemplate {
recipies: Vec<RecipeCard>,
count: u64,
@ -22,7 +24,7 @@ struct IndexTemplate {
}
#[derive(Debug, Template)]
#[template(path = "search.html")]
#[template(path = "search.jinja")]
struct SearchTemplate {
recipies: Vec<RecipeCard>,
count: u64,
@ -32,7 +34,7 @@ struct SearchTemplate {
}
#[derive(Debug, Template)]
#[template(path = "top_bar.html")]
#[template(path = "top_bar.jinja")]
struct TopBar<'s> {
session: &'s Option<User>,
}
@ -44,7 +46,7 @@ impl<'s> TopBar<'s> {
}
#[derive(Debug, Template)]
#[template(path = "sign_in/form.html")]
#[template(path = "sign_in/form.jinja")]
struct SignInForm {
not_found: bool,
email: String,
@ -139,14 +141,34 @@ struct SearchQuery {
#[post("/search")]
async fn search_results(
q: actix_web::web::Query<SearchQuery>,
q: actix_web::web::Form<SearchQuery>,
admin: Option<Identity>,
search: Data<Search>,
db: Data<DatabaseConnection>,
) -> SearchTemplate {
let query = q.into_inner().q;
let ids = match search
.send(Find {
query: query.clone(),
})
.await
{
Ok(Ok(ids)) => ids,
_ => vec![],
};
let recipies = entities::prelude::Recipies::find()
.filter(entities::recipies::Column::Id.is_in(ids))
.all(&**db)
.await
.unwrap_or_default()
.into_iter()
.map(RecipeCard)
.collect::<Vec<_>>();
SearchTemplate {
query: "".into(),
recipies: Vec::new(),
count: 0,
query: query.into(),
count: recipies.len() as u64,
recipies,
session: admin.and_then(|a| a.id().ok()),
page: Page::Search,
}
@ -157,7 +179,7 @@ async fn index_html(
db: Data<DatabaseConnection>,
q: actix_web::web::Query<Padding>,
admin: Option<Identity>,
) -> IndexTemplate {
) -> HttpResponse {
let count = (entities::prelude::Recipies::find()
.count(&**db)
.await
@ -171,16 +193,23 @@ async fn index_html(
.await
.unwrap_or_default();
IndexTemplate {
let html = IndexTemplate {
recipies: recipies.into_iter().map(RecipeCard).collect(),
count,
session: admin.and_then(|s| s.id().ok()),
page: Page::Index,
}
.render()
.unwrap();
HttpResponse::Ok()
.append_header((CONTENT_TYPE, "text/html; charset=utf-8"))
.append_header((CONTENT_LENGTH, html.len()))
.body(html)
}
#[derive(Debug, Template)]
#[template(path = "recipies/show.html")]
#[template(path = "recipies/show.jinja")]
struct RecipeDetailTemplate {
recipe: entities::recipies::Model,
tags: Vec<entities::recipe_tags::Model>,

View File

@ -43,7 +43,7 @@
],
daisyui: {
themes: ['corporate', 'dark'], // false: only light + dark | true: all themes | array: specific themes like this ["light", "dark", "cupcake"]
darkTheme: "corporate", // name of one of the included themes for dark mode
darkTheme: "dark", // name of one of the included themes for dark mode
base: true, // applies background color and foreground color for root element by default
styled: true, // include daisyUI colors and design decisions for all components
utils: true, // adds responsive and modifier utility classes

View File

@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="en">
<html lang="en" data-theme="corporate">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
@ -11,7 +11,7 @@
<body class="text-gray-500 font-body">
<div class="flex flex-col md:flex-row gap-4">
{% block navigation %}{% endblock %}
{% include "./nav.html" %}
{% include "./nav.jinja" %}
{% block content %}{% endblock %}
</div>

View File

@ -1,4 +1,4 @@
{% extends "base.html" %}
{% extends "base.jinja" %}
{% block content %}
<main class="md:col-span-3 flex flex-col gap-4 m-4">

View File

@ -1,37 +0,0 @@
{% block navigation %}
<!-- navigation -->
<div class="md:col-span-1 md:flex md:justify-end">
<nav class="text-right flex flex-col gap-8">
<div class="flex justify-between items-center">
<h1 class="font-bold uppercase p-4 border-b border-gray-100">
<a href="/" class="hover:text-gray-600">Cooked</a>
</h1>
<div class="px-4 cursor-pointer md:hidden" id="burger">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</div>
</div>
<ul class="text-sm hidden md:flex flex-col gap-8" id="menu">
<li>
<a href="/" class="px-4 flex justify-end hover:shadow-md {{(page, Page::Index)|page_class}}">
<span>Home</span>
<svg class="w-5 ml-2" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path
d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" />
</svg>
</a>
</li>
<li>
<a href="/search" class="px-4 flex justify-end hover:shadow-md {{(page, Page::Search)|page_class}}">
<span>Search</span>
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" class="w-5 ml-2">
<rect width="24" height="24" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.25007 2.38782C8.54878 2.0992 10.1243 2 12 2C13.8757 2 15.4512 2.0992 16.7499 2.38782C18.06 2.67897 19.1488 3.176 19.9864 4.01358C20.824 4.85116 21.321 5.94002 21.6122 7.25007C21.9008 8.54878 22 10.1243 22 12C22 13.8757 21.9008 15.4512 21.6122 16.7499C21.321 18.06 20.824 19.1488 19.9864 19.9864C19.1488 20.824 18.06 21.321 16.7499 21.6122C15.4512 21.9008 13.8757 22 12 22C10.1243 22 8.54878 21.9008 7.25007 21.6122C5.94002 21.321 4.85116 20.824 4.01358 19.9864C3.176 19.1488 2.67897 18.06 2.38782 16.7499C2.0992 15.4512 2 13.8757 2 12C2 10.1243 2.0992 8.54878 2.38782 7.25007C2.67897 5.94002 3.176 4.85116 4.01358 4.01358C4.85116 3.176 5.94002 2.67897 7.25007 2.38782ZM9 11.5C9 10.1193 10.1193 9 11.5 9C12.8807 9 14 10.1193 14 11.5C14 12.8807 12.8807 14 11.5 14C10.1193 14 9 12.8807 9 11.5ZM11.5 7C9.01472 7 7 9.01472 7 11.5C7 13.9853 9.01472 16 11.5 16C12.3805 16 13.202 15.7471 13.8957 15.31L15.2929 16.7071C15.6834 17.0976 16.3166 17.0976 16.7071 16.7071C17.0976 16.3166 17.0976 15.6834 16.7071 15.2929L15.31 13.8957C15.7471 13.202 16 12.3805 16 11.5C16 9.01472 13.9853 7 11.5 7Z" fill="#323232"/>
</svg>
</a>
</li>
</ul>
</nav>
</div>
{% endblock %}

80
templates/nav.jinja Normal file
View File

@ -0,0 +1,80 @@
{% block navigation %}
<!-- navigation -->
<div class="md:col-span-1 md:flex md:justify-end">
<nav class="text-right flex flex-col gap-8">
<div class="flex justify-between items-center">
<h1 class="font-bold uppercase p-4 border-b border-gray-100">
<a href="/" class="hover:text-gray-600">Cooked</a>
</h1>
<div class="px-4 cursor-pointer md:hidden" id="burger">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</div>
</div>
<ul class="text-sm hidden md:flex flex-col gap-8" id="menu">
<li>
<a href="/" class="px-4 flex justify-end hover:shadow-md {{(page, Page::Index)|page_class}}">
<span>Home</span>
<svg class="w-5 ml-2" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path
d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" />
</svg>
</a>
</li>
<li>
<a href="/search" class="px-4 flex justify-end hover:shadow-md {{(page, Page::Search)|page_class}}">
<span>Search</span>
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" class="w-5 ml-2">
<rect width="24" height="24" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.25007 2.38782C8.54878 2.0992 10.1243 2 12 2C13.8757 2 15.4512 2.0992 16.7499 2.38782C18.06 2.67897 19.1488 3.176 19.9864 4.01358C20.824 4.85116 21.321 5.94002 21.6122 7.25007C21.9008 8.54878 22 10.1243 22 12C22 13.8757 21.9008 15.4512 21.6122 16.7499C21.321 18.06 20.824 19.1488 19.9864 19.9864C19.1488 20.824 18.06 21.321 16.7499 21.6122C15.4512 21.9008 13.8757 22 12 22C10.1243 22 8.54878 21.9008 7.25007 21.6122C5.94002 21.321 4.85116 20.824 4.01358 19.9864C3.176 19.1488 2.67897 18.06 2.38782 16.7499C2.0992 15.4512 2 13.8757 2 12C2 10.1243 2.0992 8.54878 2.38782 7.25007C2.67897 5.94002 3.176 4.85116 4.01358 4.01358C4.85116 3.176 5.94002 2.67897 7.25007 2.38782ZM9 11.5C9 10.1193 10.1193 9 11.5 9C12.8807 9 14 10.1193 14 11.5C14 12.8807 12.8807 14 11.5 14C10.1193 14 9 12.8807 9 11.5ZM11.5 7C9.01472 7 7 9.01472 7 11.5C7 13.9853 9.01472 16 11.5 16C12.3805 16 13.202 15.7471 13.8957 15.31L15.2929 16.7071C15.6834 17.0976 16.3166 17.0976 16.7071 16.7071C17.0976 16.3166 17.0976 15.6834 16.7071 15.2929L15.31 13.8957C15.7471 13.202 16 12.3805 16 11.5C16 9.01472 13.9853 7 11.5 7Z" fill="#323232"/>
</svg>
</a>
</li>
<li>
<a href="/tags" class="px-4 flex justify-end hover:shadow-md {{(page, Page::Search)|page_class}}">
<span>Tags</span>
<svg fill="#000000" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 100 100" enable-background="new 0 0 100 100" xml:space="preserve" class="w-5 ml-2">
<path d="M88.286,67.965c-0.022-0.038-0.052-0.069-0.076-0.105l0.002-0.001L61.307,21.26l-0.015,0.008 c-0.23-0.368-0.558-0.637-0.929-0.801l0.038-0.022l-7.986-4.612l8.871,15.365l20.178,34.949l-0.002,0.001 c0.024,0.036,0.054,0.067,0.076,0.105c0.577,0.999,0.234,2.277-0.765,2.855l0.014,0.025l-29.861,17.24l1.293,2.239 c0.026,0.055,0.044,0.112,0.075,0.166c0.562,0.971,1.785,1.316,2.772,0.801l0.005,0.008l32.465-18.743l-0.014-0.025 C88.52,70.243,88.862,68.964,88.286,67.965z"></path> <path d="M73.88,67.143c0.999-0.577,1.341-1.855,0.765-2.855c-0.022-0.038-0.052-0.069-0.076-0.105l0.002-0.001L47.666,17.583 l-0.015,0.008c-0.23-0.368-0.558-0.637-0.929-0.801l0.038-0.022l-11.01-6.357l0,0.044c-0.554-0.315-1.232-0.367-1.846-0.107 l-0.012-0.021l-0.206,0.119c-0.004,0.003-0.009,0.003-0.013,0.006c-0.004,0.003-0.007,0.006-0.012,0.008l-21.183,12.23 c-0.656,0.378-1.021,1.058-1.037,1.764l-0.007-0.004v12.714l0.021-0.012c-0.047,0.427,0.03,0.872,0.261,1.273 c0.014,0.024,0.032,0.042,0.047,0.065l0,0l26.815,46.446c0.026,0.055,0.044,0.112,0.075,0.166c0.562,0.971,1.785,1.316,2.772,0.801 l0.005,0.008l32.465-18.743L73.88,67.143z M29.545,27.522c-1.849,1.068-4.214,0.435-5.281-1.414 c-1.068-1.849-0.434-4.213,1.415-5.281c1.849-1.068,4.213-0.434,5.281,1.416C32.028,24.091,31.395,26.456,29.545,27.522z"></path>
</svg>
</a>
</li>
<li>
<a href="/ingeredients" class="px-4 flex justify-end hover:shadow-md {{(page, Page::Search)|page_class}}">
<span>Ingeredients</span>
<svg fill="#000000" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 511.999 511.999" xml:space="preserve">
<path d="M203.271,92.915c-0.493,0-0.985,0.01-1.477,0.016l-5.121,13.178l44.17,34.042c7.051,5.434,8.359,15.553,2.926,22.604 c-3.174,4.12-7.95,6.281-12.777,6.281c-3.435,0-6.896-1.095-9.827-3.354l-27.767-21.4v29.012c0,8.901-7.216,16.117-16.117,16.117 s-16.117-7.216-16.117-16.117v-29.013l-27.766,21.4c-2.932,2.26-6.392,3.354-9.827,3.354c-4.827,0-9.601-2.161-12.777-6.281 c-5.432-7.051-4.125-17.169,2.926-22.604l50.168-38.664l3.015-7.762c-5.207-0.534-10.422-0.808-15.608-0.808 c-44.894,0-80.666,11.223-106.325,33.358C15.13,152.017,0,191.558,0,243.79c0,71.125,42.108,132.589,102.697,160.805 l136.181-232.307c14.711-25.094,36.334-43.59,61.373-53.109C275.694,101.899,243.149,92.915,203.271,92.915z"></path> </g> </g> <g> <g> <path d="M204.61,41.202c-8.294-3.222-17.636,0.887-20.859,9.184l-16.845,43.337l34.89-0.793l0.869-2.238l11.127-28.63 C217.018,53.766,212.907,44.427,204.61,41.202z"></path> </g> </g> <g> <g> <path d="M511.878,187.62c-1.107-9.953-9.533-17.312-19.316-17.312c-0.717,0-1.441,0.04-2.171,0.122l-43.375,4.819l37.389-37.39 c7.599-7.599,7.599-19.916,0-27.516c-3.799-3.799-8.779-5.7-13.757-5.7c-4.98,0-9.959,1.9-13.759,5.7l-37.39,37.39l4.821-43.374 c1.187-10.679-6.509-20.3-17.19-21.486c-0.73-0.082-1.455-0.121-2.173-0.121c-9.783,0-18.209,7.36-19.314,17.31l-6.876,61.886 c-12.27-7.242-25.753-11.089-39.284-11.089c-2.802,0-5.606,0.183-8.404,0.515c-0.202,0.026-0.406,0.032-0.608,0.058 c-23.544,3.021-44.693,17.795-58.023,40.534l-29.023,49.51l42.511,42.511c7.597,7.597,7.597,19.918-0.001,27.516 c-7.597,7.597-19.917,7.597-27.516,0c-0.001,0,0,0-0.001,0l-35.33-35.331l-82.676,141.034l-14.885,25.39 c-4.479,7.638-3.235,17.336,3.026,23.596c3.749,3.75,8.732,5.701,13.765,5.701c3.373,0,6.767-0.877,9.832-2.672l235.427-138.006 l-39.587-39.589l-24.758-24.76c-7.599-7.597-7.6-19.919-0.001-27.516c7.597-7.597,19.919-7.597,27.517,0l3.806,3.806 l65.787,65.788c12.695-12.376,20.827-27.905,22.976-44.667c2.117-16.494-1.664-33.3-10.516-48.297l61.888-6.876 C505.369,207.917,513.064,198.298,511.878,187.62z"></path>
</svg>
</a>
</li>
<li>
<div class="px-4 flex justify-end hover:shadow-md">
<span>Theme</span>
<label class="swap swap-rotate w-5 ml-2">
<!-- this hidden checkbox controls the state -->
<input type="checkbox" class="theme-controller" value="dark" />
<!-- sun icon -->
<svg
class="swap-off fill-current w-5 h-5"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24">
<path
d="M5.64,17l-.71.71a1,1,0,0,0,0,1.41,1,1,0,0,0,1.41,0l.71-.71A1,1,0,0,0,5.64,17ZM5,12a1,1,0,0,0-1-1H3a1,1,0,0,0,0,2H4A1,1,0,0,0,5,12Zm7-7a1,1,0,0,0,1-1V3a1,1,0,0,0-2,0V4A1,1,0,0,0,12,5ZM5.64,7.05a1,1,0,0,0,.7.29,1,1,0,0,0,.71-.29,1,1,0,0,0,0-1.41l-.71-.71A1,1,0,0,0,4.93,6.34Zm12,.29a1,1,0,0,0,.7-.29l.71-.71a1,1,0,1,0-1.41-1.41L17,5.64a1,1,0,0,0,0,1.41A1,1,0,0,0,17.66,7.34ZM21,11H20a1,1,0,0,0,0,2h1a1,1,0,0,0,0-2Zm-9,8a1,1,0,0,0-1,1v1a1,1,0,0,0,2,0V20A1,1,0,0,0,12,19ZM18.36,17A1,1,0,0,0,17,18.36l.71.71a1,1,0,0,0,1.41,0,1,1,0,0,0,0-1.41ZM12,6.5A5.5,5.5,0,1,0,17.5,12,5.51,5.51,0,0,0,12,6.5Zm0,9A3.5,3.5,0,1,1,15.5,12,3.5,3.5,0,0,1,12,15.5Z" />
</svg>
<!-- moon icon -->
<svg
class="swap-on fill-current w-5 h-5"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24">
<path
d="M21.64,13a1,1,0,0,0-1.05-.14,8.05,8.05,0,0,1-3.37.73A8.15,8.15,0,0,1,9.08,5.49a8.59,8.59,0,0,1,.25-2A1,1,0,0,0,8,2.36,10.14,10.14,0,1,0,22,14.05,1,1,0,0,0,21.64,13Zm-9.5,6.69A8.14,8.14,0,0,1,7.08,5.22v.27A10.15,10.15,0,0,0,17.22,15.63a9.79,9.79,0,0,0,2.1-.22A8.11,8.11,0,0,1,12.14,19.73Z" />
</svg>
</label>
</div>
</li>
</ul>
</nav>
</div>
{% endblock %}

View File

@ -0,0 +1,38 @@
{% extends "base.jinja" %}
{% block content %}
<main class="md:col-span-3 flex flex-col gap-4 m-4 w-full">
{{ TopBar::new(session)|safe }}
<header class="flex flex-col gap-2">
<h2 class="text-gray-600 text-6xl font-semibold text-center">Create recipe</h2>
</header>
<div class="flex flex-col gap-8">
<div class="flex flex-wrap gap-8 justify-center">
<form action="/search" method="post" class="flex flex-col gap-4">
<label for="title" class="flex gap-4">
<span>Title:</span>
<input id="title" name="title" class="input border border-solid border-gray-200 rounded-lg" type="search" value="{{title}}" />
</label>
<label for="summary" class="flex gap-4">
<span>Summary:</span>
<textarea id="summary" name="summary" class="input border border-solid border-gray-200 rounded-lg" type="search" value="{{summary}}" />
</label>
<label for="ingeredients" class="flex gap-4">
<span>Ingeredients:</span>
<textarea id="ingeredients" name="ingeredients" class="input border border-solid border-gray-200 rounded-lg" type="search" value="{{summary}}" />
</label>
<label for="steps" class="flex gap-4">
<span>Steps:</span>
<textarea id="steps" name="steps" class="input border border-solid border-gray-200 rounded-lg" type="search" value="{{summary}}" />
</label>
<div></div>
<label for="tags" class="flex gap-4">
<span>Tags:</span>
<textarea id="tags" name="tags" class="input border border-solid border-gray-200 rounded-lg" type="search" value="{{summary}}" />
</label>
</form>
</div>
</div>
</main>
{% endblock %}

View File

@ -1,8 +1,9 @@
{% extends "base.html" %}
{% extends "base.jinja" %}
{% block content %}
<main class="m-4 flex flex-col gap-4 w-full">
{% include "../top_bar.html" %}
{% include "../top_bar.jinja" %}
<div class="sm:rounded-3xl w-full flex flex-col gap-4">
<div class="text-center w-full">
<img src={{ recipe.image_url.clone() }} alt="{{recipe.title}}" class="rounded-xl w-40 md:w-3/4 ml-auto mr-auto">
@ -43,7 +44,7 @@
</p>
<ol class="list-decimal list-inside flex flex-col gap-3">
{% for step in steps %}
<li class="font-outfit-regular text-stone-500 text-base text-base">
<li class="font-outfit-regular text-stone-500 text-base">
{{ step.body }}
<div>{{ step.hint.clone().unwrap_or_default() }}</div>
</li>
@ -55,7 +56,7 @@
<p class="text-desktop-heading-m">
Tags
</p>
<div class="flex gap-4 gap-7">
<div class="flex gap-4">
{% for tag in tags %}
<a class="bg-gray-300 dark:bg-gray-700 text-base text-gray-700 dark:text-white py-2 px-4 rounded-full font-bold hover:bg-gray-400 dark:hover:bg-gray-600 flex justify-center items-center">
{{ tag.name }}
@ -68,7 +69,7 @@
<p class="text-desktop-heading-m">
Ingredients
</p>
<div class="flex gap-4 gap-7">
<div class="flex gap-4">
{% for ingeredient in ingeredients %}
<a class="bg-gray-300 dark:bg-gray-700 text-base text-gray-700 dark:text-white py-2 px-4 rounded-full font-bold hover:bg-gray-400 dark:hover:bg-gray-600 flex justify-center items-center">
{{ ingeredient.name }}

View File

@ -0,0 +1,83 @@
{% extends "base.jinja" %}
{% block content %}
<main class="m-4 flex flex-col gap-4 w-full">
{% include "../top_bar.jinja" %}
<div class="sm:rounded-3xl w-full flex flex-col gap-4">
<div class="text-center w-full">
<img src={{ recipe.image_url.clone() }} alt="{{recipe.title}}" class="rounded-xl w-40 md:w-3/4 ml-auto mr-auto">
</div>
<div class="flex flex-col gap-7">
<h1 class="font-young-serif text-desktop-heading-l text-stone-700 sm:text-stone-800 text-center md:text-left">
{{ recipe.title }}
</h1>
<p class="font-outfit-regular text-stone-500 sm:text-base">
{{ recipe.summary.clone().unwrap_or_default() }}
</p>
</div>
<div class="divider"></div>
<div class="flex flex-col gap-7">
<p class="text-desktop-heading-m">
Ingredients
</p>
<ul class="list-disc marker:text-rose-900 list-inside flex flex-col gap-3">
{% for ingeredient in ingeredients %}
<li class="paragraph">
<span class="mr-1">
{{ ingeredient.qty }}
</span>
<span class="mr-4">
{{ ingeredient.unit }}
</span>
<span>
{{ ingeredient.name }}
</span>
</li>
{% endfor %}
</ul>
</div>
<div class="divider"></div>
<div class="marker:text-rose-900 marker:font-outfit-semibold flex flex-col gap-7">
<p class="text-desktop-heading-m">
Instructions
</p>
<ol class="list-decimal list-inside flex flex-col gap-3">
{% for step in steps %}
<li class="font-outfit-regular text-stone-500 text-base text-base">
{{ step.body }}
<div>{{ step.hint.clone().unwrap_or_default() }}</div>
</li>
{% endfor %}
</ol>
</div>
<div class="divider"></div>
<div class="marker:text-rose-900 marker:font-outfit-semibold flex flex-col gap-7">
<p class="text-desktop-heading-m">
Tags
</p>
<div class="flex gap-4 gap-7">
{% for tag in tags %}
<a class="bg-gray-300 dark:bg-gray-700 text-base text-gray-700 dark:text-white py-2 px-4 rounded-full font-bold hover:bg-gray-400 dark:hover:bg-gray-600 flex justify-center items-center">
{{ tag.name }}
</a>
{% endfor %}
</div>
</div>
<div class="divider"></div>
<div class="marker:text-rose-900 marker:font-outfit-semibold flex flex-col gap-7">
<p class="text-desktop-heading-m">
Ingredients
</p>
<div class="flex gap-4 gap-7">
{% for ingeredient in ingeredients %}
<a class="bg-gray-300 dark:bg-gray-700 text-base text-gray-700 dark:text-white py-2 px-4 rounded-full font-bold hover:bg-gray-400 dark:hover:bg-gray-600 flex justify-center items-center">
{{ ingeredient.name }}
</a>
{% endfor %}
</div>
</div>
</div>
</main>
{% endblock %}

View File

@ -1,7 +1,7 @@
{% extends "base.html" %}
{% extends "base.jinja" %}
{% block content %}
<main class="md:col-span-3 flex flex-col gap-4 m-4">
<main class="md:col-span-3 flex flex-col gap-4 m-4 md:w-full">
{{ TopBar::new(session)|safe }}
<header class="flex flex-col gap-2">
@ -10,7 +10,10 @@
<div class="flex flex-col gap-8">
<div class="flex flex-wrap gap-8 justify-center">
<form action="/search" method="post">
<input name="q" class="input input-search" type="search" />
<label for="q" class="flex gap-4">
<span>Find:</span>
<input name="q" class="input input-search border border-solid border-gray-200 rounded-lg" type="search" id="q" value="{{query}}" />
</label>
</form>
</div>
</div>

View File

@ -1,8 +1,8 @@
{% extends "base.html" %}
{% extends "base.jinja" %}
{% block content %}
<main class="m-4 flex flex-col gap-4 w-full">
{% include "../top_bar.html" %}
{% include "../top_bar.jinja" %}
<div class="w-full lg:w-800 lg:rounded-lg lg:border lg:border-0 lg:shadow lg:border-gray-300 m-auto p-12 flex flex-col gap-8">
<h1 class="text-desktop-heading-xl text-center grow w-full">Sign in</h1>

View File

@ -1,8 +0,0 @@
{% match session %}
{% when Some with (user) %}
<div class="text-base">Signed in as <span class="text-bold">{{user}}</span></div>
{% when None %}
<div class="flex justify-center">
<a href="/sign-in" class="btn sm:border-2 hover:shadow-lg transition ease-out duration-300">Login</a>
</div>
{% endmatch %}

15
templates/top_bar.jinja Normal file
View File

@ -0,0 +1,15 @@
{% match session %}
{% when Some with (user) %}
<div class="flex justify-end gap-4 items-center">
<div class="text-base">Signed in as <span class="text-bold">{{user}}</span></div>
<a href="/create">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" class="w-12">
<path d="M12 8V16M16 12H8M12 21C16.9706 21 21 16.9706 21 12C21 7.02944 16.9706 3 12 3C7.02944 3 3 7.02944 3 12C3 16.9706 7.02944 21 12 21Z" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
</a>
</div>
{% when None %}
<div class="flex justify-center">
<a href="/sign-in" class="btn sm:border-2 hover:shadow-lg transition ease-out duration-300">Login</a>
</div>
{% endmatch %}