Improve ImageField accessibility and upload robustness

This commit is contained in:
2026-09-12 10:03:06 +08:00
parent fbc383decf
commit 74792c595a
2 changed files with 21 additions and 5 deletions
+2 -2
View File
@@ -21,8 +21,8 @@ const { name, label, value = "", scope, id = name } = Astro.props;
class="image-url" class="image-url"
/> />
<div class="image-actions"> <div class="image-actions">
<input type="file" accept="image/*" data-image-input /> <input type="file" accept="image/*" aria-label="上傳圖片" data-image-input />
<span class="image-status" data-image-status></span> <span class="image-status" data-image-status role="status" aria-live="polite"></span>
</div> </div>
<img class="image-preview" data-image-preview src={value || undefined} alt="" hidden={!value} /> <img class="image-preview" data-image-preview src={value || undefined} alt="" hidden={!value} />
</div> </div>
+19 -3
View File
@@ -64,7 +64,10 @@ const isActive = (href: string) =>
canvas.width = width; canvas.width = width;
canvas.height = height; canvas.height = height;
const ctx = canvas.getContext("2d"); const ctx = canvas.getContext("2d");
if (!ctx) return file; if (!ctx) {
if (typeof bitmap.close === "function") bitmap.close();
return file;
}
ctx.drawImage(bitmap, 0, 0, width, height); ctx.drawImage(bitmap, 0, 0, width, height);
if (typeof bitmap.close === "function") bitmap.close(); if (typeof bitmap.close === "function") bitmap.close();
const blob = await new Promise<Blob | null>((resolve) => const blob = await new Promise<Blob | null>((resolve) =>
@@ -84,6 +87,7 @@ const isActive = (href: string) =>
if (!input || !status) return; if (!input || !status) return;
status.textContent = "上傳中…"; status.textContent = "上傳中…";
status.classList.remove("error");
const blob = await toOptimised(file); const blob = await toOptimised(file);
const body = new FormData(); const body = new FormData();
body.append("file", new File([blob], file.name, { type: blob.type })); body.append("file", new File([blob], file.name, { type: blob.type }));
@@ -91,9 +95,13 @@ const isActive = (href: string) =>
try { try {
const res = await fetch("/admin/upload", { method: "POST", body }); const res = await fetch("/admin/upload", { method: "POST", body });
const data = (await res.json()) as { ok: boolean; url?: string; error?: string }; const isJson = (res.headers.get("content-type") ?? "").includes("application/json");
const data = isJson
? ((await res.json()) as { ok: boolean; url?: string; error?: string })
: { ok: false, error: `上傳失敗(${res.status})。` };
if (!res.ok || !data.ok || !data.url) { if (!res.ok || !data.ok || !data.url) {
status.textContent = data.error ?? "上傳失敗。"; status.textContent = data.error ?? "上傳失敗。";
status.classList.add("error");
return; return;
} }
input.value = data.url; input.value = data.url;
@@ -104,15 +112,22 @@ const isActive = (href: string) =>
status.textContent = "✓ 已上傳"; status.textContent = "✓ 已上傳";
} catch { } catch {
status.textContent = "上傳失敗,請檢查網絡。"; status.textContent = "上傳失敗,請檢查網絡。";
status.classList.add("error");
} }
} }
document.querySelectorAll<HTMLElement>("[data-image-field]").forEach((field) => { document.querySelectorAll<HTMLElement>("[data-image-field]").forEach((field) => {
const fileInput = field.querySelector<HTMLInputElement>("[data-image-input]"); const fileInput = field.querySelector<HTMLInputElement>("[data-image-input]");
if (!fileInput) return; if (!fileInput) return;
let busy = false;
fileInput.addEventListener("change", () => { fileInput.addEventListener("change", () => {
const file = fileInput.files?.[0]; const file = fileInput.files?.[0];
if (file) void upload(field, file); fileInput.value = "";
if (!file || busy) return;
busy = true;
void upload(field, file).finally(() => {
busy = false;
});
}); });
}); });
</script> </script>
@@ -185,6 +200,7 @@ const isActive = (href: string) =>
.image-actions { display: flex; align-items: center; gap: 10px; margin-top: 8px; flex-wrap: wrap; } .image-actions { display: flex; align-items: center; gap: 10px; margin-top: 8px; flex-wrap: wrap; }
.image-actions input[type="file"] { font: inherit; font-size: 13px; max-width: 100%; } .image-actions input[type="file"] { font: inherit; font-size: 13px; max-width: 100%; }
.image-status { font-size: 12.5px; font-weight: 600; color: #0b8a5e; } .image-status { font-size: 12.5px; font-weight: 600; color: #0b8a5e; }
.image-status.error { color: #a32d2d; }
.image-preview { display: block; margin-top: 10px; max-width: 220px; border-radius: 10px; } .image-preview { display: block; margin-top: 10px; max-width: 220px; border-radius: 10px; }
.image-preview[hidden] { display: none; } .image-preview[hidden] { display: none; }
.row { display: grid; gap: 12px; grid-template-columns: 1fr; } .row { display: grid; gap: 12px; grid-template-columns: 1fr; }