Add ImageField component and client-side upload script
This commit is contained in:
@@ -0,0 +1,28 @@
|
|||||||
|
---
|
||||||
|
interface Props {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
value?: string | null;
|
||||||
|
scope: "settings" | "cases" | "posts";
|
||||||
|
id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { name, label, value = "", scope, id = name } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<div class="image-field" data-image-field data-scope={scope}>
|
||||||
|
<label for={id}>{label}</label>
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
name={name}
|
||||||
|
type="text"
|
||||||
|
value={value ?? ""}
|
||||||
|
placeholder="https://... 或按下面上傳"
|
||||||
|
class="image-url"
|
||||||
|
/>
|
||||||
|
<div class="image-actions">
|
||||||
|
<input type="file" accept="image/*" data-image-input />
|
||||||
|
<span class="image-status" data-image-status></span>
|
||||||
|
</div>
|
||||||
|
<img class="image-preview" data-image-preview src={value || undefined} alt="" hidden={!value} />
|
||||||
|
</div>
|
||||||
@@ -49,6 +49,73 @@ const isActive = (href: string) =>
|
|||||||
<main class="inner">
|
<main class="inner">
|
||||||
<slot />
|
<slot />
|
||||||
</main>
|
</main>
|
||||||
|
<script>
|
||||||
|
const MAX_WIDTH = 1600;
|
||||||
|
const QUALITY = 0.85;
|
||||||
|
|
||||||
|
async function toOptimised(file: File): Promise<Blob> {
|
||||||
|
if (!("createImageBitmap" in window)) return file;
|
||||||
|
try {
|
||||||
|
const bitmap = await createImageBitmap(file);
|
||||||
|
const scale = Math.min(1, MAX_WIDTH / bitmap.width);
|
||||||
|
const width = Math.max(1, Math.round(bitmap.width * scale));
|
||||||
|
const height = Math.max(1, Math.round(bitmap.height * scale));
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) return file;
|
||||||
|
ctx.drawImage(bitmap, 0, 0, width, height);
|
||||||
|
if (typeof bitmap.close === "function") bitmap.close();
|
||||||
|
const blob = await new Promise<Blob | null>((resolve) =>
|
||||||
|
canvas.toBlob(resolve, "image/webp", QUALITY),
|
||||||
|
);
|
||||||
|
return blob ?? file;
|
||||||
|
} catch {
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upload(field: HTMLElement, file: File): Promise<void> {
|
||||||
|
const scope = field.dataset.scope ?? "";
|
||||||
|
const input = field.querySelector<HTMLInputElement>(".image-url");
|
||||||
|
const preview = field.querySelector<HTMLImageElement>(".image-preview");
|
||||||
|
const status = field.querySelector<HTMLElement>(".image-status");
|
||||||
|
if (!input || !status) return;
|
||||||
|
|
||||||
|
status.textContent = "上傳中…";
|
||||||
|
const blob = await toOptimised(file);
|
||||||
|
const body = new FormData();
|
||||||
|
body.append("file", new File([blob], file.name, { type: blob.type }));
|
||||||
|
body.append("scope", scope);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/admin/upload", { method: "POST", body });
|
||||||
|
const data = (await res.json()) as { ok: boolean; url?: string; error?: string };
|
||||||
|
if (!res.ok || !data.ok || !data.url) {
|
||||||
|
status.textContent = data.error ?? "上傳失敗。";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
input.value = data.url;
|
||||||
|
if (preview) {
|
||||||
|
preview.src = data.url;
|
||||||
|
preview.hidden = false;
|
||||||
|
}
|
||||||
|
status.textContent = "✓ 已上傳";
|
||||||
|
} catch {
|
||||||
|
status.textContent = "上傳失敗,請檢查網絡。";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll<HTMLElement>("[data-image-field]").forEach((field) => {
|
||||||
|
const fileInput = field.querySelector<HTMLInputElement>("[data-image-input]");
|
||||||
|
if (!fileInput) return;
|
||||||
|
fileInput.addEventListener("change", () => {
|
||||||
|
const file = fileInput.files?.[0];
|
||||||
|
if (file) void upload(field, file);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
@@ -114,5 +181,11 @@ const isActive = (href: string) =>
|
|||||||
.err { color: #a32d2d; font-size: 13px; font-weight: 600; }
|
.err { color: #a32d2d; font-size: 13px; font-weight: 600; }
|
||||||
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||||
@media (max-width: 640px) { .grid2 { grid-template-columns: 1fr; } }
|
@media (max-width: 640px) { .grid2 { grid-template-columns: 1fr; } }
|
||||||
|
.image-field { display: block; }
|
||||||
|
.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-status { font-size: 12.5px; font-weight: 600; color: #0b8a5e; }
|
||||||
|
.image-preview { display: block; margin-top: 10px; max-width: 220px; border-radius: 10px; }
|
||||||
|
.image-preview[hidden] { display: none; }
|
||||||
.row { display: grid; gap: 12px; grid-template-columns: 1fr; }
|
.row { display: grid; gap: 12px; grid-template-columns: 1fr; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user