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.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"

View file

@ -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")

View file

@ -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 += `<li><b>${x[0]}:</b> ${x[1]}</li>`;
}
}

View file

@ -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}</textarea>` : ""}
);
// 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");
});

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) => {
// 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 => {

View file

@ -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,
}

View file

@ -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<usize>,
@ -44,7 +51,7 @@ pub async fn list_request(jar: CookieJar, Extension(data): Extension<State>) ->
pub async fn create_request(
jar: CookieJar,
Extension(data): Extension<State>,
Json(req): Json<CreateProduct>,
JsonMultipart(uploads, req): JsonMultipart<CreateProduct>,
) -> 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()),
}
}

View file

@ -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<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(
jar: CookieJar,
Extension(data): Extension<State>,
@ -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<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 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(

View file

@ -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
)

View file

@ -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:{}");
}

View file

@ -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(),
}
}

View file

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