feat(media): add reference-aware S3 image delivery
This commit is contained in:
@@ -5,6 +5,10 @@ import { useMemo, useState } from "react";
|
||||
|
||||
import { PublicBlockRenderer } from "@/components/public/block-renderer";
|
||||
import { blockLabels } from "@/components/admin/block-canvas";
|
||||
import {
|
||||
MediaUploader,
|
||||
type UploadedMedia,
|
||||
} from "@/components/admin/media-uploader";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -60,6 +64,7 @@ export function EditorInspector({
|
||||
onDataSourceChange,
|
||||
onSaveDataSource,
|
||||
sourceSaving,
|
||||
onMediaReady,
|
||||
}: {
|
||||
snapshot: PageSnapshot;
|
||||
draft: EditorDraft;
|
||||
@@ -69,6 +74,7 @@ export function EditorInspector({
|
||||
onDataSourceChange: (source: DataSourceSnapshot) => void;
|
||||
onSaveDataSource: (source: DataSourceSnapshot) => void;
|
||||
sourceSaving: boolean;
|
||||
onMediaReady: (media: UploadedMedia) => void;
|
||||
}) {
|
||||
const selected = draft.blocks.find((block) => block.id === selectedId);
|
||||
const preview = {
|
||||
@@ -109,6 +115,7 @@ export function EditorInspector({
|
||||
<Badge variant="outline">{blockLabels[selected.type] ?? selected.type}</Badge>
|
||||
<p className="mt-2 font-mono text-xs text-muted-foreground">{selected.id}</p>
|
||||
</div>
|
||||
<MediaUploader onReady={onMediaReady} />
|
||||
<Field>
|
||||
<FieldLabel htmlFor="block-width">ความกว้าง</FieldLabel>
|
||||
<Select
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
} from "@/components/admin/admin-sidebar";
|
||||
import { BlockCanvas } from "@/components/admin/block-canvas";
|
||||
import { EditorInspector } from "@/components/admin/editor-inspector";
|
||||
import type { UploadedMedia } from "@/components/admin/media-uploader";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -173,6 +174,28 @@ export function EditorWorkspace({
|
||||
}
|
||||
}
|
||||
|
||||
function addUploadedMedia(media: UploadedMedia) {
|
||||
const block: SerializedBlock = {
|
||||
id: crypto.randomUUID(),
|
||||
type: "image",
|
||||
schemaVersion: 1,
|
||||
config: {
|
||||
mediaId: media.id,
|
||||
alt: media.fileName.replace(/\.[^.]+$/u, ""),
|
||||
fit: "cover",
|
||||
},
|
||||
responsiveLayout: {
|
||||
width: "wide",
|
||||
mobileSpan: 4,
|
||||
tabletSpan: 8,
|
||||
desktopSpan: 12,
|
||||
hideOnMobile: false,
|
||||
},
|
||||
sortOrder: draft.blocks.length,
|
||||
};
|
||||
insert(block);
|
||||
}
|
||||
|
||||
const inspector = (
|
||||
<EditorInspector
|
||||
snapshot={snapshot}
|
||||
@@ -183,6 +206,7 @@ export function EditorWorkspace({
|
||||
onDataSourceChange={(source) => setDataSources((current) => ({ ...current, [source.id]: source }))}
|
||||
onSaveDataSource={saveSource}
|
||||
sourceSaving={sourceSaving}
|
||||
onMediaReady={addUploadedMedia}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user