add: upload alt text

This commit is contained in:
trisua 2025-07-14 15:30:17 -04:00
parent 3b5b0ce1a1
commit e0e38b2b32
13 changed files with 224 additions and 59 deletions

View file

@ -183,6 +183,7 @@ version = "1.0.0"
"settings:label.ips" = "IPs" "settings:label.ips" = "IPs"
"settings:label.generate_invites" = "Generate invites" "settings:label.generate_invites" = "Generate invites"
"settings:label.add_to_stack" = "Add to stack" "settings:label.add_to_stack" = "Add to stack"
"settings:label.alt_text" = "Alt text"
"settings:tab.security" = "Security" "settings:tab.security" = "Security"
"settings:tab.blocks" = "Blocks" "settings:tab.blocks" = "Blocks"
"settings:tab.billing" = "Billing" "settings:tab.billing" = "Billing"

View file

@ -442,7 +442,6 @@
("alt" "Image upload") ("alt" "Image upload")
("onclick" "trigger('ui::lightbox_open', ['/api/v1/uploads/{{ upload }}'])")) ("onclick" "trigger('ui::lightbox_open', ['/api/v1/uploads/{{ upload }}'])"))
(text "{% endfor %}")) (text "{% endfor %}"))
(text "{%- endif %} {%- endmacro %} {% macro notification(notification) -%}") (text "{%- endif %} {%- endmacro %} {% macro notification(notification) -%}")
(div (div
("class" "w-full card-nest") ("class" "w-full card-nest")

View file

@ -574,32 +574,51 @@
(div (div
("class" "card flex flex-col gap-2 secondary") ("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 %}") (text "{{ components::supporter_ad(body=\"Become a supporter to upload images directly to posts!\") }} {% for upload in uploads %}")
(div (details
("class" "card flex flex-wrap gap-2 items-center justify-between") ("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 (div
("class" "flex gap-2 items-center") ("class" "inner flex flex-col gap-2")
("onclick" "trigger('ui::lightbox_open', ['/api/v1/uploads/{{ upload.id }}'])") (form
("style" "cursor: pointer") ("class" "card lowered flex flex-col gap-2")
(text "{{ icon \"file-image\" }}") ("onsubmit" "update_upload_alt(event, '{{ upload.id }}')")
(b (div
(span ("class" "flex flex-col gap-1")
("class" "date") (label ("for" "alt_{{ upload.id }}") (b (str (text "settings:label.alt_text"))))
(text "{{ upload.created }}")) (textarea
(text "({{ upload.what }})"))) ("id" "alt_{{ upload.id }}")
(div ("name" "alt")
("class" "flex gap-2") ("class" "w-full")
(button ("placeholder" "Alternative text")
("class" "lowered small") (text "{{ upload.alt|safe }}")))
("onclick" "trigger('ui::lightbox_open', ['/api/v1/uploads/{{ upload.id }}'])")
(text "{{ icon \"view\" }}") (button
(span (icon (text "check"))
(text "{{ text \"general:action.view\" }}"))) (str (text "general:action.save"))))))
(button
("class" "lowered small red")
("onclick" "remove_upload('{{ upload.id }}')")
(text "{{ icon \"x\" }}")
(span
(text "{{ text \"stacks:label.remove\" }}")))))
(text "{% endfor %} {{ components::pagination(page=page, items=uploads|length, key=\"#/account/uploads\") }}") (text "{% endfor %} {{ components::pagination(page=page, items=uploads|length, key=\"#/account/uploads\") }}")
(script (script
(text "globalThis.remove_upload = async (id) => { (text "globalThis.remove_upload = async (id) => {
@ -621,6 +640,26 @@
res.message, 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 -%}") (text "{% if config.security.enable_invite_codes -%}")
@ -1508,7 +1547,6 @@
globalThis.render_preset_lis = (preset, id) => { globalThis.render_preset_lis = (preset, id) => {
for (const x of preset) { for (const x of preset) {
console.log(id);
document.getElementById(id).innerHTML += `<li><b>${x[0]}:</b> ${x[1]}</li>`; document.getElementById(id).innerHTML += `<li><b>${x[0]}:</b> ${x[1]}</li>`;
} }
} }

View file

@ -156,9 +156,7 @@ media_theme_pref();
.replaceAll(" year ago", "y"); .replaceAll(" year ago", "y");
} }
element.innerText = element.innerText = !pretty ? then.toLocaleDateString() : pretty;
pretty === undefined ? then.toLocaleDateString() : pretty;
element.style.display = "inline-block"; element.style.display = "inline-block";
} }
}); });
@ -198,9 +196,7 @@ media_theme_pref();
.replaceAll(" year ago", "y") .replaceAll(" year ago", "y")
.replaceAll("Yesterday", "1d") || ""; .replaceAll("Yesterday", "1d") || "";
element.innerText = element.innerText = !pretty ? then.toLocaleDateString() : pretty;
pretty === undefined ? then.toLocaleDateString() : pretty;
element.style.display = "inline-block"; element.style.display = "inline-block";
} }
}); });
@ -1145,8 +1141,15 @@ ${option.input_element_type === "textarea" ? `${option.value}</textarea>` : ""}
); );
// lightbox // lightbox
self.define("lightbox_open", (_, src) => { self.define("lightbox_open", async (_, src) => {
document.getElementById("lightbox_img").src = 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_img_a").href = src;
document.getElementById("lightbox").classList.remove("hidden"); document.getElementById("lightbox").classList.remove("hidden");
}); });

