112 lines
3.8 KiB
TypeScript
112 lines
3.8 KiB
TypeScript
"use client";
|
|
|
|
import { ImagePlusIcon, UploadCloudIcon } from "lucide-react";
|
|
import { useId, useState } from "react";
|
|
import { toast } from "sonner";
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
import { Field, FieldDescription, FieldLabel } from "@/components/ui/field";
|
|
import { Spinner } from "@/components/ui/spinner";
|
|
import { IMAGE_MIME_TYPES, MAX_MEDIA_BYTES } from "@/lib/media/validation";
|
|
|
|
export interface UploadedMedia {
|
|
id: string;
|
|
fileName: string;
|
|
mimeType: string;
|
|
byteSize: number;
|
|
url: string;
|
|
}
|
|
|
|
export function MediaUploader({
|
|
onReady,
|
|
}: {
|
|
onReady: (media: UploadedMedia) => void;
|
|
}) {
|
|
const inputId = useId();
|
|
const [state, setState] = useState<"idle" | "preparing" | "uploading" | "checking">("idle");
|
|
const busy = state !== "idle";
|
|
|
|
async function upload(file: File) {
|
|
if (!IMAGE_MIME_TYPES.includes(file.type as (typeof IMAGE_MIME_TYPES)[number])) {
|
|
toast.error("รองรับเฉพาะ PNG, JPEG, WebP และ GIF");
|
|
return;
|
|
}
|
|
if (file.size <= 0 || file.size > MAX_MEDIA_BYTES) {
|
|
toast.error("ไฟล์ต้องมีขนาดไม่เกิน 20 MB");
|
|
return;
|
|
}
|
|
|
|
let mediaId: string | undefined;
|
|
try {
|
|
setState("preparing");
|
|
const presignResponse = await fetch("/api/admin/media/presign", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ fileName: file.name, mimeType: file.type, byteSize: file.size }),
|
|
});
|
|
if (!presignResponse.ok) throw new Error("presign");
|
|
const upload = await presignResponse.json() as {
|
|
mediaId: string;
|
|
uploadUrl: string;
|
|
headers: Record<string, string>;
|
|
};
|
|
mediaId = upload.mediaId;
|
|
|
|
setState("uploading");
|
|
const putResponse = await fetch(upload.uploadUrl, {
|
|
method: "PUT",
|
|
headers: upload.headers,
|
|
body: file,
|
|
});
|
|
if (!putResponse.ok) throw new Error("upload");
|
|
|
|
setState("checking");
|
|
const completeResponse = await fetch(`/api/admin/media/${mediaId}/complete`, { method: "POST" });
|
|
if (!completeResponse.ok) throw new Error("complete");
|
|
const result = await completeResponse.json() as { media: UploadedMedia };
|
|
onReady(result.media);
|
|
toast.success("อัปโหลดรูปภาพแล้ว");
|
|
} catch {
|
|
if (mediaId) {
|
|
await fetch(`/api/admin/media/${mediaId}`, { method: "DELETE" }).catch(() => undefined);
|
|
}
|
|
toast.error("อัปโหลดไม่สำเร็จ ไฟล์ชั่วคราวถูกเก็บกวาดแล้ว");
|
|
} finally {
|
|
setState("idle");
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Field>
|
|
<FieldLabel htmlFor={inputId}>คลังสื่อ</FieldLabel>
|
|
<input
|
|
id={inputId}
|
|
type="file"
|
|
className="sr-only"
|
|
accept={IMAGE_MIME_TYPES.join(",")}
|
|
disabled={busy}
|
|
onChange={(event) => {
|
|
const file = event.target.files?.[0];
|
|
event.target.value = "";
|
|
if (file) void upload(file);
|
|
}}
|
|
/>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
className="w-full"
|
|
disabled={busy}
|
|
render={<label htmlFor={inputId} />}
|
|
>
|
|
{busy ? <Spinner /> : <ImagePlusIcon />}
|
|
{state === "preparing" && "กำลังเตรียม…"}
|
|
{state === "uploading" && "กำลังอัปโหลด…"}
|
|
{state === "checking" && "กำลังตรวจสอบไฟล์…"}
|
|
{state === "idle" && "อัปโหลดและเพิ่มรูปภาพ"}
|
|
{!busy ? <UploadCloudIcon className="ml-auto" /> : null}
|
|
</Button>
|
|
<FieldDescription>PNG, JPEG, WebP หรือ GIF ขนาดไม่เกิน 20 MB</FieldDescription>
|
|
</Field>
|
|
);
|
|
}
|