Initial HerbAPI implementation
Rust/Axum REST API (herbapi-api) with PostgreSQL, S3/Garage, OIDC auth. Dioxus 0.7 WASM frontend (herbapi-ui) with sidebar layout and botanical reference style. 9 SQL migrations covering families, species, cultivars, suppliers, companions, images, users, API tokens.
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::api;
|
||||
use crate::app::Route;
|
||||
use crate::components::planting_calendar::PlantingCalendar;
|
||||
|
||||
#[component]
|
||||
pub fn CultivarList() -> Element {
|
||||
let mut page = use_signal(|| 1i64);
|
||||
let mut search = use_signal(|| String::new());
|
||||
let current_page = *page.read();
|
||||
let search_str = search.read().clone();
|
||||
|
||||
let cultivars = use_resource(move || {
|
||||
let s = search_str.clone();
|
||||
async move {
|
||||
let q = if s.is_empty() { None } else { Some(s.as_str()) };
|
||||
api::list_cultivars(current_page, None, q).await
|
||||
}
|
||||
});
|
||||
|
||||
rsx! {
|
||||
div { class: "page",
|
||||
h1 { "Cultivars" }
|
||||
|
||||
div { class: "search-bar",
|
||||
input {
|
||||
r#type: "text",
|
||||
placeholder: "Search cultivars...",
|
||||
value: "{search}",
|
||||
oninput: move |e| {
|
||||
search.set(e.value());
|
||||
page.set(1);
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
match &*cultivars.read() {
|
||||
None => rsx! { p { "Loading..." } },
|
||||
Some(Err(e)) => rsx! { p { class: "error", "Error: {e}" } },
|
||||
Some(Ok(data)) => rsx! {
|
||||
div { class: "table-wrap",
|
||||
table {
|
||||
thead {
|
||||
tr {
|
||||
th { "Name" }
|
||||
th { "Organic" }
|
||||
th { "Perennial" }
|
||||
th { "Frost Tolerance" }
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
for c in data.data.iter() {
|
||||
tr {
|
||||
td {
|
||||
Link { to: Route::CultivarDetail { slug: c.slug.clone() },
|
||||
strong { "{c.name}" }
|
||||
}
|
||||
}
|
||||
td { if c.is_organic { "Yes" } else { "-" } }
|
||||
td { if c.perennial { "Yes" } else { "Annual" } }
|
||||
td { "{c.frost_tolerance.as_deref().unwrap_or(\"-\")}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if data.total > data.per_page {
|
||||
div { class: "pagination",
|
||||
button {
|
||||
disabled: current_page <= 1,
|
||||
onclick: move |_| page.set(current_page - 1),
|
||||
"Previous"
|
||||
}
|
||||
span { "Page {current_page}" }
|
||||
button {
|
||||
disabled: current_page * data.per_page >= data.total,
|
||||
onclick: move |_| page.set(current_page + 1),
|
||||
"Next"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn CultivarDetail(slug: String) -> Element {
|
||||
let slug_clone = slug.clone();
|
||||
let cultivar = use_resource(move || {
|
||||
let s = slug_clone.clone();
|
||||
async move { api::get_cultivar(&s).await }
|
||||
});
|
||||
|
||||
rsx! {
|
||||
div { class: "page cultivar-detail",
|
||||
match &*cultivar.read() {
|
||||
None => rsx! { p { "Loading..." } },
|
||||
Some(Err(e)) => rsx! { p { class: "error", "Error: {e}" } },
|
||||
Some(Ok(c)) => rsx! {
|
||||
h1 { "{c.name}" }
|
||||
if let Some(ref en) = c.name_en {
|
||||
p { class: "name-common", "{en}" }
|
||||
}
|
||||
if let Some(ref desc) = c.description {
|
||||
div { class: "description", "{desc}" }
|
||||
}
|
||||
|
||||
div { class: "badges",
|
||||
if c.is_organic {
|
||||
span { class: "badge organic", "Organic" }
|
||||
}
|
||||
if c.perennial {
|
||||
span { class: "badge", "Perennial" }
|
||||
}
|
||||
if let Some(ref ft) = c.frost_tolerance {
|
||||
span { class: "badge", "Frost: {ft}" }
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref dtg) = c.days_to_germination {
|
||||
p { "Days to germination: {dtg}" }
|
||||
}
|
||||
if let Some(ref gtd) = c.growing_time_days {
|
||||
p { "Growing time: {gtd} days" }
|
||||
}
|
||||
|
||||
// Planting calendar
|
||||
h2 { "Planting Calendar" }
|
||||
PlantingCalendar {
|
||||
indoor_sowing: c.indoor_sowing_months.clone(),
|
||||
direct_sowing: c.direct_sowing_months.clone(),
|
||||
transplanting: c.transplanting_months.clone(),
|
||||
glasshouse: c.glasshouse_months.clone(),
|
||||
harvesting: c.harvesting_months.clone(),
|
||||
}
|
||||
|
||||
if let Some(ref pg) = c.pollination_group {
|
||||
p { "Pollination group: {pg}" }
|
||||
}
|
||||
if let Some(sf) = c.self_fertile {
|
||||
if sf {
|
||||
p { "Self-fertile: Yes" }
|
||||
} else {
|
||||
p { "Self-fertile: No" }
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::api;
|
||||
use crate::app::Route;
|
||||
|
||||
#[component]
|
||||
pub fn FamilyList() -> Element {
|
||||
let mut page = use_signal(|| 1i64);
|
||||
let mut search = use_signal(|| String::new());
|
||||
let current_page = *page.read();
|
||||
let search_str = search.read().clone();
|
||||
|
||||
let families = use_resource(move || {
|
||||
let s = search_str.clone();
|
||||
async move {
|
||||
let q = if s.is_empty() { None } else { Some(s.as_str()) };
|
||||
api::list_families(current_page, q).await
|
||||
}
|
||||
});
|
||||
|
||||
rsx! {
|
||||
div { class: "page",
|
||||
h1 { "Plant Families" }
|
||||
|
||||
div { class: "search-bar",
|
||||
input {
|
||||
r#type: "text",
|
||||
placeholder: "Search families...",
|
||||
value: "{search}",
|
||||
oninput: move |e| {
|
||||
search.set(e.value());
|
||||
page.set(1);
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
match &*families.read() {
|
||||
None => rsx! { p { "Loading..." } },
|
||||
Some(Err(e)) => rsx! { p { class: "error", "Error: {e}" } },
|
||||
Some(Ok(data)) => rsx! {
|
||||
div { class: "table-wrap",
|
||||
table {
|
||||
thead {
|
||||
tr {
|
||||
th { "Scientific Name" }
|
||||
th { "English" }
|
||||
th { "German" }
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
for f in data.data.iter() {
|
||||
tr {
|
||||
td {
|
||||
Link { to: Route::FamilyDetail { slug: f.slug.clone() },
|
||||
em { "{f.name_scientific}" }
|
||||
}
|
||||
}
|
||||
td { "{f.name_en.as_deref().unwrap_or(\"-\")}" }
|
||||
td { "{f.name_de.as_deref().unwrap_or(\"-\")}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if data.total > data.per_page {
|
||||
div { class: "pagination",
|
||||
button {
|
||||
disabled: current_page <= 1,
|
||||
onclick: move |_| page.set(current_page - 1),
|
||||
"Previous"
|
||||
}
|
||||
span { "Page {current_page} of {(data.total + data.per_page - 1) / data.per_page}" }
|
||||
button {
|
||||
disabled: current_page * data.per_page >= data.total,
|
||||
onclick: move |_| page.set(current_page + 1),
|
||||
"Next"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn FamilyDetail(slug: String) -> Element {
|
||||
let slug_clone = slug.clone();
|
||||
let family = use_resource(move || {
|
||||
let s = slug_clone.clone();
|
||||
async move { api::get_family(&s).await }
|
||||
});
|
||||
|
||||
let slug_for_species = slug.clone();
|
||||
let species = use_resource(move || {
|
||||
let s = slug_for_species.clone();
|
||||
async move { api::list_species(1, Some(&s), None).await }
|
||||
});
|
||||
|
||||
rsx! {
|
||||
div { class: "page",
|
||||
match &*family.read() {
|
||||
None => rsx! { p { "Loading..." } },
|
||||
Some(Err(e)) => rsx! { p { class: "error", "Error: {e}" } },
|
||||
Some(Ok(f)) => rsx! {
|
||||
h1 { em { "{f.name_scientific}" } }
|
||||
if let Some(ref en) = f.name_en {
|
||||
p { class: "name-common", "{en}" }
|
||||
}
|
||||
if let Some(ref de) = f.name_de {
|
||||
p { class: "name-common", "{de}" }
|
||||
}
|
||||
if let Some(ref desc) = f.description {
|
||||
p { "{desc}" }
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
h2 { "Species in this Family" }
|
||||
match &*species.read() {
|
||||
None => rsx! { p { "Loading..." } },
|
||||
Some(Err(e)) => rsx! { p { class: "error", "Error: {e}" } },
|
||||
Some(Ok(data)) => rsx! {
|
||||
div { class: "card-grid",
|
||||
for s in data.data.iter() {
|
||||
div { class: "plant-card",
|
||||
Link { to: Route::SpeciesDetail { slug: s.slug.clone() },
|
||||
em { "{s.name_scientific}" }
|
||||
}
|
||||
if let Some(ref en) = s.name_en {
|
||||
p { class: "card-common", "{en}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::api;
|
||||
use crate::app::Route;
|
||||
use crate::components::plant_card::PlantCard;
|
||||
|
||||
#[component]
|
||||
pub fn Home() -> Element {
|
||||
let mut search_query = use_signal(|| String::new());
|
||||
let species = use_resource(|| async { api::list_species(1, None, None).await });
|
||||
|
||||
rsx! {
|
||||
div { class: "page home-page",
|
||||
h1 { "HerbAPI" }
|
||||
p { class: "subtitle", "Trilingual plant reference database" }
|
||||
|
||||
div { class: "search-bar",
|
||||
input {
|
||||
r#type: "text",
|
||||
placeholder: "Search plants...",
|
||||
value: "{search_query}",
|
||||
oninput: move |e| search_query.set(e.value()),
|
||||
onkeydown: move |e| {
|
||||
if e.key() == Key::Enter {
|
||||
let nav = navigator();
|
||||
nav.push(Route::SearchPage {});
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
h2 { "Recent Species" }
|
||||
match &*species.read() {
|
||||
None => rsx! { p { "Loading..." } },
|
||||
Some(Err(e)) => rsx! { p { class: "error", "Error: {e}" } },
|
||||
Some(Ok(data)) => rsx! {
|
||||
div { class: "card-grid",
|
||||
for s in data.data.iter().take(12) {
|
||||
PlantCard {
|
||||
key: "{s.id}",
|
||||
slug: s.slug.clone(),
|
||||
name: s.name_scientific.clone(),
|
||||
name_common: s.name_en.clone(),
|
||||
entity_type: "species".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
pub mod cultivars;
|
||||
pub mod families;
|
||||
pub mod home;
|
||||
pub mod search;
|
||||
pub mod species;
|
||||
pub mod suppliers;
|
||||
@@ -0,0 +1,77 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::api;
|
||||
use crate::app::Route;
|
||||
|
||||
#[component]
|
||||
pub fn SearchPage() -> Element {
|
||||
let mut query = use_signal(|| String::new());
|
||||
let mut results = use_signal(|| None::<Result<Vec<crate::types::SearchResult>, String>>);
|
||||
|
||||
let trigger_search = move || {
|
||||
let q = query.read().clone();
|
||||
if !q.is_empty() {
|
||||
spawn(async move {
|
||||
let res = api::search(&q, 50).await;
|
||||
results.set(Some(res));
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
rsx! {
|
||||
div { class: "page search-page",
|
||||
h1 { "Search" }
|
||||
|
||||
div { class: "search-bar",
|
||||
input {
|
||||
r#type: "text",
|
||||
placeholder: "Search plants, families, cultivars...",
|
||||
value: "{query}",
|
||||
oninput: move |e| query.set(e.value()),
|
||||
onkeydown: move |e| {
|
||||
if e.key() == Key::Enter {
|
||||
trigger_search();
|
||||
}
|
||||
},
|
||||
}
|
||||
button { onclick: move |_| trigger_search(), "Search" }
|
||||
}
|
||||
|
||||
match &*results.read() {
|
||||
None => rsx! {},
|
||||
Some(Err(e)) => rsx! { p { class: "error", "Error: {e}" } },
|
||||
Some(Ok(data)) => rsx! {
|
||||
p { class: "result-count", "{data.len()} results" }
|
||||
div { class: "search-results",
|
||||
for r in data.iter() {
|
||||
div { class: "search-result",
|
||||
span { class: "result-type badge", "{r.entity_type}" }
|
||||
match r.entity_type.as_str() {
|
||||
"family" => rsx! {
|
||||
Link { to: Route::FamilyDetail { slug: r.slug.clone() },
|
||||
em { "{r.name}" }
|
||||
}
|
||||
},
|
||||
"species" => rsx! {
|
||||
Link { to: Route::SpeciesDetail { slug: r.slug.clone() },
|
||||
em { "{r.name}" }
|
||||
}
|
||||
},
|
||||
"cultivar" => rsx! {
|
||||
Link { to: Route::CultivarDetail { slug: r.slug.clone() },
|
||||
strong { "{r.name}" }
|
||||
}
|
||||
},
|
||||
_ => rsx! { span { "{r.name}" } },
|
||||
}
|
||||
if let Some(ref desc) = r.description {
|
||||
p { class: "result-desc", "{desc}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::api;
|
||||
use crate::app::Route;
|
||||
use crate::components::plant_card::PlantCard;
|
||||
|
||||
#[component]
|
||||
pub fn SpeciesList() -> Element {
|
||||
let mut page = use_signal(|| 1i64);
|
||||
let mut search = use_signal(|| String::new());
|
||||
let current_page = *page.read();
|
||||
let search_str = search.read().clone();
|
||||
|
||||
let species = use_resource(move || {
|
||||
let s = search_str.clone();
|
||||
async move {
|
||||
let q = if s.is_empty() { None } else { Some(s.as_str()) };
|
||||
api::list_species(current_page, None, q).await
|
||||
}
|
||||
});
|
||||
|
||||
rsx! {
|
||||
div { class: "page",
|
||||
h1 { "Species" }
|
||||
|
||||
div { class: "search-bar",
|
||||
input {
|
||||
r#type: "text",
|
||||
placeholder: "Search species...",
|
||||
value: "{search}",
|
||||
oninput: move |e| {
|
||||
search.set(e.value());
|
||||
page.set(1);
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
match &*species.read() {
|
||||
None => rsx! { p { "Loading..." } },
|
||||
Some(Err(e)) => rsx! { p { class: "error", "Error: {e}" } },
|
||||
Some(Ok(data)) => rsx! {
|
||||
div { class: "card-grid",
|
||||
for s in data.data.iter() {
|
||||
PlantCard {
|
||||
key: "{s.id}",
|
||||
slug: s.slug.clone(),
|
||||
name: s.name_scientific.clone(),
|
||||
name_common: s.name_en.clone(),
|
||||
entity_type: "species".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
if data.total > data.per_page {
|
||||
div { class: "pagination",
|
||||
button {
|
||||
disabled: current_page <= 1,
|
||||
onclick: move |_| page.set(current_page - 1),
|
||||
"Previous"
|
||||
}
|
||||
span { "Page {current_page} of {(data.total + data.per_page - 1) / data.per_page}" }
|
||||
button {
|
||||
disabled: current_page * data.per_page >= data.total,
|
||||
onclick: move |_| page.set(current_page + 1),
|
||||
"Next"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn SpeciesDetail(slug: String) -> Element {
|
||||
let slug_clone = slug.clone();
|
||||
let species = use_resource(move || {
|
||||
let s = slug_clone.clone();
|
||||
async move { api::get_species(&s).await }
|
||||
});
|
||||
|
||||
rsx! {
|
||||
div { class: "page species-detail",
|
||||
match &*species.read() {
|
||||
None => rsx! { p { "Loading..." } },
|
||||
Some(Err(e)) => rsx! { p { class: "error", "Error: {e}" } },
|
||||
Some(Ok(s)) => {
|
||||
let species_slug = s.slug.clone();
|
||||
rsx! {
|
||||
h1 { em { "{s.name_scientific}" } }
|
||||
if let Some(ref en) = s.name_en {
|
||||
p { class: "name-common", "{en}" }
|
||||
}
|
||||
if let Some(ref de) = s.name_de {
|
||||
p { class: "name-common", "{de}" }
|
||||
}
|
||||
if let Some(ref desc) = s.description {
|
||||
div { class: "description", "{desc}" }
|
||||
}
|
||||
|
||||
// Info grid
|
||||
div { class: "info-grid",
|
||||
if let Some(ref layer) = s.plant_layer {
|
||||
div { class: "info-item",
|
||||
span { class: "info-label", "Layer" }
|
||||
span { class: "info-value", "{layer}" }
|
||||
}
|
||||
}
|
||||
if let Some(ref dt) = s.drought_tolerance {
|
||||
div { class: "info-item",
|
||||
span { class: "info-label", "Drought Tolerance" }
|
||||
span { class: "info-value", "{dt}" }
|
||||
}
|
||||
}
|
||||
if let Some(ref hz) = s.hardiness_zone_usda {
|
||||
div { class: "info-item",
|
||||
span { class: "info-label", "USDA Zone" }
|
||||
span { class: "info-value", "{hz}" }
|
||||
}
|
||||
}
|
||||
if let Some(rating) = s.edibility_rating {
|
||||
div { class: "info-item",
|
||||
span { class: "info-label", "Edibility" }
|
||||
span { class: "info-value", "{rating}/5" }
|
||||
}
|
||||
}
|
||||
if let Some(nf) = s.nitrogen_fixer {
|
||||
if nf {
|
||||
div { class: "info-item badge",
|
||||
"Nitrogen Fixer"
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(da) = s.dynamic_accumulator {
|
||||
if da {
|
||||
div { class: "info-item badge",
|
||||
"Dynamic Accumulator"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cultivars for this species
|
||||
h2 { "Cultivars" }
|
||||
CultivarListForSpecies { species_slug: species_slug }
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn CultivarListForSpecies(species_slug: String) -> Element {
|
||||
let slug = species_slug.clone();
|
||||
let cultivars = use_resource(move || {
|
||||
let s = slug.clone();
|
||||
async move { api::list_cultivars(1, Some(&s), None).await }
|
||||
});
|
||||
|
||||
rsx! {
|
||||
match &*cultivars.read() {
|
||||
None => rsx! { p { "Loading..." } },
|
||||
Some(Err(e)) => rsx! { p { class: "error", "Error: {e}" } },
|
||||
Some(Ok(data)) => {
|
||||
if data.data.is_empty() {
|
||||
rsx! { p { class: "empty", "No cultivars yet." } }
|
||||
} else {
|
||||
rsx! {
|
||||
div { class: "card-grid",
|
||||
for c in data.data.iter() {
|
||||
div { class: "plant-card",
|
||||
Link { to: Route::CultivarDetail { slug: c.slug.clone() },
|
||||
strong { "{c.name}" }
|
||||
}
|
||||
if let Some(ref en) = c.name_en {
|
||||
p { class: "card-common", "{en}" }
|
||||
}
|
||||
if c.is_organic {
|
||||
span { class: "badge organic", "Organic" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::api;
|
||||
use crate::app::Route;
|
||||
|
||||
#[component]
|
||||
pub fn SupplierList() -> Element {
|
||||
let suppliers = use_resource(|| async { api::list_suppliers().await });
|
||||
|
||||
rsx! {
|
||||
div { class: "page",
|
||||
h1 { "Suppliers" }
|
||||
|
||||
match &*suppliers.read() {
|
||||
None => rsx! { p { "Loading..." } },
|
||||
Some(Err(e)) => rsx! { p { class: "error", "Error: {e}" } },
|
||||
Some(Ok(data)) => rsx! {
|
||||
div { class: "table-wrap",
|
||||
table {
|
||||
thead {
|
||||
tr {
|
||||
th { "Name" }
|
||||
th { "Country" }
|
||||
th { "Organic" }
|
||||
th { "Demeter" }
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
for s in data.iter() {
|
||||
tr {
|
||||
td {
|
||||
Link { to: Route::SupplierDetail { slug: s.slug.clone() },
|
||||
strong { "{s.name}" }
|
||||
}
|
||||
}
|
||||
td { "{s.country.as_deref().unwrap_or(\"-\")}" }
|
||||
td { if s.is_organic { "Yes" } else { "-" } }
|
||||
td { if s.is_demeter { "Yes" } else { "-" } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn SupplierDetail(slug: String) -> Element {
|
||||
let slug_clone = slug.clone();
|
||||
let supplier = use_resource(move || {
|
||||
let s = slug_clone.clone();
|
||||
async move { api::get_supplier(&s).await }
|
||||
});
|
||||
|
||||
rsx! {
|
||||
div { class: "page",
|
||||
match &*supplier.read() {
|
||||
None => rsx! { p { "Loading..." } },
|
||||
Some(Err(e)) => rsx! { p { class: "error", "Error: {e}" } },
|
||||
Some(Ok(s)) => rsx! {
|
||||
h1 { "{s.name}" }
|
||||
if let Some(ref url) = s.url {
|
||||
p { a { href: "{url}", target: "_blank", "{url}" } }
|
||||
}
|
||||
div { class: "badges",
|
||||
if s.is_organic {
|
||||
span { class: "badge organic", "Organic" }
|
||||
}
|
||||
if s.is_demeter {
|
||||
span { class: "badge demeter", "Demeter" }
|
||||
}
|
||||
if let Some(ref country) = s.country {
|
||||
span { class: "badge", "{country}" }
|
||||
}
|
||||
}
|
||||
if let Some(ref notes) = s.notes {
|
||||
p { "{notes}" }
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user