mirror of
https://github.com/trafficlunar/tomodachi-share.git
synced 2026-05-13 13:17:45 +00:00
Compare commits
9 commits
4f03d611eb
...
8378b85167
| Author | SHA1 | Date | |
|---|---|---|---|
| 8378b85167 | |||
| 114526221b | |||
| eaa6c97c57 | |||
| 026ee50b9f | |||
| da08fe24f4 | |||
| fde3480342 | |||
| 2055f61527 | |||
| 0396ad5b0d | |||
| fd11f996df |
29 changed files with 465 additions and 157 deletions
|
|
@ -18,6 +18,8 @@ AUTH_DISCORD_ID=XXXXXXXXXXXXXXXX
|
|||
AUTH_DISCORD_SECRET=XXXXXXXXXXXXXXXX
|
||||
AUTH_GITHUB_ID=XXXXXXXXXXXXXXXX
|
||||
AUTH_GITHUB_SECRET=XXXXXXXXXXXXXXXX
|
||||
AUTH_GOOGLE_ID=XXXXXXXXXXXXXXXX
|
||||
AUTH_GOOGLE_SECRET=XXXXXXXXXXXXXXXX
|
||||
|
||||
# Currently only supports one admin
|
||||
NEXT_PUBLIC_ADMIN_USER_ID=1
|
||||
|
|
|
|||
|
|
@ -4,7 +4,34 @@ import type { NextConfig } from "next";
|
|||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
images: {
|
||||
unoptimized: true,
|
||||
localPatterns: [
|
||||
{
|
||||
pathname: "/mii/*/image",
|
||||
},
|
||||
{
|
||||
pathname: "/profile/*/picture",
|
||||
},
|
||||
{
|
||||
pathname: "/tutorial/**",
|
||||
},
|
||||
{
|
||||
pathname: "/guest.webp",
|
||||
},
|
||||
],
|
||||
remotePatterns: [
|
||||
{
|
||||
hostname: "avatars.githubusercontent.com",
|
||||
},
|
||||
{
|
||||
hostname: "cdn.discordapp.com",
|
||||
},
|
||||
{
|
||||
hostname: "studio.mii.nintendo.com",
|
||||
},
|
||||
{
|
||||
hostname: "*.googleusercontent.com",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterEnum
|
||||
ALTER TYPE "ReportReason" ADD VALUE 'BAD_QUALITY';
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
-- CreateEnum
|
||||
CREATE TYPE "MiiMakeup" AS ENUM ('FULL', 'PARTIAL', 'NONE');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "miis" ADD COLUMN "makeup" "MiiMakeup";
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "miis" ALTER COLUMN "description" SET DATA TYPE VARCHAR(512);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "users" ALTER COLUMN "description" SET DATA TYPE VARCHAR(512);
|
||||
|
|
@ -13,7 +13,7 @@ model User {
|
|||
email String @unique
|
||||
emailVerified DateTime?
|
||||
image String?
|
||||
description String? @db.VarChar(256)
|
||||
description String? @db.VarChar(512)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
|
@ -73,13 +73,15 @@ model Mii {
|
|||
name String @db.VarChar(64)
|
||||
imageCount Int @default(0)
|
||||
tags String[]
|
||||
description String? @db.VarChar(256)
|
||||
description String? @db.VarChar(512)
|
||||
platform MiiPlatform @default(THREE_DS)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { z } from "zod";
|
||||
import { Mii, Prisma } from "@prisma/client";
|
||||
import { Mii, MiiMakeup, Prisma } from "@prisma/client";
|
||||
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
|
|
@ -22,7 +22,8 @@ const uploadsDirectory = path.join(process.cwd(), "uploads", "mii");
|
|||
const editSchema = z.object({
|
||||
name: nameSchema.optional(),
|
||||
tags: tagsSchema.optional(),
|
||||
description: z.string().trim().max(256).optional(),
|
||||
description: z.string().trim().max(512).optional(),
|
||||
makeup: z.enum(MiiMakeup).optional(),
|
||||
instructions: switchMiiInstructionsSchema,
|
||||
image1: z.union([z.instanceof(File), z.any()]).optional(),
|
||||
image2: z.union([z.instanceof(File), z.any()]).optional(),
|
||||
|
|
@ -74,6 +75,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
|
|||
name: formData.get("name") ?? undefined,
|
||||
tags: rawTags,
|
||||
description: formData.get("description") ?? undefined,
|
||||
makeup: formData.get("makeup") ?? undefined,
|
||||
instructions: minifiedInstructions,
|
||||
image1: formData.get("image1"),
|
||||
image2: formData.get("image2"),
|
||||
|
|
@ -81,7 +83,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, instructions, image1, image2, image3 } = parsed.data;
|
||||
const { name, tags, description, makeup, instructions, image1, image2, image3 } = parsed.data;
|
||||
|
||||
// Validate image files
|
||||
const images: File[] = [];
|
||||
|
|
@ -102,6 +104,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
|
|||
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 (makeup !== undefined) updateData.makeup = makeup;
|
||||
if (instructions !== undefined) updateData.instructions = instructions;
|
||||
if (images.length > 0) updateData.imageCount = images.length;
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
@ -29,10 +29,11 @@ const submitSchema = z
|
|||
platform: z.enum(MiiPlatform).default("THREE_DS"),
|
||||
name: nameSchema,
|
||||
tags: tagsSchema,
|
||||
description: z.string().trim().max(256).optional(),
|
||||
description: z.string().trim().max(512).optional(),
|
||||
|
||||
// Switch
|
||||
gender: z.enum(MiiGender).default("MALE"),
|
||||
makeup: z.enum(MiiMakeup).default("PARTIAL"),
|
||||
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 ?? "PARTIAL",
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ import Providers from "./provider";
|
|||
import Header from "@/components/header";
|
||||
import Footer from "@/components/footer";
|
||||
import AdminBanner from "@/components/admin/banner";
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
import { Suspense } from "react";
|
||||
|
||||
const lexend = Lexend({
|
||||
subsets: ["latin"],
|
||||
|
|
@ -91,7 +93,11 @@ export default function RootLayout({
|
|||
)}
|
||||
|
||||
<Providers>
|
||||
<Suspense fallback={<div>Loading header...</div>}>
|
||||
<SessionProvider>
|
||||
<Header />
|
||||
</SessionProvider>
|
||||
</Suspense>
|
||||
<AdminBanner />
|
||||
<main className="px-4 py-8 max-w-7xl w-full grow flex flex-col">{children}</main>
|
||||
<Footer />
|
||||
|
|
|
|||
|
|
@ -142,6 +142,7 @@ export default async function MiiPage({ params }: Props) {
|
|||
alt="mii qr code"
|
||||
width={128}
|
||||
height={128}
|
||||
unoptimized
|
||||
className="border-2 border-amber-300 rounded-lg hover:brightness-90 transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -253,6 +254,59 @@ export default async function MiiPage({ params }: Props) {
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Makeup */}
|
||||
{mii.platform === "SWITCH" && (
|
||||
<>
|
||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mb-2 mt-2 w-full">
|
||||
<hr className="grow border-zinc-300" />
|
||||
<span>Makeup</span>
|
||||
<hr className="grow border-zinc-300" />
|
||||
</div>
|
||||
|
||||
<div data-tooltip-span title={mii.makeup ?? "NULL"} className="flex gap-1">
|
||||
{/* Tooltip */}
|
||||
<div
|
||||
className={`tooltip mt-1! ${
|
||||
mii.makeup === "FULL"
|
||||
? "bg-pink-400! border-pink-400! before:border-b-pink-400!"
|
||||
: mii.makeup === "PARTIAL"
|
||||
? "bg-purple-400! border-purple-400! before:border-b-purple-400!"
|
||||
: "bg-gray-400! border-gray-400! before:border-b-gray-400!"
|
||||
}`}
|
||||
>
|
||||
{mii.makeup === "FULL" ? "Full Makeup" : mii.makeup === "PARTIAL" ? "Partial Makeup" : "No Makeup"}
|
||||
</div>
|
||||
|
||||
{/* Full Makeup */}
|
||||
<div
|
||||
className={`rounded-xl flex justify-center items-center size-13 text-5xl border-2 shadow-sm ${
|
||||
mii.makeup === "FULL" ? "bg-pink-100 border-pink-400" : "bg-white border-gray-300"
|
||||
}`}
|
||||
>
|
||||
<Icon icon="mdi:palette" className="text-pink-400" />
|
||||
</div>
|
||||
|
||||
{/* Partial Makeup */}
|
||||
<div
|
||||
className={`rounded-xl flex justify-center items-center size-13 text-5xl border-2 shadow-sm ${
|
||||
mii.makeup === "PARTIAL" ? "bg-purple-100 border-purple-400" : "bg-white border-gray-300"
|
||||
}`}
|
||||
>
|
||||
<Icon icon="mdi:lipstick" className="text-purple-400" />
|
||||
</div>
|
||||
|
||||
{/* No Makeup */}
|
||||
<div
|
||||
className={`rounded-xl flex justify-center items-center size-13 text-5xl border-2 shadow-sm ${
|
||||
mii.makeup === "NONE" ? "bg-gray-200 border-gray-400" : "bg-white border-gray-300"
|
||||
}`}
|
||||
>
|
||||
<Icon icon="codex:cross" className="text-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="col-span-2 flex flex-col gap-4 max-md:col-span-1">
|
||||
|
|
|
|||
|
|
@ -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 }>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ export default function PrivacyPage() {
|
|||
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl p-6">
|
||||
<h1 className="text-2xl font-bold">Terms of Service</h1>
|
||||
<h2 className="font-light">
|
||||
<strong className="font-medium">Effective Date:</strong> May 02, 2025
|
||||
<strong className="font-medium">Effective Date:</strong> March 26, 2026
|
||||
</h2>
|
||||
|
||||
<hr className="border-black/20 mt-1 mb-4" />
|
||||
|
|
@ -41,6 +41,7 @@ export default function PrivacyPage() {
|
|||
<li>No impersonation of others.</li>
|
||||
<li>No malware, malicious links, or phishing content.</li>
|
||||
<li>No harassment, hate speech, threats, or bullying towards others.</li>
|
||||
<li>Miis must be high quality: for example, not following all instructions on the submit form correctly.</li>
|
||||
<li>Avoid using inappropriate language. Profanity may be automatically censored.</li>
|
||||
<li>No use of automated scripts, bots, or scrapers to access or interact with the site.</li>
|
||||
</ul>
|
||||
|
|
|
|||
|
|
@ -1,15 +1,16 @@
|
|||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
|
||||
import { auth } from "@/lib/auth";
|
||||
import { useSession } from "next-auth/react";
|
||||
|
||||
import SearchBar from "./search-bar";
|
||||
import RandomLink from "./random-link";
|
||||
import ProfileOverview from "./profile-overview";
|
||||
import LogoutButton from "./logout-button";
|
||||
|
||||
export default async function Header() {
|
||||
const session = await auth();
|
||||
export default function Header() {
|
||||
const session = useSession();
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 w-full p-4 grid grid-cols-3 gap-2 gap-x-4 items-center bg-amber-50 border-b-4 border-amber-500 shadow-md max-lg:grid-cols-2 max-md:grid-cols-1">
|
||||
|
|
@ -35,7 +36,7 @@ export default async function Header() {
|
|||
Submit
|
||||
</Link>
|
||||
</li>
|
||||
{!session?.user ? (
|
||||
{!session?.data?.user ? (
|
||||
<li>
|
||||
<Link href={"/login"} className="pill button h-full">
|
||||
Login
|
||||
|
|
|
|||
|
|
@ -12,11 +12,12 @@ interface Props {
|
|||
alt: string;
|
||||
width: number;
|
||||
height: number;
|
||||
unoptimized?: boolean;
|
||||
className?: string;
|
||||
images?: string[];
|
||||
}
|
||||
|
||||
export default function ImageViewer({ src, alt, width, height, className, images = [] }: Props) {
|
||||
export default function ImageViewer({ src, alt, width, height, unoptimized = false, className, images = [] }: Props) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
|
|
@ -73,7 +74,16 @@ export default function ImageViewer({ src, alt, width, height, className, images
|
|||
return (
|
||||
<>
|
||||
{/* not inserting pixelated image-rendering here because i thought it looked a bit weird */}
|
||||
<Image src={src} alt={alt} width={width} height={height} onClick={() => setIsOpen(true)} className={`cursor-pointer ${className}`} />
|
||||
<Image
|
||||
src={src}
|
||||
alt={alt}
|
||||
width={width}
|
||||
height={height}
|
||||
unoptimized={unoptimized}
|
||||
loading="lazy"
|
||||
onClick={() => setIsOpen(true)}
|
||||
className={`cursor-pointer ${className}`}
|
||||
/>
|
||||
|
||||
{isOpen &&
|
||||
createPortal(
|
||||
|
|
@ -104,6 +114,7 @@ export default function ImageViewer({ src, alt, width, height, className, images
|
|||
alt={alt}
|
||||
width={896}
|
||||
height={896}
|
||||
unoptimized
|
||||
priority={index === selectedIndex}
|
||||
loading={Math.abs(index - selectedIndex) <= 1 ? "eager" : "lazy"}
|
||||
className="max-w-full max-h-full object-contain drop-shadow-lg"
|
||||
|
|
|
|||
|
|
@ -22,6 +22,14 @@ export default function LoginButtons() {
|
|||
<Icon icon="mdi:github" fontSize={32} />
|
||||
Login with GitHub
|
||||
</button>
|
||||
<button
|
||||
onClick={() => signIn("google", { redirectTo: "/" })}
|
||||
aria-label="Login with Google"
|
||||
className="pill button gap-2 px-3! bg-white! border-gray-300! hover:bg-gray-100! text-black! flex items-center"
|
||||
>
|
||||
<Icon icon="material-icon-theme:google" fontSize={32} />
|
||||
Login with Google
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="relative">
|
||||
|
|
@ -114,6 +117,16 @@ export default function FilterMenu() {
|
|||
</div>
|
||||
<TagFilter isExclude />
|
||||
|
||||
{platform !== "THREE_DS" && (
|
||||
<>
|
||||
<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" />
|
||||
<span>Makeup</span>
|
||||
<hr className="grow border-zinc-300" />
|
||||
</div>
|
||||
<MakeupSelect />
|
||||
</>
|
||||
)}
|
||||
{platform !== "SWITCH" && (
|
||||
<>
|
||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium w-full mt-2 mb-1">
|
||||
|
|
|
|||
|
|
@ -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 <h1>{parsed.error.issues[0].message}</h1>;
|
||||
|
||||
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 && {
|
||||
|
|
|
|||
75
src/components/mii/list/makeup-select.tsx
Normal file
75
src/components/mii/list/makeup-select.tsx
Normal file
|
|
@ -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<MiiMakeup | null>((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 (
|
||||
<div className="flex gap-0.5 w-fit">
|
||||
{/* Full Makeup */}
|
||||
<button
|
||||
onClick={() => handleClick("FULL")}
|
||||
aria-label="Filter for Full Makeup"
|
||||
data-tooltip-span
|
||||
className={`cursor-pointer rounded-xl flex justify-center items-center size-13 text-5xl border-2 transition-all ${
|
||||
selected === "FULL" ? "bg-pink-100 border-pink-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
|
||||
}`}
|
||||
>
|
||||
<div className="tooltip bg-pink-400! border-pink-400! before:border-b-pink-400!">Full Makeup</div>
|
||||
<Icon icon="mdi:palette" className="text-pink-400" />
|
||||
</button>
|
||||
|
||||
{/* Partial Makeup */}
|
||||
<button
|
||||
onClick={() => handleClick("PARTIAL")}
|
||||
aria-label="Filter for Partial Makeup"
|
||||
data-tooltip-span
|
||||
className={`cursor-pointer rounded-xl flex justify-center items-center size-13 text-5xl border-2 transition-all ${
|
||||
selected === "PARTIAL" ? "bg-purple-100 border-purple-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
|
||||
}`}
|
||||
>
|
||||
<div className="tooltip bg-purple-400! border-purple-400! before:border-b-purple-400!">Partial Makeup</div>
|
||||
<Icon icon="mdi:lipstick" className="text-purple-400" />
|
||||
</button>
|
||||
|
||||
{/* No Makeup */}
|
||||
<button
|
||||
onClick={() => handleClick("NONE")}
|
||||
aria-label="Filter for No Makeup"
|
||||
data-tooltip-span
|
||||
className={`cursor-pointer rounded-xl flex justify-center items-center size-13 text-5xl border-2 transition-all ${
|
||||
selected === "NONE" ? "bg-gray-200 border-gray-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
|
||||
}`}
|
||||
>
|
||||
<div className="tooltip bg-gray-400! border-gray-400! before:border-b-gray-400!">No Makeup</div>
|
||||
<Icon icon="codex:cross" className="text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -5,13 +5,13 @@ import { ChangeEvent } from "react";
|
|||
|
||||
interface Props {
|
||||
data: SwitchMiiInstructions["voice"];
|
||||
onClick?: (e: ChangeEvent<HTMLInputElement, HTMLInputElement>, label: string) => void;
|
||||
onChange?: (e: ChangeEvent<HTMLInputElement, HTMLInputElement>, label: string) => void;
|
||||
onClickTone?: (i: number) => void;
|
||||
}
|
||||
|
||||
const VOICE_SETTINGS: string[] = ["Speed", "Pitch", "Depth", "Delivery"];
|
||||
|
||||
export default function VoiceViewer({ data, onClick, onClickTone }: Props) {
|
||||
export default function VoiceViewer({ data, onChange, onClickTone }: Props) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{VOICE_SETTINGS.map((label) => (
|
||||
|
|
@ -28,9 +28,9 @@ export default function VoiceViewer({ data, onClick, onClickTone }: Props) {
|
|||
max={50}
|
||||
step={1}
|
||||
value={data[label as keyof typeof data] ?? 25}
|
||||
disabled={!onClick}
|
||||
disabled={!onChange}
|
||||
onChange={(e) => {
|
||||
if (onClick) onClick(e, label);
|
||||
if (onChange) onChange(e, label);
|
||||
}}
|
||||
/>
|
||||
<div className="absolute h-4 w-1.5 rounded bg-orange-400 z-0"></div>
|
||||
|
|
@ -50,7 +50,7 @@ export default function VoiceViewer({ data, onClick, onClickTone }: Props) {
|
|||
onClick={() => {
|
||||
if (onClickTone) onClickTone(i);
|
||||
}}
|
||||
className={`transition-colors duration-100 rounded-xl ${data.tone === i ? "bg-orange-400!" : ""} ${onClick ? "hover:bg-orange-300 cursor-pointer" : ""}`}
|
||||
className={`transition-colors duration-100 rounded-xl ${data.tone === i ? "bg-orange-400!" : ""} ${onClickTone ? "hover:bg-orange-300 cursor-pointer" : ""}`}
|
||||
>
|
||||
{i + 1}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -1,21 +1,28 @@
|
|||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { useSession } from "next-auth/react";
|
||||
import Link from "next/link";
|
||||
|
||||
export default async function ProfileOverview() {
|
||||
const session = await auth();
|
||||
export default function ProfileOverview() {
|
||||
const session = useSession();
|
||||
|
||||
return (
|
||||
<li title="Your profile">
|
||||
<Link href={`/profile/${session?.user?.id}`} aria-label="Go to profile" className="pill button gap-2! p-0! h-full max-w-64" data-tooltip="Your Profile">
|
||||
<Link
|
||||
href={`/profile/${session?.data?.user?.id}`}
|
||||
aria-label="Go to profile"
|
||||
className="pill button gap-2! p-0! h-full max-w-64"
|
||||
data-tooltip="Your Profile"
|
||||
>
|
||||
<Image
|
||||
src={session?.user?.image ?? "/guest.png"}
|
||||
src={session?.data?.user?.image ?? "/guest.png"}
|
||||
alt="profile picture"
|
||||
width={40}
|
||||
height={40}
|
||||
className="rounded-full aspect-square object-cover h-full bg-white outline-2 outline-orange-400"
|
||||
/>
|
||||
<span className="pr-4 overflow-hidden whitespace-nowrap text-ellipsis w-full">{session?.user?.name ?? "unknown"}</span>
|
||||
<span className="pr-4 overflow-hidden whitespace-nowrap text-ellipsis w-full">{session?.data?.user?.name ?? "unknown"}</span>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ const reasonMap: Record<ReportReason, string> = {
|
|||
INAPPROPRIATE: "Inappropriate content",
|
||||
SPAM: "Spam",
|
||||
COPYRIGHT: "Copyrighted content",
|
||||
BAD_QUALITY: "Bad quality",
|
||||
OTHER: "Other...",
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { redirect } from "next/navigation";
|
|||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { FileWithPath } from "react-dropzone";
|
||||
import { Mii } from "@prisma/client";
|
||||
import { Mii, MiiMakeup } from "@prisma/client";
|
||||
|
||||
import { nameSchema, tagsSchema } from "@/lib/schemas";
|
||||
import { defaultInstructions, minifyInstructions } from "@/lib/switch";
|
||||
|
|
@ -18,6 +18,7 @@ import SubmitButton from "../submit-button";
|
|||
import Dropzone from "../dropzone";
|
||||
import MiiEditor from "./mii-editor";
|
||||
import SwitchSubmitTutorialButton from "../tutorial/switch-submit";
|
||||
import { Icon } from "@iconify/react";
|
||||
|
||||
interface Props {
|
||||
mii: Mii;
|
||||
|
|
@ -42,6 +43,7 @@ export default function EditForm({ mii, likes }: Props) {
|
|||
const [name, setName] = useState(mii.name);
|
||||
const [tags, setTags] = useState(mii.tags);
|
||||
const [description, setDescription] = useState(mii.description);
|
||||
const [makeup, setMakeup] = useState<MiiMakeup>(mii.makeup ?? "PARTIAL");
|
||||
const hasFilesChanged = useRef(false);
|
||||
|
||||
const instructions = useRef<SwitchMiiInstructions>({ ...defaultInstructions, ...(mii.instructions as object as Partial<SwitchMiiInstructions>) });
|
||||
|
|
@ -64,6 +66,7 @@ export default function EditForm({ mii, likes }: Props) {
|
|||
if (name != mii.name) formData.append("name", name);
|
||||
if (tags != mii.tags) formData.append("tags", JSON.stringify(tags));
|
||||
if (description && description != mii.description) formData.append("description", description);
|
||||
if (makeup != mii.makeup) formData.append("makeup", makeup);
|
||||
if (minifyInstructions(structuredClone(instructions.current)) !== (mii.instructions as object))
|
||||
formData.append("instructions", JSON.stringify(instructions.current));
|
||||
|
||||
|
|
@ -179,7 +182,7 @@ export default function EditForm({ mii, likes }: Props) {
|
|||
</label>
|
||||
<textarea
|
||||
rows={5}
|
||||
maxLength={256}
|
||||
maxLength={512}
|
||||
placeholder="(optional) Type a description..."
|
||||
className="pill input rounded-xl! resize-none col-span-2 text-sm"
|
||||
value={description ?? ""}
|
||||
|
|
@ -190,6 +193,53 @@ export default function EditForm({ mii, likes }: Props) {
|
|||
{/* Instructions (Switch only) */}
|
||||
{mii.platform === "SWITCH" && (
|
||||
<>
|
||||
<div className="w-full grid grid-cols-3 items-start">
|
||||
<label htmlFor="makeup" className="font-semibold py-2">
|
||||
Makeup
|
||||
</label>
|
||||
|
||||
<div className="col-span-2 flex gap-1">
|
||||
{/* Full Makeup */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMakeup("FULL")}
|
||||
aria-label="Full makeup"
|
||||
data-tooltip="Full Makeup"
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
<Icon icon="mdi:palette" className="text-pink-400" />
|
||||
</button>
|
||||
|
||||
{/* Partial Makeup */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMakeup("PARTIAL")}
|
||||
aria-label="Partial makeup"
|
||||
data-tooltip="Partial Makeup"
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
<Icon icon="mdi:lipstick" className="text-purple-400" />
|
||||
</button>
|
||||
|
||||
{/* No Makeup */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMakeup("NONE")}
|
||||
aria-label="No makeup"
|
||||
data-tooltip="No Makeup"
|
||||
className={`cursor-pointer rounded-xl flex justify-center items-center size-11 text-4xl border-2 transition-all after:bg-gray-400! after:border-gray-400! before:border-b-gray-400! ${
|
||||
makeup === "NONE" ? "bg-gray-200 border-gray-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
|
||||
}`}
|
||||
>
|
||||
<Icon icon="codex:cross" className="text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-8">
|
||||
<hr className="grow border-zinc-300" />
|
||||
<span>Instructions</span>
|
||||
|
|
|
|||
|
|
@ -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<MiiPlatform>("SWITCH");
|
||||
const [gender, setGender] = useState<MiiGender>("MALE");
|
||||
const [makeup, setMakeup] = useState<MiiMakeup>("PARTIAL");
|
||||
const instructions = useRef<SwitchMiiInstructions>(defaultInstructions);
|
||||
|
||||
const [error, setError] = useState<string | undefined>(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));
|
||||
|
|
@ -274,7 +276,7 @@ export default function SubmitForm() {
|
|||
<textarea
|
||||
id="description"
|
||||
rows={5}
|
||||
maxLength={256}
|
||||
maxLength={512}
|
||||
placeholder="(optional) Type a description..."
|
||||
className="pill input rounded-xl! resize-none col-span-2 text-sm"
|
||||
value={description}
|
||||
|
|
@ -283,7 +285,7 @@ export default function SubmitForm() {
|
|||
</div>
|
||||
|
||||
{/* Gender (switch only) */}
|
||||
<div className={`w-full grid grid-cols-3 items-start ${platform === "SWITCH" ? "" : "hidden"}`}>
|
||||
<div className={`w-full grid grid-cols-3 items-start z-10 ${platform === "SWITCH" ? "" : "hidden"}`}>
|
||||
<label htmlFor="gender" className="font-semibold py-2">
|
||||
Gender
|
||||
</label>
|
||||
|
|
@ -326,6 +328,54 @@ export default function SubmitForm() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Makeup (switch only) */}
|
||||
<div className={`w-full grid grid-cols-3 items-start ${platform === "SWITCH" ? "" : "hidden"}`}>
|
||||
<label htmlFor="makeup" className="font-semibold py-2">
|
||||
Makeup
|
||||
</label>
|
||||
|
||||
<div className="col-span-2 flex gap-1">
|
||||
{/* Full Makeup */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMakeup("FULL")}
|
||||
aria-label="Full makeup"
|
||||
data-tooltip="Full Makeup"
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
<Icon icon="mdi:palette" className="text-pink-400" />
|
||||
</button>
|
||||
|
||||
{/* Partial Makeup */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMakeup("PARTIAL")}
|
||||
aria-label="Partial makeup"
|
||||
data-tooltip="Partial Makeup"
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
<Icon icon="mdi:lipstick" className="text-purple-400" />
|
||||
</button>
|
||||
|
||||
{/* No Makeup */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMakeup("NONE")}
|
||||
aria-label="No makeup"
|
||||
data-tooltip="No Makeup"
|
||||
className={`cursor-pointer rounded-xl flex justify-center items-center size-11 text-4xl border-2 transition-all after:bg-gray-400! after:border-gray-400! before:border-b-gray-400! ${
|
||||
makeup === "NONE" ? "bg-gray-200 border-gray-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
|
||||
}`}
|
||||
>
|
||||
<Icon icon="codex:cross" className="text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* (Switch Only) Mii Portrait */}
|
||||
<div className={`${platform === "SWITCH" ? "" : "hidden"}`}>
|
||||
{/* Separator */}
|
||||
|
|
@ -379,7 +429,7 @@ export default function SubmitForm() {
|
|||
<div className="flex flex-col items-center gap-2">
|
||||
<MiiEditor instructions={instructions} />
|
||||
<SwitchSubmitTutorialButton />
|
||||
<span className="text-xs text-zinc-400 text-center px-32">
|
||||
<span className="text-xs text-zinc-400 text-center px-32 max-sm:px-8">
|
||||
Mii editor may be inaccurate. Instructions are recommended, but not required - you do not have to add every instruction.
|
||||
</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { Icon } from "@iconify/react";
|
||||
import { useState } from "react";
|
||||
|
||||
interface Props {
|
||||
|
|
@ -5,120 +6,87 @@ interface Props {
|
|||
}
|
||||
|
||||
export default function NumberInputs({ target }: Props) {
|
||||
const [height, setHeight] = useState(0);
|
||||
const [distance, setDistance] = useState(0);
|
||||
const [rotation, setRotation] = useState(0);
|
||||
const [size, setSize] = useState(0);
|
||||
const [stretch, setStretch] = useState(0);
|
||||
const [values, setValues] = useState<Record<string, number>>({
|
||||
height: 0,
|
||||
distance: 0,
|
||||
rotation: 0,
|
||||
size: 0,
|
||||
stretch: 0,
|
||||
});
|
||||
|
||||
if (!target) return null;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-x-4 h-min w-fit">
|
||||
{target.height !== undefined && (
|
||||
<div>
|
||||
<label htmlFor="height" className="text-xs">
|
||||
Height
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="height"
|
||||
min={-15}
|
||||
max={15}
|
||||
value={height}
|
||||
onChange={(e) => {
|
||||
const value = Number(e.target.value);
|
||||
setHeight(value);
|
||||
target.height = value;
|
||||
{["Height", "Distance", "Rotation", "Size", "Stretch"].map(
|
||||
(label) =>
|
||||
target[label.toLowerCase()] !== undefined && (
|
||||
<NumberField
|
||||
key={label}
|
||||
label={label}
|
||||
value={values[label.toLowerCase()]}
|
||||
onChange={(value) => {
|
||||
const field = label.toLowerCase();
|
||||
setValues((prev) => ({ ...prev, [field]: value }));
|
||||
target[field] = value;
|
||||
}}
|
||||
className="pill input text-sm py-1! px-3! w-full"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{target.distance !== undefined && (
|
||||
<div>
|
||||
<label htmlFor="distance" className="text-xs">
|
||||
Distance
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="distance"
|
||||
min={-15}
|
||||
max={15}
|
||||
value={distance}
|
||||
onChange={(e) => {
|
||||
const value = Number(e.target.value);
|
||||
setDistance(value);
|
||||
target.distance = value;
|
||||
}}
|
||||
className="pill input text-sm py-1! px-3! w-full"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{target.rotation !== undefined && (
|
||||
<div>
|
||||
<label htmlFor="rotation" className="text-xs">
|
||||
Rotation
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="rotation"
|
||||
min={-15}
|
||||
max={15}
|
||||
value={rotation}
|
||||
onChange={(e) => {
|
||||
const value = Number(e.target.value);
|
||||
setRotation(value);
|
||||
target.rotation = value;
|
||||
}}
|
||||
className="pill input text-sm py-1! px-3! w-full"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{target.size !== undefined && (
|
||||
<div>
|
||||
<label htmlFor="size" className="text-xs">
|
||||
Size
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="size"
|
||||
min={-15}
|
||||
max={15}
|
||||
value={size}
|
||||
onChange={(e) => {
|
||||
const value = Number(e.target.value);
|
||||
setSize(value);
|
||||
target.size = value;
|
||||
}}
|
||||
className="pill input text-sm py-1! px-3! w-full"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{target.stretch !== undefined && (
|
||||
<div>
|
||||
<label htmlFor="stretch" className="text-xs">
|
||||
Stretch
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="stretch"
|
||||
min={-15}
|
||||
max={15}
|
||||
value={stretch}
|
||||
onChange={(e) => {
|
||||
const value = Number(e.target.value);
|
||||
setStretch(value);
|
||||
target.stretch = value;
|
||||
}}
|
||||
className="pill input text-sm py-1! px-3! w-full"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface NumberFieldProps {
|
||||
label: string;
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
}
|
||||
|
||||
function NumberField({ label, value, onChange }: NumberFieldProps) {
|
||||
const MIN = -15;
|
||||
const MAX = 15;
|
||||
|
||||
const decrement = () => onChange(Math.max(MIN, value - 1));
|
||||
const increment = () => onChange(Math.min(MAX, value + 1));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor={label} className="text-xs">
|
||||
{label}
|
||||
</label>
|
||||
<div className="pill input text-sm py-1! px-2! w-full flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={decrement}
|
||||
disabled={value <= MIN}
|
||||
className="cursor-pointer flex items-center justify-center shrink-0 disabled:opacity-30"
|
||||
aria-label={`Decrease ${label}`}
|
||||
>
|
||||
<Icon icon="mdi:minus" width="16" height="16" />
|
||||
</button>
|
||||
<input
|
||||
type="number"
|
||||
id={label}
|
||||
min={MIN}
|
||||
max={MAX}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
const val = Math.min(MAX, Math.max(MIN, Number(e.target.value)));
|
||||
onChange(val);
|
||||
}}
|
||||
className="w-full text-center bg-transparent outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={increment}
|
||||
disabled={value >= MAX}
|
||||
className="cursor-pointer flex items-center justify-center shrink-0 disabled:opacity-30"
|
||||
aria-label={`Increase ${label}`}
|
||||
>
|
||||
<Icon icon="mdi:plus" width="16" height="16" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -122,7 +122,7 @@ export default function HeadTab({ instructions }: Props) {
|
|||
|
||||
<VoiceViewer
|
||||
data={voice}
|
||||
onClick={(e, label) => {
|
||||
onChange={(e, label) => {
|
||||
setVoice((p) => ({ ...p, [label]: e.target.valueAsNumber }));
|
||||
instructions.current.voice[label as keyof typeof voice] = e.target.valueAsNumber;
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
import NextAuth from "next-auth";
|
||||
import Discord from "next-auth/providers/discord";
|
||||
import Github from "next-auth/providers/github";
|
||||
import Google from "next-auth/providers/google";
|
||||
|
||||
import { PrismaAdapter } from "@auth/prisma-adapter";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||
adapter: PrismaAdapter(prisma),
|
||||
providers: [Discord, Github],
|
||||
providers: [Discord, Github, Google],
|
||||
pages: {
|
||||
signIn: "/login",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue