From 7925c9e2f52b208a9d8e15a6f8730ac2256c78f3 Mon Sep 17 00:00:00 2001 From: trafficlunar Date: Sun, 29 Mar 2026 21:39:42 +0100 Subject: [PATCH] feat: controversial miis at the time of writing, the poll is at 74% on option 2 (this one) with 14 hours to go. i keep getting reports so it's coming early --- .../migration.sql | 2 + prisma/schema.prisma | 1 + src/app/api/mii/[id]/edit/route.ts | 11 ++++- src/app/mii/[id]/page.tsx | 17 +++---- src/components/admin/reports.tsx | 2 +- src/components/carousel.tsx | 2 +- src/components/mii/list/filter-menu.tsx | 17 +++---- src/components/mii/list/index.tsx | 7 ++- src/components/mii/list/other-filters.tsx | 45 ++++++++++++++++--- src/components/submit-form/edit-form.tsx | 20 ++++++++- .../submit-form/mii-editor/number-inputs.tsx | 4 +- .../submit-form/mii-editor/tabs/eyes.tsx | 16 +++---- .../submit-form/mii-editor/tabs/other.tsx | 18 ++++---- src/lib/schemas.ts | 3 +- 14 files changed, 111 insertions(+), 54 deletions(-) create mode 100644 prisma/migrations/20260329195455_quarantined_miis/migration.sql 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 */} - {`${mii.name},
); } 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({