From 576cb698d2f9abc4b5dfc910a2f255cd3cd396b0 Mon Sep 17 00:00:00 2001 From: trafficlunar Date: Sun, 29 Mar 2026 16:54:03 +0100 Subject: [PATCH] feat: edit portrait and features --- src/app/api/mii/[id]/edit/route.ts | 81 +++++++++++++++++++-- src/components/submit-form/edit-form.tsx | 58 ++++++++++++++- src/components/submit-form/image-editor.tsx | 2 +- 3 files changed, 132 insertions(+), 9 deletions(-) diff --git a/src/app/api/mii/[id]/edit/route.ts b/src/app/api/mii/[id]/edit/route.ts index e4987cf..105eb83 100644 --- a/src/app/api/mii/[id]/edit/route.ts +++ b/src/app/api/mii/[id]/edit/route.ts @@ -24,6 +24,8 @@ const editSchema = z.object({ tags: tagsSchema.optional(), description: z.string().trim().max(512).optional(), makeup: z.enum(MiiMakeup).optional(), + miiPortraitImage: z.union([z.instanceof(File), z.any()]).optional(), + miiFeaturesImage: z.union([z.instanceof(File), z.any()]).optional(), instructions: switchMiiInstructionsSchema, image1: z.union([z.instanceof(File), z.any()]).optional(), image2: z.union([z.instanceof(File), z.any()]).optional(), @@ -76,6 +78,8 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< tags: rawTags, description: formData.get("description") ?? undefined, makeup: formData.get("makeup") ?? undefined, + miiPortraitImage: formData.get("miiPortraitImage"), + miiFeaturesImage: formData.get("miiFeaturesImage"), instructions: minifiedInstructions, image1: formData.get("image1"), image2: formData.get("image2"), @@ -83,7 +87,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< }); if (!parsed.success) return rateLimit.sendResponse({ error: parsed.error.issues[0].message }, 400); - const { name, tags, description, makeup, instructions, image1, image2, image3 } = parsed.data; + const { name, tags, description, makeup, miiPortraitImage, miiFeaturesImage, instructions, image1, image2, image3 } = parsed.data; // Validate image files const images: File[] = []; @@ -99,6 +103,18 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< } } + // Check Mii portrait & features image (Switch) + if (mii.platform === "SWITCH") { + if (miiPortraitImage) { + const validation = await validateImage(miiPortraitImage); + if (!validation.valid) return rateLimit.sendResponse({ error: `Failed to verify portrait: ${validation.error}` }, validation.status ?? 400); + } + if (miiFeaturesImage) { + const validation = await validateImage(miiFeaturesImage); + if (!validation.valid) return rateLimit.sendResponse({ error: `Failed to verify features: ${validation.error}` }, validation.status ?? 400); + } + } + // Edit Mii in database const updateData: Prisma.MiiUpdateInput = {}; if (name !== undefined) updateData.name = profanity.censor(name); // Censor potentially inappropriate words @@ -123,12 +139,12 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< }, }); + // Ensure directories exist + const miiUploadsDirectory = path.join(uploadsDirectory, miiId.toString()); + await fs.mkdir(miiUploadsDirectory, { recursive: true }); + // Only touch files if new images were uploaded if (images.length > 0) { - // Ensure directories exist - const miiUploadsDirectory = path.join(uploadsDirectory, miiId.toString()); - await fs.mkdir(miiUploadsDirectory, { recursive: true }); - // Delete all custom images const files = await fs.readdir(miiUploadsDirectory); await Promise.all(files.filter((file) => file.startsWith("image")).map((file) => fs.unlink(path.join(miiUploadsDirectory, file)))); @@ -149,7 +165,60 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< Sentry.captureException(error, { extra: { stage: "edit-custom-images" } }); return rateLimit.sendResponse({ error: "Failed to store user images" }, 500); } - } else if (description === undefined) { + } + + // Only save portrait & features for Switch Miis when they are provided + if (mii.platform === "SWITCH" && (miiPortraitImage || miiFeaturesImage)) { + try { + // Delete existing portrait/features if they're being replaced + await Promise.all( + ["mii.png", "features.png"] + .filter((file) => { + if (file === "mii.png") return miiPortraitImage; + if (file === "features.png") return miiFeaturesImage; + return false; + }) + .map((file) => fs.unlink(path.join(miiUploadsDirectory, file))), + ); + + await Promise.all( + [ + miiPortraitImage && + (async () => { + const portraitBuffer = Buffer.from(await miiPortraitImage.arrayBuffer()); + const pngBuffer = await sharp(portraitBuffer) + .resize({ + height: 500, + fit: "inside", + withoutEnlargement: true, + }) + .png({ quality: 85 }) + .toBuffer(); + await fs.writeFile(path.join(miiUploadsDirectory, "mii.png"), pngBuffer); + })(), + miiFeaturesImage && + (async () => { + const featuresBuffer = Buffer.from(await miiFeaturesImage.arrayBuffer()); + const pngBuffer = await sharp(featuresBuffer) + .resize({ + height: 800, + fit: "inside", + withoutEnlargement: true, + }) + .png({ quality: 85 }) + .toBuffer(); + await fs.writeFile(path.join(miiUploadsDirectory, "features.png"), pngBuffer); + })(), + ].filter(Boolean), + ); + } catch (error) { + console.error("Error uploading portrait/features images:", error); + Sentry.captureException(error, { extra: { stage: "edit-portrait-features" } }); + return rateLimit.sendResponse({ error: "Failed to store portrait/features images" }, 500); + } + } + + if (description === undefined) { // If images or description were not changed, regenerate the metadata image try { await generateMetadataImage(updatedMii, updatedMii.user.name!); diff --git a/src/components/submit-form/edit-form.tsx b/src/components/submit-form/edit-form.tsx index d841b6b..3f60da2 100644 --- a/src/components/submit-form/edit-form.tsx +++ b/src/components/submit-form/edit-form.tsx @@ -19,6 +19,7 @@ import Dropzone from "../dropzone"; import MiiEditor from "./mii-editor"; import SwitchSubmitTutorialButton from "../tutorial/switch-submit"; import { Icon } from "@iconify/react"; +import SwitchFileUpload from "./switch-file-upload"; interface Props { mii: Mii; @@ -63,6 +64,8 @@ export default function EditForm({ mii, likes }: Props) { const [tags, setTags] = useState(mii.tags); const [description, setDescription] = useState(mii.description); const [makeup, setMakeup] = useState(mii.makeup ?? "PARTIAL"); + const [miiPortraitUri, setMiiPortraitUri] = useState(`/mii/${mii.id}/image?type=mii`); + const [miiFeaturesUri, setMiiFeaturesUri] = useState(`/mii/${mii.id}/image?type=features`); const hasFilesChanged = useRef(false); const instructions = useRef(deepMerge(defaultInstructions, (mii.instructions as object) ?? {})); @@ -86,6 +89,7 @@ export default function EditForm({ mii, likes }: Props) { if (tags != mii.tags) formData.append("tags", JSON.stringify(tags)); if (description && description != mii.description) formData.append("description", description); if (makeup != mii.makeup) formData.append("makeup", makeup); + if (miiPortraitUri) formData.append("miiPortraitUri", miiPortraitUri); if (minifyInstructions(structuredClone(instructions.current)) !== (mii.instructions as object)) formData.append("instructions", JSON.stringify(instructions.current)); @@ -96,6 +100,32 @@ export default function EditForm({ mii, likes }: Props) { }); } + // Switch pictures + async function getBlob(uri: string): Promise { + const response = await fetch(uri); + if (!response.ok) { + setError("Failed to get Mii portrait/features screenshot. Did you upload one?"); + return null; + } + + const blob = await response.blob(); + if (!blob.type.startsWith("image/")) { + setError("Invalid image file found"); + return null; + } + + return blob; + } + + if (miiPortraitUri) { + const blob = await getBlob(miiPortraitUri); + if (blob) formData.append("miiPortraitImage", blob); + } + if (miiFeaturesUri) { + const blob = await getBlob(miiFeaturesUri); + if (blob) formData.append("miiFeaturesImage", blob); + } + const response = await fetch(`/api/mii/${mii.id}/edit`, { method: "PATCH", body: formData, @@ -137,7 +167,13 @@ export default function EditForm({ mii, likes }: Props) {
- URL.createObjectURL(file))]} /> + URL.createObjectURL(file)), + ]} + />

@@ -209,7 +245,7 @@ export default function EditForm({ mii, likes }: Props) { />

- {/* Instructions (Switch only) */} + {/* Makeup/Images/Instructions (Switch only) */} {mii.platform === "SWITCH" && ( <>
@@ -259,6 +295,24 @@ export default function EditForm({ mii, likes }: Props) {
+ {/* (Switch Only) Mii Portrait */} +
+ {/* Separator */} +
+
+ Mii Portrait +
+
+ +
+ + + +
+ +

You must upload a screenshot of the features, check tutorial on how.

+
+

Instructions diff --git a/src/components/submit-form/image-editor.tsx b/src/components/submit-form/image-editor.tsx index df1069a..e47d214 100644 --- a/src/components/submit-form/image-editor.tsx +++ b/src/components/submit-form/image-editor.tsx @@ -101,7 +101,7 @@ export default function ImageEditorPortrait({ isOpen, setIsOpen, image, setImage