feat(extra) : detect uploaded image ratio
This commit is contained in:
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -13,6 +13,8 @@ export interface UploadedMedia {
|
||||
id: string;
|
||||
url: string;
|
||||
fileName: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export function MediaUploader({
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "guides"."extra_section_row" DROP COLUMN "ratio";
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 })) })),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user