Compare commits

...

3 commits

Author SHA1 Message Date
576cb698d2 feat: edit portrait and features 2026-03-29 16:54:03 +01:00
5ef104904a feat: rotate images 2026-03-29 13:16:25 +01:00
6167da3703 chore: update packages 2026-03-29 12:41:46 +01:00
7 changed files with 786 additions and 617 deletions

View file

@ -11,20 +11,20 @@
"postinstall": "prisma generate" "postinstall": "prisma generate"
}, },
"dependencies": { "dependencies": {
"@2toad/profanity": "^3.2.0", "@2toad/profanity": "^3.3.0",
"@auth/prisma-adapter": "2.11.1", "@auth/prisma-adapter": "2.11.1",
"@bprogress/next": "^3.2.12", "@bprogress/next": "^3.2.12",
"@hello-pangea/dnd": "^18.0.1", "@hello-pangea/dnd": "^18.0.1",
"@prisma/client": "^6.19.2", "@prisma/client": "^6.19.2",
"@sentry/nextjs": "^10.45.0", "@sentry/nextjs": "^10.46.0",
"bit-buffer": "^0.3.0", "bit-buffer": "^0.3.0",
"canvas-confetti": "^1.9.4", "canvas-confetti": "^1.9.4",
"dayjs": "^1.11.20", "dayjs": "^1.11.20",
"downshift": "^9.3.2", "downshift": "^9.3.2",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"file-type": "^21.3.3", "file-type": "^22.0.0",
"jsqr": "^1.4.0", "jsqr": "^1.4.0",
"next": "16.2.0", "next": "16.2.1",
"next-auth": "5.0.0-beta.30", "next-auth": "5.0.0-beta.30",
"qrcode-generator": "^2.0.4", "qrcode-generator": "^2.0.4",
"react": "^19.2.4", "react": "^19.2.4",
@ -32,7 +32,7 @@
"react-dropzone": "^15.0.0", "react-dropzone": "^15.0.0",
"react-image-crop": "^11.0.10", "react-image-crop": "^11.0.10",
"redis": "^5.11.0", "redis": "^5.11.0",
"satori": "^0.25.0", "satori": "^0.26.0",
"seedrandom": "^3.0.5", "seedrandom": "^3.0.5",
"sharp": "^0.34.5", "sharp": "^0.34.5",
"sjcl-with-all": "1.0.8", "sjcl-with-all": "1.0.8",
@ -49,11 +49,11 @@
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/seedrandom": "^3.0.8", "@types/seedrandom": "^3.0.8",
"@types/sjcl": "^1.0.34", "@types/sjcl": "^1.0.34",
"eslint": "^10.0.3", "eslint": "^10.1.0",
"eslint-config-next": "16.2.0", "eslint-config-next": "16.2.1",
"prisma": "^6.19.2", "prisma": "^6.19.2",
"schema-dts": "^1.1.5", "schema-dts": "^2.0.0",
"tailwindcss": "^4.2.2", "tailwindcss": "^4.2.2",
"typescript": "^5.9.3" "typescript": "^6.0.2"
} }
} }

File diff suppressed because it is too large Load diff

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

@ -206,7 +206,7 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
<div className="p-4 flex flex-col gap-1 h-full"> <div className="p-4 flex flex-col gap-1 h-full">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<Link href={`/mii/${mii.id}`} className="relative font-bold text-2xl line-clamp-1 w-full" title={mii.name}> <Link href={`/mii/${mii.id}`} className="relative font-bold text-2xl line-clamp-1 w-full text-ellipsis wrap-break-word" title={mii.name}>
{mii.name} {mii.name}
</Link> </Link>
<div title={mii.platform === "SWITCH" ? "Switch" : "3DS"} className="-mr-3 text-[1.25rem] opacity-25"> <div title={mii.platform === "SWITCH" ? "Switch" : "3DS"} className="-mr-3 text-[1.25rem] opacity-25">

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

@ -11,7 +11,7 @@ interface Props {
setImage: React.Dispatch<React.SetStateAction<string | undefined>>; setImage: React.Dispatch<React.SetStateAction<string | undefined>>;
} }
export default function CropPortrait({ isOpen, setIsOpen, image, setImage }: Props) { export default function ImageEditorPortrait({ isOpen, setIsOpen, image, setImage }: Props) {
const [isVisible, setIsVisible] = useState(false); const [isVisible, setIsVisible] = useState(false);
const [crop, setCrop] = useState<Crop>(); const [crop, setCrop] = useState<Crop>();
@ -38,9 +38,27 @@ export default function CropPortrait({ isOpen, setIsOpen, image, setImage }: Pro
ctx.drawImage(image, crop.x * scaleX, crop.y * scaleY, crop.width * scaleX, crop.height * scaleY, 0, 0, crop.width * scaleX, crop.height * scaleY); ctx.drawImage(image, crop.x * scaleX, crop.y * scaleY, crop.width * scaleX, crop.height * scaleY, 0, 0, crop.width * scaleX, crop.height * scaleY);
setImage(canvas.toDataURL()); setImage(canvas.toDataURL());
close(); setCrop(undefined);
}, [crop, setImage]); }, [crop, setImage]);
const rotate = () => {
if (!imageRef.current || !canvasRef.current) return;
const image = imageRef.current;
const canvas = canvasRef.current;
const ctx = canvas.getContext("2d");
if (!ctx) return;
canvas.width = image.naturalHeight;
canvas.height = image.naturalWidth;
ctx.translate(canvas.width / 2, canvas.height / 2);
ctx.rotate(Math.PI / 2);
ctx.drawImage(image, -image.naturalWidth / 2, -image.naturalHeight / 2);
setImage(canvas.toDataURL());
};
const close = () => { const close = () => {
setIsVisible(false); setIsVisible(false);
setTimeout(() => { setTimeout(() => {
@ -68,7 +86,7 @@ export default function CropPortrait({ isOpen, setIsOpen, image, setImage }: Pro
}`} }`}
> >
<div className="flex justify-between items-center mb-2"> <div className="flex justify-between items-center mb-2">
<h2 className="text-xl font-bold">Crop Portrait</h2> <h2 className="text-xl font-bold">Edit Image</h2>
<button type="button" aria-label="Close" onClick={close} className="text-red-400 hover:text-red-500 text-2xl cursor-pointer"> <button type="button" aria-label="Close" onClick={close} className="text-red-400 hover:text-red-500 text-2xl cursor-pointer">
<Icon icon="material-symbols:close-rounded" /> <Icon icon="material-symbols:close-rounded" />
</button> </button>
@ -83,11 +101,14 @@ export default function CropPortrait({ isOpen, setIsOpen, image, setImage }: Pro
<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
</button> </button>
<button type="button" onClick={rotate} className="pill button">
Rotate
</button>
</div> </div>
</div> </div>
</div> </div>

View file

@ -5,7 +5,7 @@ import { FileWithPath } from "react-dropzone";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import Dropzone from "../dropzone"; import Dropzone from "../dropzone";
import Camera from "./camera"; import Camera from "./camera";
import CropPortrait from "./crop-portrait"; import ImageEditorPortrait from "./image-editor";
interface Props { interface Props {
text: string; text: string;
@ -55,8 +55,8 @@ export default function SwitchFileUpload({ text, forceCrop, image, setImage }: P
Use your camera Use your camera
</button> </button>
<button type="button" aria-label="Crop image" onClick={() => setIsCropOpen(true)} className="pill button gap-2"> <button type="button" aria-label="Crop image" onClick={() => setIsCropOpen(true)} className="pill button gap-2">
<Icon icon="material-symbols:crop" fontSize={20} /> <Icon icon="mdi:image-edit" fontSize={20} />
Crop Image Edit Image
</button> </button>
<Camera <Camera
@ -67,7 +67,7 @@ export default function SwitchFileUpload({ text, forceCrop, image, setImage }: P
if (forceCrop) setIsCropOpen(true); if (forceCrop) setIsCropOpen(true);
}} }}
/> />
<CropPortrait isOpen={isCropOpen} setIsOpen={setIsCropOpen} image={image} setImage={setImage} /> <ImageEditorPortrait isOpen={isCropOpen} setIsOpen={setIsCropOpen} image={image} setImage={setImage} />
</div> </div>
); );
} }