feat: edit portrait and features

This commit is contained in:
trafficlunar 2026-03-29 16:54:03 +01:00
parent 5ef104904a
commit 576cb698d2
3 changed files with 132 additions and 9 deletions

View file

@ -24,6 +24,8 @@ const editSchema = z.object({
tags: tagsSchema.optional(), tags: tagsSchema.optional(),
description: z.string().trim().max(512).optional(), description: z.string().trim().max(512).optional(),
makeup: z.enum(MiiMakeup).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, instructions: switchMiiInstructionsSchema,
image1: z.union([z.instanceof(File), z.any()]).optional(), image1: z.union([z.instanceof(File), z.any()]).optional(),
image2: 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, tags: rawTags,
description: formData.get("description") ?? undefined, description: formData.get("description") ?? undefined,
makeup: formData.get("makeup") ?? undefined, makeup: formData.get("makeup") ?? undefined,
miiPortraitImage: formData.get("miiPortraitImage"),
miiFeaturesImage: formData.get("miiFeaturesImage"),
instructions: minifiedInstructions, instructions: minifiedInstructions,
image1: formData.get("image1"), image1: formData.get("image1"),
image2: formData.get("image2"), 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); 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 // Validate image files
const images: File[] = []; 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 // Edit Mii in database
const updateData: Prisma.MiiUpdateInput = {}; const updateData: Prisma.MiiUpdateInput = {};
if (name !== undefined) updateData.name = profanity.censor(name); // Censor potentially inappropriate words 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 // Only touch files if new images were uploaded
if (images.length > 0) { if (images.length > 0) {
// Ensure directories exist
const miiUploadsDirectory = path.join(uploadsDirectory, miiId.toString());
await fs.mkdir(miiUploadsDirectory, { recursive: true });
// Delete all custom images // Delete all custom images
const files = await fs.readdir(miiUploadsDirectory); const files = await fs.readdir(miiUploadsDirectory);
await Promise.all(files.filter((file) => file.startsWith("image")).map((file) => fs.unlink(path.join(miiUploadsDirectory, file)))); 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" } }); Sentry.captureException(error, { extra: { stage: "edit-custom-images" } });
return rateLimit.sendResponse({ error: "Failed to store user images" }, 500); 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 // If images or description were not changed, regenerate the metadata image
try { try {
await generateMetadataImage(updatedMii, updatedMii.user.name!); await generateMetadataImage(updatedMii, updatedMii.user.name!);

View file

@ -19,6 +19,7 @@ import Dropzone from "../dropzone";
import MiiEditor from "./mii-editor"; import MiiEditor from "./mii-editor";
import SwitchSubmitTutorialButton from "../tutorial/switch-submit"; import SwitchSubmitTutorialButton from "../tutorial/switch-submit";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import SwitchFileUpload from "./switch-file-upload";
interface Props { interface Props {
mii: Mii; mii: Mii;
@ -63,6 +64,8 @@ export default function EditForm({ mii, likes }: Props) {
const [tags, setTags] = useState(mii.tags); const [tags, setTags] = useState(mii.tags);
const [description, setDescription] = useState(mii.description); const [description, setDescription] = useState(mii.description);
const [makeup, setMakeup] = useState<MiiMakeup>(mii.makeup ?? "PARTIAL"); const [makeup, setMakeup] = useState<MiiMakeup>(mii.makeup ?? "PARTIAL");
const [miiPortraitUri, setMiiPortraitUri] = useState<string | undefined>(`/mii/${mii.id}/image?type=mii`);
const [miiFeaturesUri, setMiiFeaturesUri] = useState<string | undefined>(`/mii/${mii.id}/image?type=features`);
const hasFilesChanged = useRef(false); const hasFilesChanged = useRef(false);
const instructions = useRef<SwitchMiiInstructions>(deepMerge(defaultInstructions, (mii.instructions as object) ?? {})); const instructions = useRef<SwitchMiiInstructions>(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 (tags != mii.tags) formData.append("tags", JSON.stringify(tags));
if (description && description != mii.description) formData.append("description", description); if (description && description != mii.description) formData.append("description", description);
if (makeup != mii.makeup) formData.append("makeup", makeup); if (makeup != mii.makeup) formData.append("makeup", makeup);
if (miiPortraitUri) formData.append("miiPortraitUri", miiPortraitUri);
if (minifyInstructions(structuredClone(instructions.current)) !== (mii.instructions as object)) if (minifyInstructions(structuredClone(instructions.current)) !== (mii.instructions as object))
formData.append("instructions", JSON.stringify(instructions.current)); 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<Blob | null> {
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`, { const response = await fetch(`/api/mii/${mii.id}/edit`, {
method: "PATCH", method: "PATCH",
body: formData, body: formData,
@ -137,7 +167,13 @@ export default function EditForm({ mii, likes }: Props) {
<form className="flex justify-center gap-4 w-full max-lg:flex-col max-lg:items-center"> <form className="flex justify-center gap-4 w-full max-lg:flex-col max-lg:items-center">
<div className="flex justify-center"> <div className="flex justify-center">
<div className="w-75 h-min flex flex-col bg-zinc-50 rounded-3xl border-2 border-zinc-300 shadow-lg p-3"> <div className="w-75 h-min flex flex-col bg-zinc-50 rounded-3xl border-2 border-zinc-300 shadow-lg p-3">
<Carousel images={[`/mii/${mii.id}/image?type=mii`, `/mii/${mii.id}/image?type=qr-code`, ...files.map((file) => URL.createObjectURL(file))]} /> <Carousel
images={[
miiPortraitUri ?? `/mii/${mii.id}/image?type=mii`,
...(mii.platform === "THREE_DS" ? [`/mii/${mii.id}/image?type=qr-code`] : [miiFeaturesUri ?? `/mii/${mii.id}/image?type=features`]),
...files.map((file) => URL.createObjectURL(file)),
]}
/>
<div className="p-4 flex flex-col gap-1 h-full"> <div className="p-4 flex flex-col gap-1 h-full">
<h1 className="font-bold text-2xl line-clamp-1" title={name}> <h1 className="font-bold text-2xl line-clamp-1" title={name}>
@ -209,7 +245,7 @@ export default function EditForm({ mii, likes }: Props) {
/> />
</div> </div>
{/* Instructions (Switch only) */} {/* Makeup/Images/Instructions (Switch only) */}
{mii.platform === "SWITCH" && ( {mii.platform === "SWITCH" && (
<> <>
<div className="w-full grid grid-cols-3 items-start"> <div className="w-full grid grid-cols-3 items-start">
@ -259,6 +295,24 @@ export default function EditForm({ mii, likes }: Props) {
</div> </div>
</div> </div>
{/* (Switch Only) Mii Portrait */}
<div>
{/* Separator */}
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-8 mb-2">
<hr className="grow border-zinc-300" />
<span>Mii Portrait</span>
<hr className="grow border-zinc-300" />
</div>
<div className="flex flex-col items-center gap-2">
<SwitchFileUpload text="a screenshot of your Mii here" image={miiPortraitUri} setImage={setMiiPortraitUri} forceCrop />
<SwitchFileUpload text="a screenshot of your Mii's features here" image={miiFeaturesUri} setImage={setMiiFeaturesUri} />
<SwitchSubmitTutorialButton />
</div>
<p className="text-xs text-zinc-400 text-center mt-2">You must upload a screenshot of the features, check tutorial on how.</p>
</div>
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-8"> <div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-8">
<hr className="grow border-zinc-300" /> <hr className="grow border-zinc-300" />
<span>Instructions</span> <span>Instructions</span>

View file

@ -101,7 +101,7 @@ export default function ImageEditorPortrait({ isOpen, setIsOpen, image, setImage
<div className="mt-4 flex justify-center gap-2"> <div className="mt-4 flex justify-center gap-2">
<button type="button" onClick={close} className="pill button"> <button type="button" onClick={close} className="pill button">
Cancel Done
</button> </button>
<button type="button" onClick={applyCrop} className="pill button"> <button type="button" onClick={applyCrop} className="pill button">
Crop Crop