diff --git a/prisma/migrations/20260329195455_quarantined_miis/migration.sql b/prisma/migrations/20260329195455_quarantined_miis/migration.sql
new file mode 100644
index 0000000..79b9e62
--- /dev/null
+++ b/prisma/migrations/20260329195455_quarantined_miis/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "miis" ADD COLUMN "quarantined" BOOLEAN NOT NULL DEFAULT false;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 72d1d89..255afb0 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -75,6 +75,7 @@ model Mii {
tags String[]
description String? @db.VarChar(512)
platform MiiPlatform @default(THREE_DS)
+ quarantined Boolean @default(false)
instructions Json?
gender MiiGender?
diff --git a/src/app/api/mii/[id]/edit/route.ts b/src/app/api/mii/[id]/edit/route.ts
index 105eb83..e27d251 100644
--- a/src/app/api/mii/[id]/edit/route.ts
+++ b/src/app/api/mii/[id]/edit/route.ts
@@ -23,6 +23,10 @@ const editSchema = z.object({
name: nameSchema.optional(),
tags: tagsSchema.optional(),
description: z.string().trim().max(512).optional(),
+ quarantined: z
+ .enum(["true", "false"])
+ .transform((v) => v === "true")
+ .optional(),
makeup: z.enum(MiiMakeup).optional(),
miiPortraitImage: z.union([z.instanceof(File), z.any()]).optional(),
miiFeaturesImage: z.union([z.instanceof(File), z.any()]).optional(),
@@ -77,6 +81,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
name: formData.get("name") ?? undefined,
tags: rawTags,
description: formData.get("description") ?? undefined,
+ quarantined: formData.get("quarantined") ?? undefined,
makeup: formData.get("makeup") ?? undefined,
miiPortraitImage: formData.get("miiPortraitImage"),
miiFeaturesImage: formData.get("miiFeaturesImage"),
@@ -87,7 +92,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, quarantined, makeup, miiPortraitImage, miiFeaturesImage, instructions, image1, image2, image3 } = parsed.data;
// Validate image files
const images: File[] = [];
@@ -115,11 +120,15 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
}
}
+ // Prevent non-admins from quarantining Miis
+ if (quarantined && session.user?.id != process.env.NEXT_PUBLIC_ADMIN_USER_ID) return rateLimit.sendResponse({ error: `You're not an admin!` }, 401);
+
// Edit Mii in database
const updateData: Prisma.MiiUpdateInput = {};
if (name !== undefined) updateData.name = profanity.censor(name); // Censor potentially inappropriate words
if (tags !== undefined) updateData.tags = tags.map((t) => profanity.censor(t));
if (description !== undefined) updateData.description = profanity.censor(description);
+ if (quarantined !== undefined) updateData.quarantined = quarantined;
if (makeup !== undefined) updateData.makeup = makeup;
if (instructions !== undefined) updateData.instructions = instructions;
if (images.length > 0) updateData.imageCount = images.length;
diff --git a/src/app/mii/[id]/page.tsx b/src/app/mii/[id]/page.tsx
index 4508e15..8747124 100644
--- a/src/app/mii/[id]/page.tsx
+++ b/src/app/mii/[id]/page.tsx
@@ -122,6 +122,12 @@ export default async function MiiPage({ params }: Props) {
return (
+ {mii.quarantined && (
+
+
+
This Mii is flagged as controversial and only appears when the filter is enabled
+
+ )}
{/* Mii Image */}
@@ -414,17 +420,6 @@ export default async function MiiPage({ params }: Props) {
)}
-
- {/* Offscreen metadata image for search engines; hidden from users */}
-
);
}
diff --git a/src/components/admin/reports.tsx b/src/components/admin/reports.tsx
index 71dac34..f3a8042 100644
--- a/src/components/admin/reports.tsx
+++ b/src/components/admin/reports.tsx
@@ -31,7 +31,7 @@ export default async function Reports() {
{report.reportType}
diff --git a/src/components/carousel.tsx b/src/components/carousel.tsx
index 8fd84a9..1a46cfe 100644
--- a/src/components/carousel.tsx
+++ b/src/components/carousel.tsx
@@ -46,7 +46,7 @@ export default function Carousel({ images, className }: Props) {
{images.map((src, index) => (
-
+
))}
diff --git a/src/components/mii/list/filter-menu.tsx b/src/components/mii/list/filter-menu.tsx
index e178994..8778bd6 100644
--- a/src/components/mii/list/filter-menu.tsx
+++ b/src/components/mii/list/filter-menu.tsx
@@ -127,16 +127,13 @@ export default function FilterMenu() {
>
)}
- {platform !== "SWITCH" && (
- <>
-
-
- Other
-
-
-
- >
- )}
+
+
+
+ Other
+
+
+
)}
diff --git a/src/components/mii/list/index.tsx b/src/components/mii/list/index.tsx
index fc6c621..37c49e6 100644
--- a/src/components/mii/list/index.tsx
+++ b/src/components/mii/list/index.tsx
@@ -28,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, makeup, allowCopying, page = 1, limit = 24, seed } = parsed.data;
+ const { q: query, sort, tags, exclude, platform, gender, makeup, allowCopying, quarantined, page = 1, limit = 24, seed } = parsed.data;
// My Likes page
let miiIdsLiked: number[] | undefined = undefined;
@@ -59,6 +59,8 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
...(allowCopying && { allowedCopying: true }),
// Makeup
...(makeup && { makeup: { equals: makeup } }),
+ // Quarantined
+ ...(!quarantined && { quarantined: false }),
// Profiles
...(userId && { userId }),
};
@@ -82,6 +84,7 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
gender: true,
makeup: true,
allowedCopying: true,
+ quarantined: true,
// Mii liked check
...(session?.user?.id && {
likedBy: {
@@ -194,7 +197,7 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
{miis.map((mii) => (
((searchParams.get("allowCopying") as unknown as boolean) ?? false);
+ const [quarantined, setQuarantined] = useState((searchParams.get("quarantined") as unknown as boolean) ?? false);
const handleChangeAllowCopying = (e: ChangeEvent) => {
setAllowCopying(e.target.checked);
@@ -27,12 +31,39 @@ export default function OtherFilters() {
});
};
+ const handleChangeQuarantined = (e: ChangeEvent) => {
+ setQuarantined(e.target.checked);
+
+ const params = new URLSearchParams(searchParams);
+ params.set("page", "1");
+
+ if (!quarantined) {
+ params.set("quarantined", "true");
+ } else {
+ params.delete("quarantined");
+ }
+
+ startTransition(() => {
+ router.push(`?${params.toString()}`, { scroll: false });
+ });
+ };
+
return (
-
-
-
-
+ <>
+ {platform === "THREE_DS" && (
+
+
+
+
+ )}
+
+
+
+
+ >
);
}
diff --git a/src/components/submit-form/edit-form.tsx b/src/components/submit-form/edit-form.tsx
index 3f60da2..15cf51c 100644
--- a/src/components/submit-form/edit-form.tsx
+++ b/src/components/submit-form/edit-form.tsx
@@ -5,6 +5,7 @@ import { redirect } from "next/navigation";
import { useCallback, useEffect, useRef, useState } from "react";
import { FileWithPath } from "react-dropzone";
import { Mii, MiiMakeup } from "@prisma/client";
+import { useSession } from "next-auth/react";
import { nameSchema, tagsSchema } from "@/lib/schemas";
import { defaultInstructions, minifyInstructions } from "@/lib/switch";
@@ -46,6 +47,7 @@ function deepMerge(target: T, source: Partial): T {
}
export default function EditForm({ mii, likes }: Props) {
+ const session = useSession();
const [files, setFiles] = useState([]);
const handleDrop = useCallback(
@@ -67,9 +69,10 @@ export default function EditForm({ mii, likes }: Props) {
const [miiPortraitUri, setMiiPortraitUri] = useState(`/mii/${mii.id}/image?type=mii`);
const [miiFeaturesUri, setMiiFeaturesUri] = useState(`/mii/${mii.id}/image?type=features`);
const hasFilesChanged = useRef(false);
-
const instructions = useRef(deepMerge(defaultInstructions, (mii.instructions as object) ?? {}));
+ const [quarantined, setQuarantined] = useState(mii.quarantined);
+
const handleSubmit = async () => {
// Validate before sending request
const nameValidation = nameSchema.safeParse(name);
@@ -90,6 +93,7 @@ export default function EditForm({ mii, likes }: Props) {
if (description && description != mii.description) formData.append("description", description);
if (makeup != mii.makeup) formData.append("makeup", makeup);
if (miiPortraitUri) formData.append("miiPortraitUri", miiPortraitUri);
+ if (quarantined != mii.quarantined) formData.append("quarantined", JSON.stringify(quarantined));
if (minifyInstructions(structuredClone(instructions.current)) !== (mii.instructions as object))
formData.append("instructions", JSON.stringify(instructions.current));
@@ -245,6 +249,20 @@ export default function EditForm({ mii, likes }: Props) {
/>
+ {session.data?.user?.id == process.env.NEXT_PUBLIC_ADMIN_USER_ID && (
+ <>
+
+
+
+
+ setQuarantined(e.target.checked)} />
+
+
+ >
+ )}
+
{/* Makeup/Images/Instructions (Switch only) */}
{mii.platform === "SWITCH" && (
<>
diff --git a/src/components/submit-form/mii-editor/number-inputs.tsx b/src/components/submit-form/mii-editor/number-inputs.tsx
index fc9f48a..33c51cd 100644
--- a/src/components/submit-form/mii-editor/number-inputs.tsx
+++ b/src/components/submit-form/mii-editor/number-inputs.tsx
@@ -44,8 +44,8 @@ interface NumberFieldProps {
}
function NumberField({ label, value, onChange }: NumberFieldProps) {
- const MIN = -15;
- const MAX = 15;
+ const MIN = -100;
+ const MAX = 100;
const decrement = () => onChange(Math.max(MIN, value - 1));
const increment = () => onChange(Math.min(MAX, value + 1));
diff --git a/src/components/submit-form/mii-editor/tabs/eyes.tsx b/src/components/submit-form/mii-editor/tabs/eyes.tsx
index ef6545c..551cdd4 100644
--- a/src/components/submit-form/mii-editor/tabs/eyes.tsx
+++ b/src/components/submit-form/mii-editor/tabs/eyes.tsx
@@ -7,14 +7,14 @@ interface Props {
instructions: React.RefObject;
}
-const TABS: { name: keyof SwitchMiiInstructions["eyes"]; length: number; colorsDisabled?: boolean }[] = [
- { name: "main", length: 76 },
- { name: "eyelashesTop", length: 6, colorsDisabled: true },
- { name: "eyelashesBottom", length: 2, colorsDisabled: true },
- { name: "eyelidTop", length: 3, colorsDisabled: true },
- { name: "eyelidBottom", length: 3, colorsDisabled: true },
- { name: "eyeliner", length: 2 },
- { name: "pupil", length: 10, colorsDisabled: true },
+const TABS: { name: keyof SwitchMiiInstructions["eyes"]; colorsDisabled?: boolean }[] = [
+ { name: "main" },
+ { name: "eyelashesTop", colorsDisabled: true },
+ { name: "eyelashesBottom", colorsDisabled: true },
+ { name: "eyelidTop", colorsDisabled: true },
+ { name: "eyelidBottom", colorsDisabled: true },
+ { name: "eyeliner" },
+ { name: "pupil", colorsDisabled: true },
];
export default function EyesTab({ instructions }: Props) {
diff --git a/src/components/submit-form/mii-editor/tabs/other.tsx b/src/components/submit-form/mii-editor/tabs/other.tsx
index 1b72e4f..e188427 100644
--- a/src/components/submit-form/mii-editor/tabs/other.tsx
+++ b/src/components/submit-form/mii-editor/tabs/other.tsx
@@ -7,15 +7,15 @@ interface Props {
instructions: React.RefObject;
}
-const TABS: { name: keyof SwitchMiiInstructions["other"]; length: number; defaultColor?: number }[] = [
- { name: "wrinkles1", length: 9 },
- { name: "wrinkles2", length: 15 },
- { name: "beard", length: 15 },
- { name: "moustache", length: 16 },
- { name: "goatee", length: 14 },
- { name: "mole", length: 2 },
- { name: "eyeShadow", length: 4, defaultColor: 139 },
- { name: "blush", length: 8 },
+const TABS: { name: keyof SwitchMiiInstructions["other"]; defaultColor?: number }[] = [
+ { name: "wrinkles1" },
+ { name: "wrinkles2" },
+ { name: "beard" },
+ { name: "moustache" },
+ { name: "goatee" },
+ { name: "mole" },
+ { name: "eyeShadow", defaultColor: 139 },
+ { name: "blush" },
];
export default function OtherTab({ instructions }: Props) {
diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts
index aef4608..6260162 100644
--- a/src/lib/schemas.ts
+++ b/src/lib/schemas.ts
@@ -62,6 +62,7 @@ export const searchSchema = z.object({
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(),
+ quarantined: z.coerce.boolean({ error: "Quarantined must be either true or false" }).optional(),
// todo: incorporate tagsSchema
// Pages
limit: z.coerce
@@ -85,7 +86,7 @@ export const userNameSchema = z
});
const colorSchema = z.number().int().min(0).max(152).optional();
-const geometrySchema = z.number().int().min(-15).max(15).optional();
+const geometrySchema = z.number().int().min(-100).max(100).optional();
export const switchMiiInstructionsSchema = z
.object({