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 '';