Compare commits

..

5 commits

10 changed files with 57 additions and 53 deletions

View file

@ -118,7 +118,9 @@ export async function POST(request: NextRequest) {
}); });
if (!parsed.success) { if (!parsed.success) {
const error = parsed.error.issues[0].message; const firstIssue = parsed.error.issues[0];
const path = firstIssue.path.length ? firstIssue.path.join(".") : "root";
const error = `${path}: ${firstIssue.message}`;
const issues = parsed.error.issues; const issues = parsed.error.issues;
const hasInstructionsErrors = issues.some((issue) => issue.path[0] === "instructions"); const hasInstructionsErrors = issues.some((issue) => issue.path[0] === "instructions");
@ -159,7 +161,7 @@ export async function POST(request: NextRequest) {
if (imageValidation.valid) { if (imageValidation.valid) {
customImages.push(img); customImages.push(img);
} else { } else {
return rateLimit.sendResponse({ error: imageValidation.error }, imageValidation.status ?? 400); return rateLimit.sendResponse({ error: `Failed to verify custom image: ${imageValidation.error}` }, imageValidation.status ?? 400);
} }
} }
@ -167,8 +169,10 @@ export async function POST(request: NextRequest) {
if (platform === "SWITCH") { if (platform === "SWITCH") {
const portraitValidation = await validateImage(miiPortraitImage); const portraitValidation = await validateImage(miiPortraitImage);
const featuresValidation = await validateImage(miiFeaturesImage); const featuresValidation = await validateImage(miiFeaturesImage);
if (!portraitValidation.valid) return rateLimit.sendResponse({ error: portraitValidation.error }, portraitValidation.status ?? 400); if (!portraitValidation.valid)
if (!featuresValidation.valid) return rateLimit.sendResponse({ error: featuresValidation.error }, featuresValidation.status ?? 400); return rateLimit.sendResponse({ error: `Failed to verify portrait: ${portraitValidation.error}` }, portraitValidation.status ?? 400);
if (!featuresValidation.valid)
return rateLimit.sendResponse({ error: `Failed to verify features: ${featuresValidation.error}` }, featuresValidation.status ?? 400);
} }
const qrBytes = new Uint8Array(qrBytesRaw ?? []); const qrBytes = new Uint8Array(qrBytesRaw ?? []);
@ -242,7 +246,6 @@ export async function POST(request: NextRequest) {
const fileLocation = path.join(miiUploadsDirectory, "mii.png"); const fileLocation = path.join(miiUploadsDirectory, "mii.png");
await fs.writeFile(fileLocation, pngBuffer); await fs.writeFile(fileLocation, pngBuffer);
await generateMetadataImage(miiRecord, session.user?.name!);
} catch (error) { } catch (error) {
// Clean up if something went wrong // Clean up if something went wrong
await prisma.mii.delete({ where: { id: miiRecord.id } }); await prisma.mii.delete({ where: { id: miiRecord.id } });
@ -252,6 +255,13 @@ export async function POST(request: NextRequest) {
return rateLimit.sendResponse({ error: "Failed to download/store Mii portrait/features" }, 500); return rateLimit.sendResponse({ error: "Failed to download/store Mii portrait/features" }, 500);
} }
try {
await generateMetadataImage(miiRecord, session.user?.name!);
} catch (error) {
console.error("Failed to generate metadata image:", error);
Sentry.captureException(error, { extra: { miiId: miiRecord.id, stage: "metadata-image-generation" } });
}
if (platform === "THREE_DS") { if (platform === "THREE_DS") {
try { try {
// Generate a new QR code for aesthetic reasons // Generate a new QR code for aesthetic reasons

View file

@ -150,13 +150,13 @@ export default function MiiInstructions({ instructions }: Props) {
{eyebrows && <Section name="Eyebrows" instructions={eyebrows}></Section>} {eyebrows && <Section name="Eyebrows" instructions={eyebrows}></Section>}
{eyes && ( {eyes && (
<Section name="Eyes" instructions={eyes}> <Section name="Eyes" instructions={eyes}>
<Section isSubSection name="Main" instructions={eyes.main} /> <Section isSubSection name="Tab 1" instructions={eyes.main} />
<Section isSubSection name="Eyelashes Top" instructions={eyes.eyelashesTop} /> <Section isSubSection name="Tab 2" instructions={eyes.eyelashesTop} />
<Section isSubSection name="Eyelashes Bottom" instructions={eyes.eyelashesBottom} /> <Section isSubSection name="Tab 3" instructions={eyes.eyelashesBottom} />
<Section isSubSection name="Eyelid Top" instructions={eyes.eyelidTop} /> <Section isSubSection name="Tab 4" instructions={eyes.eyelidTop} />
<Section isSubSection name="Eyelid Bottom" instructions={eyes.eyelidBottom} /> <Section isSubSection name="Tab 5" instructions={eyes.eyelidBottom} />
<Section isSubSection name="Eyeliner" instructions={eyes.eyeliner} /> <Section isSubSection name="Tab 6" instructions={eyes.eyeliner} />
<Section isSubSection name="Pupil" instructions={eyes.pupil} /> <Section isSubSection name="Tab 7" instructions={eyes.pupil} />
</Section> </Section>
)} )}
{nose && <Section name="Nose" instructions={nose}></Section>} {nose && <Section name="Nose" instructions={nose}></Section>}
@ -182,16 +182,16 @@ export default function MiiInstructions({ instructions }: Props) {
)} )}
{other && ( {other && (
<Section name="Other" instructions={other}> <Section name="Other" instructions={other}>
<Section isSubSection name="Wrinkles 1" instructions={other.wrinkles1} /> <Section isSubSection name="Tab 1" instructions={other.wrinkles1} />
<Section isSubSection name="Wrinkles 2" instructions={other.wrinkles2} /> <Section isSubSection name="Tab 2" instructions={other.wrinkles2} />
<Section isSubSection name="Beard" instructions={other.beard} /> <Section isSubSection name="Tab 3" instructions={other.beard} />
<Section isSubSection name="Moustache" instructions={other.moustache}> <Section isSubSection name="Tab 4" instructions={other.moustache}>
{other.moustache && other.moustache.isFlipped && <TableCell label="Flipped">{other.moustache.isFlipped ? "Yes" : "No"}</TableCell>} {other.moustache && other.moustache.isFlipped && <TableCell label="Flipped">{other.moustache.isFlipped ? "Yes" : "No"}</TableCell>}
</Section> </Section>
<Section isSubSection name="Goatee" instructions={other.goatee} /> <Section isSubSection name="Tab 5" instructions={other.goatee} />
<Section isSubSection name="Mole" instructions={other.mole} /> <Section isSubSection name="Tab 6" instructions={other.mole} />
<Section isSubSection name="Eye Shadow" instructions={other.eyeShadow} /> <Section isSubSection name="Tab 7" instructions={other.eyeShadow} />
<Section isSubSection name="Blush" instructions={other.blush} /> <Section isSubSection name="Tab 8" instructions={other.blush} />
</Section> </Section>
)} )}

View file

@ -64,8 +64,6 @@ export default function EditForm({ mii, likes }: Props) {
if (name != mii.name) formData.append("name", name); if (name != mii.name) formData.append("name", name);
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);
console.log(minifyInstructions(structuredClone(instructions.current)));
console.log(mii.instructions);
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));

View file

@ -336,8 +336,8 @@ export default function SubmitForm() {
</div> </div>
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
<SwitchFileUpload text="a screenshot of your Mii here" image={miiPortraitUri} setImage={setMiiPortraitUri} hasCrop /> <SwitchFileUpload text="a screenshot of your Mii here" image={miiPortraitUri} setImage={setMiiPortraitUri} forceCrop />
<SwitchFileUpload text="a screenshot of your Mii's features here" setImage={setMiiFeaturesUri} /> <SwitchFileUpload text="a screenshot of your Mii's features here" image={miiFeaturesUri} setImage={setMiiFeaturesUri} />
<SwitchSubmitTutorialButton /> <SwitchSubmitTutorialButton />
</div> </div>

View file

@ -23,8 +23,8 @@ export default function NumberInputs({ target }: Props) {
<input <input
type="number" type="number"
id="height" id="height"
min={-5} min={-15}
max={5} max={15}
value={height} value={height}
onChange={(e) => { onChange={(e) => {
const value = Number(e.target.value); const value = Number(e.target.value);
@ -44,8 +44,8 @@ export default function NumberInputs({ target }: Props) {
<input <input
type="number" type="number"
id="distance" id="distance"
min={-5} min={-15}
max={5} max={15}
value={distance} value={distance}
onChange={(e) => { onChange={(e) => {
const value = Number(e.target.value); const value = Number(e.target.value);
@ -65,8 +65,8 @@ export default function NumberInputs({ target }: Props) {
<input <input
type="number" type="number"
id="rotation" id="rotation"
min={-5} min={-15}
max={5} max={15}
value={rotation} value={rotation}
onChange={(e) => { onChange={(e) => {
const value = Number(e.target.value); const value = Number(e.target.value);
@ -86,8 +86,8 @@ export default function NumberInputs({ target }: Props) {
<input <input
type="number" type="number"
id="size" id="size"
min={-5} min={-15}
max={5} max={15}
value={size} value={size}
onChange={(e) => { onChange={(e) => {
const value = Number(e.target.value); const value = Number(e.target.value);
@ -107,8 +107,8 @@ export default function NumberInputs({ target }: Props) {
<input <input
type="number" type="number"
id="stretch" id="stretch"
min={-5} min={-15}
max={5} max={15}
value={stretch} value={stretch}
onChange={(e) => { onChange={(e) => {
const value = Number(e.target.value); const value = Number(e.target.value);

View file

@ -21,8 +21,8 @@ export default function EyesTab({ instructions }: Props) {
const [tab, setTab] = useState(0); const [tab, setTab] = useState(0);
const [colors, setColors] = useState<number[]>(() => const [colors, setColors] = useState<number[]>(() =>
TABS.map((t) => { TABS.map((t) => {
const entry = instructions.current.eyes[t.name]; const entry = instructions.current.eyes[t.name] ?? {};
const color = "color" in entry ? entry.color : null; const color = entry && "color" in entry ? entry.color : null;
return color ?? 122; return color ?? 122;
}), }),
); );

View file

@ -24,8 +24,8 @@ export default function OtherTab({ instructions }: Props) {
const [colors, setColors] = useState<number[]>(() => const [colors, setColors] = useState<number[]>(() =>
TABS.map((t) => { TABS.map((t) => {
const entry = instructions.current.other[t.name]; const entry = instructions.current.other[t.name] ?? {};
const color = "color" in entry ? entry.color : null; const color = entry && "color" in entry ? entry.color : null;
return color ?? t.defaultColor ?? 0; return color ?? t.defaultColor ?? 0;
}), }),
); );

View file

@ -9,12 +9,12 @@ import CropPortrait from "./crop-portrait";
interface Props { interface Props {
text: string; text: string;
hasCrop?: boolean; forceCrop?: boolean;
image?: string | undefined; image?: string | undefined;
setImage: React.Dispatch<React.SetStateAction<string | undefined>>; setImage: React.Dispatch<React.SetStateAction<string | undefined>>;
} }
export default function SwitchFileUpload({ text, hasCrop = false, image, setImage }: Props) { export default function SwitchFileUpload({ text, forceCrop, image, setImage }: Props) {
const [isCameraOpen, setIsCameraOpen] = useState(false); const [isCameraOpen, setIsCameraOpen] = useState(false);
const [isCropOpen, setIsCropOpen] = useState(false); const [isCropOpen, setIsCropOpen] = useState(false);
const [hasImage, setHasImage] = useState(false); const [hasImage, setHasImage] = useState(false);
@ -27,7 +27,7 @@ export default function SwitchFileUpload({ text, hasCrop = false, image, setImag
reader.onload = async (event) => { reader.onload = async (event) => {
setImage(event.target!.result as string); setImage(event.target!.result as string);
setHasImage(true); setHasImage(true);
if (hasCrop) setIsCropOpen(true); if (forceCrop) setIsCropOpen(true);
}; };
reader.readAsDataURL(file); reader.readAsDataURL(file);
}, },
@ -36,7 +36,7 @@ export default function SwitchFileUpload({ text, hasCrop = false, image, setImag
useEffect(() => { useEffect(() => {
if (!isCameraOpen) return; if (!isCameraOpen) return;
if (hasCrop) setIsCropOpen(true); if (forceCrop) setIsCropOpen(true);
}, [isCameraOpen]); }, [isCameraOpen]);
return ( return (
@ -61,17 +61,13 @@ export default function SwitchFileUpload({ text, hasCrop = false, image, setImag
<Icon icon="mdi:camera" fontSize={20} /> <Icon icon="mdi:camera" fontSize={20} />
Use your camera Use your camera
</button> </button>
{hasCrop && (
<>
<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="material-symbols:crop" fontSize={20} />
Crop Image Crop Image
</button> </button>
<CropPortrait isOpen={isCropOpen} setIsOpen={setIsCropOpen} image={image} setImage={setImage} />
</>
)}
<Camera isOpen={isCameraOpen} setIsOpen={setIsCameraOpen} setImage={setImage} /> <Camera isOpen={isCameraOpen} setIsOpen={setIsCameraOpen} setImage={setImage} />
<CropPortrait isOpen={isCropOpen} setIsOpen={setIsCropOpen} image={image} setImage={setImage} />
</div> </div>
); );
} }

View file

@ -17,7 +17,7 @@ import { Mii } from "@prisma/client";
const MIN_IMAGE_DIMENSIONS = [128, 128]; const MIN_IMAGE_DIMENSIONS = [128, 128];
const MAX_IMAGE_DIMENSIONS = [2000, 2000]; const MAX_IMAGE_DIMENSIONS = [2000, 2000];
const MAX_IMAGE_SIZE = 4 * 1024 * 1024; // 4 MB const MAX_IMAGE_SIZE = 8 * 1024 * 1024; // 8 MB
const ALLOWED_MIME_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"]; const ALLOWED_MIME_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"];
//#region Image validation //#region Image validation

View file

@ -84,7 +84,7 @@ export const userNameSchema = z
}); });
const colorSchema = z.number().int().min(0).max(152).optional(); const colorSchema = z.number().int().min(0).max(152).optional();
const geometrySchema = z.number().int().min(-10).max(10).optional(); const geometrySchema = z.number().int().min(-15).max(15).optional();
export const switchMiiInstructionsSchema = z export const switchMiiInstructionsSchema = z
.object({ .object({