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:
Generated
+3814
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "herbapi-ui"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
dioxus = { version = "0.7", features = ["router", "web"] }
|
||||
gloo-net = { version = "0.6", features = ["http", "json"] }
|
||||
gloo-storage = "0.3"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
uuid = { version = "1", features = ["serde", "v4", "js"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
web-sys = { version = "0.3", features = ["Document", "Element", "RequestCredentials", "Window", "HtmlInputElement", "FormData"] }
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
console_error_panic_hook = "0.1"
|
||||
tracing = "0.1"
|
||||
getrandom = { version = "0.3", features = ["wasm_js"] }
|
||||
@@ -0,0 +1,15 @@
|
||||
[application]
|
||||
name = "herbapi-ui"
|
||||
default_platform = "web"
|
||||
|
||||
[web.app]
|
||||
title = "HerbAPI — Plant Database"
|
||||
|
||||
[web.watcher]
|
||||
watch_path = ["src", "assets"]
|
||||
|
||||
[web.resource.dev]
|
||||
style = ["/assets/herbapi.css"]
|
||||
|
||||
[web.resource.release]
|
||||
style = ["/assets/herbapi.css"]
|
||||
@@ -0,0 +1,557 @@
|
||||
/* HerbAPI — Plant Database Stylesheet */
|
||||
|
||||
:root {
|
||||
--bg: #faf9f6;
|
||||
--bg-card: #ffffff;
|
||||
--bg-sidebar: #2d3a2e;
|
||||
--text: #1a1a1a;
|
||||
--text-muted: #666;
|
||||
--text-sidebar: #e0e0e0;
|
||||
--accent: #4a7c59;
|
||||
--accent-hover: #3d6b4a;
|
||||
--accent-light: #e8f0ea;
|
||||
--border: #ddd;
|
||||
--radius: 6px;
|
||||
--shadow: 0 1px 3px rgba(0,0,0,0.08);
|
||||
|
||||
/* Planting calendar colors */
|
||||
--cal-indoor: #7b68ee;
|
||||
--cal-direct: #3cb371;
|
||||
--cal-transplant: #ff8c00;
|
||||
--cal-glass: #87ceeb;
|
||||
--cal-harvest: #dc143c;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
em {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Layout */
|
||||
|
||||
.app-layout {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 220px;
|
||||
background: var(--bg-sidebar);
|
||||
color: var(--text-sidebar);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1.5rem 0;
|
||||
flex-shrink: 0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0 1.25rem 1.5rem;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.1);
|
||||
}
|
||||
|
||||
.brand-icon {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.brand-text-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.brand-text {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.brand-sub {
|
||||
font-size: 0.7rem;
|
||||
opacity: 0.6;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1.25rem;
|
||||
color: var(--text-sidebar);
|
||||
text-decoration: none;
|
||||
transition: background 0.15s;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
background: rgba(255,255,255,0.08);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.sidebar-user {
|
||||
margin-top: auto;
|
||||
padding: 1rem 1.25rem;
|
||||
border-top: 1px solid rgba(255,255,255,0.1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.logout-link,
|
||||
.login-link {
|
||||
color: rgba(255,255,255,0.6);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.logout-link:hover,
|
||||
.login-link:hover {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Content */
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
padding: 2rem 3rem;
|
||||
max-width: 1200px;
|
||||
}
|
||||
|
||||
.page {
|
||||
animation: fadeIn 0.2s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.8rem;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.3rem;
|
||||
margin: 2rem 0 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.name-common {
|
||||
color: var(--text-muted);
|
||||
font-size: 1rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 1rem 0;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
/* Search */
|
||||
|
||||
.search-bar {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.search-bar input {
|
||||
flex: 1;
|
||||
padding: 0.6rem 1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.95rem;
|
||||
background: var(--bg-card);
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.search-bar input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.search-bar button {
|
||||
padding: 0.6rem 1.2rem;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.search-bar button:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.plant-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1rem;
|
||||
box-shadow: var(--shadow);
|
||||
transition: box-shadow 0.15s, transform 0.15s;
|
||||
}
|
||||
|
||||
.plant-card:hover {
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.12);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.card-scientific {
|
||||
font-size: 1rem;
|
||||
display: block;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.card-common {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
thead {
|
||||
background: var(--accent-light);
|
||||
}
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
padding: 0.75rem 1rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 0.6rem 1rem;
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
tr:hover td {
|
||||
background: var(--accent-light);
|
||||
}
|
||||
|
||||
/* Badges */
|
||||
|
||||
.badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 3px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
background: var(--accent-light);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.badge.organic {
|
||||
background: #e8f5e9;
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
.badge.demeter {
|
||||
background: #fff3e0;
|
||||
color: #e65100;
|
||||
}
|
||||
|
||||
/* Info grid (species detail) */
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 0.75rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.6rem 0.8rem;
|
||||
}
|
||||
|
||||
.info-item.badge {
|
||||
background: var(--accent-light);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
display: block;
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.15rem;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Planting Calendar */
|
||||
|
||||
.planting-calendar {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1rem;
|
||||
overflow-x: auto;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.cal-row {
|
||||
display: grid;
|
||||
grid-template-columns: 120px repeat(12, 1fr);
|
||||
gap: 2px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.cal-header {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cal-label {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.3rem 0.5rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cal-cell {
|
||||
height: 28px;
|
||||
border-radius: 3px;
|
||||
background: #f0f0f0;
|
||||
text-align: center;
|
||||
line-height: 28px;
|
||||
}
|
||||
|
||||
.cal-cell.active.cal-indoor {
|
||||
background: var(--cal-indoor);
|
||||
}
|
||||
|
||||
.cal-cell.active.cal-direct {
|
||||
background: var(--cal-direct);
|
||||
}
|
||||
|
||||
.cal-cell.active.cal-transplant {
|
||||
background: var(--cal-transplant);
|
||||
}
|
||||
|
||||
.cal-cell.active.cal-glass {
|
||||
background: var(--cal-glass);
|
||||
}
|
||||
|
||||
.cal-cell.active.cal-harvest {
|
||||
background: var(--cal-harvest);
|
||||
}
|
||||
|
||||
/* Pagination */
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.pagination button {
|
||||
padding: 0.4rem 1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg-card);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.pagination button:hover:not(:disabled) {
|
||||
background: var(--accent-light);
|
||||
}
|
||||
|
||||
.pagination button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.pagination span {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Search results */
|
||||
|
||||
.result-count {
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.search-results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.search-result {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.75rem 1rem;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.result-type {
|
||||
font-size: 0.65rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.result-desc {
|
||||
width: 100%;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* 404 */
|
||||
|
||||
.not-found {
|
||||
text-align: center;
|
||||
padding: 4rem 0;
|
||||
}
|
||||
|
||||
.not-found h1 {
|
||||
font-size: 4rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Empty states */
|
||||
|
||||
.empty {
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #c62828;
|
||||
background: #ffebee;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
width: 60px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-brand,
|
||||
.sidebar-user,
|
||||
.brand-text-group {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.card-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
use gloo_net::http::Request;
|
||||
use serde::de::DeserializeOwned;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::types::*;
|
||||
|
||||
const API_BASE: &str = "/api/v1";
|
||||
|
||||
async fn get_json<T: DeserializeOwned>(path: &str) -> Result<T, String> {
|
||||
let resp = Request::get(path)
|
||||
.credentials(web_sys::RequestCredentials::Include)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Request failed: {e}"))?;
|
||||
|
||||
if !resp.ok() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("HTTP {}: {}", resp.status(), body));
|
||||
}
|
||||
|
||||
resp.json().await.map_err(|e| format!("JSON parse error: {e}"))
|
||||
}
|
||||
|
||||
async fn post_json<B: serde::Serialize, T: DeserializeOwned>(
|
||||
path: &str,
|
||||
body: &B,
|
||||
) -> Result<T, String> {
|
||||
let resp = Request::post(path)
|
||||
.credentials(web_sys::RequestCredentials::Include)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(serde_json::to_string(body).map_err(|e| e.to_string())?)
|
||||
.map_err(|e| format!("Request build error: {e}"))?
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Request failed: {e}"))?;
|
||||
|
||||
if !resp.ok() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("HTTP {}: {}", resp.status(), body));
|
||||
}
|
||||
|
||||
resp.json().await.map_err(|e| format!("JSON parse error: {e}"))
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
async fn put_json<B: serde::Serialize, T: DeserializeOwned>(
|
||||
path: &str,
|
||||
body: &B,
|
||||
) -> Result<T, String> {
|
||||
let resp = Request::put(path)
|
||||
.credentials(web_sys::RequestCredentials::Include)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(serde_json::to_string(body).map_err(|e| e.to_string())?)
|
||||
.map_err(|e| format!("Request build error: {e}"))?
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Request failed: {e}"))?;
|
||||
|
||||
if !resp.ok() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("HTTP {}: {}", resp.status(), body));
|
||||
}
|
||||
|
||||
resp.json().await.map_err(|e| format!("JSON parse error: {e}"))
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
async fn delete_req(path: &str) -> Result<(), String> {
|
||||
let resp = Request::delete(path)
|
||||
.credentials(web_sys::RequestCredentials::Include)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Request failed: {e}"))?;
|
||||
|
||||
if !resp.ok() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("HTTP {}: {}", resp.status(), body));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- Auth ---
|
||||
pub async fn get_current_user() -> Result<MeResponse, String> {
|
||||
get_json("/auth/me").await
|
||||
}
|
||||
|
||||
// --- Families ---
|
||||
pub async fn list_families(page: i64, search: Option<&str>) -> Result<PaginatedResponse<Family>, String> {
|
||||
let mut url = format!("{API_BASE}/families?page={page}&per_page=25");
|
||||
if let Some(q) = search {
|
||||
url.push_str(&format!("&search={q}"));
|
||||
}
|
||||
get_json(&url).await
|
||||
}
|
||||
|
||||
pub async fn get_family(slug: &str) -> Result<Family, String> {
|
||||
get_json(&format!("{API_BASE}/families/{slug}")).await
|
||||
}
|
||||
|
||||
// --- Species ---
|
||||
pub async fn list_species(page: i64, family: Option<&str>, search: Option<&str>) -> Result<PaginatedResponse<Species>, String> {
|
||||
let mut url = format!("{API_BASE}/species?page={page}&per_page=25");
|
||||
if let Some(f) = family {
|
||||
url.push_str(&format!("&family={f}"));
|
||||
}
|
||||
if let Some(q) = search {
|
||||
url.push_str(&format!("&search={q}"));
|
||||
}
|
||||
get_json(&url).await
|
||||
}
|
||||
|
||||
pub async fn get_species(slug: &str) -> Result<Species, String> {
|
||||
get_json(&format!("{API_BASE}/species/{slug}")).await
|
||||
}
|
||||
|
||||
// --- Cultivars ---
|
||||
pub async fn list_cultivars(page: i64, species: Option<&str>, search: Option<&str>) -> Result<PaginatedResponse<Cultivar>, String> {
|
||||
let mut url = format!("{API_BASE}/cultivars?page={page}&per_page=25");
|
||||
if let Some(s) = species {
|
||||
url.push_str(&format!("&species={s}"));
|
||||
}
|
||||
if let Some(q) = search {
|
||||
url.push_str(&format!("&search={q}"));
|
||||
}
|
||||
get_json(&url).await
|
||||
}
|
||||
|
||||
pub async fn get_cultivar(slug: &str) -> Result<Cultivar, String> {
|
||||
get_json(&format!("{API_BASE}/cultivars/{slug}")).await
|
||||
}
|
||||
|
||||
// --- Suppliers ---
|
||||
pub async fn list_suppliers() -> Result<Vec<Supplier>, String> {
|
||||
get_json(&format!("{API_BASE}/suppliers")).await
|
||||
}
|
||||
|
||||
pub async fn get_supplier(slug: &str) -> Result<Supplier, String> {
|
||||
get_json(&format!("{API_BASE}/suppliers/{slug}")).await
|
||||
}
|
||||
|
||||
pub async fn get_cultivar_suppliers(id: Uuid) -> Result<Vec<CultivarSupplier>, String> {
|
||||
get_json(&format!("{API_BASE}/cultivars/{id}/suppliers")).await
|
||||
}
|
||||
|
||||
// --- Companions ---
|
||||
pub async fn get_companions(species_id: Uuid) -> Result<Vec<CompanionRelationship>, String> {
|
||||
get_json(&format!("{API_BASE}/species/{species_id}/companions")).await
|
||||
}
|
||||
|
||||
// --- Images ---
|
||||
pub async fn get_images(entity_type: &str, entity_id: Uuid) -> Result<Vec<Image>, String> {
|
||||
get_json(&format!("{API_BASE}/images/{entity_type}/{entity_id}")).await
|
||||
}
|
||||
|
||||
// --- Search ---
|
||||
pub async fn search(query: &str, limit: i64) -> Result<Vec<SearchResult>, String> {
|
||||
get_json(&format!("{API_BASE}/search?q={query}&limit={limit}")).await
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::api;
|
||||
use crate::types::MeResponse;
|
||||
|
||||
#[derive(Routable, Clone, Debug, PartialEq)]
|
||||
#[rustfmt::skip]
|
||||
pub enum Route {
|
||||
#[layout(Layout)]
|
||||
#[route("/")]
|
||||
Home {},
|
||||
#[route("/families")]
|
||||
FamilyList {},
|
||||
#[route("/families/:slug")]
|
||||
FamilyDetail { slug: String },
|
||||
#[route("/species")]
|
||||
SpeciesList {},
|
||||
#[route("/species/:slug")]
|
||||
SpeciesDetail { slug: String },
|
||||
#[route("/cultivars")]
|
||||
CultivarList {},
|
||||
#[route("/cultivars/:slug")]
|
||||
CultivarDetail { slug: String },
|
||||
#[route("/suppliers")]
|
||||
SupplierList {},
|
||||
#[route("/suppliers/:slug")]
|
||||
SupplierDetail { slug: String },
|
||||
#[route("/search")]
|
||||
SearchPage {},
|
||||
#[end_layout]
|
||||
#[route("/:..segments")]
|
||||
NotFound { segments: Vec<String> },
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn App() -> Element {
|
||||
rsx! {
|
||||
Router::<Route> {}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn Layout() -> Element {
|
||||
// Try to get current user (may be None for public access)
|
||||
let auth = use_resource(|| async { api::get_current_user().await.ok() });
|
||||
let user: Option<MeResponse> = auth.read().as_ref().and_then(|r| r.clone());
|
||||
|
||||
rsx! {
|
||||
div { class: "app-layout",
|
||||
nav { class: "sidebar",
|
||||
div { class: "sidebar-brand",
|
||||
span { class: "brand-icon", "\u{1F33F}" }
|
||||
div { class: "brand-text-group",
|
||||
span { class: "brand-text", "HerbAPI" }
|
||||
span { class: "brand-sub", "Plant Database" }
|
||||
}
|
||||
}
|
||||
div { class: "sidebar-nav",
|
||||
NavLink { to: Route::Home {}, label: "Home" }
|
||||
NavLink { to: Route::FamilyList {}, label: "Families" }
|
||||
NavLink { to: Route::SpeciesList {}, label: "Species" }
|
||||
NavLink { to: Route::CultivarList {}, label: "Cultivars" }
|
||||
NavLink { to: Route::SupplierList {}, label: "Suppliers" }
|
||||
NavLink { to: Route::SearchPage {}, label: "Search" }
|
||||
}
|
||||
div { class: "sidebar-user",
|
||||
if let Some(ref u) = user {
|
||||
span { class: "user-name", "{u.nickname.as_deref().or(u.name.as_deref()).unwrap_or(&u.email)}" }
|
||||
a { class: "logout-link", href: "/auth/oidc/logout", "Logout" }
|
||||
} else {
|
||||
a { class: "login-link", href: "/auth/oidc/login", "Login" }
|
||||
}
|
||||
}
|
||||
}
|
||||
main { class: "content",
|
||||
Outlet::<Route> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn NavLink(to: Route, label: &'static str) -> Element {
|
||||
rsx! {
|
||||
Link { to: to, class: "nav-link",
|
||||
span { class: "nav-label", "{label}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn NotFound(segments: Vec<String>) -> Element {
|
||||
rsx! {
|
||||
div { class: "not-found",
|
||||
h1 { "404" }
|
||||
p { "Page not found: /{segments.join(\"/\")}" }
|
||||
Link { to: Route::Home {}, "Back to Home" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export page components for the router
|
||||
pub use crate::pages::cultivars::{CultivarDetail, CultivarList};
|
||||
pub use crate::pages::families::{FamilyDetail, FamilyList};
|
||||
pub use crate::pages::home::Home;
|
||||
pub use crate::pages::search::SearchPage;
|
||||
pub use crate::pages::species::{SpeciesDetail, SpeciesList};
|
||||
pub use crate::pages::suppliers::{SupplierDetail, SupplierList};
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod plant_card;
|
||||
pub mod planting_calendar;
|
||||
@@ -0,0 +1,24 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::app::Route;
|
||||
|
||||
#[component]
|
||||
pub fn PlantCard(slug: String, name: String, name_common: Option<String>, entity_type: String) -> Element {
|
||||
let route = match entity_type.as_str() {
|
||||
"species" => Route::SpeciesDetail { slug: slug.clone() },
|
||||
"cultivar" => Route::CultivarDetail { slug: slug.clone() },
|
||||
"family" => Route::FamilyDetail { slug: slug.clone() },
|
||||
_ => Route::Home {},
|
||||
};
|
||||
|
||||
rsx! {
|
||||
div { class: "plant-card",
|
||||
Link { to: route,
|
||||
em { class: "card-scientific", "{name}" }
|
||||
}
|
||||
if let Some(ref common) = name_common {
|
||||
p { class: "card-common", "{common}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
const MONTH_LABELS: [&str; 12] = ["J", "F", "M", "A", "M", "J", "J", "A", "S", "O", "N", "D"];
|
||||
|
||||
#[component]
|
||||
pub fn PlantingCalendar(
|
||||
indoor_sowing: Option<Vec<i32>>,
|
||||
direct_sowing: Option<Vec<i32>>,
|
||||
transplanting: Option<Vec<i32>>,
|
||||
glasshouse: Option<Vec<i32>>,
|
||||
harvesting: Option<Vec<i32>>,
|
||||
) -> Element {
|
||||
let rows: Vec<(&str, &str, &Option<Vec<i32>>)> = vec![
|
||||
("Indoor Sowing", "cal-indoor", &indoor_sowing),
|
||||
("Direct Sowing", "cal-direct", &direct_sowing),
|
||||
("Transplanting", "cal-transplant", &transplanting),
|
||||
("Glasshouse", "cal-glass", &glasshouse),
|
||||
("Harvesting", "cal-harvest", &harvesting),
|
||||
];
|
||||
|
||||
// Check if any data exists
|
||||
let has_data = rows.iter().any(|(_, _, months)| months.is_some());
|
||||
if !has_data {
|
||||
return rsx! { p { class: "empty", "No planting calendar data." } };
|
||||
}
|
||||
|
||||
rsx! {
|
||||
div { class: "planting-calendar",
|
||||
// Header row with month labels
|
||||
div { class: "cal-row cal-header",
|
||||
div { class: "cal-label" }
|
||||
for label in MONTH_LABELS.iter() {
|
||||
div { class: "cal-cell", "{label}" }
|
||||
}
|
||||
}
|
||||
// Data rows
|
||||
for (name, class, months) in rows.iter() {
|
||||
if months.is_some() {
|
||||
div { class: "cal-row",
|
||||
div { class: "cal-label", "{name}" }
|
||||
for month in 1..=12i32 {
|
||||
{
|
||||
let active = months.as_ref()
|
||||
.map(|m| m.contains(&month))
|
||||
.unwrap_or(false);
|
||||
rsx! {
|
||||
div {
|
||||
class: if active { format!("cal-cell {class} active") } else { "cal-cell".to_string() },
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
mod api;
|
||||
mod app;
|
||||
mod components;
|
||||
mod pages;
|
||||
mod types;
|
||||
|
||||
fn main() {
|
||||
console_error_panic_hook::set_once();
|
||||
dioxus::launch(app::App);
|
||||
}
|
||||
@@ -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}" }
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct PaginatedResponse<T> {
|
||||
pub data: Vec<T>,
|
||||
pub total: i64,
|
||||
pub page: i64,
|
||||
pub per_page: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct Family {
|
||||
pub id: Uuid,
|
||||
pub slug: String,
|
||||
pub name_scientific: String,
|
||||
pub name_en: Option<String>,
|
||||
pub name_de: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct Species {
|
||||
pub id: Uuid,
|
||||
pub slug: String,
|
||||
pub family_id: Uuid,
|
||||
pub name_scientific: String,
|
||||
pub name_en: Option<String>,
|
||||
pub name_de: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub soil_moisture: Option<String>,
|
||||
pub ph_min: Option<f64>,
|
||||
pub ph_max: Option<f64>,
|
||||
pub hardiness_zone_usda: Option<String>,
|
||||
pub hardiness_zone_at: Option<String>,
|
||||
pub drought_tolerance: Option<String>,
|
||||
pub edibility_rating: Option<i16>,
|
||||
pub food_uses: Option<String>,
|
||||
pub medicinal_uses: Option<String>,
|
||||
pub other_uses: Option<String>,
|
||||
pub native_range: Option<String>,
|
||||
pub plant_layer: Option<String>,
|
||||
pub nitrogen_fixer: Option<bool>,
|
||||
pub dynamic_accumulator: Option<bool>,
|
||||
pub wikidata_qid: Option<String>,
|
||||
pub primary_image_key: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct Cultivar {
|
||||
pub id: Uuid,
|
||||
pub slug: String,
|
||||
pub species_id: Uuid,
|
||||
pub name: String,
|
||||
pub name_en: Option<String>,
|
||||
pub name_de: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub is_organic: bool,
|
||||
pub perennial: bool,
|
||||
pub growing_time_days: Option<i32>,
|
||||
pub days_to_germination: Option<i32>,
|
||||
pub frost_tolerance: Option<String>,
|
||||
pub indoor_sowing_months: Option<Vec<i32>>,
|
||||
pub direct_sowing_months: Option<Vec<i32>>,
|
||||
pub transplanting_months: Option<Vec<i32>>,
|
||||
pub glasshouse_months: Option<Vec<i32>>,
|
||||
pub harvesting_months: Option<Vec<i32>>,
|
||||
pub pollination_group: Option<String>,
|
||||
pub self_fertile: Option<bool>,
|
||||
pub primary_image_key: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct Supplier {
|
||||
pub id: Uuid,
|
||||
pub slug: String,
|
||||
pub name: String,
|
||||
pub url: Option<String>,
|
||||
pub is_organic: bool,
|
||||
pub is_demeter: bool,
|
||||
pub country: Option<String>,
|
||||
pub notes: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct CultivarSupplier {
|
||||
pub id: Uuid,
|
||||
pub cultivar_id: Uuid,
|
||||
pub supplier_id: Uuid,
|
||||
pub article_number: Option<String>,
|
||||
pub product_url: Option<String>,
|
||||
pub price_eur: Option<f64>,
|
||||
pub pack_size: Option<f64>,
|
||||
pub pack_unit: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct CompanionRelationship {
|
||||
pub id: Uuid,
|
||||
pub species_a_id: Uuid,
|
||||
pub species_b_id: Uuid,
|
||||
pub relationship: String,
|
||||
pub mechanism: Option<String>,
|
||||
pub source_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct Image {
|
||||
pub id: Uuid,
|
||||
pub entity_type: String,
|
||||
pub entity_id: Uuid,
|
||||
pub s3_key: String,
|
||||
pub caption: Option<String>,
|
||||
pub is_primary: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct SearchResult {
|
||||
pub entity_type: String,
|
||||
pub id: Uuid,
|
||||
pub slug: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub rank: f32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct MeResponse {
|
||||
pub id: Uuid,
|
||||
pub email: String,
|
||||
pub name: Option<String>,
|
||||
pub nickname: Option<String>,
|
||||
pub admin: bool,
|
||||
}
|
||||
Reference in New Issue
Block a user