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