View file

@ -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) => { Ok(id) => {
// write to uploads // 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) { let image = match images.get(i) {
Some(img) => img, Some(img) => img,
None => { None => {

View file

@ -637,6 +637,8 @@ pub fn routes() -> Router {
// uploads // uploads
.route("/uploads/{id}", get(uploads::get_request)) .route("/uploads/{id}", get(uploads::get_request))
.route("/uploads/{id}", delete(uploads::delete_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 // services
.route("/services", get(services::list_request)) .route("/services", get(services::list_request))
.route("/services", post(services::create_request)) .route("/services", post(services::create_request))
@ -1124,3 +1126,8 @@ pub struct UpdateProductDescription {
pub struct UpdateProductPrice { pub struct UpdateProductPrice {
pub price: ProductPrice, pub price: ProductPrice,
} }
#[derive(Deserialize)]
pub struct UpdateUploadAlt {
pub alt: String,
}

View file

@ -1,13 +1,20 @@
use crate::{ use crate::{
get_user_from_token, get_user_from_token,
image::{save_webp_buffer, JsonMultipart},
routes::api::v1::{ routes::api::v1::{
CreateProduct, UpdateProductDescription, UpdateProductName, UpdateProductPrice, communities::posts::MAXIMUM_FILE_SIZE, CreateProduct, UpdateProductDescription,
UpdateProductName, UpdateProductPrice,
}, },
State, State,
}; };
use axum::{extract::Path, response::IntoResponse, Extension, Json}; use axum::{extract::Path, response::IntoResponse, Extension, Json};
use axum_extra::extract::CookieJar; 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( pub async fn get_request(
Path(id): Path<usize>, Path(id): Path<usize>,
@ -44,7 +51,7 @@ pub async fn list_request(jar: CookieJar, Extension(data): Extension<State>) ->
pub async fn create_request( pub async fn create_request(
jar: CookieJar, jar: CookieJar,
Extension(data): Extension<State>, Extension(data): Extension<State>,
Json(req): Json<CreateProduct>, JsonMultipart(uploads, req): JsonMultipart<CreateProduct>,
) -> impl IntoResponse { ) -> impl IntoResponse {
let data = &(data.read().await).0; let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateProducts) { 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()), None => return Json(Error::NotAllowed.into()),
}; };
match data if uploads.len() > 4 {
.create_product(Product::new( return Json(
user.id, Error::MiscError("Too many uploads. Please use a maximum of 4".to_string()).into(),
req.name, );
req.description, }
req.price,
req.product_type, let mut product = Product::new(
)) user.id,
.await req.name,
{ req.description,
Ok(x) => Json(ApiReturn { req.price,
ok: true, req.product_type,
message: "Product created".to_string(), );
payload: x.id.to_string(),
}), // 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()), Err(e) => Json(e.into()),
} }
} }

View file

@ -2,7 +2,7 @@ use std::fs::exists;
use axum::{body::Body, extract::Path, response::IntoResponse, Extension, Json}; use axum::{body::Body, extract::Path, response::IntoResponse, Extension, Json};
use axum_extra::extract::CookieJar; use axum_extra::extract::CookieJar;
use pathbufd::PathBufD; 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 super::auth::images::read_image;
use tetratto_core::model::{carp::CarpGraph, oauth, uploads::MediaType, ApiReturn, Error}; 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))) Ok(([("Content-Type", upload.what.mime())], Body::from(bytes)))
} }
pub async fn get_json_request(
Path(id): Path<usize>,
Extension(data): Extension<State>,
) -> 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( pub async fn delete_request(
jar: CookieJar, jar: CookieJar,
Extension(data): Extension<State>, Extension(data): Extension<State>,
@ -72,3 +90,25 @@ pub async fn delete_request(
Err(e) => Json(e.into()), Err(e) => Json(e.into()),
} }
} }
pub async fn update_alt_request(
jar: CookieJar,
Extension(data): Extension<State>,
Path(id): Path<usize>,
Json(props): Json<UpdateUploadAlt>,
) -> 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()),
}
}

