mirror of
https://github.com/trafficlunar/tomodachi-share.git
synced 2026-05-13 21:27:46 +00:00
feat: edit portrait and features
This commit is contained in:
parent
5ef104904a
commit
576cb698d2
3 changed files with 132 additions and 9 deletions
|
|
@ -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!);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue