diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index 788ca48..de5a411 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -183,6 +183,7 @@ version = "1.0.0" "settings:label.ips" = "IPs" "settings:label.generate_invites" = "Generate invites" "settings:label.add_to_stack" = "Add to stack" +"settings:label.alt_text" = "Alt text" "settings:tab.security" = "Security" "settings:tab.blocks" = "Blocks" "settings:tab.billing" = "Billing" diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index 1e3aa17..c81ef23 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -442,7 +442,6 @@ ("alt" "Image upload") ("onclick" "trigger('ui::lightbox_open', ['/api/v1/uploads/{{ upload }}'])")) (text "{% endfor %}")) - (text "{%- endif %} {%- endmacro %} {% macro notification(notification) -%}") (div ("class" "w-full card-nest") diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp index da41608..6be8134 100644 --- a/crates/app/src/public/html/profile/settings.lisp +++ b/crates/app/src/public/html/profile/settings.lisp @@ -574,32 +574,51 @@ (div ("class" "card flex flex-col gap-2 secondary") (text "{{ components::supporter_ad(body=\"Become a supporter to upload images directly to posts!\") }} {% for upload in uploads %}") - (div - ("class" "card flex flex-wrap gap-2 items-center justify-between") + (details + ("class" "accordion w-full") + (summary + ("class" "card flex flex-wrap gap-2 items-center justify-between") + (div + ("class" "flex gap-2 items-center") + (icon_class (text "chevron-down") (text "dropdown_arrow")) + (b + (span + ("class" "date") + (text "{{ upload.created }}")) + (text " ({{ upload.what }})"))) + (div + ("class" "flex gap-2") + (button + ("class" "raised small") + ("onclick" "trigger('ui::lightbox_open', ['/api/v1/uploads/{{ upload.id }}'])") + (text "{{ icon \"view\" }}") + (span + (text "{{ text \"general:action.view\" }}"))) + (button + ("class" "raised small red") + ("onclick" "remove_upload('{{ upload.id }}')") + (text "{{ icon \"x\" }}") + (span + (text "{{ text \"stacks:label.remove\" }}"))))) + (div - ("class" "flex gap-2 items-center") - ("onclick" "trigger('ui::lightbox_open', ['/api/v1/uploads/{{ upload.id }}'])") - ("style" "cursor: pointer") - (text "{{ icon \"file-image\" }}") - (b - (span - ("class" "date") - (text "{{ upload.created }}")) - (text "({{ upload.what }})"))) - (div - ("class" "flex gap-2") - (button - ("class" "lowered small") - ("onclick" "trigger('ui::lightbox_open', ['/api/v1/uploads/{{ upload.id }}'])") - (text "{{ icon \"view\" }}") - (span - (text "{{ text \"general:action.view\" }}"))) - (button - ("class" "lowered small red") - ("onclick" "remove_upload('{{ upload.id }}')") - (text "{{ icon \"x\" }}") - (span - (text "{{ text \"stacks:label.remove\" }}"))))) + ("class" "inner flex flex-col gap-2") + (form + ("class" "card lowered flex flex-col gap-2") + ("onsubmit" "update_upload_alt(event, '{{ upload.id }}')") + (div + ("class" "flex flex-col gap-1") + (label ("for" "alt_{{ upload.id }}") (b (str (text "settings:label.alt_text")))) + (textarea + ("id" "alt_{{ upload.id }}") + ("name" "alt") + ("class" "w-full") + ("placeholder" "Alternative text") + (text "{{ upload.alt|safe }}"))) + + (button + (icon (text "check")) + (str (text "general:action.save")))))) (text "{% endfor %} {{ components::pagination(page=page, items=uploads|length, key=\"#/account/uploads\") }}") (script (text "globalThis.remove_upload = async (id) => { @@ -621,6 +640,26 @@ res.message, ]); }); + }; + + globalThis.update_upload_alt = async (e, id) => { + e.preventDefault(); + fetch(`/api/v1/uploads/${id}/alt`, { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + alt: e.target.alt.value, + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); };")))))) (text "{% if config.security.enable_invite_codes -%}") @@ -1508,7 +1547,6 @@ globalThis.render_preset_lis = (preset, id) => { for (const x of preset) { - console.log(id); document.getElementById(id).innerHTML += `
  • ${x[0]}: ${x[1]}
  • `; } } diff --git a/crates/app/src/public/js/atto.js b/crates/app/src/public/js/atto.js index f67cd2c..157d6d3 100644 --- a/crates/app/src/public/js/atto.js +++ b/crates/app/src/public/js/atto.js @@ -156,9 +156,7 @@ media_theme_pref(); .replaceAll(" year ago", "y"); } - element.innerText = - pretty === undefined ? then.toLocaleDateString() : pretty; - + element.innerText = !pretty ? then.toLocaleDateString() : pretty; element.style.display = "inline-block"; } }); @@ -198,9 +196,7 @@ media_theme_pref(); .replaceAll(" year ago", "y") .replaceAll("Yesterday", "1d") || ""; - element.innerText = - pretty === undefined ? then.toLocaleDateString() : pretty; - + element.innerText = !pretty ? then.toLocaleDateString() : pretty; element.style.display = "inline-block"; } }); @@ -1145,8 +1141,15 @@ ${option.input_element_type === "textarea" ? `${option.value}` : ""} ); // lightbox - self.define("lightbox_open", (_, src) => { + self.define("lightbox_open", async (_, src) => { document.getElementById("lightbox_img").src = src; + + const data = await (await fetch(`${src}/data`)).json(); + document + .getElementById("lightbox_img") + .setAttribute("alt", data.payload.alt); + document.getElementById("lightbox_img").title = data.payload.alt; + document.getElementById("lightbox_img_a").href = src; document.getElementById("lightbox").classList.remove("hidden"); }); diff --git a/crates/app/src/routes/api/v1/communities/posts.rs b/crates/app/src/routes/api/v1/communities/posts.rs index b4b3896..d65ce53 100644 --- a/crates/app/src/routes/api/v1/communities/posts.rs +++ b/crates/app/src/routes/api/v1/communities/posts.rs @@ -152,10 +152,11 @@ pub async fn create_request( } // ... - match data.create_post(props.clone()).await { + let uploads = props.uploads.clone(); + match data.create_post(props).await { Ok(id) => { // write to uploads - for (i, upload_id) in props.uploads.iter().enumerate() { + for (i, upload_id) in uploads.iter().enumerate() { let image = match images.get(i) { Some(img) => img, None => { diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index d4e19c1..517016b 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -637,6 +637,8 @@ pub fn routes() -> Router { // uploads .route("/uploads/{id}", get(uploads::get_request)) .route("/uploads/{id}", delete(uploads::delete_request)) + .route("/uploads/{id}/data", get(uploads::get_json_request)) + .route("/uploads/{id}/alt", post(uploads::update_alt_request)) // services .route("/services", get(services::list_request)) .route("/services", post(services::create_request)) @@ -1124,3 +1126,8 @@ pub struct UpdateProductDescription { pub struct UpdateProductPrice { pub price: ProductPrice, } + +#[derive(Deserialize)] +pub struct UpdateUploadAlt { + pub alt: String, +} diff --git a/crates/app/src/routes/api/v1/products.rs b/crates/app/src/routes/api/v1/products.rs index 5812127..6a48dd3 100644 --- a/crates/app/src/routes/api/v1/products.rs +++ b/crates/app/src/routes/api/v1/products.rs @@ -1,13 +1,20 @@ use crate::{ get_user_from_token, + image::{save_webp_buffer, JsonMultipart}, routes::api::v1::{ - CreateProduct, UpdateProductDescription, UpdateProductName, UpdateProductPrice, + communities::posts::MAXIMUM_FILE_SIZE, CreateProduct, UpdateProductDescription, + UpdateProductName, UpdateProductPrice, }, State, }; use axum::{extract::Path, response::IntoResponse, Extension, Json}; use axum_extra::extract::CookieJar; -use tetratto_core::model::{products::Product, oauth, ApiReturn, Error}; +use tetratto_core::model::{ + oauth, + products::Product, + uploads::{MediaType, MediaUpload}, + ApiReturn, Error, +}; pub async fn get_request( Path(id): Path, @@ -44,7 +51,7 @@ pub async fn list_request(jar: CookieJar, Extension(data): Extension) -> pub async fn create_request( jar: CookieJar, Extension(data): Extension, - Json(req): Json, + JsonMultipart(uploads, req): JsonMultipart, ) -> impl IntoResponse { let data = &(data.read().await).0; let user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateProducts) { @@ -52,21 +59,75 @@ pub async fn create_request( None => return Json(Error::NotAllowed.into()), }; - match data - .create_product(Product::new( - user.id, - req.name, - req.description, - req.price, - req.product_type, - )) - .await - { - Ok(x) => Json(ApiReturn { - ok: true, - message: "Product created".to_string(), - payload: x.id.to_string(), - }), + if uploads.len() > 4 { + return Json( + Error::MiscError("Too many uploads. Please use a maximum of 4".to_string()).into(), + ); + } + + let mut product = Product::new( + user.id, + req.name, + req.description, + req.price, + req.product_type, + ); + + // check sizes + for img in &uploads { + if img.len() > MAXIMUM_FILE_SIZE { + return Json(Error::FileTooLarge.into()); + } + } + + // create uploads + for _ in 0..uploads.len() { + product.uploads.push( + match data + .create_upload(MediaUpload::new(MediaType::Webp, product.owner)) + .await + { + Ok(u) => u.id, + Err(e) => return Json(e.into()), + }, + ); + } + + let product_uploads = product.uploads.clone(); + match data.create_product(product).await { + Ok(x) => { + // store uploads + for (i, upload_id) in product_uploads.iter().enumerate() { + let image = match uploads.get(i) { + Some(img) => img, + None => { + if let Err(e) = data.delete_upload(*upload_id).await { + return Json(e.into()); + } + + continue; + } + }; + + let upload = match data.get_upload_by_id(*upload_id).await { + Ok(u) => u, + Err(e) => return Json(e.into()), + }; + + if let Err(e) = + save_webp_buffer(&upload.path(&data.0.0).to_string(), image.to_vec(), None) + { + return Json(Error::MiscError(e.to_string()).into()); + } + } + + // ... + Json(ApiReturn { + ok: true, + message: "Product created".to_string(), + payload: x.id.to_string(), + }) + } Err(e) => Json(e.into()), } } diff --git a/crates/app/src/routes/api/v1/uploads.rs b/crates/app/src/routes/api/v1/uploads.rs index 0e7d6ab..02673fe 100644 --- a/crates/app/src/routes/api/v1/uploads.rs +++ b/crates/app/src/routes/api/v1/uploads.rs @@ -2,7 +2,7 @@ use std::fs::exists; use axum::{body::Body, extract::Path, response::IntoResponse, Extension, Json}; use axum_extra::extract::CookieJar; use pathbufd::PathBufD; -use crate::{get_user_from_token, State}; +use crate::{get_user_from_token, routes::api::v1::UpdateUploadAlt, State}; use super::auth::images::read_image; use tetratto_core::model::{carp::CarpGraph, oauth, uploads::MediaType, ApiReturn, Error}; @@ -52,6 +52,24 @@ pub async fn get_request( Ok(([("Content-Type", upload.what.mime())], Body::from(bytes))) } +pub async fn get_json_request( + Path(id): Path, + Extension(data): Extension, +) -> impl IntoResponse { + let data = &(data.read().await).0; + + let upload = match data.get_upload_by_id(id).await { + Ok(u) => u, + Err(e) => return Json(e.into()), + }; + + Json(ApiReturn { + ok: true, + message: "Success".to_string(), + payload: Some(upload), + }) +} + pub async fn delete_request( jar: CookieJar, Extension(data): Extension, @@ -72,3 +90,25 @@ pub async fn delete_request( Err(e) => Json(e.into()), } } + +pub async fn update_alt_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, + Json(props): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageUploads) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.update_upload_alt(id, &user, &props.alt).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Upload updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} diff --git a/crates/app/src/routes/pages/marketplace.rs b/crates/app/src/routes/pages/marketplace.rs index 69a2b3d..f2b6b11 100644 --- a/crates/app/src/routes/pages/marketplace.rs +++ b/crates/app/src/routes/pages/marketplace.rs @@ -22,8 +22,14 @@ pub async fn seller_settings_request( } }; + let products = match data.0.get_products_by_user(user.id).await { + Ok(x) => x, + Err(e) => return Err(Html(render_error(e, &jar, &data, &None).await)), + }; + let lang = get_lang!(jar, data.0); - let context = initial_context(&data.0.0.0, lang, &Some(user)).await; + let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await; + context.insert("list", &products); // return Ok(Html( diff --git a/crates/core/src/database/drivers/sql/create_uploads.sql b/crates/core/src/database/drivers/sql/create_uploads.sql index a563080..57d4037 100644 --- a/crates/core/src/database/drivers/sql/create_uploads.sql +++ b/crates/core/src/database/drivers/sql/create_uploads.sql @@ -2,5 +2,6 @@ CREATE TABLE IF NOT EXISTS uploads ( id BIGINT NOT NULL PRIMARY KEY, created BIGINT NOT NULL, owner BIGINT NOT NULL, - what TEXT NOT NULL + what TEXT NOT NULL, + alt TEXT NOT NULL ) diff --git a/crates/core/src/database/uploads.rs b/crates/core/src/database/uploads.rs index e3b2cb5..f669c53 100644 --- a/crates/core/src/database/uploads.rs +++ b/crates/core/src/database/uploads.rs @@ -16,10 +16,11 @@ impl DataManager { created: get!(x->1(i64)) as usize, owner: get!(x->2(i64)) as usize, what: serde_json::from_str(&get!(x->3(String))).unwrap(), + alt: get!(x->4(String)), } } - auto_method!(get_upload_by_id(usize as i64)@get_upload_from_row -> "SELECT * FROM uploads WHERE id = $1" --name="upload" --returns=MediaUpload --cache-key-tmpl="atto.uploads:{}"); + auto_method!(get_upload_by_id(usize as i64)@get_upload_from_row -> "SELECT * FROM uploads WHERE id = $1" --name="upload" --returns=MediaUpload --cache-key-tmpl="atto.upload:{}"); /// Get all uploads (paginated). /// @@ -113,12 +114,13 @@ impl DataManager { let res = execute!( &conn, - "INSERT INTO uploads VALUES ($1, $2, $3, $4)", + "INSERT INTO uploads VALUES ($1, $2, $3, $4, $5)", params![ &(data.id as i64), &(data.created as i64), &(data.owner as i64), &serde_json::to_string(&data.what).unwrap().as_str(), + &data.alt, ] ); @@ -187,4 +189,6 @@ impl DataManager { self.0.1.remove(format!("atto.upload:{}", id)).await; Ok(()) } + + auto_method!(update_upload_alt(&str)@get_upload_by_id:FinePermission::MANAGE_UPLOADS; -> "UPDATE uploads SET alt = $1 WHERE id = $2" --cache-key-tmpl="atto.upload:{}"); } diff --git a/crates/core/src/model/uploads.rs b/crates/core/src/model/uploads.rs index 35165c6..9ab2d97 100644 --- a/crates/core/src/model/uploads.rs +++ b/crates/core/src/model/uploads.rs @@ -44,6 +44,7 @@ pub struct MediaUpload { pub created: usize, pub owner: usize, pub what: MediaType, + pub alt: String, } impl MediaUpload { @@ -54,6 +55,7 @@ impl MediaUpload { created: unix_epoch_timestamp(), owner, what, + alt: String::new(), } } diff --git a/sql_changes/uploads_alt.sql b/sql_changes/uploads_alt.sql new file mode 100644 index 0000000..3d6298c --- /dev/null +++ b/sql_changes/uploads_alt.sql @@ -0,0 +1,2 @@ +ALTER TABLE uploads +ADD COLUMN alt TEXT NOT NULL DEFAULT '';