mirror of
https://github.com/trafficlunar/tomodachi-share.git
synced 2026-05-13 13:17:45 +00:00
Compare commits
3 commits
576cb698d2
...
9f65847c3b
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f65847c3b | |||
| 825f74ef6d | |||
| 7925c9e2f5 |
18 changed files with 177 additions and 76 deletions
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "miis" ADD COLUMN "quarantined" BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
@ -75,6 +75,7 @@ model Mii {
|
||||||
tags String[]
|
tags String[]
|
||||||
description String? @db.VarChar(512)
|
description String? @db.VarChar(512)
|
||||||
platform MiiPlatform @default(THREE_DS)
|
platform MiiPlatform @default(THREE_DS)
|
||||||
|
quarantined Boolean @default(false)
|
||||||
|
|
||||||
instructions Json?
|
instructions Json?
|
||||||
gender MiiGender?
|
gender MiiGender?
|
||||||
|
|
|
||||||
BIN
public/tutorial/switch/features.png
Normal file
BIN
public/tutorial/switch/features.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 359 KiB |
BIN
public/tutorial/switch/portrait.png
Normal file
BIN
public/tutorial/switch/portrait.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 182 KiB |
|
|
@ -23,6 +23,10 @@ const editSchema = z.object({
|
||||||
name: nameSchema.optional(),
|
name: nameSchema.optional(),
|
||||||
tags: tagsSchema.optional(),
|
tags: tagsSchema.optional(),
|
||||||
description: z.string().trim().max(512).optional(),
|
description: z.string().trim().max(512).optional(),
|
||||||
|
quarantined: z
|
||||||
|
.enum(["true", "false"])
|
||||||
|
.transform((v) => v === "true")
|
||||||
|
.optional(),
|
||||||
makeup: z.enum(MiiMakeup).optional(),
|
makeup: z.enum(MiiMakeup).optional(),
|
||||||
miiPortraitImage: z.union([z.instanceof(File), z.any()]).optional(),
|
miiPortraitImage: z.union([z.instanceof(File), z.any()]).optional(),
|
||||||
miiFeaturesImage: 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,
|
name: formData.get("name") ?? undefined,
|
||||||
tags: rawTags,
|
tags: rawTags,
|
||||||
description: formData.get("description") ?? undefined,
|
description: formData.get("description") ?? undefined,
|
||||||
|
quarantined: formData.get("quarantined") ?? undefined,
|
||||||
makeup: formData.get("makeup") ?? undefined,
|
makeup: formData.get("makeup") ?? undefined,
|
||||||
miiPortraitImage: formData.get("miiPortraitImage"),
|
miiPortraitImage: formData.get("miiPortraitImage"),
|
||||||
miiFeaturesImage: formData.get("miiFeaturesImage"),
|
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);
|
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
|
// Validate image files
|
||||||
const images: File[] = [];
|
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
|
// Edit Mii in database
|
||||||
const updateData: Prisma.MiiUpdateInput = {};
|
const updateData: Prisma.MiiUpdateInput = {};
|
||||||
if (name !== undefined) updateData.name = profanity.censor(name); // Censor potentially inappropriate words
|
if (name !== undefined) updateData.name = profanity.censor(name); // Censor potentially inappropriate words
|
||||||
if (tags !== undefined) updateData.tags = tags.map((t) => profanity.censor(t));
|
if (tags !== undefined) updateData.tags = tags.map((t) => profanity.censor(t));
|
||||||
if (description !== undefined) updateData.description = profanity.censor(description);
|
if (description !== undefined) updateData.description = profanity.censor(description);
|
||||||
|
if (quarantined !== undefined) updateData.quarantined = quarantined;
|
||||||
if (makeup !== undefined) updateData.makeup = makeup;
|
if (makeup !== undefined) updateData.makeup = makeup;
|
||||||
if (instructions !== undefined) updateData.instructions = instructions;
|
if (instructions !== undefined) updateData.instructions = instructions;
|
||||||
if (images.length > 0) updateData.imageCount = images.length;
|
if (images.length > 0) updateData.imageCount = images.length;
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,12 @@ export default async function MiiPage({ params }: Props) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<div className="max-w-5xl w-full flex flex-col gap-4">
|
<div className="max-w-5xl w-full flex flex-col gap-4">
|
||||||
|
{mii.quarantined && (
|
||||||
|
<div className="bg-red-100 border-2 border-red-400 rounded-2xl shadow-lg p-4 flex items-center gap-3 text-red-700">
|
||||||
|
<Icon icon="material-symbols:warning-rounded" className="text-2xl shrink-0" />
|
||||||
|
<p className="font-medium">This Mii is flagged as controversial and only appears when the filter is enabled</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="relative grid grid-cols-3 gap-4 max-md:grid-cols-1">
|
<div className="relative grid grid-cols-3 gap-4 max-md:grid-cols-1">
|
||||||
<div className="bg-amber-50 rounded-3xl border-2 border-amber-500 shadow-lg p-4 h-min flex flex-col items-center max-w-md w-full max-md:place-self-center max-md:row-start-2">
|
<div className="bg-amber-50 rounded-3xl border-2 border-amber-500 shadow-lg p-4 h-min flex flex-col items-center max-w-md w-full max-md:place-self-center max-md:row-start-2">
|
||||||
{/* Mii Image */}
|
{/* Mii Image */}
|
||||||
|
|
@ -414,17 +420,6 @@ export default async function MiiPage({ params }: Props) {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Offscreen metadata image for search engines; hidden from users */}
|
|
||||||
<Image
|
|
||||||
src={`/mii/${mii.id}/image?type=metadata`}
|
|
||||||
alt={`${mii.name}, a ${mii.gender ? mii.gender.toLowerCase() : ""} Mii ${mii.tags.length ? ` with tags: ${mii.tags.join(", ")}` : ""}`}
|
|
||||||
loading="lazy"
|
|
||||||
unoptimized
|
|
||||||
width={1}
|
|
||||||
height={1}
|
|
||||||
className="absolute left-[-999999]"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,8 @@ import { ReportStatus } from "@prisma/client";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export default async function Reports() {
|
export default async function Reports() {
|
||||||
const reports = await prisma.report.findMany();
|
const reports = await prisma.report.findMany({ orderBy: { createdAt: "desc" } });
|
||||||
|
// TODO: add pagination
|
||||||
|
|
||||||
const updateStatus = async (formData: FormData) => {
|
const updateStatus = async (formData: FormData) => {
|
||||||
"use server";
|
"use server";
|
||||||
|
|
@ -31,7 +32,7 @@ export default async function Reports() {
|
||||||
<div className="flex gap-1 w-max">
|
<div className="flex gap-1 w-max">
|
||||||
<span
|
<span
|
||||||
className={`text-xs font-semibold px-2 py-1 rounded-full border ${
|
className={`text-xs font-semibold px-2 py-1 rounded-full border ${
|
||||||
report.reportType == "USER" ? "bg-red-200 text-red-800 border-orange-400" : "bg-cyan-200 text-cyan-800 border-cyan-400"
|
report.reportType == "USER" ? "bg-red-200 text-red-800 border-red-400" : "bg-cyan-200 text-cyan-800 border-cyan-400"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{report.reportType}
|
{report.reportType}
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ export default function Carousel({ images, className }: Props) {
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
{images.map((src, index) => (
|
{images.map((src, index) => (
|
||||||
<div key={index} className="shrink-0 w-full">
|
<div key={index} className="shrink-0 w-full">
|
||||||
<ImageViewer src={src} alt="mii image" width={480} height={320} className="w-full h-auto aspect-3/2 object-contain" images={images} />
|
<ImageViewer src={src} alt="mii image" width={240} height={160} className="w-full h-auto aspect-3/2 object-contain" images={images} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -127,16 +127,13 @@ export default function FilterMenu() {
|
||||||
<MakeupSelect />
|
<MakeupSelect />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{platform !== "SWITCH" && (
|
|
||||||
<>
|
|
||||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium w-full mt-2 mb-1">
|
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium w-full mt-2 mb-1">
|
||||||
<hr className="grow border-zinc-300" />
|
<hr className="grow border-zinc-300" />
|
||||||
<span>Other</span>
|
<span>Other</span>
|
||||||
<hr className="grow border-zinc-300" />
|
<hr className="grow border-zinc-300" />
|
||||||
</div>
|
</div>
|
||||||
<OtherFilters />
|
<OtherFilters />
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
|
||||||
const parsed = searchSchema.safeParse(searchParams);
|
const parsed = searchSchema.safeParse(searchParams);
|
||||||
if (!parsed.success) return <h1>{parsed.error.issues[0].message}</h1>;
|
if (!parsed.success) return <h1>{parsed.error.issues[0].message}</h1>;
|
||||||
|
|
||||||
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
|
// My Likes page
|
||||||
let miiIdsLiked: number[] | undefined = undefined;
|
let miiIdsLiked: number[] | undefined = undefined;
|
||||||
|
|
@ -59,6 +59,8 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
|
||||||
...(allowCopying && { allowedCopying: true }),
|
...(allowCopying && { allowedCopying: true }),
|
||||||
// Makeup
|
// Makeup
|
||||||
...(makeup && { makeup: { equals: makeup } }),
|
...(makeup && { makeup: { equals: makeup } }),
|
||||||
|
// Quarantined
|
||||||
|
...(!quarantined && { quarantined: false }),
|
||||||
// Profiles
|
// Profiles
|
||||||
...(userId && { userId }),
|
...(userId && { userId }),
|
||||||
};
|
};
|
||||||
|
|
@ -82,6 +84,7 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
|
||||||
gender: true,
|
gender: true,
|
||||||
makeup: true,
|
makeup: true,
|
||||||
allowedCopying: true,
|
allowedCopying: true,
|
||||||
|
quarantined: true,
|
||||||
// Mii liked check
|
// Mii liked check
|
||||||
...(session?.user?.id && {
|
...(session?.user?.id && {
|
||||||
likedBy: {
|
likedBy: {
|
||||||
|
|
@ -194,7 +197,7 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
|
||||||
{miis.map((mii) => (
|
{miis.map((mii) => (
|
||||||
<div
|
<div
|
||||||
key={mii.id}
|
key={mii.id}
|
||||||
className="flex flex-col relative bg-zinc-50 rounded-3xl border-2 border-zinc-300 shadow-lg p-[0.8rem] transition hover:scale-105 hover:bg-cyan-100 hover:border-cyan-600"
|
className={`flex flex-col relative bg-zinc-50 rounded-3xl border-2 shadow-lg p-[0.8rem] transition hover:scale-105 hover:bg-cyan-100 hover:border-cyan-600 ${mii.quarantined ? "border-red-300" : "border-zinc-300"}`}
|
||||||
>
|
>
|
||||||
<Carousel
|
<Carousel
|
||||||
images={[
|
images={[
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,18 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { Icon } from "@iconify/react";
|
||||||
|
import { MiiPlatform } from "@prisma/client";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import React, { ChangeEvent, ChangeEventHandler, useState, useTransition } from "react";
|
import { ChangeEvent, useState, useTransition } from "react";
|
||||||
|
|
||||||
export default function OtherFilters() {
|
export default function OtherFilters() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const [, startTransition] = useTransition();
|
const [, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const platform = (searchParams.get("platform") as MiiPlatform) || undefined;
|
||||||
const [allowCopying, setAllowCopying] = useState<boolean>((searchParams.get("allowCopying") as unknown as boolean) ?? false);
|
const [allowCopying, setAllowCopying] = useState<boolean>((searchParams.get("allowCopying") as unknown as boolean) ?? false);
|
||||||
|
const [quarantined, setQuarantined] = useState<boolean>((searchParams.get("quarantined") as unknown as boolean) ?? false);
|
||||||
|
|
||||||
const handleChangeAllowCopying = (e: ChangeEvent<HTMLInputElement>) => {
|
const handleChangeAllowCopying = (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
setAllowCopying(e.target.checked);
|
setAllowCopying(e.target.checked);
|
||||||
|
|
@ -27,12 +31,39 @@ export default function OtherFilters() {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleChangeQuarantined = (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
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 (
|
return (
|
||||||
|
<>
|
||||||
|
{platform === "THREE_DS" && (
|
||||||
<div className="flex justify-between items-center w-full">
|
<div className="flex justify-between items-center w-full">
|
||||||
<label htmlFor="allowCopying" className="text-sm">
|
<label htmlFor="allowCopying" className="text-sm">
|
||||||
Allow Copying
|
Allow Copying
|
||||||
</label>
|
</label>
|
||||||
<input type="checkbox" id="allowCopying" className="checkbox-alt" checked={allowCopying} onChange={handleChangeAllowCopying} />
|
<input type="checkbox" id="allowCopying" className="checkbox-alt" checked={allowCopying} onChange={handleChangeAllowCopying} />
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-between items-center w-full">
|
||||||
|
<label htmlFor="quarantined" className="text-sm">
|
||||||
|
Show Controversial Miis
|
||||||
|
</label>
|
||||||
|
<input type="checkbox" id="quarantined" className="checkbox-alt" checked={quarantined} onChange={handleChangeQuarantined} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { redirect } from "next/navigation";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { FileWithPath } from "react-dropzone";
|
import { FileWithPath } from "react-dropzone";
|
||||||
import { Mii, MiiMakeup } from "@prisma/client";
|
import { Mii, MiiMakeup } from "@prisma/client";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
|
||||||
import { nameSchema, tagsSchema } from "@/lib/schemas";
|
import { nameSchema, tagsSchema } from "@/lib/schemas";
|
||||||
import { defaultInstructions, minifyInstructions } from "@/lib/switch";
|
import { defaultInstructions, minifyInstructions } from "@/lib/switch";
|
||||||
|
|
@ -46,6 +47,7 @@ function deepMerge<T>(target: T, source: Partial<T>): T {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function EditForm({ mii, likes }: Props) {
|
export default function EditForm({ mii, likes }: Props) {
|
||||||
|
const session = useSession();
|
||||||
const [files, setFiles] = useState<FileWithPath[]>([]);
|
const [files, setFiles] = useState<FileWithPath[]>([]);
|
||||||
|
|
||||||
const handleDrop = useCallback(
|
const handleDrop = useCallback(
|
||||||
|
|
@ -67,9 +69,10 @@ export default function EditForm({ mii, likes }: Props) {
|
||||||
const [miiPortraitUri, setMiiPortraitUri] = useState<string | undefined>(`/mii/${mii.id}/image?type=mii`);
|
const [miiPortraitUri, setMiiPortraitUri] = useState<string | undefined>(`/mii/${mii.id}/image?type=mii`);
|
||||||
const [miiFeaturesUri, setMiiFeaturesUri] = useState<string | undefined>(`/mii/${mii.id}/image?type=features`);
|
const [miiFeaturesUri, setMiiFeaturesUri] = useState<string | undefined>(`/mii/${mii.id}/image?type=features`);
|
||||||
const hasFilesChanged = useRef(false);
|
const hasFilesChanged = useRef(false);
|
||||||
|
|
||||||
const instructions = useRef<SwitchMiiInstructions>(deepMerge(defaultInstructions, (mii.instructions as object) ?? {}));
|
const instructions = useRef<SwitchMiiInstructions>(deepMerge(defaultInstructions, (mii.instructions as object) ?? {}));
|
||||||
|
|
||||||
|
const [quarantined, setQuarantined] = useState(mii.quarantined);
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
// Validate before sending request
|
// Validate before sending request
|
||||||
const nameValidation = nameSchema.safeParse(name);
|
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 (description && description != mii.description) formData.append("description", description);
|
||||||
if (makeup != mii.makeup) formData.append("makeup", makeup);
|
if (makeup != mii.makeup) formData.append("makeup", makeup);
|
||||||
if (miiPortraitUri) formData.append("miiPortraitUri", miiPortraitUri);
|
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))
|
if (minifyInstructions(structuredClone(instructions.current)) !== (mii.instructions as object))
|
||||||
formData.append("instructions", JSON.stringify(instructions.current));
|
formData.append("instructions", JSON.stringify(instructions.current));
|
||||||
|
|
||||||
|
|
@ -245,6 +249,20 @@ export default function EditForm({ mii, likes }: Props) {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{session.data?.user?.id == process.env.NEXT_PUBLIC_ADMIN_USER_ID && (
|
||||||
|
<>
|
||||||
|
<div className="w-full grid grid-cols-3 items-center">
|
||||||
|
<label htmlFor="quarantined" className="font-semibold py-2">
|
||||||
|
Quarantined
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="col-span-2 flex gap-1">
|
||||||
|
<input type="checkbox" id="quarantined" className="checkbox-alt" checked={quarantined} onChange={(e) => setQuarantined(e.target.checked)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Makeup/Images/Instructions (Switch only) */}
|
{/* Makeup/Images/Instructions (Switch only) */}
|
||||||
{mii.platform === "SWITCH" && (
|
{mii.platform === "SWITCH" && (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ import LikeButton from "../like-button";
|
||||||
import Carousel from "../carousel";
|
import Carousel from "../carousel";
|
||||||
import SubmitButton from "../submit-button";
|
import SubmitButton from "../submit-button";
|
||||||
import Dropzone from "../dropzone";
|
import Dropzone from "../dropzone";
|
||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
export default function SubmitForm() {
|
export default function SubmitForm() {
|
||||||
const [files, setFiles] = useState<FileWithPath[]>([]);
|
const [files, setFiles] = useState<FileWithPath[]>([]);
|
||||||
|
|
@ -376,22 +377,62 @@ export default function SubmitForm() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* (Switch Only) Mii Portrait */}
|
{/* (Switch Only) Mii Screenshots */}
|
||||||
<div className={`${platform === "SWITCH" ? "" : "hidden"}`}>
|
<div className={`${platform === "SWITCH" ? "" : "hidden"}`}>
|
||||||
{/* Separator */}
|
{/* Separator */}
|
||||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-8 mb-2">
|
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-8 mb-2">
|
||||||
<hr className="grow border-zinc-300" />
|
<hr className="grow border-zinc-300" />
|
||||||
<span>Mii Portrait</span>
|
<span>Mii Screenshots</span>
|
||||||
<hr className="grow border-zinc-300" />
|
<hr className="grow border-zinc-300" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col items-center gap-2">
|
<div className="flex flex-col items-center gap-4 w-full">
|
||||||
|
{/* Step 1 - Portrait */}
|
||||||
|
<div className="flex flex-col items-center gap-2 w-full">
|
||||||
|
<div className="flex items-center gap-2 self-start">
|
||||||
|
<span className="bg-orange-400 text-white text-xs font-bold rounded-full size-5 flex items-center justify-center shrink-0">1</span>
|
||||||
|
<span className="text-sm font-semibold text-zinc-600">Portrait screenshot</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 w-full items-start max-sm:flex-col max-sm:items-center">
|
||||||
|
<div data-tooltip="Your screenshot should look like this">
|
||||||
|
<Image
|
||||||
|
src="/tutorial/switch/portrait.png"
|
||||||
|
alt="Example portrait screenshot"
|
||||||
|
width={80}
|
||||||
|
height={80}
|
||||||
|
className="size-20 object-cover rounded-xl border-2 border-orange-300 shrink-0 opacity-70"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<SwitchFileUpload text="a screenshot of your Mii here" image={miiPortraitUri} setImage={setMiiPortraitUri} forceCrop />
|
<SwitchFileUpload text="a screenshot of your Mii here" image={miiPortraitUri} setImage={setMiiPortraitUri} forceCrop />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step 2 - Features */}
|
||||||
|
<div className="flex flex-col items-center gap-2 w-full">
|
||||||
|
<div className="flex items-center gap-2 self-start">
|
||||||
|
<span className="bg-orange-400 text-white text-xs font-bold rounded-full size-5 flex items-center justify-center shrink-0">2</span>
|
||||||
|
<span className="text-sm font-semibold text-zinc-600">
|
||||||
|
Features screenshot <span className="text-orange-500">(the features panel - see example)</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 w-full items-start max-sm:flex-col max-sm:items-center">
|
||||||
|
<div data-tooltip="Your features screenshot should show this">
|
||||||
|
<Image
|
||||||
|
src="/tutorial/switch/features.png"
|
||||||
|
alt="Example features screenshot showing the parts panel"
|
||||||
|
width={80}
|
||||||
|
height={80}
|
||||||
|
className="size-20 object-cover rounded-xl border-2 border-orange-300 shrink-0 opacity-70"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<SwitchFileUpload text="a screenshot of your Mii's features here" image={miiFeaturesUri} setImage={setMiiFeaturesUri} />
|
<SwitchFileUpload text="a screenshot of your Mii's features here" image={miiFeaturesUri} setImage={setMiiFeaturesUri} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<SwitchSubmitTutorialButton />
|
<SwitchSubmitTutorialButton />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-xs text-zinc-400 text-center mt-2">You must upload a screenshot of the features, check tutorial on how.</p>
|
<p className="text-xs text-zinc-400 text-center mt-2">A tutorial on how to screenshot the features is above.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* (3DS only) QR code scanning */}
|
{/* (3DS only) QR code scanning */}
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,11 @@ interface Props {
|
||||||
|
|
||||||
export default function NumberInputs({ target }: Props) {
|
export default function NumberInputs({ target }: Props) {
|
||||||
const [values, setValues] = useState<Record<string, number>>({
|
const [values, setValues] = useState<Record<string, number>>({
|
||||||
height: 0,
|
height: target?.height ?? 0,
|
||||||
distance: 0,
|
distance: target?.distance ?? 0,
|
||||||
rotation: 0,
|
rotation: target?.rotation ?? 0,
|
||||||
size: 0,
|
size: target?.size ?? 0,
|
||||||
stretch: 0,
|
stretch: target?.stretch ?? 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!target) return null;
|
if (!target) return null;
|
||||||
|
|
@ -44,8 +44,8 @@ interface NumberFieldProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
function NumberField({ label, value, onChange }: NumberFieldProps) {
|
function NumberField({ label, value, onChange }: NumberFieldProps) {
|
||||||
const MIN = -15;
|
const MIN = -100;
|
||||||
const MAX = 15;
|
const MAX = 100;
|
||||||
|
|
||||||
const decrement = () => onChange(Math.max(MIN, value - 1));
|
const decrement = () => onChange(Math.max(MIN, value - 1));
|
||||||
const increment = () => onChange(Math.min(MAX, value + 1));
|
const increment = () => onChange(Math.min(MAX, value + 1));
|
||||||
|
|
|
||||||
|
|
@ -7,14 +7,14 @@ interface Props {
|
||||||
instructions: React.RefObject<SwitchMiiInstructions>;
|
instructions: React.RefObject<SwitchMiiInstructions>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TABS: { name: keyof SwitchMiiInstructions["eyes"]; length: number; colorsDisabled?: boolean }[] = [
|
const TABS: { name: keyof SwitchMiiInstructions["eyes"]; colorsDisabled?: boolean }[] = [
|
||||||
{ name: "main", length: 76 },
|
{ name: "main" },
|
||||||
{ name: "eyelashesTop", length: 6, colorsDisabled: true },
|
{ name: "eyelashesTop", colorsDisabled: true },
|
||||||
{ name: "eyelashesBottom", length: 2, colorsDisabled: true },
|
{ name: "eyelashesBottom", colorsDisabled: true },
|
||||||
{ name: "eyelidTop", length: 3, colorsDisabled: true },
|
{ name: "eyelidTop", colorsDisabled: true },
|
||||||
{ name: "eyelidBottom", length: 3, colorsDisabled: true },
|
{ name: "eyelidBottom", colorsDisabled: true },
|
||||||
{ name: "eyeliner", length: 2 },
|
{ name: "eyeliner" },
|
||||||
{ name: "pupil", length: 10, colorsDisabled: true },
|
{ name: "pupil", colorsDisabled: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function EyesTab({ instructions }: Props) {
|
export default function EyesTab({ instructions }: Props) {
|
||||||
|
|
@ -60,7 +60,7 @@ export default function EyesTab({ instructions }: Props) {
|
||||||
|
|
||||||
<div className="absolute inset-0 flex flex-col justify-center items-center">
|
<div className="absolute inset-0 flex flex-col justify-center items-center">
|
||||||
<ColorPicker disabled={currentTab.colorsDisabled} color={colors[tab]} setColor={setColor} tab={tab === 5 ? "eyeliner" : "eyes"} />
|
<ColorPicker disabled={currentTab.colorsDisabled} color={colors[tab]} setColor={setColor} tab={tab === 5 ? "eyeliner" : "eyes"} />
|
||||||
<NumberInputs target={instructions.current.eyes[currentTab.name]} />
|
<NumberInputs key={tab} target={instructions.current.eyes[currentTab.name]} />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -7,15 +7,15 @@ interface Props {
|
||||||
instructions: React.RefObject<SwitchMiiInstructions>;
|
instructions: React.RefObject<SwitchMiiInstructions>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TABS: { name: keyof SwitchMiiInstructions["other"]; length: number; defaultColor?: number }[] = [
|
const TABS: { name: keyof SwitchMiiInstructions["other"]; defaultColor?: number }[] = [
|
||||||
{ name: "wrinkles1", length: 9 },
|
{ name: "wrinkles1" },
|
||||||
{ name: "wrinkles2", length: 15 },
|
{ name: "wrinkles2" },
|
||||||
{ name: "beard", length: 15 },
|
{ name: "beard" },
|
||||||
{ name: "moustache", length: 16 },
|
{ name: "moustache" },
|
||||||
{ name: "goatee", length: 14 },
|
{ name: "goatee" },
|
||||||
{ name: "mole", length: 2 },
|
{ name: "mole" },
|
||||||
{ name: "eyeShadow", length: 4, defaultColor: 139 },
|
{ name: "eyeShadow", defaultColor: 139 },
|
||||||
{ name: "blush", length: 8 },
|
{ name: "blush" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function OtherTab({ instructions }: Props) {
|
export default function OtherTab({ instructions }: Props) {
|
||||||
|
|
@ -66,7 +66,7 @@ export default function OtherTab({ instructions }: Props) {
|
||||||
|
|
||||||
<div className="absolute inset-0 flex flex-col justify-center items-center">
|
<div className="absolute inset-0 flex flex-col justify-center items-center">
|
||||||
<ColorPicker disabled={tab === 0 || tab === 1} color={colors[tab]} setColor={setColor} tab={tab === 6 ? "eyeliner" : "hair"} />
|
<ColorPicker disabled={tab === 0 || tab === 1} color={colors[tab]} setColor={setColor} tab={tab === 6 ? "eyeliner" : "hair"} />
|
||||||
<NumberInputs target={instructions.current.other[currentTab.name]} />
|
<NumberInputs key={tab} target={instructions.current.other[currentTab.name]} />
|
||||||
|
|
||||||
{tab === 3 && (
|
{tab === 3 && (
|
||||||
<div className="flex gap-1.5 items-center mt-4">
|
<div className="flex gap-1.5 items-center mt-4">
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ export default function SwitchFileUpload({ text, forceCrop, image, setImage }: P
|
||||||
|
|
||||||
<span>or</span>
|
<span>or</span>
|
||||||
|
|
||||||
|
<div className="flex gap-2 max-sm:flex-col">
|
||||||
<button type="button" aria-label="Use your camera" onClick={() => setIsCameraOpen(true)} className="pill button gap-2">
|
<button type="button" aria-label="Use your camera" onClick={() => setIsCameraOpen(true)} className="pill button gap-2">
|
||||||
<Icon icon="mdi:camera" fontSize={20} />
|
<Icon icon="mdi:camera" fontSize={20} />
|
||||||
Use your camera
|
Use your camera
|
||||||
|
|
@ -58,6 +59,7 @@ export default function SwitchFileUpload({ text, forceCrop, image, setImage }: P
|
||||||
<Icon icon="mdi:image-edit" fontSize={20} />
|
<Icon icon="mdi:image-edit" fontSize={20} />
|
||||||
Edit Image
|
Edit Image
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Camera
|
<Camera
|
||||||
isOpen={isCameraOpen}
|
isOpen={isCameraOpen}
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
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(),
|
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(),
|
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
|
// todo: incorporate tagsSchema
|
||||||
// Pages
|
// Pages
|
||||||
limit: z.coerce
|
limit: z.coerce
|
||||||
|
|
@ -85,7 +86,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(-15).max(15).optional();
|
const geometrySchema = z.number().int().min(-100).max(100).optional();
|
||||||
|
|
||||||
export const switchMiiInstructionsSchema = z
|
export const switchMiiInstructionsSchema = z
|
||||||
.object({
|
.object({
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue