Compare commits

..

No commits in common. "576cb698d2f9abc4b5dfc910a2f255cd3cd396b0" and "41a31311b5aa6bf87191434b00a5c3d5ea7d4e05" have entirely different histories.

7 changed files with 617 additions and 786 deletions

View file

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

File diff suppressed because it is too large Load diff

View file

@ -24,8 +24,6 @@ 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(),
@ -78,8 +76,6 @@ 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"),
@ -87,7 +83,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, miiPortraitImage, miiFeaturesImage, instructions, image1, image2, image3 } = parsed.data;
const { name, tags, description, makeup, instructions, image1, image2, image3 } = parsed.data;
// Validate image files
const images: File[] = [];
@ -103,18 +99,6 @@ 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
@ -139,12 +123,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))));
@ -165,60 +149,7 @@ 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);
}
}
// 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) {
} else if (description === undefined) {
// If images or description were not changed, regenerate the metadata image
try {
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="flex justify-between items-center">
<Link href={`/mii/${mii.id}`} className="relative font-bold text-2xl line-clamp-1 w-full text-ellipsis wrap-break-word" title={mii.name}>
<Link href={`/mii/${mii.id}`} className="relative font-bold text-2xl line-clamp-1 w-full" title={mii.name}>
{mii.name}
</Link>
<div title={mii.platform === "SWITCH" ? "Switch" : "3DS"} className="-mr-3 text-[1.25rem] opacity-25">

View file

@ -11,7 +11,7 @@ interface Props {
setImage: React.Dispatch<React.SetStateAction<string | undefined>>;
}
export default function ImageEditorPortrait({ isOpen, setIsOpen, image, setImage }: Props) {
export default function CropPortrait({ isOpen, setIsOpen, image, setImage }: Props) {
const [isVisible, setIsVisible] = useState(false);
const [crop, setCrop] = useState<Crop>();
@ -38,27 +38,9 @@ export default function ImageEditorPortrait({ isOpen, setIsOpen, image, setImage
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());
setCrop(undefined);
close();
}, [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 = () => {
setIsVisible(false);
setTimeout(() => {
@ -86,7 +68,7 @@ export default function ImageEditorPortrait({ isOpen, setIsOpen, image, setImage
}`}
>
<div className="flex justify-between items-center mb-2">
<h2 className="text-xl font-bold">Edit Image</h2>
<h2 className="text-xl font-bold">Crop Portrait</h2>
<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" />
</button>
@ -101,14 +83,11 @@ export default function ImageEditorPortrait({ isOpen, setIsOpen, image, setImage
<div className="mt-4 flex justify-center gap-2">
<button type="button" onClick={close} className="pill button">
Done
Cancel
</button>
<button type="button" onClick={applyCrop} className="pill button">
Crop
</button>
<button type="button" onClick={rotate} className="pill button">
Rotate
</button>
</div>
</div>
</div>

View file

@ -19,7 +19,6 @@ 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;
@ -64,8 +63,6 @@ export default function EditForm({ mii, likes }: Props) {
const [tags, setTags] = useState(mii.tags);
const [description, setDescription] = useState(mii.description);
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 instructions = useRef<SwitchMiiInstructions>(deepMerge(defaultInstructions, (mii.instructions as object) ?? {}));
@ -89,7 +86,6 @@ 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));
@ -100,32 +96,6 @@ 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`, {
method: "PATCH",
body: formData,
@ -167,13 +137,7 @@ export default function EditForm({ mii, likes }: Props) {
<form className="flex justify-center gap-4 w-full max-lg:flex-col max-lg:items-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">
<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)),
]}
/>
<Carousel images={[`/mii/${mii.id}/image?type=mii`, `/mii/${mii.id}/image?type=qr-code`, ...files.map((file) => URL.createObjectURL(file))]} />
<div className="p-4 flex flex-col gap-1 h-full">
<h1 className="font-bold text-2xl line-clamp-1" title={name}>
@ -245,7 +209,7 @@ export default function EditForm({ mii, likes }: Props) {
/>
</div>
{/* Makeup/Images/Instructions (Switch only) */}
{/* Instructions (Switch only) */}
{mii.platform === "SWITCH" && (
<>
<div className="w-full grid grid-cols-3 items-start">
@ -295,24 +259,6 @@ export default function EditForm({ mii, likes }: Props) {
</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">
<hr className="grow border-zinc-300" />
<span>Instructions</span>

View file

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