View file

@ -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 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 // return
Ok(Html( Ok(Html(

View file

@ -2,5 +2,6 @@ CREATE TABLE IF NOT EXISTS uploads (
id BIGINT NOT NULL PRIMARY KEY, id BIGINT NOT NULL PRIMARY KEY,
created BIGINT NOT NULL, created BIGINT NOT NULL,
owner BIGINT NOT NULL, owner BIGINT NOT NULL,
what TEXT NOT NULL what TEXT NOT NULL,
alt TEXT NOT NULL
) )

View file

@ -16,10 +16,11 @@ impl DataManager {
created: get!(x->1(i64)) as usize, created: get!(x->1(i64)) as usize,
owner: get!(x->2(i64)) as usize, owner: get!(x->2(i64)) as usize,
what: serde_json::from_str(&get!(x->3(String))).unwrap(), 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). /// Get all uploads (paginated).
/// ///
@ -113,12 +114,13 @@ impl DataManager {
let res = execute!( let res = execute!(
&conn, &conn,
"INSERT INTO uploads VALUES ($1, $2, $3, $4)", "INSERT INTO uploads VALUES ($1, $2, $3, $4, $5)",
params![ params![
&(data.id as i64), &(data.id as i64),
&(data.created as i64), &(data.created as i64),
&(data.owner as i64), &(data.owner as i64),
&serde_json::to_string(&data.what).unwrap().as_str(), &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; self.0.1.remove(format!("atto.upload:{}", id)).await;
Ok(()) 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:{}");
} }

View file

@ -44,6 +44,7 @@ pub struct MediaUpload {
pub created: usize, pub created: usize,
pub owner: usize, pub owner: usize,
pub what: MediaType, pub what: MediaType,
pub alt: String,
} }
impl MediaUpload { impl MediaUpload {
@ -54,6 +55,7 @@ impl MediaUpload {
created: unix_epoch_timestamp(), created: unix_epoch_timestamp(),
owner, owner,
what, what,
alt: String::new(),
} }
} }

View file

@ -0,0 +1,2 @@
ALTER TABLE uploads
ADD COLUMN alt TEXT NOT NULL DEFAULT '';