feat(guides): add structured image-first editor
This commit is contained in:
+46
-2
@@ -1,13 +1,14 @@
|
||||
import "server-only";
|
||||
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
|
||||
import { getDb } from "@/db";
|
||||
import { media } from "@/db/schema";
|
||||
import { getMediaStorage } from "@/lib/media/storage";
|
||||
import { getMediaStorage, publicMediaUrl } from "@/lib/media/storage";
|
||||
import {
|
||||
hasValidImageSignature,
|
||||
MAX_MEDIA_BYTES,
|
||||
mediaUploadSchema,
|
||||
safeObjectFileName,
|
||||
type MediaUploadInput,
|
||||
} from "@/lib/media/validation";
|
||||
@@ -37,6 +38,49 @@ export async function createPendingMedia(
|
||||
return { id, objectKey, uploadUrl, expiresIn };
|
||||
}
|
||||
|
||||
export async function uploadPublicMedia(
|
||||
file: File,
|
||||
authorId: string,
|
||||
): Promise<{ id: string; url: string; fileName: string }> {
|
||||
const parsed = mediaUploadSchema.parse({
|
||||
fileName: file.name,
|
||||
mimeType: file.type,
|
||||
byteSize: file.size,
|
||||
});
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
if (!hasValidImageSignature(bytes.subarray(0, 16), parsed.mimeType)) {
|
||||
throw new Error("ไฟล์ไม่ตรงกับชนิดรูปภาพที่ระบุ");
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const objectKey = `uploads/${id}/${safeObjectFileName(parsed.fileName)}`;
|
||||
const storage = await getMediaStorage();
|
||||
await storage.write(objectKey, bytes, { type: parsed.mimeType, acl: "public-read" });
|
||||
try {
|
||||
await getDb().insert(media).values({
|
||||
id,
|
||||
objectKey,
|
||||
fileName: parsed.fileName,
|
||||
mimeType: parsed.mimeType,
|
||||
byteSize: parsed.byteSize,
|
||||
status: "ready",
|
||||
currentReferenceCount: 0,
|
||||
createdById: authorId,
|
||||
});
|
||||
} catch (cause) {
|
||||
await storage.delete(objectKey).catch(() => undefined);
|
||||
throw cause;
|
||||
}
|
||||
return { id, url: publicMediaUrl(objectKey), fileName: parsed.fileName };
|
||||
}
|
||||
|
||||
export async function addCurrentMediaReference(mediaId: string): Promise<void> {
|
||||
await getDb()
|
||||
.update(media)
|
||||
.set({ currentReferenceCount: sql`${media.currentReferenceCount} + 1` })
|
||||
.where(eq(media.id, mediaId));
|
||||
}
|
||||
|
||||
export async function completePendingMedia(mediaId: string) {
|
||||
const [record] = await getDb()
|
||||
.select()
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import "server-only";
|
||||
|
||||
import type { S3Client } from "bun";
|
||||
|
||||
let client: S3Client | undefined;
|
||||
@@ -26,3 +24,8 @@ export async function getMediaStorage(): Promise<S3Client> {
|
||||
client = await loading;
|
||||
return client;
|
||||
}
|
||||
|
||||
export function publicMediaUrl(objectKey: string): string {
|
||||
const base = required("S3_PUBLIC_URL").replace(/\/+$/u, "");
|
||||
return `${base}/${objectKey.split("/").map(encodeURIComponent).join("/")}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user