diff --git a/prisma/migrations/20260326221722_bad_quality_report/migration.sql b/prisma/migrations/20260326221722_bad_quality_report/migration.sql
new file mode 100644
index 0000000..cbc949c
--- /dev/null
+++ b/prisma/migrations/20260326221722_bad_quality_report/migration.sql
@@ -0,0 +1,2 @@
+-- AlterEnum
+ALTER TYPE "ReportReason" ADD VALUE 'BAD_QUALITY';
diff --git a/prisma/migrations/20260328112742_makeup_filter/migration.sql b/prisma/migrations/20260328112742_makeup_filter/migration.sql
new file mode 100644
index 0000000..c331452
--- /dev/null
+++ b/prisma/migrations/20260328112742_makeup_filter/migration.sql
@@ -0,0 +1,5 @@
+-- CreateEnum
+CREATE TYPE "MiiMakeup" AS ENUM ('FULL', 'PARTIAL', 'NONE');
+
+-- AlterTable
+ALTER TABLE "miis" ADD COLUMN "makeup" "MiiMakeup";
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index a5aeeba..f4b5dfe 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -76,10 +76,12 @@ model Mii {
description String? @db.VarChar(256)
platform MiiPlatform @default(THREE_DS)
- instructions Json?
+ instructions Json?
+ gender MiiGender?
+ makeup MiiMakeup?
+
firstName String?
lastName String?
- gender MiiGender?
islandName String?
allowedCopying Boolean?
@@ -166,6 +168,12 @@ enum MiiGender {
NONBINARY
}
+enum MiiMakeup {
+ FULL
+ PARTIAL
+ NONE
+}
+
enum ReportType {
MII
USER
@@ -175,6 +183,7 @@ enum ReportReason {
INAPPROPRIATE
SPAM
COPYRIGHT
+ BAD_QUALITY
OTHER
}
diff --git a/src/app/api/report/route.ts b/src/app/api/report/route.ts
index 3897082..4973e12 100644
--- a/src/app/api/report/route.ts
+++ b/src/app/api/report/route.ts
@@ -10,8 +10,8 @@ import { RateLimit } from "@/lib/rate-limit";
const reportSchema = z.object({
id: z.coerce.number({ error: "ID must be a number" }).int({ error: "ID must be an integer" }).positive({ error: "ID must be valid" }),
type: z.enum(["mii", "user"], { error: "Type must be either 'mii' or 'user'" }),
- reason: z.enum(["inappropriate", "spam", "copyright", "other"], {
- message: "Reason must be either 'inappropriate', 'spam', 'copyright', or 'other'",
+ reason: z.enum(["inappropriate", "spam", "copyright", "bad_quality", "other"], {
+ message: "Reason must be either 'inappropriate', 'spam', 'copyright', 'bad_quality' or 'other'",
}),
notes: z.string().trim().max(256).optional(),
});
diff --git a/src/app/api/submit/route.ts b/src/app/api/submit/route.ts
index 3dadc43..2507ca2 100644
--- a/src/app/api/submit/route.ts
+++ b/src/app/api/submit/route.ts
@@ -8,7 +8,7 @@ import sharp from "sharp";
import qrcode from "qrcode-generator";
import { profanity } from "@2toad/profanity";
-import { MiiGender, MiiPlatform } from "@prisma/client";
+import { MiiGender, MiiMakeup, MiiPlatform } from "@prisma/client";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
@@ -33,6 +33,7 @@ const submitSchema = z
// Switch
gender: z.enum(MiiGender).default("MALE"),
+ makeup: z.enum(MiiMakeup).default("NONE"),
miiPortraitImage: z.union([z.instanceof(File), z.any()]).optional(),
miiFeaturesImage: z.union([z.instanceof(File), z.any()]).optional(),
instructions: switchMiiInstructionsSchema,
@@ -106,6 +107,7 @@ export async function POST(request: NextRequest) {
description: formData.get("description"),
gender: formData.get("gender") ?? undefined, // ZOD MOMENT
+ makeup: formData.get("makeup") ?? undefined,
miiPortraitImage: formData.get("miiPortraitImage"),
miiFeaturesImage: formData.get("miiFeaturesImage"),
instructions: minifiedInstructions,
@@ -139,6 +141,7 @@ export async function POST(request: NextRequest) {
description: uncensoredDescription,
qrBytesRaw,
gender,
+ makeup,
miiPortraitImage,
miiFeaturesImage,
image1,
@@ -209,6 +212,7 @@ export async function POST(request: NextRequest) {
}
: {
instructions: minifiedInstructions,
+ makeup: makeup ?? "NONE",
}),
},
});
diff --git a/src/app/mii/[id]/page.tsx b/src/app/mii/[id]/page.tsx
index 834bced..7eb0a06 100644
--- a/src/app/mii/[id]/page.tsx
+++ b/src/app/mii/[id]/page.tsx
@@ -253,6 +253,59 @@ export default async function MiiPage({ params }: Props) {
)}
+
+ {/* Makeup */}
+ {mii.platform === "SWITCH" && (
+ <>
+
+
+ Makeup
+
+
+
+
+ {/* Tooltip */}
+
+ {mii.makeup === "FULL" ? "Full Makeup" : mii.makeup === "PARTIAL" ? "Partial Makeup" : "No Makeup"}
+
+
+ {/* Full Makeup */}
+
+
+
+
+ {/* Partial Makeup */}
+
+
+
+
+ {/* No Makeup */}
+
+
+
+
+ >
+ )}
diff --git a/src/app/page.tsx b/src/app/page.tsx
index e92c503..ff7d46a 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -10,6 +10,8 @@ import Countdown from "@/components/countdown";
import MiiList from "@/components/mii/list";
import Skeleton from "@/components/mii/list/skeleton";
+export const revalidate = 60;
+
interface Props {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}
diff --git a/src/app/terms-of-service/page.tsx b/src/app/terms-of-service/page.tsx
index 2ff6ca4..47353c2 100644
--- a/src/app/terms-of-service/page.tsx
+++ b/src/app/terms-of-service/page.tsx
@@ -10,7 +10,7 @@ export default function PrivacyPage() {
Terms of Service
- Effective Date: May 02, 2025
+ Effective Date: March 26, 2026
@@ -41,6 +41,7 @@ export default function PrivacyPage() {
No impersonation of others.
No malware, malicious links, or phishing content.
No harassment, hate speech, threats, or bullying towards others.
+
Miis must be high quality: for example, not following all instructions on the submit form correctly.
Avoid using inappropriate language. Profanity may be automatically censored.
No use of automated scripts, bots, or scrapers to access or interact with the site.
diff --git a/src/components/mii/list/filter-menu.tsx b/src/components/mii/list/filter-menu.tsx
index e1cbe69..e178994 100644
--- a/src/components/mii/list/filter-menu.tsx
+++ b/src/components/mii/list/filter-menu.tsx
@@ -4,12 +4,13 @@ import { useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { Icon } from "@iconify/react";
-import { MiiGender, MiiPlatform } from "@prisma/client";
+import { MiiGender, MiiMakeup, MiiPlatform } from "@prisma/client";
import PlatformSelect from "./platform-select";
import TagFilter from "./tag-filter";
import GenderSelect from "./gender-select";
import OtherFilters from "./other-filters";
+import MakeupSelect from "./makeup-select";
export default function FilterMenu() {
const searchParams = useSearchParams();
@@ -19,6 +20,7 @@ export default function FilterMenu() {
const platform = (searchParams.get("platform") as MiiPlatform) || undefined;
const gender = (searchParams.get("gender") as MiiGender) || undefined;
+ const makeup = (searchParams.get("makeup") as MiiMakeup) || undefined;
const rawTags = searchParams.get("tags") || "";
const rawExclude = searchParams.get("exclude") || "";
const allowCopying = (searchParams.get("allowCopying") as unknown as boolean) || false;
@@ -66,9 +68,10 @@ export default function FilterMenu() {
if (platform) count++;
if (gender) count++;
if (allowCopying) count++;
+ if (makeup) count++;
setFilterCount(count);
- }, [tags, exclude, platform, gender, allowCopying]);
+ }, [tags, exclude, platform, gender, allowCopying, makeup]);
return (
@@ -114,6 +117,16 @@ export default function FilterMenu() {
+ {platform !== "THREE_DS" && (
+ <>
+
+
+ Makeup
+
+
+
+ >
+ )}
{platform !== "SWITCH" && (
<>
diff --git a/src/components/mii/list/index.tsx b/src/components/mii/list/index.tsx
index 3be9b00..555d7ec 100644
--- a/src/components/mii/list/index.tsx
+++ b/src/components/mii/list/index.tsx
@@ -1,7 +1,6 @@
-import { headers } from "next/headers";
import Link from "next/link";
-import { MiiGender, MiiPlatform, Prisma } from "@prisma/client";
+import { Prisma } from "@prisma/client";
import { Icon } from "@iconify/react";
import crypto from "crypto";
@@ -29,7 +28,7 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
const parsed = searchSchema.safeParse(searchParams);
if (!parsed.success) return
{parsed.error.issues[0].message}
;
- const { q: query, sort, tags, exclude, platform, gender, allowCopying, page = 1, limit = 24, seed } = parsed.data;
+ const { q: query, sort, tags, exclude, platform, gender, makeup, allowCopying, page = 1, limit = 24, seed } = parsed.data;
// My Likes page
let miiIdsLiked: number[] | undefined = undefined;
@@ -58,6 +57,8 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
...(gender && { gender: { equals: gender } }),
// Allow Copying
...(allowCopying && { allowedCopying: true }),
+ // Makeup
+ ...(makeup && { makeup: { equals: makeup } }),
// Profiles
...(userId && { userId }),
};
@@ -79,6 +80,7 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
tags: true,
createdAt: true,
gender: true,
+ makeup: true,
allowedCopying: true,
// Mii liked check
...(session?.user?.id && {
diff --git a/src/components/mii/list/makeup-select.tsx b/src/components/mii/list/makeup-select.tsx
new file mode 100644
index 0000000..80ae9ee
--- /dev/null
+++ b/src/components/mii/list/makeup-select.tsx
@@ -0,0 +1,75 @@
+"use client";
+
+import { useRouter, useSearchParams } from "next/navigation";
+import { useState, useTransition } from "react";
+import { Icon } from "@iconify/react";
+import { MiiMakeup, MiiPlatform } from "@prisma/client";
+
+export default function MakeupSelect() {
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const [, startTransition] = useTransition();
+
+ const [selected, setSelected] = useState
((searchParams.get("makeup") as MiiMakeup) ?? null);
+
+ const handleClick = (makeup: MiiMakeup) => {
+ const filter = selected === makeup ? null : makeup;
+ setSelected(filter);
+
+ const params = new URLSearchParams(searchParams);
+ params.set("page", "1");
+
+ if (filter) {
+ params.set("makeup", filter);
+ } else {
+ params.delete("makeup");
+ }
+
+ startTransition(() => {
+ router.push(`?${params.toString()}`, { scroll: false });
+ });
+ };
+
+ return (
+
+ {/* Full Makeup */}
+
+
+ {/* Partial Makeup */}
+
+
+ {/* No Makeup */}
+
+
+ );
+}
diff --git a/src/components/report/reason-selector.tsx b/src/components/report/reason-selector.tsx
index 0da7e22..5121ab3 100644
--- a/src/components/report/reason-selector.tsx
+++ b/src/components/report/reason-selector.tsx
@@ -13,6 +13,7 @@ const reasonMap: Record = {
INAPPROPRIATE: "Inappropriate content",
SPAM: "Spam",
COPYRIGHT: "Copyrighted content",
+ BAD_QUALITY: "Bad quality",
OTHER: "Other...",
};
diff --git a/src/components/submit-form/index.tsx b/src/components/submit-form/index.tsx
index 7937486..b0204f9 100644
--- a/src/components/submit-form/index.tsx
+++ b/src/components/submit-form/index.tsx
@@ -7,7 +7,7 @@ import { FileWithPath } from "react-dropzone";
import { Icon } from "@iconify/react";
import qrcode from "qrcode-generator";
-import { MiiGender, MiiPlatform } from "@prisma/client";
+import { MiiGender, MiiMakeup, MiiPlatform } from "@prisma/client";
import { nameSchema, tagsSchema } from "@/lib/schemas";
import { convertQrCode } from "@/lib/qr-codes";
@@ -52,6 +52,7 @@ export default function SubmitForm() {
const [platform, setPlatform] = useState("SWITCH");
const [gender, setGender] = useState("MALE");
+ const [makeup, setMakeup] = useState("NONE");
const instructions = useRef(defaultInstructions);
const [error, setError] = useState(undefined);
@@ -99,6 +100,7 @@ export default function SubmitForm() {
}
formData.append("gender", gender);
+ formData.append("makeup", makeup);
formData.append("miiPortraitImage", portraitBlob);
formData.append("miiFeaturesImage", featuresBlob);
formData.append("instructions", JSON.stringify(instructions.current));
@@ -326,6 +328,54 @@ export default function SubmitForm() {
+ {/* Makeup (switch only) */}
+
+
+
+
+ {/* Full Makeup */}
+
+
+ {/* Partial Makeup */}
+
+
+ {/* No Makeup */}
+
+
+
+
{/* (Switch Only) Mii Portrait */}
{/* Separator */}
diff --git a/src/lib/images.tsx b/src/lib/images.tsx
index b4787d7..949425d 100644
--- a/src/lib/images.tsx
+++ b/src/lib/images.tsx
@@ -16,7 +16,7 @@ import satori, { Font } from "satori";
import { Mii } from "@prisma/client";
const MIN_IMAGE_DIMENSIONS = [128, 128];
-const MAX_IMAGE_DIMENSIONS = [2000, 2000];
+const MAX_IMAGE_DIMENSIONS = [8000, 8000];
const MAX_IMAGE_SIZE = 8 * 1024 * 1024; // 8 MB
const ALLOWED_MIME_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"];
@@ -49,7 +49,7 @@ export async function validateImage(file: File): Promise<{ valid: boolean; error
metadata.height < MIN_IMAGE_DIMENSIONS[1] ||
metadata.height > MAX_IMAGE_DIMENSIONS[1]
) {
- return { valid: false, error: "Image dimensions are invalid. Resolution must be between 128x128 and 2000x2000" };
+ return { valid: false, error: "Image dimensions are invalid. Resolution must be between 128x128 and 8000x8000" };
}
// Check for inappropriate content
diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts
index bf92f36..aef4608 100644
--- a/src/lib/schemas.ts
+++ b/src/lib/schemas.ts
@@ -1,4 +1,4 @@
-import { MiiGender, MiiPlatform } from "@prisma/client";
+import { MiiGender, MiiMakeup, MiiPlatform } from "@prisma/client";
import { z } from "zod";
// profanity censoring bypasses the regex in some of these but I think it's funny
@@ -60,6 +60,7 @@ export const searchSchema = z.object({
),
platform: z.enum(MiiPlatform, { error: "Platform must be either 'THREE_DS', or 'SWITCH'" }).optional(),
gender: z.enum(MiiGender, { error: "Gender must be either 'MALE', 'FEMALE', or 'NONBINARY' if on Switch platform" }).optional(),
+ makeup: z.enum(MiiMakeup, { error: "Makeup must be either 'FULL', 'PARTIAL', or 'NONE'" }).optional(),
allowCopying: z.coerce.boolean({ error: "Allow Copying must be either true or false" }).optional(),
// todo: incorporate tagsSchema
// Pages