feat: youtube video instructions

also the repo is public again
This commit is contained in:
trafficlunar 2026-04-08 11:29:59 +01:00
parent 94389ddc8f
commit d947f09112
11 changed files with 893 additions and 15 deletions

View file

@ -32,6 +32,10 @@ const editSchema = z.object({
makeup: z.enum(MiiMakeup).optional(),
miiPortraitImage: z.union([z.instanceof(File), z.any()]).optional(),
miiFeaturesImage: z.union([z.instanceof(File), z.any()]).optional(),
youtubeId: z
.string()
.regex(/^[a-zA-Z0-9_-]{11}$/, "Invalid YouTube video ID")
.optional(),
instructions: switchMiiInstructionsSchema,
image1: z.union([z.instanceof(File), z.any()]).optional(),
image2: z.union([z.instanceof(File), z.any()]).optional(),
@ -88,6 +92,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
makeup: formData.get("makeup") ?? undefined,
miiPortraitImage: formData.get("miiPortraitImage"),
miiFeaturesImage: formData.get("miiFeaturesImage"),
youtubeId: formData.get("youtubeId"),
instructions: minifiedInstructions,
image1: formData.get("image1"),
image2: formData.get("image2"),
@ -95,7 +100,8 @@ 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, quarantined, gender, makeup, miiPortraitImage, miiFeaturesImage, instructions, image1, image2, image3 } = parsed.data;
const { name, tags, description, quarantined, gender, makeup, miiPortraitImage, miiFeaturesImage, youtubeId, instructions, image1, image2, image3 } =
parsed.data;
// Validate image files
let wasImagesModerated = false;
@ -133,11 +139,12 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
if (quarantined !== undefined) updateData.quarantined = quarantined;
if (mii.platform === "SWITCH" && gender !== undefined) updateData.gender = gender;
if (makeup !== undefined) updateData.makeup = makeup;
if (youtubeId !== undefined) updateData.youtubeId = youtubeId;
if (instructions !== undefined) updateData.instructions = instructions;
if (images.length > 0) updateData.imageCount = images.length;
const imagesChanged = images.length > 0 || miiPortraitImage || miiFeaturesImage;
if ((settings.queueEnabled && imagesChanged) || wasImagesModerated) updateData.in_queue = true;
const imagesChanged = images.length > 0 || miiPortraitImage || miiFeaturesImage;
if ((settings.queueEnabled && imagesChanged) || wasImagesModerated) updateData.in_queue = true;
if (Object.keys(updateData).length === 0) return rateLimit.sendResponse({ error: "Nothing was changed" }, 400);
const updatedMii = await prisma.mii.update({

View file

@ -37,6 +37,10 @@ const submitSchema = z
makeup: z.enum(MiiMakeup).default("PARTIAL"),
miiPortraitImage: z.union([z.instanceof(File), z.any()]).optional(),
miiFeaturesImage: z.union([z.instanceof(File), z.any()]).optional(),
youtubeId: z
.string()
.regex(/^[a-zA-Z0-9_-]{11}$/, "Invalid YouTube video ID")
.optional(),
instructions: switchMiiInstructionsSchema,
// QR code
@ -108,6 +112,7 @@ export async function POST(request: NextRequest) {
makeup: formData.get("makeup") ?? undefined,
miiPortraitImage: formData.get("miiPortraitImage"),
miiFeaturesImage: formData.get("miiFeaturesImage"),
youtubeId: formData.get("youtubeId"),
instructions: minifiedInstructions,
qrBytesRaw: rawQrBytesRaw,
@ -142,6 +147,7 @@ export async function POST(request: NextRequest) {
makeup,
miiPortraitImage,
miiFeaturesImage,
youtubeId,
image1,
image2,
image3,
@ -206,6 +212,7 @@ export async function POST(request: NextRequest) {
allowedCopying: conversion.mii.allowCopying,
}
: {
youtubeId,
instructions: minifiedInstructions,
makeup: makeup ?? "PARTIAL",
}),

View file

@ -381,7 +381,28 @@ export default async function MiiPage({ params }: Props) {
</div>
{/* Instructions */}
{mii.platform === "SWITCH" && <MiiInstructions instructions={mii.instructions as Partial<SwitchMiiInstructions>} />}
{mii.platform === "SWITCH" && (
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex flex-col gap-3 max-h-96 overflow-y-auto">
<h2 className="text-xl font-semibold text-amber-700 flex items-center gap-2">
<Icon icon="fa7-solid:list" />
Instructions
</h2>
{mii.youtubeId && (
<iframe
src={`https://www.youtube-nocookie.com/embed/${mii.youtubeId}`}
title="YouTube video player"
allow="clipboard-write; encrypted-media;"
referrerPolicy="strict-origin-when-cross-origin"
allowFullScreen
loading="lazy"
className="aspect-video rounded-2xl w-full max-w-135"
></iframe>
)}
<MiiInstructions instructions={mii.instructions as Partial<SwitchMiiInstructions>} />
</div>
)}
</div>
</div>

View file

@ -120,12 +120,7 @@ export default function MiiInstructions({ instructions }: Props) {
const { head, hair, eyebrows, eyes, nose, lips, ears, glasses, other, height, weight, birthday, datingPreferences, voice, personality } = instructions;
return (
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex flex-col gap-3 max-h-96 overflow-y-auto">
<h2 className="text-xl font-semibold text-amber-700 flex items-center gap-2">
<Icon icon="fa7-solid:list" />
Instructions
</h2>
<>
{head && (
<Section name="Head" instructions={head}>
{not(head.skinColor) && (
@ -264,6 +259,6 @@ export default function MiiInstructions({ instructions }: Props) {
)}
</div>
)}
</div>
</>
);
}

View file

@ -69,6 +69,7 @@ export default function EditForm({ mii, likes }: Props) {
const [makeup, setMakeup] = useState<MiiMakeup>(mii.makeup ?? "PARTIAL");
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 [youtubeId, setYouTubeId] = useState(mii.youtubeId ?? "");
const instructions = useRef<SwitchMiiInstructions>(deepMerge(defaultInstructions, (mii.instructions as object) ?? {}));
const [quarantined, setQuarantined] = useState(mii.quarantined);
@ -98,6 +99,7 @@ export default function EditForm({ mii, likes }: Props) {
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 (youtubeId != mii.youtubeId) formData.append("youtubeId", youtubeId);
if (minifyInstructions(structuredClone(instructions.current)) !== (mii.instructions as object))
formData.append("instructions", JSON.stringify(instructions.current));
@ -394,6 +396,27 @@ export default function EditForm({ mii, likes }: Props) {
<hr className="grow border-zinc-300" />
</div>
{/* YouTube */}
<div className="w-full grid grid-cols-3 items-center">
<label htmlFor="youtube" className="font-semibold">
YouTube Video
</label>
<input
id="youtube"
type="text"
className="pill input w-full col-span-2"
minLength={2}
maxLength={64}
placeholder="Paste a URL or video ID..."
value={youtubeId}
onChange={(e) => {
const val = e.target.value;
const match = val.match(/(?:youtube\.com\/(?:watch\?v=|shorts\/|embed\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/);
setYouTubeId(match ? match[1] : val);
}}
/>
</div>
<MiiEditor instructions={instructions} />
<SwitchSubmitTutorialButton />
</>

View file

@ -58,6 +58,7 @@ export default function SubmitForm({ inQueueMiisCount }: Props) {
const [platform, setPlatform] = useState<MiiPlatform>("SWITCH");
const [gender, setGender] = useState<MiiGender>("MALE");
const [makeup, setMakeup] = useState<MiiMakeup>("PARTIAL");
const [youtubeId, setYouTubeId] = useState("");
const instructions = useRef<SwitchMiiInstructions>(defaultInstructions);
const [error, setError] = useState<string | undefined>(undefined);
@ -108,6 +109,7 @@ export default function SubmitForm({ inQueueMiisCount }: Props) {
formData.append("makeup", makeup);
formData.append("miiPortraitImage", portraitBlob);
formData.append("miiFeaturesImage", featuresBlob);
formData.append("youtubeId", youtubeId);
formData.append("instructions", JSON.stringify(instructions.current));
}
@ -299,7 +301,7 @@ export default function SubmitForm({ inQueueMiisCount }: Props) {
</div>
{/* Gender (switch only) */}
<div className={`w-full grid grid-cols-3 items-start z-10 ${platform === "SWITCH" ? "" : "hidden"}`}>
<div className={`w-full grid grid-cols-3 items-start z-20 ${platform === "SWITCH" ? "" : "hidden"}`}>
<label htmlFor="gender" className="font-semibold py-2">
Gender
</label>
@ -354,7 +356,7 @@ export default function SubmitForm({ inQueueMiisCount }: Props) {
type="button"
onClick={() => setMakeup("FULL")}
aria-label="Full Face Paint"
data-tooltip="Full Face Paint"
data-tooltip="Face covered more than 80%"
className={`cursor-pointer rounded-xl flex justify-center items-center size-11 text-4xl border-2 transition-all after:bg-pink-400! after:border-pink-400! before:border-b-pink-400! ${
makeup === "FULL" ? "bg-pink-100 border-pink-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
}`}
@ -367,7 +369,7 @@ export default function SubmitForm({ inQueueMiisCount }: Props) {
type="button"
onClick={() => setMakeup("PARTIAL")}
aria-label="Partial Face Paint"
data-tooltip="Partial Face Paint"
data-tooltip="For at least any face paint"
className={`cursor-pointer rounded-xl flex justify-center items-center size-11 text-4xl border-2 transition-all after:bg-purple-400! after:border-purple-400! before:border-b-purple-400! ${
makeup === "PARTIAL" ? "bg-purple-100 border-purple-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
}`}
@ -481,6 +483,27 @@ export default function SubmitForm({ inQueueMiisCount }: Props) {
</div>
<div className="flex flex-col items-center gap-2">
{/* YouTube */}
<div className="w-full grid grid-cols-3 items-center">
<label htmlFor="youtube" className="font-semibold">
YouTube Video
</label>
<input
id="youtube"
type="text"
className="pill input w-full col-span-2"
minLength={2}
maxLength={64}
placeholder="Paste a URL or video ID..."
value={youtubeId}
onChange={(e) => {
const val = e.target.value;
const match = val.match(/(?:youtube\.com\/(?:watch\?v=|shorts\/|embed\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/);
setYouTubeId(match ? match[1] : val);
}}
/>
</div>
<MiiEditor instructions={instructions} />
<SwitchSubmitTutorialButton />
<span className="text-xs text-zinc-400 text-center px-32 max-sm:px-8">