eshop
This commit is contained in:
807
src/controllers/catalog.rs
Normal file
807
src/controllers/catalog.rs
Normal file
@@ -0,0 +1,807 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::{
|
||||
controllers::{
|
||||
admin,
|
||||
auth as auth_controller,
|
||||
i18n::current_lang,
|
||||
media::{detect_image_extension, store_upload, IMAGE_MAX_BYTES, IMAGE_STORAGE_DIR},
|
||||
},
|
||||
models::{
|
||||
_entities::{categories, product_images, products},
|
||||
users,
|
||||
},
|
||||
};
|
||||
use axum::extract::{DefaultBodyLimit, Multipart};
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
use loco_rs::prelude::*;
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, EntityTrait, ModelTrait, PaginatorTrait, QueryFilter,
|
||||
QueryOrder, QuerySelect, Set,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn slugify(value: &str) -> String {
|
||||
let mut slug = String::new();
|
||||
let mut last_was_dash = false;
|
||||
for ch in value.chars().flat_map(char::to_lowercase) {
|
||||
if ch.is_ascii_alphanumeric() {
|
||||
slug.push(ch);
|
||||
last_was_dash = false;
|
||||
} else if !last_was_dash && !slug.is_empty() {
|
||||
slug.push('-');
|
||||
last_was_dash = true;
|
||||
}
|
||||
}
|
||||
slug.trim_matches('-').to_string()
|
||||
}
|
||||
|
||||
fn normalize_empty(value: Option<String>) -> Option<String> {
|
||||
value.and_then(|value| {
|
||||
let value = value.trim().to_string();
|
||||
if value.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(value)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse a price typed in major units ("12", "12.5", "12.34") into integer
|
||||
/// minor units (cents). Rejects negatives and more than two decimals.
|
||||
fn parse_price_to_cents(value: &str) -> Result<i64> {
|
||||
let value = value.trim().replace(',', ".");
|
||||
let invalid = || Error::BadRequest("invalid price".to_string());
|
||||
let (whole, frac) = match value.split_once('.') {
|
||||
Some((w, f)) => (w, f),
|
||||
None => (value.as_str(), ""),
|
||||
};
|
||||
if frac.len() > 2 || !whole.chars().all(|c| c.is_ascii_digit()) || whole.is_empty() {
|
||||
return Err(invalid());
|
||||
}
|
||||
if !frac.chars().all(|c| c.is_ascii_digit()) {
|
||||
return Err(invalid());
|
||||
}
|
||||
let whole: i64 = whole.parse().map_err(|_| invalid())?;
|
||||
let cents: i64 = match frac.len() {
|
||||
0 => 0,
|
||||
1 => frac.parse::<i64>().map_err(|_| invalid())? * 10,
|
||||
_ => frac.parse().map_err(|_| invalid())?,
|
||||
};
|
||||
Ok(whole * 100 + cents)
|
||||
}
|
||||
|
||||
/// Render minor units as a human price string, e.g. `1234` -> `"12.34"`.
|
||||
pub(crate) fn format_price(cents: i64) -> String {
|
||||
format!("{}.{:02}", cents / 100, (cents % 100).abs())
|
||||
}
|
||||
|
||||
async fn logged_in_admin(ctx: &AppContext, jar: &CookieJar) -> bool {
|
||||
let Some(cookie) = jar.get(auth_controller::AUTH_COOKIE) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(jwt_config) = ctx.config.get_jwt_config() else {
|
||||
return false;
|
||||
};
|
||||
let Ok(claims) = loco_rs::auth::jwt::JWT::new(&jwt_config.secret).validate(cookie.value())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let Ok(user) = users::Model::find_by_pid(&ctx.db, &claims.claims.pid).await else {
|
||||
return false;
|
||||
};
|
||||
admin::is_admin(ctx, &user)
|
||||
}
|
||||
|
||||
async fn unique_slug<F, Fut>(base: &str, mut exists: F) -> Result<String>
|
||||
where
|
||||
F: FnMut(String) -> Fut,
|
||||
Fut: std::future::Future<Output = Result<bool>>,
|
||||
{
|
||||
let base = if base.is_empty() {
|
||||
"item".to_string()
|
||||
} else {
|
||||
base.to_string()
|
||||
};
|
||||
let mut slug = base.clone();
|
||||
let mut suffix = 2;
|
||||
while exists(slug.clone()).await? {
|
||||
slug = format!("{base}-{suffix}");
|
||||
suffix += 1;
|
||||
}
|
||||
Ok(slug)
|
||||
}
|
||||
|
||||
/// Collected multipart form: text fields keyed by name, plus the raw bytes of
|
||||
/// an `image` file part if one was uploaded (an empty file input is ignored).
|
||||
struct MultipartForm {
|
||||
fields: HashMap<String, String>,
|
||||
image: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl MultipartForm {
|
||||
fn text(&self, key: &str) -> Option<String> {
|
||||
normalize_empty(self.fields.get(key).cloned())
|
||||
}
|
||||
|
||||
fn checked(&self, key: &str) -> bool {
|
||||
matches!(self.fields.get(key).map(String::as_str), Some("on" | "true" | "1"))
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_multipart_form(mut multipart: Multipart) -> Result<MultipartForm> {
|
||||
let mut fields = HashMap::new();
|
||||
let mut image = None;
|
||||
|
||||
while let Some(mut field) = multipart
|
||||
.next_field()
|
||||
.await
|
||||
.map_err(|err| Error::BadRequest(format!("invalid multipart data: {err}")))?
|
||||
{
|
||||
let name = field.name().unwrap_or("").to_string();
|
||||
if name == "image" {
|
||||
let mut data = Vec::new();
|
||||
while let Some(chunk) = field
|
||||
.chunk()
|
||||
.await
|
||||
.map_err(|err| Error::BadRequest(format!("invalid multipart chunk: {err}")))?
|
||||
{
|
||||
data.extend_from_slice(&chunk);
|
||||
if data.len() > IMAGE_MAX_BYTES {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"image is larger than {} MB",
|
||||
IMAGE_MAX_BYTES / 1024 / 1024
|
||||
)));
|
||||
}
|
||||
}
|
||||
if !data.is_empty() {
|
||||
image = Some(data);
|
||||
}
|
||||
} else {
|
||||
let value = field
|
||||
.text()
|
||||
.await
|
||||
.map_err(|err| Error::BadRequest(format!("invalid multipart field: {err}")))?;
|
||||
fields.insert(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(MultipartForm { fields, image })
|
||||
}
|
||||
|
||||
async fn store_image(ctx: &AppContext, data: Vec<u8>) -> Result<String> {
|
||||
let extension = detect_image_extension(&data)?;
|
||||
store_upload(ctx, IMAGE_STORAGE_DIR, extension, data).await
|
||||
}
|
||||
|
||||
async fn category_by_id(ctx: &AppContext, id: i32) -> Result<categories::Model> {
|
||||
categories::Entity::find_by_id(id)
|
||||
.one(&ctx.db)
|
||||
.await?
|
||||
.ok_or_else(|| Error::NotFound)
|
||||
}
|
||||
|
||||
async fn product_by_id(ctx: &AppContext, id: i32) -> Result<products::Model> {
|
||||
products::Entity::find_by_id(id)
|
||||
.one(&ctx.db)
|
||||
.await?
|
||||
.ok_or_else(|| Error::NotFound)
|
||||
}
|
||||
|
||||
async fn first_image(ctx: &AppContext, product_id: i32) -> Result<Option<String>> {
|
||||
Ok(product_images::Entity::find()
|
||||
.filter(product_images::Column::ProductId.eq(product_id))
|
||||
.order_by_asc(product_images::Column::Position)
|
||||
.one(&ctx.db)
|
||||
.await?
|
||||
.map(|image| image.image_id))
|
||||
}
|
||||
|
||||
/// Shape a product for templates: the model fields plus a formatted price,
|
||||
/// its (optional) primary image filename and category name.
|
||||
fn product_json(
|
||||
product: &products::Model,
|
||||
image: Option<String>,
|
||||
category_name: Option<String>,
|
||||
) -> serde_json::Value {
|
||||
json!({
|
||||
"id": product.id,
|
||||
"name": product.name,
|
||||
"slug": product.slug,
|
||||
"description": product.description,
|
||||
"price": format_price(product.price_cents),
|
||||
"currency": product.currency,
|
||||
"sku": product.sku,
|
||||
"stock": product.stock,
|
||||
"published": product.published,
|
||||
"image": image,
|
||||
"category_name": category_name,
|
||||
})
|
||||
}
|
||||
|
||||
/// Latest published products (with primary image), shaped for templates.
|
||||
/// Reused by the home page landing grid.
|
||||
pub(crate) async fn featured_products(
|
||||
ctx: &AppContext,
|
||||
limit: u64,
|
||||
) -> Result<Vec<serde_json::Value>> {
|
||||
let list = products::Entity::find()
|
||||
.filter(products::Column::Published.eq(true))
|
||||
.order_by_desc(products::Column::PublishedAt)
|
||||
.limit(limit)
|
||||
.all(&ctx.db)
|
||||
.await?;
|
||||
let mut rows = Vec::new();
|
||||
for product in list {
|
||||
let image = first_image(ctx, product.id).await?;
|
||||
rows.push(product_json(&product, image, None));
|
||||
}
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Admin: products
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[debug_handler]
|
||||
async fn admin_products(
|
||||
auth: auth::JWT,
|
||||
jar: CookieJar,
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
admin::current_admin(auth, &ctx).await?;
|
||||
let list = products::Entity::find()
|
||||
.order_by_desc(products::Column::CreatedAt)
|
||||
.all(&ctx.db)
|
||||
.await?;
|
||||
let mut rows = Vec::new();
|
||||
for product in list {
|
||||
let image = first_image(&ctx, product.id).await?;
|
||||
let category_name = match product.category_id {
|
||||
Some(id) => category_by_id(&ctx, id).await.ok().map(|c| c.name),
|
||||
None => None,
|
||||
};
|
||||
rows.push(product_json(&product, image, category_name));
|
||||
}
|
||||
format::view(
|
||||
&v,
|
||||
"admin/catalog/products.html",
|
||||
json!({ "products": rows, "lang": current_lang(&jar) }),
|
||||
)
|
||||
}
|
||||
|
||||
async fn product_form_context(ctx: &AppContext, jar: &CookieJar) -> Result<serde_json::Value> {
|
||||
let categories = categories::Entity::find()
|
||||
.order_by_asc(categories::Column::Position)
|
||||
.order_by_asc(categories::Column::Name)
|
||||
.all(&ctx.db)
|
||||
.await?;
|
||||
Ok(json!({ "categories": categories, "lang": current_lang(jar) }))
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn admin_product_new(
|
||||
auth: auth::JWT,
|
||||
jar: CookieJar,
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
admin::current_admin(auth, &ctx).await?;
|
||||
let mut context = product_form_context(&ctx, &jar).await?;
|
||||
context["product"] = serde_json::Value::Null;
|
||||
format::view(&v, "admin/catalog/product_form.html", context)
|
||||
}
|
||||
|
||||
async fn parse_product_fields(
|
||||
ctx: &AppContext,
|
||||
form: &MultipartForm,
|
||||
current_id: Option<i32>,
|
||||
) -> Result<(String, String, Option<String>, i64, String, Option<String>, i32, Option<i32>, bool)> {
|
||||
let name = form
|
||||
.text("name")
|
||||
.ok_or_else(|| Error::BadRequest("product name is required".to_string()))?;
|
||||
let price_cents = parse_price_to_cents(
|
||||
form.text("price")
|
||||
.ok_or_else(|| Error::BadRequest("price is required".to_string()))?
|
||||
.as_str(),
|
||||
)?;
|
||||
let currency = form.text("currency").unwrap_or_else(|| "EUR".to_string());
|
||||
let description = form.text("description");
|
||||
let sku = form.text("sku");
|
||||
let stock = form
|
||||
.text("stock")
|
||||
.and_then(|s| s.parse::<i32>().ok())
|
||||
.filter(|n| *n >= 0)
|
||||
.unwrap_or(0);
|
||||
let category_id = form.text("category_id").and_then(|s| s.parse::<i32>().ok());
|
||||
let published = form.checked("published");
|
||||
|
||||
let desired = form
|
||||
.text("slug")
|
||||
.map(|s| slugify(&s))
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| slugify(&name));
|
||||
let slug = unique_slug(&desired, |candidate| {
|
||||
let ctx = ctx.clone();
|
||||
async move {
|
||||
let mut query =
|
||||
products::Entity::find().filter(products::Column::Slug.eq(candidate));
|
||||
if let Some(id) = current_id {
|
||||
query = query.filter(products::Column::Id.ne(id));
|
||||
}
|
||||
Ok(query.count(&ctx.db).await? > 0)
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok((
|
||||
name, slug, description, price_cents, currency, sku, stock, category_id, published,
|
||||
))
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn admin_product_create(
|
||||
auth: auth::JWT,
|
||||
State(ctx): State<AppContext>,
|
||||
multipart: Multipart,
|
||||
) -> Result<Response> {
|
||||
admin::current_admin(auth, &ctx).await?;
|
||||
let form = read_multipart_form(multipart).await?;
|
||||
let (name, slug, description, price_cents, currency, sku, stock, category_id, published) =
|
||||
parse_product_fields(&ctx, &form, None).await?;
|
||||
|
||||
let product = products::ActiveModel {
|
||||
name: Set(name),
|
||||
slug: Set(slug),
|
||||
description: Set(description),
|
||||
price_cents: Set(price_cents),
|
||||
currency: Set(currency),
|
||||
sku: Set(sku),
|
||||
stock: Set(stock),
|
||||
view_count: Set(0),
|
||||
published: Set(published),
|
||||
published_at: Set(published.then(|| chrono::Utc::now().into())),
|
||||
category_id: Set(category_id),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(&ctx.db)
|
||||
.await?;
|
||||
|
||||
if let Some(data) = form.image {
|
||||
let filename = store_image(&ctx, data).await?;
|
||||
product_images::ActiveModel {
|
||||
product_id: Set(product.id),
|
||||
image_id: Set(filename),
|
||||
position: Set(0),
|
||||
alt: Set(None),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(&ctx.db)
|
||||
.await?;
|
||||
}
|
||||
|
||||
format::redirect("/admin/catalog/products")
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn admin_product_edit(
|
||||
auth: auth::JWT,
|
||||
jar: CookieJar,
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
Path(id): Path<i32>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
admin::current_admin(auth, &ctx).await?;
|
||||
let product = product_by_id(&ctx, id).await?;
|
||||
let image = first_image(&ctx, id).await?;
|
||||
let mut context = product_form_context(&ctx, &jar).await?;
|
||||
context["product"] = json!({
|
||||
"id": product.id,
|
||||
"name": product.name,
|
||||
"slug": product.slug,
|
||||
"description": product.description,
|
||||
"price": format_price(product.price_cents),
|
||||
"currency": product.currency,
|
||||
"sku": product.sku,
|
||||
"stock": product.stock,
|
||||
"published": product.published,
|
||||
"category_id": product.category_id,
|
||||
"image": image,
|
||||
});
|
||||
format::view(&v, "admin/catalog/product_form.html", context)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn admin_product_update(
|
||||
auth: auth::JWT,
|
||||
Path(id): Path<i32>,
|
||||
State(ctx): State<AppContext>,
|
||||
multipart: Multipart,
|
||||
) -> Result<Response> {
|
||||
admin::current_admin(auth, &ctx).await?;
|
||||
let existing = product_by_id(&ctx, id).await?;
|
||||
let was_published = existing.published;
|
||||
let form = read_multipart_form(multipart).await?;
|
||||
let (name, slug, description, price_cents, currency, sku, stock, category_id, published) =
|
||||
parse_product_fields(&ctx, &form, Some(id)).await?;
|
||||
|
||||
let mut product = existing.into_active_model();
|
||||
product.name = Set(name);
|
||||
product.slug = Set(slug);
|
||||
product.description = Set(description);
|
||||
product.price_cents = Set(price_cents);
|
||||
product.currency = Set(currency);
|
||||
product.sku = Set(sku);
|
||||
product.stock = Set(stock);
|
||||
product.category_id = Set(category_id);
|
||||
product.published = Set(published);
|
||||
if published && !was_published {
|
||||
product.published_at = Set(Some(chrono::Utc::now().into()));
|
||||
} else if !published {
|
||||
product.published_at = Set(None);
|
||||
}
|
||||
product.update(&ctx.db).await?;
|
||||
|
||||
if let Some(data) = form.image {
|
||||
let filename = store_image(&ctx, data).await?;
|
||||
let next_position = product_images::Entity::find()
|
||||
.filter(product_images::Column::ProductId.eq(id))
|
||||
.count(&ctx.db)
|
||||
.await? as i32;
|
||||
product_images::ActiveModel {
|
||||
product_id: Set(id),
|
||||
image_id: Set(filename),
|
||||
position: Set(next_position),
|
||||
alt: Set(None),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(&ctx.db)
|
||||
.await?;
|
||||
}
|
||||
|
||||
format::redirect("/admin/catalog/products")
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn admin_product_delete(
|
||||
auth: auth::JWT,
|
||||
Path(id): Path<i32>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
admin::current_admin(auth, &ctx).await?;
|
||||
product_by_id(&ctx, id).await?.delete(&ctx.db).await?;
|
||||
format::redirect("/admin/catalog/products")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Admin: categories
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[debug_handler]
|
||||
async fn admin_categories(
|
||||
auth: auth::JWT,
|
||||
jar: CookieJar,
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
admin::current_admin(auth, &ctx).await?;
|
||||
let list = categories::Entity::find()
|
||||
.order_by_asc(categories::Column::Position)
|
||||
.order_by_asc(categories::Column::Name)
|
||||
.all(&ctx.db)
|
||||
.await?;
|
||||
let mut rows = Vec::new();
|
||||
for category in list {
|
||||
let product_count = products::Entity::find()
|
||||
.filter(products::Column::CategoryId.eq(category.id))
|
||||
.count(&ctx.db)
|
||||
.await?;
|
||||
rows.push(json!({ "category": category, "product_count": product_count }));
|
||||
}
|
||||
format::view(
|
||||
&v,
|
||||
"admin/catalog/categories.html",
|
||||
json!({ "categories": rows, "lang": current_lang(&jar) }),
|
||||
)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn admin_category_new(
|
||||
auth: auth::JWT,
|
||||
jar: CookieJar,
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
admin::current_admin(auth, &ctx).await?;
|
||||
format::view(
|
||||
&v,
|
||||
"admin/catalog/category_form.html",
|
||||
json!({ "category": serde_json::Value::Null, "lang": current_lang(&jar) }),
|
||||
)
|
||||
}
|
||||
|
||||
async fn parse_category_fields(
|
||||
ctx: &AppContext,
|
||||
form: &MultipartForm,
|
||||
current_id: Option<i32>,
|
||||
) -> Result<(String, String, Option<String>, i32, bool)> {
|
||||
let name = form
|
||||
.text("name")
|
||||
.ok_or_else(|| Error::BadRequest("category name is required".to_string()))?;
|
||||
let description = form.text("description");
|
||||
let position = form
|
||||
.text("position")
|
||||
.and_then(|s| s.parse::<i32>().ok())
|
||||
.unwrap_or(0);
|
||||
let published = form.checked("published");
|
||||
|
||||
let desired = form
|
||||
.text("slug")
|
||||
.map(|s| slugify(&s))
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| slugify(&name));
|
||||
let slug = unique_slug(&desired, |candidate| {
|
||||
let ctx = ctx.clone();
|
||||
async move {
|
||||
let mut query =
|
||||
categories::Entity::find().filter(categories::Column::Slug.eq(candidate));
|
||||
if let Some(id) = current_id {
|
||||
query = query.filter(categories::Column::Id.ne(id));
|
||||
}
|
||||
Ok(query.count(&ctx.db).await? > 0)
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok((name, slug, description, position, published))
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn admin_category_create(
|
||||
auth: auth::JWT,
|
||||
State(ctx): State<AppContext>,
|
||||
multipart: Multipart,
|
||||
) -> Result<Response> {
|
||||
admin::current_admin(auth, &ctx).await?;
|
||||
let form = read_multipart_form(multipart).await?;
|
||||
let (name, slug, description, position, published) =
|
||||
parse_category_fields(&ctx, &form, None).await?;
|
||||
let image_id = match form.image {
|
||||
Some(data) => Some(store_image(&ctx, data).await?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
categories::ActiveModel {
|
||||
name: Set(name),
|
||||
slug: Set(slug),
|
||||
description: Set(description),
|
||||
image_id: Set(image_id),
|
||||
position: Set(position),
|
||||
published: Set(published),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(&ctx.db)
|
||||
.await?;
|
||||
|
||||
format::redirect("/admin/catalog/categories")
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn admin_category_edit(
|
||||
auth: auth::JWT,
|
||||
jar: CookieJar,
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
Path(id): Path<i32>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
admin::current_admin(auth, &ctx).await?;
|
||||
format::view(
|
||||
&v,
|
||||
"admin/catalog/category_form.html",
|
||||
json!({ "category": category_by_id(&ctx, id).await?, "lang": current_lang(&jar) }),
|
||||
)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn admin_category_update(
|
||||
auth: auth::JWT,
|
||||
Path(id): Path<i32>,
|
||||
State(ctx): State<AppContext>,
|
||||
multipart: Multipart,
|
||||
) -> Result<Response> {
|
||||
admin::current_admin(auth, &ctx).await?;
|
||||
let existing = category_by_id(&ctx, id).await?;
|
||||
let form = read_multipart_form(multipart).await?;
|
||||
let (name, slug, description, position, published) =
|
||||
parse_category_fields(&ctx, &form, Some(id)).await?;
|
||||
|
||||
let mut category = existing.into_active_model();
|
||||
category.name = Set(name);
|
||||
category.slug = Set(slug);
|
||||
category.description = Set(description);
|
||||
category.position = Set(position);
|
||||
category.published = Set(published);
|
||||
if let Some(data) = form.image {
|
||||
category.image_id = Set(Some(store_image(&ctx, data).await?));
|
||||
}
|
||||
category.update(&ctx.db).await?;
|
||||
|
||||
format::redirect("/admin/catalog/categories")
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn admin_category_delete(
|
||||
auth: auth::JWT,
|
||||
Path(id): Path<i32>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
admin::current_admin(auth, &ctx).await?;
|
||||
category_by_id(&ctx, id).await?.delete(&ctx.db).await?;
|
||||
format::redirect("/admin/catalog/categories")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public storefront
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[debug_handler]
|
||||
async fn shop_index(
|
||||
jar: CookieJar,
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
let list = products::Entity::find()
|
||||
.filter(products::Column::Published.eq(true))
|
||||
.order_by_desc(products::Column::PublishedAt)
|
||||
.all(&ctx.db)
|
||||
.await?;
|
||||
let mut rows = Vec::new();
|
||||
for product in list {
|
||||
let image = first_image(&ctx, product.id).await?;
|
||||
rows.push(product_json(&product, image, None));
|
||||
}
|
||||
let categories = categories::Entity::find()
|
||||
.filter(categories::Column::Published.eq(true))
|
||||
.order_by_asc(categories::Column::Position)
|
||||
.all(&ctx.db)
|
||||
.await?;
|
||||
|
||||
format::view(
|
||||
&v,
|
||||
"shop/index.html",
|
||||
json!({
|
||||
"products": rows,
|
||||
"categories": categories,
|
||||
"logged_in_admin": logged_in_admin(&ctx, &jar).await,
|
||||
"lang": current_lang(&jar),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn shop_show(
|
||||
jar: CookieJar,
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
Path(slug): Path<String>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
let product = products::Entity::find()
|
||||
.filter(products::Column::Slug.eq(slug))
|
||||
.filter(products::Column::Published.eq(true))
|
||||
.one(&ctx.db)
|
||||
.await?
|
||||
.ok_or_else(|| Error::NotFound)?;
|
||||
|
||||
let mut active = product.clone().into_active_model();
|
||||
active.view_count = Set(product.view_count + 1);
|
||||
let product = active.update(&ctx.db).await?;
|
||||
|
||||
let images = product_images::Entity::find()
|
||||
.filter(product_images::Column::ProductId.eq(product.id))
|
||||
.order_by_asc(product_images::Column::Position)
|
||||
.all(&ctx.db)
|
||||
.await?;
|
||||
let category = match product.category_id {
|
||||
Some(id) => category_by_id(&ctx, id).await.ok(),
|
||||
None => None,
|
||||
};
|
||||
|
||||
format::view(
|
||||
&v,
|
||||
"shop/show.html",
|
||||
json!({
|
||||
"product": product_json(&product, None, category.as_ref().map(|c| c.name.clone())),
|
||||
"images": images.iter().map(|i| i.image_id.clone()).collect::<Vec<_>>(),
|
||||
"category": category,
|
||||
"logged_in_admin": logged_in_admin(&ctx, &jar).await,
|
||||
"lang": current_lang(&jar),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn shop_category(
|
||||
jar: CookieJar,
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
Path(slug): Path<String>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
let category = categories::Entity::find()
|
||||
.filter(categories::Column::Slug.eq(slug))
|
||||
.filter(categories::Column::Published.eq(true))
|
||||
.one(&ctx.db)
|
||||
.await?
|
||||
.ok_or_else(|| Error::NotFound)?;
|
||||
|
||||
let list = products::Entity::find()
|
||||
.filter(products::Column::CategoryId.eq(category.id))
|
||||
.filter(products::Column::Published.eq(true))
|
||||
.order_by_desc(products::Column::PublishedAt)
|
||||
.all(&ctx.db)
|
||||
.await?;
|
||||
let mut rows = Vec::new();
|
||||
for product in list {
|
||||
let image = first_image(&ctx, product.id).await?;
|
||||
rows.push(product_json(&product, image, None));
|
||||
}
|
||||
|
||||
format::view(
|
||||
&v,
|
||||
"shop/category.html",
|
||||
json!({
|
||||
"category": category,
|
||||
"products": rows,
|
||||
"logged_in_admin": logged_in_admin(&ctx, &jar).await,
|
||||
"lang": current_lang(&jar),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn routes() -> Routes {
|
||||
let image_limit = DefaultBodyLimit::max(IMAGE_MAX_BYTES + 1024 * 1024);
|
||||
Routes::new()
|
||||
// public storefront
|
||||
.add("/shop", get(shop_index))
|
||||
.add("/shop/{slug}", get(shop_show))
|
||||
.add("/category/{slug}", get(shop_category))
|
||||
// admin products
|
||||
.add("/admin/catalog/products", get(admin_products))
|
||||
.add("/admin/catalog/products/new", get(admin_product_new))
|
||||
.add(
|
||||
"/admin/catalog/products",
|
||||
post(admin_product_create).layer(image_limit.clone()),
|
||||
)
|
||||
.add("/admin/catalog/products/{id}/edit", get(admin_product_edit))
|
||||
.add(
|
||||
"/admin/catalog/products/{id}",
|
||||
post(admin_product_update).layer(image_limit.clone()),
|
||||
)
|
||||
.add(
|
||||
"/admin/catalog/products/{id}/delete",
|
||||
post(admin_product_delete),
|
||||
)
|
||||
// admin categories
|
||||
.add("/admin/catalog/categories", get(admin_categories))
|
||||
.add("/admin/catalog/categories/new", get(admin_category_new))
|
||||
.add(
|
||||
"/admin/catalog/categories",
|
||||
post(admin_category_create).layer(image_limit.clone()),
|
||||
)
|
||||
.add(
|
||||
"/admin/catalog/categories/{id}/edit",
|
||||
get(admin_category_edit),
|
||||
)
|
||||
.add(
|
||||
"/admin/catalog/categories/{id}",
|
||||
post(admin_category_update).layer(image_limit),
|
||||
)
|
||||
.add(
|
||||
"/admin/catalog/categories/{id}/delete",
|
||||
post(admin_category_delete),
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user