feat(extra) : detect uploaded image ratio
CI / Verify (push) Successful in 1m5s
CI / Build immutable images and deploy (push) Successful in 3m8s

This commit is contained in:
2026-09-04 21:23:26 +07:00 Unverified
parent c0d0e0c18f
commit 513e37ad83
11 changed files with 3475 additions and 59 deletions
+1 -1
View File
@@ -22,5 +22,5 @@ export default async function StructuredEditorPage({ params }: { params: Promise
const [catalog, content] = await Promise.all([getActiveCatalog(), getAdminGuideContent(guide.slug)]);
if (!catalog || !content) notFound();
const guideCharacter = catalog.characters.find((item) => item.key === guide.characterKey);
return <div className="min-h-svh"><AdminHeader /><main className="mx-auto flex w-full max-w-6xl flex-col gap-6 p-4 sm:p-6 lg:p-8"><header className="flex items-center gap-4"><Button variant="outline" size="icon" render={<Link href={`/admin/${character}`} />} nativeButton={false} aria-label="กลับหน้า Guide"><ArrowLeftIcon /></Button><div className="min-w-0"><p className="text-sm text-muted-foreground">{guide.name}</p><h1 className="whitespace-nowrap font-heading text-3xl font-semibold">{guide.sections.find((section) => section.slug === page)?.title ?? page}</h1></div></header><GuideSectionNav characterKey={guide.characterKey} current={page as (typeof sections)[number]} /><GuideSectionEditor section={page as (typeof sections)[number]} guide={guide} weapons={catalog.weapons} artifacts={catalog.artifacts} characters={catalog.characters} catalogConstellations={guideCharacter?.constellations ?? []} catalogTalents={guideCharacter?.talents ?? []} initial={{ sectionNote: guide.sections.find((section) => section.slug === page)?.note ?? null, weaponGroups: content.weaponGroups, artifactGroups: content.artifactGroups.map((group) => ({ id: group.id, name: group.name, rows: group.rows.map((row) => ({ firstArtifactKey: row.firstArtifactKey, secondArtifactKey: row.secondArtifactKey, percent: row.percent, note: row.note })) })), artifactProfile: content.artifactProfile, constellationGroups: content.constellationGroups.map((group) => ({ id: group.id, note: group.note, rows: group.rows })), teams: content.teams.map((team) => ({ id: team.id, title: team.title, rotationSeconds: team.rotationSeconds, combo: team.combo, members: team.members.map((member) => ({ characterKey: member.characterKey, weaponKey: member.weaponKey, artifactKey: member.artifactKey, secondArtifactKey: member.secondArtifactKey, constellation: member.constellation, refinement: member.refinement, damage: member.damage })) })), extras: content.extras.map((section) => ({ title: section.title, rows: section.rows.map((row) => ({ mediaId: row.mediaId, ratio: row.ratio === "1:1" ? "1:1" : "16:9", heading: row.heading, body: row.body, percent: row.percent, imageUrl: row.imageUrl })) })) }} /></main></div>;
return <div className="min-h-svh"><AdminHeader /><main className="mx-auto flex w-full max-w-6xl flex-col gap-6 p-4 sm:p-6 lg:p-8"><header className="flex items-center gap-4"><Button variant="outline" size="icon" render={<Link href={`/admin/${character}`} />} nativeButton={false} aria-label="กลับหน้า Guide"><ArrowLeftIcon /></Button><div className="min-w-0"><p className="text-sm text-muted-foreground">{guide.name}</p><h1 className="whitespace-nowrap font-heading text-3xl font-semibold">{guide.sections.find((section) => section.slug === page)?.title ?? page}</h1></div></header><GuideSectionNav characterKey={guide.characterKey} current={page as (typeof sections)[number]} /><GuideSectionEditor section={page as (typeof sections)[number]} guide={guide} weapons={catalog.weapons} artifacts={catalog.artifacts} characters={catalog.characters} catalogConstellations={guideCharacter?.constellations ?? []} catalogTalents={guideCharacter?.talents ?? []} initial={{ sectionNote: guide.sections.find((section) => section.slug === page)?.note ?? null, weaponGroups: content.weaponGroups, artifactGroups: content.artifactGroups.map((group) => ({ id: group.id, name: group.name, rows: group.rows.map((row) => ({ firstArtifactKey: row.firstArtifactKey, secondArtifactKey: row.secondArtifactKey, percent: row.percent, note: row.note })) })), artifactProfile: content.artifactProfile, constellationGroups: content.constellationGroups.map((group) => ({ id: group.id, note: group.note, rows: group.rows })), teams: content.teams.map((team) => ({ id: team.id, title: team.title, rotationSeconds: team.rotationSeconds, combo: team.combo, members: team.members.map((member) => ({ characterKey: member.characterKey, weaponKey: member.weaponKey, artifactKey: member.artifactKey, secondArtifactKey: member.secondArtifactKey, constellation: member.constellation, refinement: member.refinement, damage: member.damage })) })), extras: content.extras.map((section) => ({ title: section.title, rows: section.rows.map((row) => ({ mediaId: row.mediaId, heading: row.heading, body: row.body, percent: row.percent, imageUrl: row.imageUrl, imageWidth: row.imageWidth, imageHeight: row.imageHeight })) })) }} /></main></div>;
}
+15 -49
View File
@@ -306,11 +306,12 @@ export interface GuideSectionEditorProps {
title: string;
rows: Array<{
mediaId: string | null;
ratio: "1:1" | "16:9";
heading: string;
body: string;
percent: string | null;
imageUrl: string | null;
imageWidth: number | null;
imageHeight: number | null;
}>;
}>;
};
@@ -3255,7 +3256,6 @@ function ExtraEditor({ guide, initial }: EditorBase) {
title: section.title,
rows: section.rows.map((row) => ({
mediaId: row.mediaId,
ratio: row.ratio,
heading: row.heading,
body: row.body,
percent:
@@ -3386,12 +3386,13 @@ function ExtraEditor({ guide, initial }: EditorBase) {
<FieldGroup>
{row.imageUrl ? (
<div
className={cn(
"relative overflow-hidden rounded-lg border",
row.ratio === "1:1"
? "aspect-square"
: "aspect-video",
)}
className="relative overflow-hidden rounded-lg border"
style={{
aspectRatio:
row.imageWidth && row.imageHeight
? `${row.imageWidth} / ${row.imageHeight}`
: "16 / 9",
}}
>
<Image
src={row.imageUrl}
@@ -3425,6 +3426,8 @@ function ExtraEditor({ guide, initial }: EditorBase) {
...candidate,
mediaId: null,
imageUrl: null,
imageWidth: null,
imageHeight: null,
}
: candidate,
),
@@ -3451,6 +3454,8 @@ function ExtraEditor({ guide, initial }: EditorBase) {
...candidate,
mediaId: asset.id,
imageUrl: asset.url,
imageWidth: asset.width,
imageHeight: asset.height,
}
: candidate,
),
@@ -3460,46 +3465,6 @@ function ExtraEditor({ guide, initial }: EditorBase) {
)
}
/>
<Field>
<FieldLabel>อัตราส่วนภาพ</FieldLabel>
<ToggleGroup
variant="outline"
value={[row.ratio]}
onValueChange={(selected) => {
const ratio = selected[0];
if (ratio === "1:1" || ratio === "16:9")
setSections(
sections.map((item, index) =>
index === sectionIndex
? {
...item,
rows: item.rows.map(
(candidate, inner) =>
inner === rowIndex
? { ...candidate, ratio }
: candidate,
),
}
: item,
),
);
}}
className="flex-wrap justify-start"
>
<ToggleGroupItem
className={SELECTED_TOGGLE_CLASS}
value="1:1"
>
1:1
</ToggleGroupItem>
<ToggleGroupItem
className={SELECTED_TOGGLE_CLASS}
value="16:9"
>
16:9
</ToggleGroupItem>
</ToggleGroup>
</Field>
<Field>
<FieldLabel>หัวข้อ</FieldLabel>
<Input
@@ -3628,7 +3593,8 @@ function ExtraEditor({ guide, initial }: EditorBase) {
{
mediaId: null,
imageUrl: null,
ratio: "16:9",
imageWidth: null,
imageHeight: null,
heading: "",
body: "",
percent: null,
+2
View File
@@ -13,6 +13,8 @@ export interface UploadedMedia {
id: string;
url: string;
fileName: string;
width: number;
height: number;
}
export function MediaUploader({
+7 -4
View File
@@ -1006,10 +1006,13 @@ function ExtraSection({ extra }: { extra: PublicGuide["extras"][number] }) {
<Card key={row.id} className="overflow-hidden py-0">
{row.imageUrl ? (
<div
className={cn(
"relative bg-muted",
row.ratio === "1:1" ? "aspect-square" : "aspect-video",
)}
className="relative bg-muted"
style={{
aspectRatio:
row.imageWidth && row.imageHeight
? `${row.imageWidth} / ${row.imageHeight}`
: "16 / 9",
}}
>
<Image
src={row.imageUrl}
-1
View File
@@ -655,7 +655,6 @@ export const extraSectionRows = guidesSchema.table(
id: uuid("id").defaultRandom().primaryKey(),
sectionId: uuid("section_id").notNull().references(() => extraSections.sectionId, { onDelete: "cascade" }),
mediaId: uuid("media_id").references(() => media.id, { onDelete: "set null" }),
ratio: varchar("ratio", { length: 5 }).default("16:9").notNull(),
heading: text("heading").notNull(),
body: text("body").notNull(),
percent: numeric("percent", { precision: 8, scale: 2 }),
+1
View File
@@ -0,0 +1 @@
ALTER TABLE "guides"."extra_section_row" DROP COLUMN "ratio";
File diff suppressed because it is too large Load Diff
+7
View File
@@ -176,6 +176,13 @@
"when": 1788524071533,
"tag": "0024_spooky_shadowcat",
"breakpoints": true
},
{
"idx": 25,
"version": "7",
"when": 1788531440452,
"tag": "0025_bored_cobalt_man",
"breakpoints": true
}
]
}
+1 -1
View File
@@ -172,7 +172,7 @@ async function getGuideContent(slug: string, publicOnly: boolean) {
}),
})),
teams: teams.map((team) => ({ ...team, members: members.filter(({ member }) => member.teamId === team.id).map(({ member, character }) => { const weapon = memberWeapons.find((item) => item.key === member.weaponKey); const artifact = memberArtifacts.find((item) => item.key === member.artifactKey); const secondArtifact = memberArtifacts.find((item) => item.key === member.secondArtifactKey); return { ...member, character: catalogCharacterWithUrls(character), weapon: weapon ? { ...weapon, imageUrl: publicMediaUrl(weapon.imageKey) } : null, artifact: artifact ? { ...artifact, imageUrl: publicMediaUrl(artifact.imageKey) } : null, secondArtifact: secondArtifact ? { ...secondArtifact, imageUrl: publicMediaUrl(secondArtifact.imageKey) } : null }; }) })),
extras: sections.filter((section) => section.kind === "extra").map((section) => ({ ...section, rows: extraRows.filter(({ row: extraRow }) => extraRow.sectionId === section.id).map(({ row: extraRow, asset }) => ({ ...extraRow, imageUrl: asset ? publicMediaUrl(asset.objectKey) : null })) })),
extras: sections.filter((section) => section.kind === "extra").map((section) => ({ ...section, rows: extraRows.filter(({ row: extraRow }) => extraRow.sectionId === section.id).map(({ row: extraRow, asset }) => ({ ...extraRow, imageUrl: asset ? publicMediaUrl(asset.objectKey) : null, imageWidth: asset?.width ?? null, imageHeight: asset?.height ?? null })) })),
};
}
-1
View File
@@ -137,7 +137,6 @@ export const extraSectionSchema = z.strictObject({
title: z.string().trim().min(1).max(160),
rows: z.array(z.strictObject({
mediaId: z.string().uuid().nullable(),
ratio: z.enum(["1:1", "16:9"]),
heading: z.string().trim().min(1).max(200),
body: z.string().trim().max(2_000),
percent: percent.nullable(),
+17 -2
View File
@@ -1,6 +1,7 @@
import "server-only";
import { and, eq, sql } from "drizzle-orm";
import sharp from "sharp";
import { getDb } from "@/db";
import { media } from "@/db/schema";
@@ -41,7 +42,7 @@ export async function createPendingMedia(
export async function uploadPublicMedia(
file: File,
authorId: string,
): Promise<{ id: string; url: string; fileName: string }> {
): Promise<{ id: string; url: string; fileName: string; width: number; height: number }> {
const parsed = mediaUploadSchema.parse({
fileName: file.name,
mimeType: file.type,
@@ -51,6 +52,12 @@ export async function uploadPublicMedia(
if (!hasValidImageSignature(bytes.subarray(0, 16), parsed.mimeType)) {
throw new Error("ไฟล์ไม่ตรงกับชนิดรูปภาพที่ระบุ");
}
const metadata = await sharp(bytes).metadata();
const width = metadata.autoOrient.width || metadata.width;
const height = metadata.autoOrient.height || metadata.height;
if (!width || !height) {
throw new Error("ไม่สามารถอ่านขนาดรูปภาพได้");
}
const id = crypto.randomUUID();
const objectKey = `uploads/${id}/${safeObjectFileName(parsed.fileName)}`;
@@ -63,6 +70,8 @@ export async function uploadPublicMedia(
fileName: parsed.fileName,
mimeType: parsed.mimeType,
byteSize: parsed.byteSize,
width,
height,
status: "ready",
currentReferenceCount: 0,
createdById: authorId,
@@ -71,7 +80,13 @@ export async function uploadPublicMedia(
await storage.delete(objectKey).catch(() => undefined);
throw cause;
}
return { id, url: publicMediaUrl(objectKey), fileName: parsed.fileName };
return {
id,
url: publicMediaUrl(objectKey),
fileName: parsed.fileName,
width,
height,
};
}
export async function addCurrentMediaReference(mediaId: string): Promise<void> {