More images, translations, product page
4
Cargo.lock
generated
@ -629,6 +629,7 @@ dependencies = [
|
|||||||
"actix-web-httpauth",
|
"actix-web-httpauth",
|
||||||
"actix-web-opentelemetry",
|
"actix-web-opentelemetry",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
"bytes",
|
||||||
"cart_manager",
|
"cart_manager",
|
||||||
"chrono",
|
"chrono",
|
||||||
"config",
|
"config",
|
||||||
@ -1133,6 +1134,7 @@ dependencies = [
|
|||||||
"actix 0.13.0",
|
"actix 0.13.0",
|
||||||
"actix-rt",
|
"actix-rt",
|
||||||
"actix-web",
|
"actix-web",
|
||||||
|
"bytes",
|
||||||
"config",
|
"config",
|
||||||
"database_manager",
|
"database_manager",
|
||||||
"dotenv",
|
"dotenv",
|
||||||
@ -1396,6 +1398,8 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"actix 0.13.0",
|
"actix 0.13.0",
|
||||||
"actix-rt",
|
"actix-rt",
|
||||||
|
"actix-web",
|
||||||
|
"bytes",
|
||||||
"chrono",
|
"chrono",
|
||||||
"config",
|
"config",
|
||||||
"log",
|
"log",
|
||||||
|
@ -7,7 +7,10 @@ edition = "2021"
|
|||||||
model = { path = "../../shared/model" }
|
model = { path = "../../shared/model" }
|
||||||
config = { path = "../../shared/config" }
|
config = { path = "../../shared/config" }
|
||||||
|
|
||||||
|
bytes = { version = "1.1.0" }
|
||||||
|
|
||||||
actix = { version = "0.13", features = [] }
|
actix = { version = "0.13", features = [] }
|
||||||
|
actix-web = { version = "4.0.1" }
|
||||||
actix-rt = { version = "2.7", features = [] }
|
actix-rt = { version = "2.7", features = [] }
|
||||||
|
|
||||||
thiserror = { version = "1.0.31" }
|
thiserror = { version = "1.0.31" }
|
||||||
|
@ -157,7 +157,7 @@ pub struct WriteResult {
|
|||||||
#[rtype(result = "Result<WriteResult>")]
|
#[rtype(result = "Result<WriteResult>")]
|
||||||
pub struct WriteFile {
|
pub struct WriteFile {
|
||||||
pub file_name: String,
|
pub file_name: String,
|
||||||
pub stream: tokio::sync::mpsc::UnboundedReceiver<u8>,
|
pub stream: tokio::sync::mpsc::UnboundedReceiver<actix_web::web::Bytes>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fs_async_handler!(WriteFile, write_file, WriteResult);
|
fs_async_handler!(WriteFile, write_file, WriteResult);
|
||||||
@ -210,7 +210,7 @@ pub(crate) async fn write_file(msg: WriteFile, config: SharedAppConfig) -> Resul
|
|||||||
if counter % 100_000 == 0 {
|
if counter % 100_000 == 0 {
|
||||||
log::debug!("Wrote {} for {:?}", counter, file_name);
|
log::debug!("Wrote {} for {:?}", counter, file_name);
|
||||||
}
|
}
|
||||||
match file.write(&[b]) {
|
match file.write(&b) {
|
||||||
Ok(_) => {}
|
Ok(_) => {}
|
||||||
Err(e) if e.kind() == std::io::ErrorKind::StorageFull => return Err(Error::NoSpace),
|
Err(e) if e.kind() == std::io::ErrorKind::StorageFull => return Err(Error::NoSpace),
|
||||||
Err(e) => return Err(Error::CantWrite(e)),
|
Err(e) => return Err(Error::CantWrite(e)),
|
||||||
@ -221,6 +221,6 @@ pub(crate) async fn write_file(msg: WriteFile, config: SharedAppConfig) -> Resul
|
|||||||
Ok(WriteResult {
|
Ok(WriteResult {
|
||||||
file_name: FileName::new(file_name),
|
file_name: FileName::new(file_name),
|
||||||
unique_name: UniqueName::new(unique_name),
|
unique_name: UniqueName::new(unique_name),
|
||||||
local_path: LocalPath::from(path.to_str().unwrap_or_default().to_string()),
|
local_path: LocalPath::new(path.to_str().unwrap_or_default()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -17,6 +17,8 @@ fs_manager = { path = "../actors/fs_manager" }
|
|||||||
|
|
||||||
human-panic = { version = "1.0.3" }
|
human-panic = { version = "1.0.3" }
|
||||||
|
|
||||||
|
bytes = { version = "1.1.0" }
|
||||||
|
|
||||||
actix = { version = "0.13", features = [] }
|
actix = { version = "0.13", features = [] }
|
||||||
actix-rt = { version = "2.7", features = [] }
|
actix-rt = { version = "2.7", features = [] }
|
||||||
actix-web = { version = "4.0", features = [] }
|
actix-web = { version = "4.0", features = [] }
|
||||||
|
1
api/assets/svg/plate.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 288.643 288.643" style="enable-background:new 0 0 288.643 288.643" xml:space="preserve"><circle style="fill:#fff" cx="144.321" cy="144.322" r="136.821"/><path style="fill:#414042" d="M144.321.001C64.742.001 0 64.743 0 144.322s64.742 144.321 144.321 144.321S288.643 223.9 288.643 144.322 223.9.001 144.321.001zm0 273.642C73.014 273.643 15 215.63 15 144.322S73.014 15.001 144.321 15.001s129.321 58.013 129.321 129.321-58.013 129.321-129.321 129.321z"/><path style="fill:#414042" d="M144.321 47.206c-53.551 0-97.117 43.566-97.117 97.117s43.566 97.117 97.117 97.117a96.968 96.968 0 0 0 64.523-24.533 7.498 7.498 0 0 0 .619-10.588 7.5 7.5 0 0 0-10.588-.619 81.994 81.994 0 0 1-54.555 20.74c-45.279 0-82.117-36.837-82.117-82.117 0-45.279 36.838-82.117 82.117-82.117s82.117 36.837 82.117 82.117c0 11.936-2.502 23.442-7.437 34.197a7.5 7.5 0 0 0 3.69 9.944 7.498 7.498 0 0 0 9.944-3.69c5.84-12.732 8.802-26.342 8.802-40.452.003-53.55-43.564-97.116-97.115-97.116z"/></svg>
|
After Width: | Height: | Size: 1015 B |
@ -51,11 +51,9 @@ async fn upload_product_image(
|
|||||||
};
|
};
|
||||||
let read = async {
|
let read = async {
|
||||||
while let Some(Ok(data)) = field.next().await {
|
while let Some(Ok(data)) = field.next().await {
|
||||||
for b in data {
|
if let Err(e) = tx.send(data) {
|
||||||
if let Err(e) = tx.send(b) {
|
log::error!("{e:?}");
|
||||||
log::error!("{e:?}");
|
return Err(UploadError::InvalidName);
|
||||||
return Err(UploadError::InvalidName);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
|
@ -84,6 +84,7 @@ async fn svg(path: Path<String>) -> HttpResponse {
|
|||||||
"sweets" => serve_svg!("sweets"),
|
"sweets" => serve_svg!("sweets"),
|
||||||
"vegetables" => serve_svg!("vegetables"),
|
"vegetables" => serve_svg!("vegetables"),
|
||||||
"memory" => serve_svg!("memory"),
|
"memory" => serve_svg!("memory"),
|
||||||
|
"plates" => serve_svg!("plate"),
|
||||||
_ => HttpResponse::NotFound().finish(),
|
_ => HttpResponse::NotFound().finish(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
After Width: | Height: | Size: 101 KiB |
After Width: | Height: | Size: 714 KiB |
After Width: | Height: | Size: 196 KiB |
After Width: | Height: | Size: 586 KiB |
BIN
assets/examples/backup-images/pexels-alex-azabache-3907507.webp
Normal file
After Width: | Height: | Size: 523 KiB |
BIN
assets/examples/backup-images/pexels-binoid-cbd-3612182.webp
Normal file
After Width: | Height: | Size: 331 KiB |
BIN
assets/examples/backup-images/pexels-caio-1279107.webp
Normal file
After Width: | Height: | Size: 1.8 MiB |
BIN
assets/examples/backup-images/pexels-eprism-studio-335257.webp
Normal file
After Width: | Height: | Size: 61 KiB |
BIN
assets/examples/backup-images/pexels-gabriel-freytez-341523.webp
Normal file
After Width: | Height: | Size: 151 KiB |
After Width: | Height: | Size: 136 KiB |
BIN
assets/examples/backup-images/pexels-luis-quintero-1738641.webp
Normal file
After Width: | Height: | Size: 2.1 MiB |
BIN
assets/examples/backup-images/pexels-math-90946.webp
Normal file
After Width: | Height: | Size: 328 KiB |
BIN
assets/examples/backup-images/pexels-mike-380954.webp
Normal file
After Width: | Height: | Size: 373 KiB |
BIN
assets/examples/backup-images/pexels-pixabay-279906.webp
Normal file
After Width: | Height: | Size: 163 KiB |
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 31 KiB |
BIN
assets/examples/images/pexels-agnese-lunecka-10322857.webp
Normal file
After Width: | Height: | Size: 154 KiB |
BIN
assets/examples/images/pexels-agnese-lunecka-11179383.webp
Normal file
After Width: | Height: | Size: 36 KiB |
BIN
assets/examples/images/pexels-agnese-lunecka-11328773.webp
Normal file
After Width: | Height: | Size: 214 KiB |
Before Width: | Height: | Size: 523 KiB After Width: | Height: | Size: 61 KiB |
Before Width: | Height: | Size: 331 KiB After Width: | Height: | Size: 57 KiB |
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 48 KiB |
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 13 KiB |
Before Width: | Height: | Size: 151 KiB After Width: | Height: | Size: 18 KiB |
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 37 KiB |
Before Width: | Height: | Size: 2.1 MiB After Width: | Height: | Size: 64 KiB |
Before Width: | Height: | Size: 328 KiB After Width: | Height: | Size: 32 KiB |
Before Width: | Height: | Size: 373 KiB After Width: | Height: | Size: 25 KiB |
Before Width: | Height: | Size: 163 KiB After Width: | Height: | Size: 32 KiB |
@ -9,6 +9,8 @@ config = { path = "../shared/config" }
|
|||||||
database_manager = { path = "../actors/database_manager", features = ["dummy"] }
|
database_manager = { path = "../actors/database_manager", features = ["dummy"] }
|
||||||
fs_manager = { path = "../actors/fs_manager", features = [] }
|
fs_manager = { path = "../actors/fs_manager", features = [] }
|
||||||
|
|
||||||
|
bytes = { version = "1.1.0" }
|
||||||
|
|
||||||
actix = { version = "0.13", features = [] }
|
actix = { version = "0.13", features = [] }
|
||||||
actix-rt = { version = "2.7", features = [] }
|
actix-rt = { version = "2.7", features = [] }
|
||||||
actix-web = { version = "4.0", features = [] }
|
actix-web = { version = "4.0", features = [] }
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
use actix::{Actor, Addr};
|
use actix::{Actor, Addr};
|
||||||
|
use actix_web::web::BytesMut;
|
||||||
use config::SharedAppConfig;
|
use config::SharedAppConfig;
|
||||||
use database_manager::{query_db, Database};
|
use database_manager::{query_db, Database};
|
||||||
use fs_manager::query_fs;
|
use fs_manager::query_fs;
|
||||||
@ -18,15 +19,21 @@ async fn create_photo(
|
|||||||
tokio::fs::File::open(std::path::Path::new("./assets/examples/images").join(file))
|
tokio::fs::File::open(std::path::Path::new("./assets/examples/images").join(file))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
while let Ok(b) = file.read_u8().await {
|
let mut buffer = BytesMut::with_capacity(1024);
|
||||||
tx.send(b).unwrap();
|
while let Ok(len) = file.read_buf(&mut buffer).await {
|
||||||
|
if len == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
tx.send(actix_web::web::Bytes::from(buffer)).unwrap();
|
||||||
|
buffer = BytesMut::with_capacity(1024);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let write = async {
|
let write = async {
|
||||||
let fs_manager::WriteResult {
|
let fs_manager::WriteResult {
|
||||||
unique_name: _,
|
unique_name,
|
||||||
local_path,
|
local_path,
|
||||||
|
file_name,
|
||||||
} = query_fs!(
|
} = query_fs!(
|
||||||
fs,
|
fs,
|
||||||
fs_manager::WriteFile {
|
fs_manager::WriteFile {
|
||||||
@ -39,7 +46,8 @@ async fn create_photo(
|
|||||||
db,
|
db,
|
||||||
database_manager::CreatePhoto {
|
database_manager::CreatePhoto {
|
||||||
local_path,
|
local_path,
|
||||||
file_name: model::FileName::new(file)
|
file_name,
|
||||||
|
unique_name
|
||||||
},
|
},
|
||||||
crate::Error::WritePhoto(file.into())
|
crate::Error::WritePhoto(file.into())
|
||||||
);
|
);
|
||||||
@ -135,7 +143,25 @@ pub(crate) async fn create_photos(
|
|||||||
seed.clone(),
|
seed.clone(),
|
||||||
fs.clone(),
|
fs.clone(),
|
||||||
"pexels-Venus-HD-Make-up-and-perfume-2587370.webp"
|
"pexels-Venus-HD-Make-up-and-perfume-2587370.webp"
|
||||||
)
|
),
|
||||||
|
create_photo(
|
||||||
|
db.clone(),
|
||||||
|
seed.clone(),
|
||||||
|
fs.clone(),
|
||||||
|
"pexels-agnese-lunecka-10322857.webp"
|
||||||
|
),
|
||||||
|
create_photo(
|
||||||
|
db.clone(),
|
||||||
|
seed.clone(),
|
||||||
|
fs.clone(),
|
||||||
|
"pexels-agnese-lunecka-11179383.webp"
|
||||||
|
),
|
||||||
|
create_photo(
|
||||||
|
db.clone(),
|
||||||
|
seed.clone(),
|
||||||
|
fs.clone(),
|
||||||
|
"pexels-agnese-lunecka-11328773.webp"
|
||||||
|
),
|
||||||
);
|
);
|
||||||
results.0.unwrap();
|
results.0.unwrap();
|
||||||
results.1.unwrap();
|
results.1.unwrap();
|
||||||
@ -148,5 +174,6 @@ pub(crate) async fn create_photos(
|
|||||||
results.8.unwrap();
|
results.8.unwrap();
|
||||||
results.9.unwrap();
|
results.9.unwrap();
|
||||||
results.10.unwrap();
|
results.10.unwrap();
|
||||||
|
results.11.unwrap();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
@ -122,7 +122,25 @@ pub(crate) async fn create_product_photos(
|
|||||||
seed.clone(),
|
seed.clone(),
|
||||||
"Venus HD Professional",
|
"Venus HD Professional",
|
||||||
"pexels-Venus-HD-Make-up-and-perfume-2587370.webp"
|
"pexels-Venus-HD-Make-up-and-perfume-2587370.webp"
|
||||||
)
|
),
|
||||||
|
create_product_photo(
|
||||||
|
db.clone(),
|
||||||
|
seed.clone(),
|
||||||
|
"Fancy Plate",
|
||||||
|
"pexels-agnese-lunecka-10322857.webp"
|
||||||
|
),
|
||||||
|
create_product_photo(
|
||||||
|
db.clone(),
|
||||||
|
seed.clone(),
|
||||||
|
"Fancy Plate",
|
||||||
|
"pexels-agnese-lunecka-11179383.webp"
|
||||||
|
),
|
||||||
|
create_product_photo(
|
||||||
|
db.clone(),
|
||||||
|
seed.clone(),
|
||||||
|
"Fancy Plate",
|
||||||
|
"pexels-agnese-lunecka-11328773.webp"
|
||||||
|
),
|
||||||
);
|
);
|
||||||
results.0.unwrap();
|
results.0.unwrap();
|
||||||
results.1.unwrap();
|
results.1.unwrap();
|
||||||
|
@ -54,21 +54,77 @@ pub(crate) async fn create_products(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let results = tokio::join!(
|
let results = tokio::join!(
|
||||||
create_product(db.clone(), seed.clone(), "Nikon", "Cameras",),
|
create_product(
|
||||||
create_product(db.clone(), seed.clone(), "Bonoid CBD", "Drugstore",),
|
db.clone(),
|
||||||
create_product(db.clone(), seed.clone(), "Casio Speaker", "Speakers",),
|
seed.clone(),
|
||||||
create_product(db.clone(), seed.clone(), "Eprism Studio", "Drugstore",),
|
"Nikon",
|
||||||
create_product(db.clone(), seed.clone(), "Best Phones 2022", "Phones",),
|
model::Category::CAMERAS_NAME,
|
||||||
create_product(db.clone(), seed.clone(), "Sweet cake", "Sweets",),
|
),
|
||||||
create_product(db.clone(), seed.clone(), "Lexal 128G", "Memory",),
|
create_product(
|
||||||
create_product(db.clone(), seed.clone(), "Fujifilm X-T10", "Cameras",),
|
db.clone(),
|
||||||
create_product(db.clone(), seed.clone(), "Sweet Tower", "Sweets",),
|
seed.clone(),
|
||||||
create_product(db.clone(), seed.clone(), "Nikon Lenses", "Cameras",),
|
"Bonoid CBD",
|
||||||
|
model::Category::DRUGSTORE_NAME,
|
||||||
|
),
|
||||||
|
create_product(
|
||||||
|
db.clone(),
|
||||||
|
seed.clone(),
|
||||||
|
"Casio Speaker",
|
||||||
|
model::Category::SPEAKERS_NAME,
|
||||||
|
),
|
||||||
|
create_product(
|
||||||
|
db.clone(),
|
||||||
|
seed.clone(),
|
||||||
|
"Eprism Studio",
|
||||||
|
model::Category::DRUGSTORE_NAME,
|
||||||
|
),
|
||||||
|
create_product(
|
||||||
|
db.clone(),
|
||||||
|
seed.clone(),
|
||||||
|
"Best Phones 2022",
|
||||||
|
model::Category::PHONES_NAME,
|
||||||
|
),
|
||||||
|
create_product(
|
||||||
|
db.clone(),
|
||||||
|
seed.clone(),
|
||||||
|
"Sweet cake",
|
||||||
|
model::Category::SWEETS_NAME,
|
||||||
|
),
|
||||||
|
create_product(
|
||||||
|
db.clone(),
|
||||||
|
seed.clone(),
|
||||||
|
"Lexal 128G",
|
||||||
|
model::Category::MEMORY_NAME,
|
||||||
|
),
|
||||||
|
create_product(
|
||||||
|
db.clone(),
|
||||||
|
seed.clone(),
|
||||||
|
"Fujifilm X-T10",
|
||||||
|
model::Category::CAMERAS_NAME,
|
||||||
|
),
|
||||||
|
create_product(
|
||||||
|
db.clone(),
|
||||||
|
seed.clone(),
|
||||||
|
"Sweet Tower",
|
||||||
|
model::Category::SWEETS_NAME,
|
||||||
|
),
|
||||||
|
create_product(
|
||||||
|
db.clone(),
|
||||||
|
seed.clone(),
|
||||||
|
"Nikon Lenses",
|
||||||
|
model::Category::CAMERAS_NAME,
|
||||||
|
),
|
||||||
create_product(
|
create_product(
|
||||||
db.clone(),
|
db.clone(),
|
||||||
seed.clone(),
|
seed.clone(),
|
||||||
"Venus HD Professional",
|
"Venus HD Professional",
|
||||||
"Drugstore",
|
model::Category::DRUGSTORE_NAME,
|
||||||
|
),
|
||||||
|
create_product(
|
||||||
|
db.clone(),
|
||||||
|
seed.clone(),
|
||||||
|
"Fancy Plate",
|
||||||
|
model::Category::PLATES_NAME,
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
results.0.unwrap();
|
results.0.unwrap();
|
||||||
|
@ -39,46 +39,86 @@ pub struct Category {
|
|||||||
pub svg: &'static str,
|
pub svg: &'static str,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const CATEGORIES: [Category; 8] = [
|
impl Category {
|
||||||
|
pub const CAMERAS_NAME: &'static str = "Cameras";
|
||||||
|
pub const CAMERAS_KEY: &'static str = "cameras";
|
||||||
|
|
||||||
|
pub const DRUGSTORE_NAME: &'static str = "Drugstore";
|
||||||
|
pub const DRUGSTORE_KEY: &'static str = "drugstore";
|
||||||
|
|
||||||
|
pub const SPEAKERS_NAME: &'static str = "Speakers";
|
||||||
|
pub const SPEAKERS_KEY: &'static str = "speakers";
|
||||||
|
|
||||||
|
pub const PHONES_NAME: &'static str = "Phones";
|
||||||
|
pub const PHONES_KEY: &'static str = "phones";
|
||||||
|
|
||||||
|
pub const SWEETS_NAME: &'static str = "Sweets";
|
||||||
|
pub const SWEETS_KEY: &'static str = "sweets";
|
||||||
|
|
||||||
|
pub const MEMORY_NAME: &'static str = "Memory";
|
||||||
|
pub const MEMORY_KEY: &'static str = "memory";
|
||||||
|
|
||||||
|
pub const PANTS_NAME: &'static str = "Pants";
|
||||||
|
pub const PANTS_KEY: &'static str = "pants";
|
||||||
|
|
||||||
|
pub const CLOTHES_NAME: &'static str = "Clothes";
|
||||||
|
pub const CLOTHES_KEY: &'static str = "clothes";
|
||||||
|
|
||||||
|
pub const PLATES_NAME: &'static str = "Plates";
|
||||||
|
pub const PLATES_KEY: &'static str = "plates";
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! category_svg {
|
||||||
|
($name: expr) => {
|
||||||
|
concat!("/svg/", $name, ".svg")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const CATEGORIES: [Category; 9] = [
|
||||||
Category {
|
Category {
|
||||||
name: "Cameras",
|
name: Category::CAMERAS_NAME,
|
||||||
key: "cameras",
|
key: Category::CAMERAS_KEY,
|
||||||
svg: "/svg/cameras.svg",
|
svg: category_svg!("cameras"),
|
||||||
},
|
},
|
||||||
Category {
|
Category {
|
||||||
name: "Drugstore",
|
name: Category::DRUGSTORE_NAME,
|
||||||
key: "drugstore",
|
key: Category::DRUGSTORE_KEY,
|
||||||
svg: "/svg/drugstore.svg",
|
svg: category_svg!("drugstore"),
|
||||||
},
|
},
|
||||||
Category {
|
Category {
|
||||||
name: "Speakers",
|
name: Category::SPEAKERS_NAME,
|
||||||
key: "speakers",
|
key: Category::SPEAKERS_KEY,
|
||||||
svg: "/svg/speakers.svg",
|
svg: category_svg!("speakers"),
|
||||||
},
|
},
|
||||||
Category {
|
Category {
|
||||||
name: "Phones",
|
name: Category::PHONES_NAME,
|
||||||
key: "phones",
|
key: Category::PHONES_KEY,
|
||||||
svg: "/svg/phones.svg",
|
svg: category_svg!("phones"),
|
||||||
},
|
},
|
||||||
Category {
|
Category {
|
||||||
name: "Sweets",
|
name: Category::SWEETS_NAME,
|
||||||
key: "sweets",
|
key: Category::SWEETS_KEY,
|
||||||
svg: "/svg/sweets.svg",
|
svg: category_svg!("sweets"),
|
||||||
},
|
},
|
||||||
Category {
|
Category {
|
||||||
name: "Memory",
|
name: Category::MEMORY_NAME,
|
||||||
key: "memory",
|
key: Category::MEMORY_KEY,
|
||||||
svg: "/svg/memory.svg",
|
svg: category_svg!("memory"),
|
||||||
},
|
},
|
||||||
Category {
|
Category {
|
||||||
name: "Pants",
|
name: Category::PANTS_NAME,
|
||||||
key: "pants",
|
key: Category::PANTS_KEY,
|
||||||
svg: "/svg/pants.svg",
|
svg: category_svg!("pants"),
|
||||||
},
|
},
|
||||||
Category {
|
Category {
|
||||||
name: "Clothes",
|
name: Category::CLOTHES_NAME,
|
||||||
key: "clothes",
|
key: Category::CLOTHES_KEY,
|
||||||
svg: "/svg/clothes.svg",
|
svg: category_svg!("clothes"),
|
||||||
|
},
|
||||||
|
Category {
|
||||||
|
name: Category::PLATES_NAME,
|
||||||
|
key: Category::PLATES_KEY,
|
||||||
|
svg: category_svg!("plates"),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -386,6 +426,31 @@ pub enum Day {
|
|||||||
Sunday = 1 << 6,
|
Sunday = 1 << 6,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Day {
|
||||||
|
pub fn name(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
Self::Monday => "Monday",
|
||||||
|
Self::Tuesday => "Tuesday",
|
||||||
|
Self::Wednesday => "Wednesday",
|
||||||
|
Self::Thursday => "Thursday",
|
||||||
|
Self::Friday => "Friday",
|
||||||
|
Self::Saturday => "Saturday",
|
||||||
|
Self::Sunday => "Sunday",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn short_name(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Monday => "Mon",
|
||||||
|
Self::Tuesday => "Tue",
|
||||||
|
Self::Wednesday => "Wed",
|
||||||
|
Self::Thursday => "Thu",
|
||||||
|
Self::Friday => "Fri",
|
||||||
|
Self::Saturday => "Sat",
|
||||||
|
Self::Sunday => "Sun",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl TryFrom<i32> for Day {
|
impl TryFrom<i32> for Day {
|
||||||
type Error = TransformError;
|
type Error = TransformError;
|
||||||
|
|
||||||
@ -411,10 +476,18 @@ impl TryFrom<i32> for Day {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg_attr(feature = "dummy", derive(fake::Dummy))]
|
#[cfg_attr(feature = "dummy", derive(fake::Dummy))]
|
||||||
#[derive(Serialize, Deserialize, Hash, Debug, Deref)]
|
#[derive(Serialize, Deserialize, Hash, Debug)]
|
||||||
#[serde(transparent)]
|
#[serde(transparent)]
|
||||||
pub struct Days(Vec<Day>);
|
pub struct Days(Vec<Day>);
|
||||||
|
|
||||||
|
impl std::ops::Deref for Days {
|
||||||
|
type Target = Vec<Day>;
|
||||||
|
|
||||||
|
fn deref(&self) -> &Self::Target {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(feature = "db")]
|
#[cfg(feature = "db")]
|
||||||
impl<'q> ::sqlx::encode::Encode<'q, sqlx::Postgres> for Days
|
impl<'q> ::sqlx::encode::Encode<'q, sqlx::Postgres> for Days
|
||||||
where
|
where
|
||||||
|
@ -1,16 +1,69 @@
|
|||||||
pub struct I18n {}
|
mod pl;
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
pub struct I18n {
|
||||||
|
store: HashMap<String, HashMap<String, &'static str>>,
|
||||||
|
lang: String,
|
||||||
|
}
|
||||||
|
|
||||||
impl I18n {
|
impl I18n {
|
||||||
pub fn load() -> Self {
|
pub fn load() -> Self {
|
||||||
// let languages: js_sys::Array = seed::window().navigator().languages();
|
let mut i18n = Self {
|
||||||
// for lang in languages {
|
store: HashMap::with_capacity(1_000),
|
||||||
// let l: wasm_bindgen::JsValue = lang;
|
lang: "".into(),
|
||||||
// if let Some(s) = l.as_string() {
|
};
|
||||||
// if let Ok(local) = pure_rust_locales::Locale::try_from(&s) {
|
pl::define(&mut i18n);
|
||||||
// //
|
let languages: js_sys::Array = seed::window().navigator().languages();
|
||||||
// }
|
languages.find(&mut |lang, _idx, _array| {
|
||||||
// }
|
let l: wasm_bindgen::JsValue = lang;
|
||||||
// }
|
if let Some(s) = l.as_string() {
|
||||||
Self {}
|
if i18n.store.contains_key(&s) {
|
||||||
|
i18n.lang = s;
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
i18n
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scope(&mut self, lang: &'static str) -> Scope {
|
||||||
|
Scope { store: self, lang }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn t(&self, key: &'static str) -> &'static str {
|
||||||
|
self.store
|
||||||
|
.get(self.lang.as_str())
|
||||||
|
.and_then(|s| s.get(key))
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn l(&self, key: &String) -> String {
|
||||||
|
self.store
|
||||||
|
.get(self.lang.as_str())
|
||||||
|
.and_then(|s| s.get(key))
|
||||||
|
.copied()
|
||||||
|
.map(Into::into)
|
||||||
|
.unwrap_or_else(|| key.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Scope<'store, 'lang> {
|
||||||
|
lang: &'lang str,
|
||||||
|
store: &'store mut I18n,
|
||||||
|
}
|
||||||
|
impl<'store, 'lang> Scope<'store, 'lang> {
|
||||||
|
fn define(&mut self, key: &str, value: &'static str) -> &mut Self {
|
||||||
|
self.store
|
||||||
|
.store
|
||||||
|
.entry(self.lang.into())
|
||||||
|
.or_insert_with(|| HashMap::with_capacity(1_000))
|
||||||
|
.insert(key.into(), value);
|
||||||
|
self
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
24
web/src/i18n/pl.rs
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
use crate::i18n::I18n;
|
||||||
|
|
||||||
|
pub fn define(i18n: &mut I18n) {
|
||||||
|
i18n.scope("pl")
|
||||||
|
.define("Mon", "Pon")
|
||||||
|
.define("Tue", "Wt")
|
||||||
|
.define("Wed", "Śr")
|
||||||
|
.define("Thu", "Czw")
|
||||||
|
.define("Fri", "Pt")
|
||||||
|
.define("Sat", "Sob")
|
||||||
|
.define("Sun", "Ndz")
|
||||||
|
.define("Add to Cart", "Dodaj")
|
||||||
|
.define("Qty", "Ilość")
|
||||||
|
.define("Delivery every", "Dostawa w każdy")
|
||||||
|
.define("Cameras", "Kamery")
|
||||||
|
.define("Drugstore", "Drogeria")
|
||||||
|
.define("Speakers", "Głośniki")
|
||||||
|
.define("Phones", "Telefony")
|
||||||
|
.define("Sweets", "Słodycze")
|
||||||
|
.define("Memory", "Pamięć")
|
||||||
|
.define("Pants", "Spodnie")
|
||||||
|
.define("Clothes", "Ubrania")
|
||||||
|
.define("Plates", "Talerze");
|
||||||
|
}
|
@ -7,6 +7,7 @@ pub mod shared;
|
|||||||
use seed::empty;
|
use seed::empty;
|
||||||
use seed::prelude::*;
|
use seed::prelude::*;
|
||||||
|
|
||||||
|
use crate::i18n::I18n;
|
||||||
use crate::model::Model;
|
use crate::model::Model;
|
||||||
use crate::pages::{Msg, Page, PublicPage};
|
use crate::pages::{Msg, Page, PublicPage};
|
||||||
|
|
||||||
@ -75,6 +76,7 @@ fn init(url: Url, orders: &mut impl Orders<Msg>) -> Model {
|
|||||||
.flatten()
|
.flatten()
|
||||||
.and_then(|el: web_sys::Element| el.get_attribute("href")),
|
.and_then(|el: web_sys::Element| el.get_attribute("href")),
|
||||||
shared: shared::Model::default(),
|
shared: shared::Model::default(),
|
||||||
|
i18n: I18n::load(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
use seed::Url;
|
use seed::Url;
|
||||||
|
|
||||||
use crate::Page;
|
use crate::{I18n, Page};
|
||||||
|
|
||||||
pub struct Model {
|
pub struct Model {
|
||||||
pub url: Url,
|
pub url: Url,
|
||||||
@ -8,4 +8,5 @@ pub struct Model {
|
|||||||
pub page: Page,
|
pub page: Page,
|
||||||
pub logo: Option<String>,
|
pub logo: Option<String>,
|
||||||
pub shared: crate::shared::Model,
|
pub shared: crate::shared::Model,
|
||||||
|
pub i18n: I18n,
|
||||||
}
|
}
|
||||||
|
@ -12,13 +12,13 @@ pub mod layout {
|
|||||||
use seed::*;
|
use seed::*;
|
||||||
|
|
||||||
pub fn view<Msg>(
|
pub fn view<Msg>(
|
||||||
url: Url,
|
model: &crate::Model,
|
||||||
content: Node<Msg>,
|
content: Node<Msg>,
|
||||||
categories: Option<&[model::api::Category]>,
|
categories: Option<&[model::api::Category]>,
|
||||||
) -> Node<Msg> {
|
) -> Node<Msg> {
|
||||||
let sidebar = match categories {
|
let sidebar = match categories {
|
||||||
Some(categories) => {
|
Some(categories) => {
|
||||||
let sidebar = super::sidebar::view(url, categories);
|
let sidebar = super::sidebar::view(model, categories);
|
||||||
div![
|
div![
|
||||||
C!["flex flex-col w-64 h-screen px-4 py-8 overflow-y-auto border-r"],
|
C!["flex flex-col w-64 h-screen px-4 py-8 overflow-y-auto border-r"],
|
||||||
sidebar
|
sidebar
|
||||||
@ -40,10 +40,8 @@ pub mod sidebar {
|
|||||||
|
|
||||||
use crate::pages::Urls;
|
use crate::pages::Urls;
|
||||||
|
|
||||||
pub fn view<Msg>(url: Url, categories: &[model::api::Category]) -> Node<Msg> {
|
pub fn view<Msg>(model: &crate::Model, categories: &[model::api::Category]) -> Node<Msg> {
|
||||||
let categories = categories
|
let categories = categories.iter().map(|category| item(model, category));
|
||||||
.iter()
|
|
||||||
.map(|category| item(url.clone(), category));
|
|
||||||
|
|
||||||
div![
|
div![
|
||||||
C!["flex flex-col justify-between mt-6"],
|
C!["flex flex-col justify-between mt-6"],
|
||||||
@ -51,8 +49,8 @@ pub mod sidebar {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
fn item<Msg>(url: Url, category: &model::api::Category) -> Node<Msg> {
|
fn item<Msg>(model: &crate::Model, category: &model::api::Category) -> Node<Msg> {
|
||||||
let url = Urls::new(url)
|
let url = Urls::new(model.url.clone())
|
||||||
.listing()
|
.listing()
|
||||||
.add_path_part(category.key.as_str());
|
.add_path_part(category.key.as_str());
|
||||||
li![
|
li![
|
||||||
@ -64,7 +62,7 @@ pub mod sidebar {
|
|||||||
C!["w-6 h-6"],
|
C!["w-6 h-6"],
|
||||||
attrs!["src" => category.svg.as_str(), "style" => ""]
|
attrs!["src" => category.svg.as_str(), "style" => ""]
|
||||||
],
|
],
|
||||||
span![C!["mx-4 font-medium"], category.name.as_str()]
|
span![C!["mx-4 font-medium"], model.i18n.l(&category.name)]
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
@ -125,7 +125,7 @@ pub fn view(model: &crate::Model, page: &ListingPage) -> Node<crate::Msg> {
|
|||||||
|
|
||||||
div![
|
div![
|
||||||
crate::shared::view::public_navbar(model),
|
crate::shared::view::public_navbar(model),
|
||||||
super::layout::view(model.url.clone(), content, Some(&page.categories))
|
super::layout::view(&model, content, Some(&page.categories))
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -173,7 +173,10 @@ fn product(model: &crate::Model, product: &model::api::Product) -> Node<Msg> {
|
|||||||
],
|
],
|
||||||
div![
|
div![
|
||||||
C!["flex items-center justify-between"],
|
C!["flex items-center justify-between"],
|
||||||
a![C!["px-6 py-2 text-sm text-white bg-indigo-500 rounded-lg outline-none hover:bg-indigo-600 ring-indigo-300"], "Add to Cart"],
|
a![
|
||||||
|
C!["px-6 py-2 text-sm text-white bg-indigo-500 rounded-lg outline-none hover:bg-indigo-600 ring-indigo-300"],
|
||||||
|
model.i18n.t("Add to Cart")
|
||||||
|
],
|
||||||
div![C!["mt-1 text-xl font-semibold"], price],
|
div![C!["mt-1 text-xl font-semibold"], price],
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
|
@ -4,12 +4,14 @@ use seed::*;
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum Msg {
|
pub enum Msg {
|
||||||
ProductFetched(fetch::Result<model::api::Product>),
|
ProductFetched(fetch::Result<model::api::Product>),
|
||||||
|
SelectImage(usize),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
pub struct ProductPage {
|
pub struct ProductPage {
|
||||||
pub product_id: Option<model::ProductId>,
|
pub product_id: Option<model::ProductId>,
|
||||||
pub product: Option<model::api::Product>,
|
pub product: Option<model::api::Product>,
|
||||||
|
pub selected_image: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn init(mut url: Url, orders: &mut impl Orders<Msg>) -> ProductPage {
|
pub fn init(mut url: Url, orders: &mut impl Orders<Msg>) -> ProductPage {
|
||||||
@ -23,6 +25,7 @@ pub fn init(mut url: Url, orders: &mut impl Orders<Msg>) -> ProductPage {
|
|||||||
ProductPage {
|
ProductPage {
|
||||||
product_id: Some(product_id),
|
product_id: Some(product_id),
|
||||||
product: None,
|
product: None,
|
||||||
|
selected_image: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -36,6 +39,9 @@ pub fn update(msg: Msg, model: &mut ProductPage, _orders: &mut impl Orders<Msg>)
|
|||||||
Msg::ProductFetched(Err(e)) => {
|
Msg::ProductFetched(Err(e)) => {
|
||||||
seed::error!(e);
|
seed::error!(e);
|
||||||
}
|
}
|
||||||
|
Msg::SelectImage(selected) => {
|
||||||
|
model.selected_image = selected;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -44,11 +50,23 @@ pub fn view(model: &crate::Model, page: &ProductPage) -> Node<crate::Msg> {
|
|||||||
None => return empty!(),
|
None => return empty!(),
|
||||||
Some(product) => product,
|
Some(product) => product,
|
||||||
};
|
};
|
||||||
let images = product
|
let large_photo = product
|
||||||
.photos
|
.photos
|
||||||
.iter()
|
.get(page.selected_image)
|
||||||
.enumerate()
|
.map(image)
|
||||||
.map(|(idx, img)| image(idx, img));
|
.unwrap_or_else(|| empty![]);
|
||||||
|
let small_photos = {
|
||||||
|
if product.photos.len() <= 1 {
|
||||||
|
empty![]
|
||||||
|
} else {
|
||||||
|
let photos = product
|
||||||
|
.photos
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(idx, img)| small_image(idx, page.selected_image, img));
|
||||||
|
div![C!["flex -mx-2 mb-4"], photos]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let description = product
|
let description = product
|
||||||
.long_description
|
.long_description
|
||||||
@ -56,16 +74,20 @@ pub fn view(model: &crate::Model, page: &ProductPage) -> Node<crate::Msg> {
|
|||||||
.split('\n')
|
.split('\n')
|
||||||
.map(|s| div![s]);
|
.map(|s| div![s]);
|
||||||
|
|
||||||
|
let delivery = delivery_available(product, model);
|
||||||
|
|
||||||
let content = div![
|
let content = div![
|
||||||
C!["max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 mt-6"],
|
C!["max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 mt-6"],
|
||||||
|
attrs!["id" => "full-window"],
|
||||||
div![
|
div![
|
||||||
C!["flex flex-col md:flex-row -mx-4"],
|
C!["flex flex-col md:flex-row -mx-4"],
|
||||||
|
attrs!["id" => "product-header"],
|
||||||
div![
|
div![
|
||||||
C!["md:flex-1 px-4"], attrs!["id" => "photos"],
|
C!["md:flex-1 px-4"], attrs!["id" => "photos"],
|
||||||
div![
|
div![
|
||||||
attrs!["x-data" => "{ image: 1}"],
|
div![C!["h-64 md:h-80 rounded-lg bg-gray-100 mb-4"], large_photo]
|
||||||
div![C!["h-64 md:h-80 rounded-lg bg-gray-100 mb-4"], images]
|
],
|
||||||
]
|
small_photos
|
||||||
],
|
],
|
||||||
div![
|
div![
|
||||||
C!["md:flex-1 px-4"], attrs!["id" => "details"],
|
C!["md:flex-1 px-4"], attrs!["id" => "details"],
|
||||||
@ -73,22 +95,82 @@ pub fn view(model: &crate::Model, page: &ProductPage) -> Node<crate::Msg> {
|
|||||||
C!["mb-2 leading-tight tracking-tight font-bold text-gray-800 text-2xl md:text-3xl"],
|
C!["mb-2 leading-tight tracking-tight font-bold text-gray-800 text-2xl md:text-3xl"],
|
||||||
product.name.as_str()
|
product.name.as_str()
|
||||||
],
|
],
|
||||||
div![description]
|
div![
|
||||||
]
|
delivery
|
||||||
|
],
|
||||||
|
div![
|
||||||
|
C!["flex py-4 space-x-4"],
|
||||||
|
div![
|
||||||
|
C!["relative"],
|
||||||
|
label![
|
||||||
|
C!["text-center left-0 pt-2 right-0 absolute block text-xs uppercase text-gray-400 tracking-wide font-semibold"],
|
||||||
|
attrs!["for" => "quantity"],
|
||||||
|
model.i18n.t("Qty")
|
||||||
|
],
|
||||||
|
input![C![""], attrs!["id" => "quantity", "type" => "number"]]
|
||||||
|
],
|
||||||
|
button![
|
||||||
|
C!["px-6 py-3 text-sm text-white bg-indigo-500 rounded-lg outline-none hover:bg-indigo-600 ring-indigo-300"],
|
||||||
|
model.i18n.t("Add to Cart"),
|
||||||
|
ev("click", move |ev| {
|
||||||
|
ev.prevent_default();
|
||||||
|
ev.stop_propagation();
|
||||||
|
None as Option<Msg>
|
||||||
|
})
|
||||||
|
]
|
||||||
|
]
|
||||||
|
],
|
||||||
|
],
|
||||||
|
div![
|
||||||
|
C!["-mx-4"],
|
||||||
|
attrs!["id" => "product-header"],
|
||||||
|
div![description]
|
||||||
]
|
]
|
||||||
].map_msg(map_to_global);
|
].map_msg(map_to_global);
|
||||||
|
|
||||||
div![
|
div![
|
||||||
crate::shared::view::public_navbar(model),
|
crate::shared::view::public_navbar(model),
|
||||||
super::layout::view(model.url.clone(), content, None)
|
super::layout::view(model, content, None)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
fn image(idx: usize, img: &model::api::Photo) -> Node<Msg> {
|
fn delivery_available(product: &model::api::Product, model: &crate::Model) -> Node<Msg> {
|
||||||
|
let days = product
|
||||||
|
.deliver_days_flag
|
||||||
|
.iter()
|
||||||
|
.map(|day| div![
|
||||||
|
C!["focus:outline-none w-14 md:w-18 rounded-lg h-14 md:h-18 bg-gray-100 flex items-center justify-center ring-indigo-300 ring-inset"],
|
||||||
|
model.i18n.t(day.short_name())
|
||||||
|
]);
|
||||||
|
div![
|
||||||
|
div![C![""], model.i18n.t("Delivery every")],
|
||||||
|
div![C!["flex py-4 space-x-4"], days]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn small_image(idx: usize, selected: usize, img: &model::api::Photo) -> Node<Msg> {
|
||||||
|
div![
|
||||||
|
C!["flex-1 px-2"],
|
||||||
|
button![
|
||||||
|
C!["focus:outline-none w-full rounded-lg h-24 md:h-32 bg-gray-100 flex items-center justify-center ring-indigo-300 ring-inset"],
|
||||||
|
IF![selected == idx => C!["ring-2"]],
|
||||||
|
img![
|
||||||
|
C!["h-24 md:h-32"],
|
||||||
|
attrs!["src" => img.url.as_str()]
|
||||||
|
],
|
||||||
|
ev("click", move |ev| {
|
||||||
|
ev.prevent_default();
|
||||||
|
ev.stop_propagation();
|
||||||
|
Msg::SelectImage(idx)
|
||||||
|
})
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn image(img: &model::api::Photo) -> Node<Msg> {
|
||||||
div![
|
div![
|
||||||
C!["h-64 md:h-80 rounded-lg bg-gray-100 mb-4 flex items-center justify-center"],
|
C!["h-64 md:h-80 rounded-lg bg-gray-100 mb-4 flex items-center justify-center"],
|
||||||
attrs!["x-show" => format!("image == {}", idx + 1)],
|
img![C!["h-64 md:h-80"], attrs!["src" => img.url.as_str()]]
|
||||||
img![attrs!["src" => img.url.as_str()]]
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|