Merge branch 'main' into feat/living-the-dream-qr-code

This commit is contained in:
trafficlunar 2026-01-02 15:14:06 +00:00
commit 2af1bf18a6
78 changed files with 3287 additions and 2325 deletions

View file

@ -5,6 +5,7 @@ import { auth } from "@/lib/auth";
import BannerForm from "@/components/admin/banner-form";
import ControlCenter from "@/components/admin/control-center";
import RegenerateImagesButton from "@/components/admin/regenerate-images";
import UserManagement from "@/components/admin/user-management";
import Reports from "@/components/admin/reports";
@ -31,36 +32,37 @@ export default async function AdminPage() {
{/* Separator */}
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium my-1">
<hr className="flex-grow border-zinc-300" />
<hr className="grow border-zinc-300" />
<span>Banners</span>
<hr className="flex-grow border-zinc-300" />
<hr className="grow border-zinc-300" />
</div>
<BannerForm />
{/* Separator */}
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium my-1">
<hr className="flex-grow border-zinc-300" />
<hr className="grow border-zinc-300" />
<span>Control Center</span>
<hr className="flex-grow border-zinc-300" />
<hr className="grow border-zinc-300" />
</div>
<ControlCenter />
<RegenerateImagesButton />
{/* Separator */}
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium my-1">
<hr className="flex-grow border-zinc-300" />
<hr className="grow border-zinc-300" />
<span>User Management</span>
<hr className="flex-grow border-zinc-300" />
<hr className="grow border-zinc-300" />
</div>
<UserManagement />
{/* Separator */}
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium my-1">
<hr className="flex-grow border-zinc-300" />
<hr className="grow border-zinc-300" />
<span>Reports</span>
<hr className="flex-grow border-zinc-300" />
<hr className="grow border-zinc-300" />
</div>
<Reports />

View file

@ -0,0 +1,41 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { generateMetadataImage } from "@/lib/images";
export async function PATCH() {
const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (Number(session.user.id) !== Number(process.env.NEXT_PUBLIC_ADMIN_USER_ID)) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
// Start processing in background
regenerateImages().catch(console.error);
return NextResponse.json({ success: true });
}
async function regenerateImages() {
// Get miis in batches to reduce memory usage
const BATCH_SIZE = 10;
const totalMiis = await prisma.mii.count();
let processed = 0;
for (let skip = 0; skip < totalMiis; skip += BATCH_SIZE) {
const miis = await prisma.mii.findMany({
skip,
take: BATCH_SIZE,
include: { user: { select: { name: true } } },
});
// Process each batch sequentially to avoid overwhelming the server
for (const mii of miis) {
try {
await generateMetadataImage(mii, mii.user.name);
processed++;
} catch (error) {
console.error(`Failed to generate image for mii ${mii.id}:`, error);
}
}
}
}

View file

@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from "next/server";
import { profanity } from "@2toad/profanity";
import z from "zod";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { RateLimit } from "@/lib/rate-limit";
export async function PATCH(request: NextRequest) {
const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const rateLimit = new RateLimit(request, 3);
const check = await rateLimit.handle();
if (check) return check;
const { description } = await request.json();
if (!description) return rateLimit.sendResponse({ error: "New about me is required" }, 400);
const validation = z.string().trim().max(256).safeParse(description);
if (!validation.success) return rateLimit.sendResponse({ error: validation.error.issues[0].message }, 400);
try {
await prisma.user.update({
where: { id: Number(session.user.id) },
data: { description: profanity.censor(description) },
});
} catch (error) {
console.error("Failed to update description:", error);
return rateLimit.sendResponse({ error: "Failed to update description" }, 500);
}
return rateLimit.sendResponse({ success: true });
}

View file

@ -44,13 +44,6 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
where: {
id: miiId,
},
include: {
user: {
select: {
username: true,
},
},
},
});
if (!mii) return rateLimit.sendResponse({ error: "Mii not found" }, 404);
@ -102,11 +95,18 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
if (images.length > 0) updateData.imageCount = images.length;
if (Object.keys(updateData).length == 0) return rateLimit.sendResponse({ error: "Nothing was changed" }, 400);
await prisma.mii.update({
const updatedMii = await prisma.mii.update({
where: {
id: miiId,
},
data: updateData,
include: {
user: {
select: {
name: true,
},
},
},
});
// Only touch files if new images were uploaded
@ -137,7 +137,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
} else if (description === undefined) {
// If images or description were not changed, regenerate the metadata image
try {
await generateMetadataImage(mii, mii.user.username!);
await generateMetadataImage(updatedMii, updatedMii.user.name!);
} catch (error) {
console.error(error);
return rateLimit.sendResponse({ error: `Failed to generate 'metadata' type image for mii ${miiId}` }, 500);

View file

@ -1,11 +1,10 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { ReportReason, ReportType } from "@prisma/client";
import { Prisma, ReportReason, ReportType } from "@prisma/client";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { RateLimit } from "@/lib/rate-limit";
import { MiiWithUsername } from "@/types";
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" }),
@ -30,7 +29,15 @@ export async function POST(request: NextRequest) {
if (!parsed.success) return rateLimit.sendResponse({ error: parsed.error.issues[0].message }, 400);
const { id, type, reason, notes } = parsed.data;
let mii: MiiWithUsername | null = null;
let mii: Prisma.MiiGetPayload<{
include: {
user: {
select: {
username: true;
};
};
};
}> | null = null;
// Check if the Mii or User exists
if (type === "mii") {

View file

@ -0,0 +1,79 @@
import { NextRequest } from "next/server";
import crypto from "crypto";
import seedrandom from "seedrandom";
import { searchSchema } from "@/lib/schemas";
import { RateLimit } from "@/lib/rate-limit";
import { prisma } from "@/lib/prisma";
import { Prisma } from "@prisma/client";
export async function GET(request: NextRequest) {
const rateLimit = new RateLimit(request, 24, "/api/search");
const check = await rateLimit.handle();
if (check) return check;
const parsed = searchSchema.safeParse(Object.fromEntries(request.nextUrl.searchParams));
if (!parsed.success) return rateLimit.sendResponse({ error: parsed.error.issues[0].message }, 400);
const { q: query, sort, tags, gender, page = 1, limit = 24, seed } = parsed.data;
const where: Prisma.MiiWhereInput = {
// Searching
...(query && {
OR: [{ name: { contains: query, mode: "insensitive" } }, { tags: { has: query } }, { description: { contains: query, mode: "insensitive" } }],
}),
// Tag filtering
...(tags && tags.length > 0 && { tags: { hasEvery: tags } }),
// Gender
...(gender && { gender: { equals: gender } }),
};
const skip = (page - 1) * limit;
if (sort === "random") {
// Use seed for consistent random results
const randomSeed = seed || crypto.randomInt(0, 1_000_000_000);
// Get all IDs that match the where conditions
const matchingIds = await prisma.mii.findMany({
where,
select: { id: true },
});
if (matchingIds.length === 0) return rateLimit.sendResponse({ error: "No Miis found!" }, 404);
const rng = seedrandom(randomSeed.toString());
// Randomize all IDs using the Durstenfeld algorithm
for (let i = matchingIds.length - 1; i > 0; i--) {
const j = Math.floor(rng() * (i + 1));
[matchingIds[i], matchingIds[j]] = [matchingIds[j], matchingIds[i]];
}
// Convert to number[] array and return paginated results
return rateLimit.sendResponse(matchingIds.slice(skip, skip + limit).map((i) => i.id));
} else {
// Sorting by likes, newest, or oldest
let orderBy: Prisma.MiiOrderByWithRelationInput[];
if (sort === "likes") {
orderBy = [{ likedBy: { _count: "desc" } }, { name: "asc" }];
} else if (sort === "oldest") {
orderBy = [{ createdAt: "asc" }, { name: "asc" }];
} else {
// default to newest
orderBy = [{ createdAt: "desc" }, { name: "asc" }];
}
const list = await prisma.mii.findMany({
where,
orderBy,
select: { id: true },
skip,
take: limit,
});
return rateLimit.sendResponse(list.map((mii) => mii.id));
}
}

View file

@ -33,13 +33,16 @@ const submitSchema = z
miiPortraitImage: z.union([z.instanceof(File), z.any()]).optional(),
// QR code
qrBytesRaw: z.array(z.number(), { error: "A QR code is required" }).length(372, { error: "QR code size is not a valid Tomodachi Life QR code" }),
qrBytesRaw: z.array(z.number(), { error: "A QR code is required" }).length(372, {
error: "QR code size is not a valid Tomodachi Life QR code",
}),
// Custom images
image1: z.union([z.instanceof(File), z.any()]).optional(),
image2: z.union([z.instanceof(File), z.any()]).optional(),
image3: z.union([z.instanceof(File), z.any()]).optional(),
})
// This refine function is probably useless
.refine(
(data) => {
// If platform is Switch, gender and miiPortraitImage must be present
@ -117,8 +120,12 @@ export async function POST(request: NextRequest) {
}
}
console.log(data.miiPortraitImage);
// Check Mii portrait image as well (Switch)
if (data.platform === "SWITCH") {
if (data.miiPortraitImage.length === 0) return rateLimit.sendResponse({ error: "No mii portrait found!" }, 400);
const imageValidation = await validateImage(data.miiPortraitImage);
if (!imageValidation.valid) return rateLimit.sendResponse({ error: imageValidation.error }, imageValidation.status ?? 400);
}
@ -217,10 +224,15 @@ export async function POST(request: NextRequest) {
}
try {
await generateMetadataImage(miiRecord, session.user.username!);
await generateMetadataImage(miiRecord, session.user.name!);
} catch (error) {
console.error(error);
return rateLimit.sendResponse({ error: `Failed to generate 'metadata' type image for mii ${miiRecord.id}` }, 500);
return rateLimit.sendResponse(
{
error: `Failed to generate 'metadata' type image for mii ${miiRecord.id}`,
},
500
);
}
// Compress and store user images

View file

@ -21,14 +21,14 @@ export default async function CreateUsernamePage() {
}
return (
<div className="flex-grow flex items-center justify-center">
<div className="grow flex items-center justify-center">
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg px-10 py-12 max-w-md text-center">
<h1 className="text-3xl font-bold mb-4">Welcome to the island!</h1>
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mb-6">
<hr className="flex-grow border-zinc-300" />
<hr className="grow border-zinc-300" />
<span>Please create a username</span>
<hr className="flex-grow border-zinc-300" />
<hr className="grow border-zinc-300" />
</div>
<UsernameForm />

View file

@ -32,7 +32,7 @@ body {
}
.pill {
@apply flex justify-center items-center px-5 py-2 bg-orange-300 border-2 border-orange-400 rounded-4xl shadow-md;
@apply flex justify-center items-center px-5 py-2 bg-orange-300 border-2 border-orange-400 rounded-3xl shadow-md;
}
.button {
@ -40,15 +40,15 @@ body {
}
.button:disabled {
@apply text-zinc-600 !bg-zinc-100 !border-zinc-300 cursor-auto;
@apply text-zinc-600 bg-zinc-100! border-zinc-300! cursor-auto;
}
.input {
@apply !bg-orange-200 outline-0 focus:ring-[3px] ring-orange-400/50 transition placeholder:text-black/40;
@apply bg-orange-200! outline-0 focus:ring-[3px] ring-orange-400/50 transition placeholder:text-black/40;
}
.input:disabled {
@apply text-zinc-600 !bg-zinc-100 !border-zinc-300;
@apply text-zinc-600 bg-zinc-100! border-zinc-300!;
}
.checkbox {
@ -88,7 +88,7 @@ body {
}
[data-tooltip-span] > .tooltip {
@apply absolute left-1/2 top-full mt-2 px-2 py-1 bg-orange-400 border border-orange-400 rounded-md text-sm text-white whitespace-nowrap select-none pointer-events-none shadow-md opacity-0 scale-75 transition-all duration-200 ease-out origin-top -translate-x-1/2 z-[999999];
@apply absolute left-1/2 top-full mt-2 px-2 py-1 bg-orange-400 border border-orange-400 rounded-md text-sm text-white whitespace-nowrap select-none pointer-events-none shadow-md opacity-0 scale-75 transition-all duration-200 ease-out origin-top -translate-x-1/2 z-999999;
}
[data-tooltip-span] > .tooltip::before {

View file

@ -92,7 +92,7 @@ export default function RootLayout({
<Providers>
<Header />
<AdminBanner />
<main className="px-4 py-8 max-w-7xl w-full flex-grow flex flex-col">{children}</main>
<main className="px-4 py-8 max-w-7xl w-full grow flex flex-col">{children}</main>
<Footer />
</Providers>
</body>

View file

@ -17,14 +17,14 @@ export default async function LoginPage() {
}
return (
<div className="flex-grow flex items-center justify-center">
<div className="grow flex items-center justify-center">
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg px-10 py-12 max-w-md text-center">
<h1 className="text-3xl font-bold mb-4">Welcome to TomodachiShare!</h1>
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mb-8">
<hr className="flex-grow border-zinc-300" />
<hr className="grow border-zinc-300" />
<span>Choose your login method</span>
<hr className="flex-grow border-zinc-300" />
<hr className="grow border-zinc-300" />
</div>
<LoginButtons />

View file

@ -0,0 +1,49 @@
import { NextRequest } from "next/server";
import { idSchema } from "@/lib/schemas";
import { RateLimit } from "@/lib/rate-limit";
import { prisma } from "@/lib/prisma";
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const rateLimit = new RateLimit(request, 200, "/mii/data");
const check = await rateLimit.handle();
if (check) return check;
const { id: slugId } = await params;
const parsed = idSchema.safeParse(slugId);
if (!parsed.success) return rateLimit.sendResponse({ error: parsed.error.issues[0].message }, 400);
const miiId = parsed.data;
const data = await prisma.mii.findUnique({
where: { id: miiId },
select: {
id: true,
name: true,
_count: {
select: {
likedBy: true,
},
},
imageCount: true,
tags: true,
description: true,
firstName: true,
lastName: true,
gender: true,
islandName: true,
allowedCopying: true,
createdAt: true,
user: { select: { id: true, username: true, name: true } },
},
});
if (!data) {
return rateLimit.sendResponse({ error: "Mii not found" }, 404);
}
const { _count, ...rest } = data;
return rateLimit.sendResponse({
...rest,
likes: _count.likedBy,
});
}

View file

@ -1,4 +1,5 @@
import { NextRequest } from "next/server";
import { Prisma } from "@prisma/client";
import fs from "fs/promises";
import path from "path";
@ -8,7 +9,6 @@ import { idSchema } from "@/lib/schemas";
import { RateLimit } from "@/lib/rate-limit";
import { generateMetadataImage } from "@/lib/images";
import { prisma } from "@/lib/prisma";
import { MiiWithUsername } from "@/types";
const searchParamsSchema = z.object({
type: z
@ -37,7 +37,15 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
let buffer: Buffer | undefined;
// Only find Mii if image type is 'metadata'
let mii: MiiWithUsername | null = null;
let mii: Prisma.MiiGetPayload<{
include: {
user: {
select: {
name: true;
};
};
};
}> | null = null;
if (imageType === "metadata") {
mii = await prisma.mii.findUnique({
@ -47,7 +55,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
include: {
user: {
select: {
username: true,
name: true,
},
},
},
@ -68,7 +76,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
console.log(`Metadata image not found for mii ID ${miiId}, generating metadata image...`);
try {
buffer = await generateMetadataImage(mii, mii.user.username!);
buffer = await generateMetadataImage(mii, mii.user.name!);
} catch (error) {
console.error(error);
return rateLimit.sendResponse({ error: `Failed to generate 'metadata' type image for mii ${miiId}` }, 500);

View file

@ -14,6 +14,7 @@ import DeleteMiiButton from "@/components/delete-mii";
import ShareMiiButton from "@/components/share-mii-button";
import ThreeDsScanTutorialButton from "@/components/tutorial/3ds-scan";
import SwitchScanTutorialButton from "@/components/tutorial/switch-scan";
import Description from "@/components/description";
interface Props {
params: Promise<{ id: string }>;
@ -55,7 +56,12 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
type: "article",
title: `${mii.name} - TomodachiShare`,
description: `Check out '${mii.name}', a Tomodachi Life Mii created by ${username} on TomodachiShare with ${mii._count.likedBy} likes.`,
images: [metadataImageUrl],
images: [
{
url: metadataImageUrl,
alt: `${mii.name}, ${mii.tags.join(", ")} ${mii.gender} Mii character`,
},
],
publishedTime: mii.createdAt.toISOString(),
authors: username,
},
@ -63,7 +69,12 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
card: "summary_large_image",
title: `${mii.name} - TomodachiShare`,
description: `Check out '${mii.name}', a Tomodachi Life Mii created by ${username} on TomodachiShare with ${mii._count.likedBy} likes.`,
images: [metadataImageUrl],
images: [
{
url: metadataImageUrl,
alt: `${mii.name}, ${mii.tags.join(", ")} ${mii.gender} Mii character`,
},
],
creator: username,
},
alternates: {
@ -83,6 +94,7 @@ export default async function MiiPage({ params }: Props) {
include: {
user: {
select: {
name: true,
username: true,
},
},
@ -110,13 +122,13 @@ export default async function MiiPage({ params }: Props) {
<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 flex flex-col items-center max-w-md w-full max-md:place-self-center max-md:row-start-2">
{/* Mii Image */}
<div className="bg-gradient-to-b from-amber-100 to-amber-200 overflow-hidden rounded-xl w-full mb-4 flex justify-center h-50">
<div className="bg-linear-to-b from-amber-100 to-amber-200 overflow-hidden rounded-xl w-full mb-4 flex justify-center">
<ImageViewer
src={`/mii/${mii.id}/image?type=mii`}
alt="mii headshot"
width={200}
height={200}
className="drop-shadow-lg hover:scale-105 transition-transform duration-300 object-contain size-full"
className="drop-shadow-lg hover:scale-105 transition-transform"
/>
</div>
{/* QR Code */}
@ -144,24 +156,22 @@ export default async function MiiPage({ params }: Props) {
From: <span className="text-right font-medium">{mii.islandName} Island</span>
</li>
<li>
Allowed Copying: <input type="checkbox" checked={mii.allowedCopying ?? false} disabled className="checkbox !cursor-auto" />
Allowed Copying: <input type="checkbox" checked={mii.allowedCopying ?? false} disabled className="checkbox cursor-auto!" />
</li>
</ul>
)}
{/* Mii Platform */}
<div className={`flex items-center gap-4 text-zinc-500 text-sm font-medium mb-2 w-full ${mii.platform !== "THREE_DS" && "mt-2"}`}>
<hr className="flex-grow border-zinc-300" />
<hr className="grow border-zinc-300" />
<span>Platform</span>
<hr className="flex-grow border-zinc-300" />
<hr className="grow border-zinc-300" />
</div>
<div data-tooltip-span title={mii.platform} className="grid grid-cols-2 gap-2 mb-2">
<div
className={`tooltip !mt-1 ${
mii.platform === "THREE_DS"
? "!bg-sky-400 !border-sky-400 before:!border-b-sky-400"
: "!bg-red-400 !border-red-400 before:!border-b-red-400"
className={`tooltip mt-1! ${
mii.platform === "THREE_DS" ? "bg-sky-400! border-sky-400! before:border-b-sky-400!" : "bg-red-400! border-red-400! before:border-b-red-400!"
}`}
>
{mii.platform === "THREE_DS" ? "3DS" : "Switch"}
@ -186,17 +196,15 @@ export default async function MiiPage({ params }: Props) {
{/* Mii Gender */}
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mb-2 w-full">
<hr className="flex-grow border-zinc-300" />
<hr className="grow border-zinc-300" />
<span>Gender</span>
<hr className="flex-grow border-zinc-300" />
<hr className="grow border-zinc-300" />
</div>
<div data-tooltip-span title={mii.gender ?? "NULL"} className="grid grid-cols-2 gap-2">
<div
className={`tooltip !mt-1 ${
mii.gender === "MALE"
? "!bg-blue-400 !border-blue-400 before:!border-b-blue-400"
: "!bg-pink-400 !border-pink-400 before:!border-b-pink-400"
className={`tooltip mt-1! ${
mii.gender === "MALE" ? "bg-blue-400! border-blue-400! before:border-b-blue-400!" : "bg-pink-400! border-pink-400! before:border-b-pink-400!"
}`}
>
{mii.gender === "MALE" ? "Male" : "Female"}
@ -225,15 +233,9 @@ export default async function MiiPage({ params }: Props) {
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex flex-col gap-1">
<div className="flex justify-between items-start">
{/* Submission name */}
<h1 className="text-4xl font-extrabold break-words text-amber-700">{mii.name}</h1>
<h1 className="text-4xl font-extrabold wrap-break-word text-amber-700">{mii.name}</h1>
{/* Like button */}
<LikeButton
likes={mii._count.likedBy ?? 0}
miiId={mii.id}
isLiked={(mii.likedBy ?? []).length > 0}
isLoggedIn={session?.user != null}
big
/>
<LikeButton likes={mii._count.likedBy ?? 0} miiId={mii.id} isLiked={(mii.likedBy ?? []).length > 0} isLoggedIn={session?.user != null} big />
</div>
{/* Tags */}
<div id="tags" className="flex flex-wrap gap-1 mt-1 *:px-2 *:py-1 *:bg-orange-300 *:rounded-full *:text-xs">
@ -247,7 +249,7 @@ export default async function MiiPage({ params }: Props) {
{/* Author and Created date */}
<div className="mt-2">
<Link href={`/profile/${mii.userId}`} className="text-lg">
By: <span className="font-bold">@{mii.user.username}</span>
By <span className="font-bold">{mii.user.name}</span>
</Link>
<h4 className="text-sm">
Created:{" "}
@ -265,7 +267,7 @@ export default async function MiiPage({ params }: Props) {
</div>
{/* Description */}
{mii.description && <p className="text-sm mt-2 ml-2 bg-white/50 p-3 rounded-lg border border-orange-200">{mii.description}</p>}
{mii.description && <Description text={mii.description} className="ml-2" />}
</div>
{/* Buttons */}
@ -302,7 +304,7 @@ export default async function MiiPage({ params }: Props) {
{images.map((src, index) => (
<div
key={index}
className="relative aspect-[3/2] rounded-xl bg-black/65 border-2 border-amber-400 shadow-md overflow-hidden transition hover:shadow-lg shadow-black/30"
className="relative aspect-3/2 rounded-xl bg-black/65 border-2 border-amber-400 shadow-md overflow-hidden transition hover:shadow-lg shadow-black/30"
>
<Image
src={src}
@ -317,7 +319,7 @@ export default async function MiiPage({ params }: Props) {
alt="mii screenshot"
width={256}
height={170}
className="aspect-[3/2] w-full object-contain hover:scale-105 duration-300 transition-transform relative z-10"
className="aspect-3/2 w-full object-contain hover:scale-105 duration-300 transition-transform relative z-10"
images={images}
/>
</div>

View file

@ -9,7 +9,7 @@ export const metadata: Metadata = {
export default function NotFound() {
return (
<div className="flex-grow flex items-center justify-center">
<div className="grow flex items-center justify-center">
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-8 max-w-xs w-full text-center flex flex-col">
<h2 className="text-7xl font-black">404</h2>
<p>Page not found - you swam off the island!</p>

View file

@ -51,7 +51,7 @@ export default async function ExiledPage() {
const duration = activePunishment.type === "TEMP_EXILE" && Math.ceil(expiresAt.diff(createdAt, "days", true));
return (
<div className="flex-grow flex items-center justify-center">
<div className="grow flex items-center justify-center">
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-8 max-w-xl w-full flex flex-col">
<h2 className="text-4xl font-black mb-2">
{activePunishment.type === "PERM_EXILE"
@ -78,9 +78,9 @@ export default async function ExiledPage() {
</p>
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-4">
<hr className="flex-grow border-zinc-300" />
<hr className="grow border-zinc-300" />
<span>Violating Items</span>
<hr className="flex-grow border-zinc-300" />
<hr className="grow border-zinc-300" />
</div>
<div className="flex flex-col gap-2 p-4">

View file

@ -2,6 +2,7 @@ import { Metadata } from "next";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import ProfileSettings from "@/components/profile-settings";
import ProfileInformation from "@/components/profile-information";
@ -20,10 +21,12 @@ export default async function ProfileSettingsPage() {
if (!session) redirect("/login");
const user = await prisma.user.findUnique({ where: { id: Number(session.user.id!) }, select: { description: true } });
return (
<div>
<ProfileInformation page="settings" />
<ProfileSettings />
<ProfileSettings currentDescription={user?.description} />
</div>
);
}

View file

@ -16,7 +16,6 @@ export default function robots(): MetadataRoute.Robots {
"/report/mii/*",
"/report/user/*",
"/admin",
"/_next/image",
],
},
sitemap: `${process.env.NEXT_PUBLIC_BASE_URL}/sitemap.xml`,

View file

@ -3,6 +3,8 @@ import type { MetadataRoute } from "next";
type SitemapRoute = MetadataRoute.Sitemap[0];
export const revalidate = 43200; // update every 12 hours
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL;
if (!baseUrl) {
@ -41,7 +43,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
url: `${baseUrl}/profile/${user.id}`,
lastModified: user.updatedAt,
changeFrequency: "weekly",
priority: 0.3,
priority: 0.2,
} as SitemapRoute)
),
];

View file

@ -37,7 +37,7 @@ export default async function SubmitPage() {
if (!value)
return (
<div className="flex-grow flex items-center justify-center">
<div className="grow flex items-center justify-center">
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-8 max-w-xs w-full text-center flex flex-col">
<h2 className="text-5xl font-black">Sorry</h2>
<p className="mt-1">Submissions are disabled</p>

View file

@ -1,7 +1,7 @@
"use client";
import { useSearchParams } from "next/navigation";
import { Suspense } from "react";
import { Suspense, useEffect, useState } from "react";
import useSWR from "swr";
import { Icon } from "@iconify/react";
@ -18,7 +18,7 @@ function RedirectBanner() {
if (from !== "old-domain") return null;
return (
<div className="w-full h-10 bg-orange-300 border-y-2 border-y-orange-400 mt-1 shadow-md flex justify-center items-center gap-2 text-orange-900 text-nowrap overflow-x-auto font-semibold max-sm:justify-start">
<div className="w-full h-10 bg-orange-300 border-y-2 border-y-orange-400 mt-1 pl-2 shadow-md flex justify-center items-center gap-2 text-orange-900 text-nowrap overflow-x-auto font-semibold max-sm:justify-start">
<Icon icon="humbleicons:link" className="text-2xl min-w-6" />
<span>We have moved URLs, welcome to tomodachishare.com!</span>
</div>
@ -27,13 +27,39 @@ function RedirectBanner() {
export default function AdminBanner() {
const { data } = useSWR<ApiResponse>("/api/admin/banner", fetcher);
const [shouldShow, setShouldShow] = useState(true);
useEffect(() => {
if (!data?.message) return;
// Check if the current banner text was closed by the user
const closedBanner = window.localStorage.getItem("closedBanner");
setShouldShow(data.message !== closedBanner);
}, [data]);
const handleClose = () => {
if (!data) return;
// Close banner and remember it
window.localStorage.setItem("closedBanner", data.message);
setShouldShow(false);
};
return (
<>
{data && data.message && (
<div className="w-full h-10 bg-orange-300 border-y-2 border-y-orange-400 mt-1 shadow-md flex justify-center items-center gap-2 text-orange-900 text-nowrap overflow-x-auto font-semibold max-sm:justify-start">
<Icon icon="humbleicons:exclamation" className="text-2xl min-w-6" />
<span>{data.message}</span>
{data && data.message && shouldShow && (
<div className="relative w-full h-10 bg-orange-300 border-y-2 border-y-orange-400 mt-1 pl-2 shadow-md flex justify-center text-orange-900 text-nowrap overflow-x-auto font-semibold max-sm:justify-between">
<div className="flex gap-2 h-full items-center w-fit">
<Icon icon="humbleicons:exclamation" className="text-2xl min-w-6" />
<span>{data.message}</span>
</div>
<button
onClick={handleClose}
className="min-sm:absolute right-2 cursor-pointer p-1.5"
>
<Icon icon="humbleicons:times" className="text-2xl min-w-6" />
</button>
</div>
)}
<Suspense>

View file

@ -26,7 +26,7 @@ export default function ControlCenter() {
<input
name="submit"
type="checkbox"
className="checkbox !size-6"
className="checkbox size-6!"
placeholder="Enter banner text"
checked={canSubmit}
onChange={(e) => setCanSubmit(e.target.checked)}

View file

@ -55,7 +55,7 @@ export default function PunishmentDeletionDialog({ punishmentId }: Props) {
{isOpen &&
createPortal(
<div className="fixed inset-0 w-full h-[calc(100%-var(--header-height))] top-[var(--header-height)] flex items-center justify-center z-40">
<div className="fixed inset-0 w-full h-[calc(100%-var(--header-height))] top-(--header-height) flex items-center justify-center z-40">
<div
onClick={close}
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${

View file

@ -0,0 +1,86 @@
"use client";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { Icon } from "@iconify/react";
import SubmitButton from "../submit-button";
export default function RegenerateImagesButton() {
const [isOpen, setIsOpen] = useState(false);
const [isVisible, setIsVisible] = useState(false);
const [error, setError] = useState<string | undefined>(undefined);
const handleSubmit = async () => {
const response = await fetch("/api/admin/regenerate-metadata-images", { method: "PATCH" });
if (!response.ok) {
const data = await response.json();
setError(data.error);
return;
}
close();
};
const close = () => {
setIsVisible(false);
setTimeout(() => {
setIsOpen(false);
}, 300);
};
useEffect(() => {
if (isOpen) {
// slight delay to trigger animation
setTimeout(() => setIsVisible(true), 10);
}
}, [isOpen]);
return (
<>
<button onClick={() => setIsOpen(true)} className="pill button w-fit">
Regenerate all Mii metadata images
</button>
{isOpen &&
createPortal(
<div className="fixed inset-0 w-full h-[calc(100%-var(--header-height))] top-(--header-height) flex items-center justify-center z-40">
<div
onClick={close}
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${
isVisible ? "opacity-100" : "opacity-0"
}`}
/>
<div
className={`z-50 bg-orange-50 border-2 border-amber-500 rounded-2xl shadow-lg p-6 w-full max-w-md transition-discrete duration-300 flex flex-col ${
isVisible ? "scale-100 opacity-100" : "scale-75 opacity-0"
}`}
>
<div className="flex justify-between items-center mb-2">
<h2 className="text-xl font-bold">Regenerate Images</h2>
<button onClick={close} aria-label="Close" className="text-red-400 hover:text-red-500 text-2xl cursor-pointer">
<Icon icon="material-symbols:close-rounded" />
</button>
</div>
<p className="text-sm text-zinc-500">Are you sure? This will delete and regenerate every metadata image.</p>
{error && <span className="text-red-400 font-bold mt-2">Error: {error}</span>}
<div className="flex justify-end gap-2 mt-4">
<button onClick={close} className="pill button">
Cancel
</button>
<SubmitButton onClick={handleSubmit} />
</div>
</div>
</div>,
document.body
)}
</>
);
}

View file

@ -34,7 +34,7 @@ export default function ReturnToIsland({ hasExpired }: Props) {
disabled={hasExpired}
checked={isChecked}
onChange={(e) => setIsChecked(e.target.checked)}
className={`checkbox ${hasExpired && "text-zinc-600 !bg-zinc-100 !border-zinc-300"}`}
className={`checkbox ${hasExpired && "text-zinc-600 bg-zinc-100! border-zinc-300!"}`}
/>
<label htmlFor="agreement" className={`${hasExpired && "text-zinc-500"}`}>
I Agree

View file

@ -238,7 +238,7 @@ export default function Punishments() {
rows={2}
maxLength={256}
placeholder="Type notes here for the punishment..."
className="pill input !rounded-xl resize-none"
className="pill input rounded-xl! resize-none"
value={notes}
onChange={(e) => setNotes(e.target.value)}
/>
@ -249,7 +249,7 @@ export default function Punishments() {
rows={2}
maxLength={256}
placeholder="Type profile-related reasons here for the punishment..."
className="pill input !rounded-xl resize-none"
className="pill input rounded-xl! resize-none"
value={reasons}
onChange={(e) => setReasons(e.target.value)}
/>
@ -273,7 +273,7 @@ export default function Punishments() {
value={newMii.reason}
onChange={(e) => setNewMii({ ...newMii, reason: e.target.value })}
/>
<button type="button" aria-label="Add Mii" onClick={addMiiToList} className="pill button aspect-square !p-2.5">
<button type="button" aria-label="Add Mii" onClick={addMiiToList} className="pill button aspect-square p-2.5!">
<Icon icon="ic:baseline-plus" className="size-4" />
</button>
</div>

View file

@ -43,8 +43,8 @@ export default function Carousel({ images, className }: Props) {
<div className={`overflow-hidden rounded-xl bg-zinc-300 ${className ?? ""}`} ref={emblaRef}>
<div className="flex">
{images.map((src, index) => (
<div key={index} className="flex-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} />
<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} />
</div>
))}
</div>

View file

@ -69,7 +69,7 @@ export default function DeleteMiiButton({ miiId, miiName, likes, inMiiPage }: Pr
{isOpen &&
createPortal(
<div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-[var(--header-height)] flex items-center justify-center z-40">
<div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-(--header-height) flex items-center justify-center z-40">
<div
onClick={close}
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${
@ -107,7 +107,7 @@ export default function DeleteMiiButton({ miiId, miiName, likes, inMiiPage }: Pr
<button onClick={close} className="pill button">
Cancel
</button>
<SubmitButton onClick={handleSubmit} text="Delete" className="!bg-red-400 !border-red-500 hover:!bg-red-500" />
<SubmitButton onClick={handleSubmit} text="Delete" className="bg-red-400! border-red-500! hover:bg-red-500!" />
</div>
</div>
</div>,

View file

@ -0,0 +1,83 @@
import Image from "next/image";
import Link from "next/link";
import { prisma } from "@/lib/prisma";
import ProfilePicture from "./profile-picture";
interface Props {
text: string;
className?: string;
}
export default function Description({ text, className }: Props) {
return (
<p className={`text-sm mt-2 bg-white/50 p-3 rounded-lg border border-orange-200 whitespace-break-spaces max-h-54 overflow-y-auto ${className}`}>
{/* Adds fancy formatting when linking to other pages on the site */}
{(() => {
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "https://tomodachishare.com";
// Match both mii and profile links
const regex = new RegExp(`(${baseUrl.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&")}/(?:mii|profile)/\\d+)`, "g");
const parts = text.split(regex);
return parts.map(async (part, index) => {
const miiMatch = part.match(new RegExp(`^${baseUrl}/mii/(\\d+)$`));
const profileMatch = part.match(new RegExp(`^${baseUrl}/profile/(\\d+)$`));
if (miiMatch) {
const id = Number(miiMatch[1]);
const linkedMii = await prisma.mii.findUnique({
where: {
id,
},
});
if (!linkedMii) return;
return (
<Link
key={index}
href={`/mii/${id}`}
className="inline-flex items-center align-bottom gap-1.5 pr-2 bg-amber-100 border border-amber-400 rounded-lg mx-1 text-amber-800 text-xs"
>
<Image src={`/mii/${id}/image?type=mii`} alt="mii" width={24} height={24} className="bg-white rounded-lg border-r border-amber-400" />
{linkedMii.name}
</Link>
);
}
if (profileMatch) {
const id = Number(profileMatch[1]);
const linkedProfile = await prisma.user.findUnique({
where: {
id,
},
});
if (!linkedProfile) return;
return (
<Link
key={index}
href={`/profile/${id}`}
className="inline-flex items-center align-bottom gap-1.5 pr-2 bg-orange-100 border border-orange-400 rounded-lg mx-1 text-orange-800 text-xs"
>
<ProfilePicture
src={linkedProfile.image || "/guest.webp"}
width={24}
height={24}
className="bg-white rounded-lg border-r border-orange-400"
/>
{linkedProfile.name}
</Link>
);
}
// Regular text
return <span key={index}>{part}</span>;
});
})()}
</p>
);
}

View file

@ -32,7 +32,7 @@ export default function Dropzone({ onDrop, options, children }: Props) {
{...getRootProps()}
onDragOver={() => setIsDraggingOver(true)}
onDragLeave={() => setIsDraggingOver(false)}
className={`relative bg-orange-200 flex flex-col justify-center items-center gap-2 p-4 rounded-xl border-2 border-dashed border-amber-500 select-none h-full transition-all duration-200 ${
className={`relative bg-orange-200 flex flex-col justify-center items-center gap-2 p-4 rounded-xl border-2 border-dashed border-amber-500 select-none size-full transition-all duration-200 ${
isDraggingOver && "scale-105 brightness-90 shadow-xl"
}`}
>

View file

@ -28,10 +28,23 @@ export default function Footer() {
</span>
<a
href="https://discord.gg/48cXBFKvWQ"
target="_blank"
className="text-[#5865F2] hover:text-[#454FBF] transition-colors duration-200 hover:underline inline-flex items-end gap-1"
>
<Icon icon="ic:baseline-discord" className="text-lg" />
Discord
</a>
<span className="text-zinc-400 hidden sm:inline" aria-hidden="true">
</span>
<a
href="https://github.com/trafficlunar/tomodachi-share"
target="_blank"
className="text-zinc-500 hover:text-zinc-700 transition-colors duration-200 hover:underline inline-flex items-center gap-1"
className="text-zinc-500 hover:text-zinc-700 transition-colors duration-200 hover:underline inline-flex items-end gap-1"
>
<Icon icon="mdi:github" className="text-lg" />
Source Code

View file

@ -77,7 +77,7 @@ export default function ImageViewer({ src, alt, width, height, className, images
{isOpen &&
createPortal(
<div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-[var(--header-height)] flex items-center justify-center z-40">
<div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-(--header-height) flex items-center justify-center z-40">
<div
onClick={close}
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${
@ -99,7 +99,7 @@ export default function ImageViewer({ src, alt, width, height, className, images
<div className="overflow-hidden rounded-2xl h-full" ref={emblaRef}>
<div className="flex h-full items-center">
{imagesMap.map((image, index) => (
<div key={index} className="flex-shrink-0 w-full">
<div key={index} className="shrink-0 w-full">
<Image
src={image}
alt={alt}

View file

@ -9,7 +9,7 @@ export default function LoginButtons() {
<button
onClick={() => signIn("discord", { redirectTo: "/create-username" })}
aria-label="Login with Discord"
className="pill button gap-2 !px-3 !bg-indigo-400 !border-indigo-500 hover:!bg-indigo-500"
className="pill button gap-2 px-3! bg-indigo-400! border-indigo-500! hover:bg-indigo-500!"
>
<Icon icon="ic:baseline-discord" fontSize={32} />
Login with Discord
@ -17,7 +17,7 @@ export default function LoginButtons() {
<button
onClick={() => signIn("github", { redirectTo: "/create-username" })}
aria-label="Login with GitHub"
className="pill button gap-2 !px-3 !bg-zinc-700 !border-zinc-800 hover:!bg-zinc-800 text-white"
className="pill button gap-2 px-3! bg-zinc-700! border-zinc-800! hover:bg-zinc-800! text-white"
>
<Icon icon="mdi:github" fontSize={32} />
Login with GitHub

View file

@ -6,7 +6,7 @@ import { signOut } from "next-auth/react";
export default function LogoutButton() {
return (
<li title="Logout">
<button onClick={() => signOut()} aria-label="Log Out" className="pill button !p-0 aspect-square h-full" data-tooltip="Log Out">
<button onClick={() => signOut()} aria-label="Log Out" className="pill button p-0! aspect-square h-full" data-tooltip="Log Out">
<Icon icon="ic:round-logout" fontSize={24} />
</button>
</li>

View file

@ -10,13 +10,17 @@ export default function GenderSelect() {
const searchParams = useSearchParams();
const [, startTransition] = useTransition();
const [selected, setSelected] = useState<MiiGender | null>((searchParams.get("gender") as MiiGender) ?? null);
const [selected, setSelected] = useState<MiiGender | null>(
(searchParams.get("gender") as MiiGender) ?? null
);
const handleClick = (gender: MiiGender) => {
const filter = selected === gender ? null : gender;
setSelected(filter);
const params = new URLSearchParams(searchParams);
params.set("page", "1");
if (filter) {
params.set("gender", filter);
} else {
@ -35,10 +39,14 @@ export default function GenderSelect() {
aria-label="Filter for Male Miis"
data-tooltip-span
className={`cursor-pointer rounded-xl flex justify-center items-center size-13 text-5xl border-2 transition-all ${
selected === "MALE" ? "bg-blue-100 border-blue-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
selected === "MALE"
? "bg-blue-100 border-blue-400 shadow-md"
: "bg-white border-gray-300 hover:border-gray-400"
}`}
>
<div className="tooltip !bg-blue-400 !border-blue-400 before:!border-b-blue-400">Male</div>
<div className="tooltip bg-blue-400! border-blue-400! before:border-b-blue-400!">
Male
</div>
<Icon icon="foundation:male" className="text-blue-400" />
</button>
@ -47,10 +55,14 @@ export default function GenderSelect() {
aria-label="Filter for Female Miis"
data-tooltip-span
className={`cursor-pointer rounded-xl flex justify-center items-center size-13 text-5xl border-2 transition-all ${
selected === "FEMALE" ? "bg-pink-100 border-pink-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
selected === "FEMALE"
? "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">Female</div>
<div className="tooltip bg-pink-400! border-pink-400! before:border-b-pink-400!">
Female
</div>
<Icon icon="foundation:female" className="text-pink-400" />
</button>
</div>

View file

@ -2,11 +2,11 @@ import Link from "next/link";
import { MiiGender, MiiPlatform, Prisma } from "@prisma/client";
import { Icon } from "@iconify/react";
import { z } from "zod";
import crypto from "crypto";
import seedrandom from "seedrandom";
import { querySchema } from "@/lib/schemas";
import { searchSchema } from "@/lib/schemas";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
@ -23,44 +23,26 @@ interface Props {
inLikesPage?: boolean; // Self-explanatory
}
const searchSchema = z.object({
q: querySchema.optional(),
sort: z.enum(["likes", "newest", "oldest", "random"], { error: "Sort must be either 'likes', 'newest', 'oldest', or 'random'" }).default("newest"),
tags: z
.string()
.optional()
.transform((value) =>
value
?.split(",")
.map((tag) => tag.trim())
.filter((tag) => tag.length > 0)
),
platform: z.enum(MiiPlatform, { error: "Platform must be either 'THREE_DS', or 'SWITCH'" }).optional(),
gender: z.enum(MiiGender, { error: "Gender must be either 'MALE', or 'FEMALE'" }).optional(),
// todo: incorporate tagsSchema
// Pages
limit: z.coerce
.number({ error: "Limit must be a number" })
.int({ error: "Limit must be an integer" })
.min(1, { error: "Limit must be at least 1" })
.max(100, { error: "Limit cannot be more than 100" })
.optional(),
page: z.coerce
.number({ error: "Page must be a number" })
.int({ error: "Page must be an integer" })
.min(1, { error: "Page must be at least 1" })
.optional(),
// Random sort
seed: z.coerce.number({ error: "Seed must be a number" }).int({ error: "Seed must be an integer" }).optional(),
});
export default async function MiiList({ searchParams, userId, inLikesPage }: Props) {
export default async function MiiList({
searchParams,
userId,
inLikesPage,
}: Props) {
const session = await auth();
const parsed = searchSchema.safeParse(searchParams);
if (!parsed.success) return <h1>{parsed.error.issues[0].message}</h1>;
const { q: query, sort, tags, platform, gender, page = 1, limit = 24, seed } = parsed.data;
const {
q: query,
sort,
tags,
platform,
gender,
page = 1,
limit = 24,
seed,
} = parsed.data;
// My Likes page
let miiIdsLiked: number[] | undefined = undefined;
@ -78,7 +60,11 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
...(inLikesPage && miiIdsLiked && { id: { in: miiIdsLiked } }),
// Searching
...(query && {
OR: [{ name: { contains: query, mode: "insensitive" } }, { tags: { has: query } }, { description: { contains: query, mode: "insensitive" } }],
OR: [
{ name: { contains: query, mode: "insensitive" } },
{ tags: { has: query } },
{ description: { contains: query, mode: "insensitive" } },
],
}),
// Tag filtering
...(tags && tags.length > 0 && { tags: { hasEvery: tags } }),
@ -128,7 +114,7 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
if (sort === "random") {
// Use seed for consistent random results
const randomSeed = seed || Math.floor(Math.random() * 1_000_000_000);
const randomSeed = seed || crypto.randomInt(0, 1_000_000_000);
// Get all IDs that match the where conditions
const matchingIds = await prisma.mii.findMany({
@ -174,7 +160,13 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
[totalCount, filteredCount, list] = await Promise.all([
prisma.mii.count({ where: { ...where, userId } }),
prisma.mii.count({ where, skip, take: limit }),
prisma.mii.findMany({ where, orderBy, select, skip: (page - 1) * limit, take: limit }),
prisma.mii.findMany({
where,
orderBy,
select,
skip: (page - 1) * limit,
take: limit,
}),
]);
}
@ -191,20 +183,28 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
<div className="flex items-center gap-2">
{totalCount == filteredCount ? (
<>
<span className="text-2xl font-bold text-amber-900">{totalCount}</span>
<span className="text-lg text-amber-700">{totalCount === 1 ? "Mii" : "Miis"}</span>
<span className="text-2xl font-bold text-amber-900">
{totalCount}
</span>
<span className="text-lg text-amber-700">
{totalCount === 1 ? "Mii" : "Miis"}
</span>
</>
) : (
<>
<span className="text-2xl font-bold text-amber-900">{filteredCount}</span>
<span className="text-2xl font-bold text-amber-900">
{filteredCount}
</span>
<span className="text-sm text-amber-700">of</span>
<span className="text-lg font-semibold text-amber-800">{totalCount}</span>
<span className="text-lg font-semibold text-amber-800">
{totalCount}
</span>
<span className="text-lg text-amber-700">Miis</span>
</>
)}
</div>
<div className="relative flex items-center justify-end gap-2 w-full min-md:max-w-2/3 max-md:justify-center">
<div className="relative flex items-center justify-end gap-2 w-full md:max-w-2/3 max-md:justify-center">
<FilterMenu />
<SortSelect />
</div>
@ -220,37 +220,66 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
images={[
`/mii/${mii.id}/image?type=mii`,
`/mii/${mii.id}/image?type=qr-code`,
...Array.from({ length: mii.imageCount }, (_, index) => `/mii/${mii.id}/image?type=image${index}`),
...Array.from(
{ length: mii.imageCount },
(_, index) => `/mii/${mii.id}/image?type=image${index}`
),
]}
/>
<div className="p-4 flex flex-col gap-1 h-full">
<Link href={`/mii/${mii.id}`} className="font-bold text-2xl line-clamp-1" title={mii.name}>
<Link
href={`/mii/${mii.id}`}
className="font-bold text-2xl line-clamp-1"
title={mii.name}
>
{mii.name}
</Link>
<div id="tags" className="flex flex-wrap gap-1">
{mii.tags.map((tag) => (
<Link href={{ query: { tags: tag } }} key={tag} className="px-2 py-1 bg-orange-300 rounded-full text-xs">
<Link
href={{ query: { tags: tag } }}
key={tag}
className="px-2 py-1 bg-orange-300 rounded-full text-xs"
>
{tag}
</Link>
))}
</div>
<div className="mt-auto grid grid-cols-2 items-center">
<LikeButton likes={mii.likes} miiId={mii.id} isLiked={mii.isLiked} isLoggedIn={session?.user != null} abbreviate />
<LikeButton
likes={mii.likes}
miiId={mii.id}
isLiked={mii.isLiked}
isLoggedIn={session?.user != null}
abbreviate
/>
{!userId && (
<Link href={`/profile/${mii.user?.id}`} className="text-sm text-right overflow-hidden text-ellipsis">
<Link
href={`/profile/${mii.user?.id}`}
className="text-sm text-right overflow-hidden text-ellipsis"
>
@{mii.user?.username}
</Link>
)}
{userId && Number(session?.user.id) == userId && (
<div className="flex gap-1 text-2xl justify-end text-zinc-400">
<Link href={`/edit/${mii.id}`} title="Edit Mii" aria-label="Edit Mii" data-tooltip="Edit">
<Link
href={`/edit/${mii.id}`}
title="Edit Mii"
aria-label="Edit Mii"
data-tooltip="Edit"
>
<Icon icon="mdi:pencil" />
</Link>
<DeleteMiiButton miiId={mii.id} miiName={mii.name} likes={mii.likes} />
<DeleteMiiButton
miiId={mii.id}
miiName={mii.name}
likes={mii.likes}
/>
</div>
)}
</div>

View file

@ -44,8 +44,8 @@ export default function Pagination({ lastPage }: Props) {
aria-label="Go to First Page"
aria-disabled={page === 1}
tabIndex={page === 1 ? -1 : undefined}
className={`pill button !bg-orange-100 !p-0.5 aspect-square text-2xl ${
page === 1 ? "pointer-events-none opacity-50" : "hover:!bg-orange-400"
className={`pill button bg-orange-100! p-0.5! aspect-square text-2xl ${
page === 1 ? "pointer-events-none opacity-50" : "hover:bg-orange-400!"
}`}
>
<Icon icon="stash:chevron-double-left" />
@ -57,7 +57,7 @@ export default function Pagination({ lastPage }: Props) {
aria-label="Go to Previous Page"
aria-disabled={page === 1}
tabIndex={page === 1 ? -1 : undefined}
className={`pill !bg-orange-100 !p-0.5 aspect-square text-2xl ${page === 1 ? "pointer-events-none opacity-50" : "hover:!bg-orange-400"}`}
className={`pill bg-orange-100! p-0.5! aspect-square text-2xl ${page === 1 ? "pointer-events-none opacity-50" : "hover:bg-orange-400!"}`}
>
<Icon icon="stash:chevron-left" />
</Link>
@ -70,7 +70,7 @@ export default function Pagination({ lastPage }: Props) {
href={createPageUrl(number)}
aria-label={`Go to Page ${number}`}
aria-current={number === page ? "page" : undefined}
className={`pill !p-0 w-8 h-8 text-center !rounded-md ${number == page ? "!bg-orange-400" : "!bg-orange-100 hover:!bg-orange-400"}`}
className={`pill p-0! w-8 h-8 text-center rounded-md! ${number == page ? "bg-orange-400!" : "bg-orange-100! hover:bg-orange-400!"}`}
>
{number}
</Link>
@ -79,12 +79,12 @@ export default function Pagination({ lastPage }: Props) {
{/* Next page */}
<Link
href={page === lastPage ? "#" : createPageUrl(page + 1)}
href={page >= lastPage ? "#" : createPageUrl(page + 1)}
aria-label="Go to Next Page"
aria-disabled={page === lastPage}
tabIndex={page === lastPage ? -1 : undefined}
className={`pill button !bg-orange-100 !p-0.5 aspect-square text-2xl ${
page === lastPage ? "pointer-events-none opacity-50" : "hover:!bg-orange-400"
aria-disabled={page >= lastPage}
tabIndex={page >= lastPage ? -1 : undefined}
className={`pill button bg-orange-100! p-0.5! aspect-square text-2xl ${
page >= lastPage ? "pointer-events-none opacity-50" : "hover:bg-orange-400!"
}`}
>
<Icon icon="stash:chevron-right" />
@ -92,12 +92,12 @@ export default function Pagination({ lastPage }: Props) {
{/* Go to last page */}
<Link
href={page === lastPage ? "#" : createPageUrl(lastPage)}
href={page >= lastPage ? "#" : createPageUrl(lastPage)}
aria-label="Go to Last Page"
aria-disabled={page === lastPage}
tabIndex={page === lastPage ? -1 : undefined}
className={`pill button !bg-orange-100 !p-0.5 aspect-square text-2xl ${
page === lastPage ? "pointer-events-none opacity-50" : "hover:!bg-orange-400"
aria-disabled={page >= lastPage}
tabIndex={page >= lastPage ? -1 : undefined}
className={`pill button bg-orange-100! p-0.5! aspect-square text-2xl ${
page >= lastPage ? "pointer-events-none opacity-50" : "hover:bg-orange-400!"
}`}
>
<Icon icon="stash:chevron-double-right" />

View file

@ -21,7 +21,7 @@ export default function Skeleton() {
<div key={index} className="flex flex-col bg-zinc-50 rounded-3xl border-2 border-zinc-300 shadow-lg p-3">
{/* Carousel Skeleton */}
<div className="relative rounded-xl bg-zinc-300 border-2 border-zinc-300 mb-1">
<div className="aspect-[3/2]"></div>
<div className="aspect-3/2"></div>
</div>
{/* Content */}

View file

@ -23,6 +23,7 @@ export default function SortSelect() {
if (!selectedItem) return;
const params = new URLSearchParams(searchParams);
params.set("page", "1");
params.set("sort", selectedItem);
if (selectedItem == "random") {
@ -38,7 +39,7 @@ export default function SortSelect() {
return (
<div className="relative w-fit">
{/* Toggle button to open the dropdown */}
<button type="button" {...getToggleButtonProps()} aria-label="Sort dropdown" className="pill input w-full gap-1 !justify-between text-nowrap">
<button type="button" {...getToggleButtonProps()} aria-label="Sort dropdown" className="pill input w-full gap-1 justify-between! text-nowrap">
<span>Sort by </span>
{selectedItem || "Select a way to sort"}
<Icon icon="tabler:chevron-down" className="ml-2 size-5" />

View file

@ -36,6 +36,8 @@ export default function TagFilter() {
if (urlTags === stateTags) return;
const params = new URLSearchParams(searchParams);
params.set("page", "1");
if (tags.length > 0) {
params.set("tags", stateTags);
} else {

View file

@ -5,6 +5,7 @@ import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import ProfilePicture from "./profile-picture";
import Description from "./description";
interface Props {
userId?: number;
@ -34,7 +35,7 @@ export default async function ProfileInformation({ userId, page }: Props) {
{/* User information */}
<div className="flex flex-col w-full relative py-3">
<div className="flex items-center gap-2">
<h1 className="text-3xl font-extrabold break-words">{user.name}</h1>
<h1 className="text-3xl font-extrabold wrap-break-word">{user.name}</h1>
{isAdmin && (
<div data-tooltip="Admin" className="text-orange-400">
<Icon icon="mdi:shield-moon" className="text-2xl" />
@ -46,9 +47,9 @@ export default async function ProfileInformation({ userId, page }: Props) {
</div>
)}
</div>
<h2 className="text-black/60 text-sm font-semibold break-words">@{user?.username}</h2>
<h2 className="text-black/60 text-sm font-semibold wrap-break-word">@{user?.username}</h2>
<div className="mt-auto text-sm flex gap-8">
<div className="mt-3 text-sm flex gap-8">
<h4 title={`${user.createdAt.toLocaleTimeString("en-GB", { timeZone: "UTC" })} UTC`}>
<span className="font-medium">Created:</span>{" "}
{user.createdAt.toLocaleDateString("en-GB", { month: "long", day: "2-digit", year: "numeric" })}
@ -57,6 +58,8 @@ export default async function ProfileInformation({ userId, page }: Props) {
Liked <span className="font-bold">{likedMiis}</span> Miis
</h4>
</div>
{user.description && <Description text={user.description} className="max-h-32!" />}
</div>
</div>

View file

@ -10,7 +10,7 @@ export default async function ProfileOverview() {
<Link
href={`/profile/${session?.user.id}`}
aria-label="Go to profile"
className="pill button !gap-2 !p-0 h-full max-w-64"
className="pill button gap-2! p-0! h-full max-w-64"
data-tooltip="Your Profile"
>
<Image

View file

@ -7,5 +7,5 @@ export default function ProfilePicture(props: Partial<ImageProps>) {
const { src, ...rest } = props;
const [imgSrc, setImgSrc] = useState(src);
return <Image {...rest} src={imgSrc || "/guest.webp"} alt={"profile picture"} width={128} height={128} onError={() => setImgSrc("/guest.webp")} />;
return <Image width={128} height={128} {...rest} src={imgSrc || "/guest.webp"} alt={"profile picture"} onError={() => setImgSrc("/guest.webp")} />;
}

View file

@ -42,14 +42,14 @@ export default function DeleteAccount() {
<button
name="deletion"
onClick={() => setIsOpen(true)}
className="pill button w-fit h-min ml-auto !bg-red-400 !border-red-500 hover:!bg-red-500"
className="pill button w-fit h-min ml-auto bg-red-400! border-red-500! hover:bg-red-500!"
>
Delete Account
</button>
{isOpen &&
createPortal(
<div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-[var(--header-height)] flex items-center justify-center z-40">
<div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-(--header-height) flex items-center justify-center z-40">
<div
onClick={close}
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${
@ -79,7 +79,7 @@ export default function DeleteAccount() {
<button onClick={close} className="pill button">
Cancel
</button>
<SubmitButton onClick={handleSubmit} text="Delete" className="!bg-red-400 !border-red-500 hover:!bg-red-500" />
<SubmitButton onClick={handleSubmit} text="Delete" className="bg-red-400! border-red-500! hover:bg-red-500!" />
</div>
</div>
</div>,

View file

@ -9,18 +9,48 @@ import { displayNameSchema, usernameSchema } from "@/lib/schemas";
import ProfilePictureSettings from "./profile-picture";
import SubmitDialogButton from "./submit-dialog-button";
import DeleteAccount from "./delete-account";
import z from "zod";
export default function ProfileSettings() {
interface Props {
currentDescription: string | null | undefined;
}
export default function ProfileSettings({ currentDescription }: Props) {
const router = useRouter();
const [description, setDescription] = useState(currentDescription);
const [displayName, setDisplayName] = useState("");
const [username, setUsername] = useState("");
const [descriptionChangeError, setDescriptionChangeError] = useState<string | undefined>(undefined);
const [displayNameChangeError, setDisplayNameChangeError] = useState<string | undefined>(undefined);
const [usernameChangeError, setUsernameChangeError] = useState<string | undefined>(undefined);
const usernameDate = dayjs().add(90, "days");
const handleSubmitDescriptionChange = async (close: () => void) => {
const parsed = z.string().trim().max(256).safeParse(description);
if (!parsed.success) {
setDescriptionChangeError(parsed.error.issues[0].message);
return;
}
const response = await fetch("/api/auth/about-me", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ description }),
});
if (!response.ok) {
const { error } = await response.json();
setDescriptionChangeError(error);
return;
}
close();
router.refresh();
};
const handleSubmitDisplayNameChange = async (close: () => void) => {
const parsed = displayNameSchema.safeParse(displayName);
if (!parsed.success) {
@ -76,25 +106,54 @@ export default function ProfileSettings() {
{/* Separator */}
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mb-1">
<hr className="flex-grow border-zinc-300" />
<hr className="grow border-zinc-300" />
<span>Account Info</span>
<hr className="flex-grow border-zinc-300" />
<hr className="grow border-zinc-300" />
</div>
{/* Profile Picture */}
<ProfilePictureSettings />
{/* Description */}
<div className="grid grid-cols-5 gap-4 max-lg:grid-cols-1">
<div className="col-span-3">
<label className="font-semibold">About Me</label>
<p className="text-sm text-zinc-500">Write about yourself on your profile</p>
</div>
<div className="flex justify-end gap-1 h-min col-span-2">
<div className="flex-1">
<textarea
rows={5}
maxLength={256}
placeholder="(optional) Type about yourself..."
className="pill input rounded-xl! resize-none text-sm w-full"
value={description || ""}
onChange={(e) => setDescription(e.target.value)}
/>
<p className="text-xs text-zinc-400 mt-1 text-right">{(description || "").length}/256</p>
</div>
<SubmitDialogButton
title="Confirm About Me Change"
description="Are you sure? You can change it again later."
error={descriptionChangeError}
onSubmit={handleSubmitDescriptionChange}
/>
</div>
</div>
{/* Change Name */}
<div className="grid grid-cols-2 gap-4 max-lg:grid-cols-1">
<div>
<div className="grid grid-cols-5 gap-4 max-lg:grid-cols-1">
<div className="col-span-3">
<label className="font-semibold">Change Display Name</label>
<p className="text-sm text-zinc-500">This is a display name shown on your profile feel free to change it anytime</p>
</div>
<div className="flex justify-end gap-1 h-min">
<div className="flex justify-end gap-1 h-min col-span-2">
<input
type="text"
className="pill input w-full max-w-64"
className="pill input flex-1"
placeholder="Type here..."
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
@ -114,14 +173,14 @@ export default function ProfileSettings() {
</div>
{/* Change Username */}
<div className="grid grid-cols-2 gap-4 max-lg:grid-cols-1">
<div>
<div className="grid grid-cols-5 gap-4 max-lg:grid-cols-1">
<div className="col-span-3">
<label className="font-semibold">Change Username</label>
<p className="text-sm text-zinc-500">Your unique tag on the site. Can only be changed once every 90 days</p>
</div>
<div className="flex justify-end gap-1">
<div className="relative w-full max-w-64">
<div className="flex justify-end gap-1 col-span-2">
<div className="relative flex-1">
<input
type="text"
className="pill input w-full indent-4"
@ -152,9 +211,9 @@ export default function ProfileSettings() {
{/* Separator */}
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium my-1">
<hr className="flex-grow border-zinc-300" />
<hr className="grow border-zinc-300" />
<span>Danger Zone</span>
<hr className="flex-grow border-zinc-300" />
<hr className="grow border-zinc-300" />
</div>
{/* Delete Account */}

View file

@ -43,13 +43,13 @@ export default function ProfilePictureSettings() {
}, []);
return (
<div className="grid grid-cols-2">
<div>
<div className="grid grid-cols-5 gap-4 max-lg:grid-cols-1">
<div className="col-span-3">
<label className="font-semibold">Profile Picture</label>
<p className="text-sm text-zinc-500">Manage your profile picture. Can only be changed once every 7 days.</p>
</div>
<div className="flex flex-col">
<div className="flex flex-col col-span-2">
<div className="flex justify-end">
<Dropzone onDrop={handleDrop} options={{ maxFiles: 1 }}>
<p className="text-center text-xs">
@ -74,7 +74,7 @@ export default function ProfilePictureSettings() {
data-tooltip="Delete Picture"
aria-label="Delete Picture"
onClick={() => setNewPicture(undefined)}
className="pill button aspect-square !p-1 text-2xl !bg-red-400 !border-red-500"
className="pill button aspect-square p-1! text-2xl bg-red-400! border-red-500!"
>
<Icon icon="mdi:trash-outline" />
</button>

View file

@ -37,13 +37,13 @@ export default function SubmitDialogButton({ title, description, onSubmit, error
return (
<>
<button onClick={() => setIsOpen(true)} aria-label="Open Submit Dialog" className="pill button size-11 !p-1 text-2xl">
<button onClick={() => setIsOpen(true)} aria-label="Open Submit Dialog" className="pill button size-11 p-1! text-2xl">
<Icon icon="material-symbols:check-rounded" />
</button>
{isOpen &&
createPortal(
<div className="fixed inset-0 w-full h-[calc(100%-var(--header-height))] top-[var(--header-height)] flex items-center justify-center z-40">
<div className="fixed inset-0 w-full h-[calc(100%-var(--header-height))] top-(--header-height) flex items-center justify-center z-40">
<div
onClick={close}
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${

View file

@ -5,7 +5,7 @@ import { Icon } from "@iconify/react";
export default function RandomLink() {
return (
<Link href={"/random"} aria-label="Go to Random Link" className="pill button !p-0 h-full aspect-square" data-tooltip="Go to a Random Mii">
<Link href={"/random"} aria-label="Go to Random Link" className="pill button p-0! h-full aspect-square" data-tooltip="Go to a Random Mii">
<Icon icon="mdi:dice-3" fontSize={28} />
</Link>
);

View file

@ -67,7 +67,7 @@ export default function ReportMiiForm({ mii, likes }: Props) {
rows={3}
maxLength={256}
placeholder="Type notes here for the report..."
className="pill input !rounded-xl resize-none col-span-2"
className="pill input rounded-xl! resize-none col-span-2"
value={notes}
onChange={(e) => setNotes(e.target.value)}
/>

View file

@ -40,7 +40,7 @@ export default function ReasonSelector({ reason, setReason }: Props) {
type="button"
{...getToggleButtonProps()}
aria-label="Report reason dropdown"
className="pill input w-full gap-1 !justify-between text-nowrap"
className="pill input w-full gap-1 justify-between! text-nowrap"
>
{selectedItem?.label || <span className="text-black/40">Select a reason for the report...</span>}
<Icon icon="tabler:chevron-down" className="ml-2 size-5" />

View file

@ -65,7 +65,7 @@ export default function ReportUserForm({ user }: Props) {
rows={3}
maxLength={256}
placeholder="Type notes here for the report..."
className="pill input !rounded-xl resize-none col-span-2"
className="pill input rounded-xl! resize-none col-span-2"
value={notes}
onChange={(e) => setNotes(e.target.value)}
/>

View file

@ -67,7 +67,7 @@ export default function ShareMiiButton({ miiId }: Props) {
{isOpen &&
createPortal(
<div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-[var(--header-height)] flex items-center justify-center z-40">
<div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-(--header-height) flex items-center justify-center z-40">
<div
onClick={close}
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${
@ -92,7 +92,7 @@ export default function ShareMiiButton({ miiId }: Props) {
{/* Copy button */}
<button
className="!absolute top-2.5 right-2.5 cursor-pointer"
className="absolute! top-2.5 right-2.5 cursor-pointer"
data-tooltip={hasCopiedUrl ? "Copied!" : "Copy URL"}
onClick={handleCopyUrl}
>
@ -118,9 +118,9 @@ export default function ShareMiiButton({ miiId }: Props) {
{/* Separator */}
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium my-4">
<hr className="flex-grow border-zinc-300" />
<hr className="grow border-zinc-300" />
<span>or</span>
<hr className="flex-grow border-zinc-300" />
<hr className="grow border-zinc-300" />
</div>
<div className="flex justify-center items-center p-4 w-full bg-orange-100 border border-orange-400 rounded-lg">
@ -139,7 +139,7 @@ export default function ShareMiiButton({ miiId }: Props) {
{/* Save button */}
<a
href={`/mii/${miiId}/image?type=metadata`}
className="pill button !p-0 aspect-square cursor-pointer text-xl"
className="pill button p-0! aspect-square size-11 cursor-pointer text-xl"
aria-label="Save Image"
data-tooltip="Save Image"
download={"hello.png"}
@ -149,7 +149,7 @@ export default function ShareMiiButton({ miiId }: Props) {
{/* Copy button */}
<button
className="pill button !p-0 aspect-square cursor-pointer"
className="pill button p-0! aspect-square size-11 cursor-pointer"
aria-label="Copy Image"
data-tooltip={hasCopiedImage ? "Copied!" : "Copy Image"}
onClick={handleCopyImage}

View file

@ -106,7 +106,7 @@ export default function EditForm({ mii, likes }: Props) {
return (
<form className="flex justify-center gap-4 w-full max-lg:flex-col max-lg:items-center">
<div className="flex justify-center">
<div className="w-[18.75rem] h-min flex flex-col bg-zinc-50 rounded-3xl border-2 border-zinc-300 shadow-lg p-3">
<div className="w-75 h-min flex flex-col bg-zinc-50 rounded-3xl border-2 border-zinc-300 shadow-lg p-3">
<Carousel
images={[`/mii/${mii.id}/image?type=mii`, `/mii/${mii.id}/image?type=qr-code`, ...files.map((file) => URL.createObjectURL(file))]}
/>
@ -139,9 +139,9 @@ export default function EditForm({ mii, likes }: Props) {
{/* Separator */}
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium my-1">
<hr className="flex-grow border-zinc-300" />
<hr className="grow border-zinc-300" />
<span>Info</span>
<hr className="flex-grow border-zinc-300" />
<hr className="grow border-zinc-300" />
</div>
<div className="w-full grid grid-cols-3 items-center">
@ -164,7 +164,7 @@ export default function EditForm({ mii, likes }: Props) {
<label htmlFor="tags" className="font-semibold">
Tags
</label>
<TagSelector tags={tags} setTags={setTags} />
<TagSelector tags={tags} setTags={setTags} showTagLimit />
</div>
<div className="w-full grid grid-cols-3 items-start">
@ -172,10 +172,10 @@ export default function EditForm({ mii, likes }: Props) {
Description
</label>
<textarea
rows={3}
rows={5}
maxLength={256}
placeholder="(optional) Type a description..."
className="pill input !rounded-xl resize-none col-span-2"
className="pill input rounded-xl! resize-none col-span-2 text-sm"
value={description ?? ""}
onChange={(e) => setDescription(e.target.value)}
/>
@ -183,9 +183,9 @@ export default function EditForm({ mii, likes }: Props) {
{/* Separator */}
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-8 mb-2">
<hr className="flex-grow border-zinc-300" />
<hr className="grow border-zinc-300" />
<span>Custom images</span>
<hr className="flex-grow border-zinc-300" />
<hr className="grow border-zinc-300" />
</div>
<div className="max-w-md w-full self-center">

View file

@ -43,13 +43,13 @@ export default function ImageList({ files, setFiles }: Props) {
alt={file.name}
width={96}
height={96}
className="aspect-[3/2] object-contain w-24 rounded-md bg-orange-300 border-2 border-orange-400"
className="aspect-3/2 object-contain w-24 rounded-md bg-orange-300 border-2 border-orange-400"
/>
<div className="flex flex-col justify-center w-full min-w-0">
<span className="font-semibold overflow-hidden text-ellipsis">{file.name}</span>
<button
onClick={() => handleDelete(index)}
className="pill button text-xs w-min !px-3 !py-1 !bg-red-300 !border-red-400 hover:!bg-red-400"
className="pill button text-xs w-min px-3! py-1! bg-red-300! border-red-400! hover:bg-red-400!"
>
Delete
</button>

View file

@ -27,17 +27,6 @@ import SubmitButton from "../submit-button";
import Dropzone from "../dropzone";
export default function SubmitForm() {
const [platform, setPlatform] = useState<MiiPlatform>("SWITCH");
const [name, setName] = useState("");
const [tags, setTags] = useState<string[]>([]);
const [description, setDescription] = useState("");
const [gender, setGender] = useState<MiiGender>("MALE");
const [qrBytesRaw, setQrBytesRaw] = useState<number[]>([]);
const [miiPortraitUri, setMiiPortraitUri] = useState<string | undefined>();
const [generatedQrCodeUri, setGeneratedQrCodeUri] = useState<string | undefined>();
const [error, setError] = useState<string | undefined>(undefined);
const [files, setFiles] = useState<FileWithPath[]>([]);
const handleDrop = useCallback(
@ -48,6 +37,20 @@ export default function SubmitForm() {
[files.length]
);
const [isQrScannerOpen, setIsQrScannerOpen] = useState(false);
const [miiPortraitUri, setMiiPortraitUri] = useState<string | undefined>();
const [generatedQrCodeUri, setGeneratedQrCodeUri] = useState<string | undefined>();
const [name, setName] = useState("");
const [tags, setTags] = useState<string[]>([]);
const [description, setDescription] = useState("");
const [qrBytesRaw, setQrBytesRaw] = useState<number[]>([]);
const [platform, setPlatform] = useState<MiiPlatform>("SWITCH");
const [gender, setGender] = useState<MiiGender>("MALE");
const [error, setError] = useState<string | undefined>(undefined);
const handleSubmit = async () => {
// Validate before sending request
const nameValidation = nameSchema.safeParse(name);
@ -75,7 +78,17 @@ export default function SubmitForm() {
if (platform === "SWITCH") {
const response = await fetch(miiPortraitUri!);
if (!response.ok) {
setError("Failed to check Mii portrait. Did you upload one?");
return;
}
const blob = await response.blob();
if (!blob.type.startsWith("image/")) {
setError("Invalid image file returned");
return;
}
formData.append("gender", gender);
formData.append("miiPortraitImage", blob);
@ -139,10 +152,8 @@ export default function SubmitForm() {
return (
<form className="flex justify-center gap-4 w-full max-lg:flex-col max-lg:items-center">
<div className="flex justify-center">
<div className="w-[18.75rem] h-min flex flex-col bg-zinc-50 rounded-3xl border-2 border-zinc-300 shadow-lg p-3">
<Carousel
images={[miiPortraitUri ?? "/loading.svg", generatedQrCodeUri ?? "/loading.svg", ...files.map((file) => URL.createObjectURL(file))]}
/>
<div className="w-75 h-min flex flex-col bg-zinc-50 rounded-3xl border-2 border-zinc-300 shadow-lg p-3">
<Carousel images={[miiPortraitUri ?? "/loading.svg", generatedQrCodeUri ?? "/loading.svg", ...files.map((file) => URL.createObjectURL(file))]} />
<div className="p-4 flex flex-col gap-1 h-full">
<h1 className="font-bold text-2xl line-clamp-1" title={name}>
@ -172,9 +183,9 @@ export default function SubmitForm() {
{/* Separator */}
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium my-1">
<hr className="flex-grow border-zinc-300" />
<hr className="grow border-zinc-300" />
<span>Info</span>
<hr className="flex-grow border-zinc-300" />
<hr className="grow border-zinc-300" />
</div>
{/* Platform select */}
@ -184,6 +195,7 @@ export default function SubmitForm() {
</label>
<div className="relative col-span-2 grid grid-cols-2 bg-orange-300 border-2 border-orange-400 rounded-4xl shadow-md inset-shadow-sm/10">
{/* Animated indicator */}
{/* TODO: maybe change width as part of animation? */}
<div
className={`absolute inset-0 w-1/2 bg-orange-200 rounded-4xl transition-transform duration-300 ${
platform === "SWITCH" ? "translate-x-0" : "translate-x-full"
@ -194,8 +206,8 @@ export default function SubmitForm() {
<button
type="button"
onClick={() => setPlatform("SWITCH")}
className={`p-2 text-black/35 cursor-pointer flex justify-center items-center gap-2 z-10 transition-colors ${
platform === "SWITCH" && "!text-black"
className={`p-2 text-slate-800/35 cursor-pointer flex justify-center items-center gap-2 z-10 transition-colors ${
platform === "SWITCH" && "text-slate-800!"
}`}
>
<Icon icon="cib:nintendo-switch" className="text-2xl" />
@ -206,8 +218,8 @@ export default function SubmitForm() {
<button
type="button"
onClick={() => setPlatform("THREE_DS")}
className={`p-2 text-black/35 cursor-pointer flex justify-center items-center gap-2 z-10 transition-colors ${
platform === "THREE_DS" && "!text-black"
className={`p-2 text-slate-800/35 cursor-pointer flex justify-center items-center gap-2 z-10 transition-colors ${
platform === "THREE_DS" && "text-slate-800!"
}`}
>
<Icon icon="cib:nintendo-3ds" className="text-2xl" />
@ -237,7 +249,7 @@ export default function SubmitForm() {
<label htmlFor="tags" className="font-semibold">
Tags
</label>
<TagSelector tags={tags} setTags={setTags} />
<TagSelector tags={tags} setTags={setTags} showTagLimit />
</div>
{/* Description */}
@ -247,10 +259,10 @@ export default function SubmitForm() {
</label>
<textarea
name="description"
rows={3}
rows={5}
maxLength={256}
placeholder="(optional) Type a description..."
className="pill input !rounded-xl resize-none col-span-2"
className="pill input rounded-xl! resize-none col-span-2 text-sm"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
@ -292,9 +304,9 @@ export default function SubmitForm() {
<>
{/* Separator */}
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-8 mb-2">
<hr className="flex-grow border-zinc-300" />
<hr className="grow border-zinc-300" />
<span>Mii Portrait</span>
<hr className="flex-grow border-zinc-300" />
<hr className="grow border-zinc-300" />
</div>
<div className="flex flex-col items-center gap-2">
@ -305,16 +317,21 @@ export default function SubmitForm() {
{/* QR code selector */}
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-8 mb-2">
<hr className="flex-grow border-zinc-300" />
<hr className="grow border-zinc-300" />
<span>QR Code</span>
<hr className="flex-grow border-zinc-300" />
<hr className="grow border-zinc-300" />
</div>
<div className="flex flex-col items-center gap-2">
<QrUpload setQrBytesRaw={setQrBytesRaw} />
<span>or</span>
<QrScanner setQrBytesRaw={setQrBytesRaw} />
<button type="button" aria-label="Use your camera" onClick={() => setIsQrScannerOpen(true)} className="pill button gap-2">
<Icon icon="mdi:camera" fontSize={20} />
Use your camera
</button>
<QrScanner isOpen={isQrScannerOpen} setIsOpen={setIsQrScannerOpen} setQrBytesRaw={setQrBytesRaw} />
{platform === "THREE_DS" ? (
<>
<ThreeDsSubmitTutorialButton />
@ -328,12 +345,12 @@ export default function SubmitForm() {
{/* Custom images selector */}
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-6 mb-2">
<hr className="flex-grow border-zinc-300" />
<hr className="grow border-zinc-300" />
<span>Custom images</span>
<hr className="flex-grow border-zinc-300" />
<hr className="grow border-zinc-300" />
</div>
<div className="max-w-md w-full self-center">
<div className="max-w-md w-full self-center flex flex-col items-center">
<Dropzone onDrop={handleDrop}>
<p className="text-center text-sm">
Drag and drop your images here
@ -341,6 +358,8 @@ export default function SubmitForm() {
or click to open
</p>
</Dropzone>
<span className="text-xs text-zinc-400 mt-2">Animated images currently not supported.</span>
</div>
<ImageList files={files} setFiles={setFiles} />

View file

@ -1,6 +1,6 @@
"use client";
import { useCallback } from "react";
import { useCallback, useState } from "react";
import { FileWithPath } from "react-dropzone";
import Dropzone from "../dropzone";
@ -9,6 +9,8 @@ interface Props {
}
export default function PortraitUpload({ setImage }: Props) {
const [hasImage, setHasImage] = useState(false);
const handleDrop = useCallback(
(acceptedFiles: FileWithPath[]) => {
const file = acceptedFiles[0];
@ -16,6 +18,7 @@ export default function PortraitUpload({ setImage }: Props) {
const reader = new FileReader();
reader.onload = async (event) => {
setImage(event.target!.result as string);
setHasImage(true);
};
reader.readAsDataURL(file);
},
@ -26,9 +29,15 @@ export default function PortraitUpload({ setImage }: Props) {
<div className="max-w-md w-full">
<Dropzone onDrop={handleDrop} options={{ maxFiles: 1 }}>
<p className="text-center text-sm">
Drag and drop your Mii&apos;s portrait here
<br />
or click to open
{!hasImage ? (
<>
Drag and drop your Mii&apos;s portrait here
<br />
or click to open
</>
) : (
"Uploaded!"
)}
</p>
</Dropzone>
</div>

View file

@ -9,14 +9,17 @@ import QrFinder from "./qr-finder";
import { useSelect } from "downshift";
interface Props {
isOpen: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
setQrBytesRaw: React.Dispatch<React.SetStateAction<number[]>>;
}
export default function QrScanner({ setQrBytesRaw }: Props) {
const [isOpen, setIsOpen] = useState(false);
export default function QrScanner({ isOpen, setIsOpen, setQrBytesRaw }: Props) {
const [isVisible, setIsVisible] = useState(false);
const [permissionGranted, setPermissionGranted] = useState<boolean | null>(null);
const [permissionGranted, setPermissionGranted] = useState<boolean | null>(
null
);
const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);
const [selectedDeviceId, setSelectedDeviceId] = useState<string | null>(null);
@ -39,7 +42,8 @@ export default function QrScanner({ setQrBytesRaw }: Props) {
selectedItem,
} = useSelect({
items: cameraItems,
selectedItem: cameraItems.find((item) => item.value === selectedDeviceId) ?? null,
selectedItem:
cameraItems.find((item) => item.value === selectedDeviceId) ?? null,
onSelectedItemChange: ({ selectedItem }) => {
setSelectedDeviceId(selectedItem?.value ?? null);
},
@ -65,7 +69,12 @@ export default function QrScanner({ setQrBytesRaw }: Props) {
canvas.height = video.videoHeight;
ctx.drawImage(video, 0, 0, video.videoWidth, video.videoHeight);
const imageData = ctx.getImageData(0, 0, video.videoWidth, video.videoHeight);
const imageData = ctx.getImageData(
0,
0,
video.videoWidth,
video.videoHeight
);
const code = jsQR(imageData.data, imageData.width, imageData.height);
if (!code) return;
@ -126,112 +135,128 @@ export default function QrScanner({ setQrBytesRaw }: Props) {
};
}, [isOpen, permissionGranted, scanQRCode]);
if (!isOpen) return null;
return (
<>
<button type="button" aria-label="Use your camera" onClick={() => setIsOpen(true)} className="pill button gap-2">
<Icon icon="mdi:camera" fontSize={20} />
Use your camera
</button>
<div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-(--header-height) flex items-center justify-center z-40">
<div
onClick={close}
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${
isVisible ? "opacity-100" : "opacity-0"
}`}
/>
{isOpen && (
<div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-[var(--header-height)] flex items-center justify-center z-40">
<div
<div
className={`z-50 bg-orange-50 border-2 border-amber-500 rounded-2xl shadow-lg p-6 w-full max-w-md transition-discrete duration-300 ${
isVisible ? "scale-100 opacity-100" : "scale-75 opacity-0"
}`}
>
<div className="flex justify-between items-center mb-2">
<h2 className="text-xl font-bold">Scan QR Code</h2>
<button
type="button"
aria-label="Close"
onClick={close}
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${
isVisible ? "opacity-100" : "opacity-0"
}`}
/>
<div
className={`z-50 bg-orange-50 border-2 border-amber-500 rounded-2xl shadow-lg p-6 w-full max-w-md transition-discrete duration-300 ${
isVisible ? "scale-100 opacity-100" : "scale-75 opacity-0"
}`}
className="text-red-400 hover:text-red-500 text-2xl cursor-pointer"
>
<div className="flex justify-between items-center mb-2">
<h2 className="text-xl font-bold">Scan QR Code</h2>
<button type="button" aria-label="Close" onClick={close} className="text-red-400 hover:text-red-500 text-2xl cursor-pointer">
<Icon icon="material-symbols:close-rounded" />
<Icon icon="material-symbols:close-rounded" />
</button>
</div>
{devices.length > 1 && (
<div className="mb-4 flex flex-col gap-1">
<label className="text-sm font-semibold">Camera:</label>
<div className="relative w-full">
{/* Toggle button to open the dropdown */}
<button
type="button"
aria-label="Select camera dropdown"
{...getToggleButtonProps({}, { suppressRefError: true })}
className="pill input w-full px-2! py-0.5! justify-between! text-sm"
>
{selectedItem?.label || "Select a camera"}
<Icon icon="tabler:chevron-down" className="ml-2 size-5" />
</button>
</div>
{devices.length > 1 && (
<div className="mb-4 flex flex-col gap-1">
<label className="text-sm font-semibold">Camera:</label>
<div className="relative w-full">
{/* Toggle button to open the dropdown */}
<button
type="button"
aria-label="Select camera dropdown"
{...getToggleButtonProps({}, { suppressRefError: true })}
className="pill input w-full !px-2 !py-0.5 !justify-between text-sm"
>
{selectedItem?.label || "Select a camera"}
<Icon icon="tabler:chevron-down" className="ml-2 size-5" />
</button>
{/* Dropdown menu */}
<ul
{...getMenuProps({}, { suppressRefError: true })}
className={`absolute z-50 w-full bg-orange-200 border-2 border-orange-400 rounded-lg mt-1 shadow-lg max-h-60 overflow-y-auto ${
isDropdownOpen ? "block" : "hidden"
}`}
>
{isDropdownOpen &&
cameraItems.map((item, index) => (
<li
key={item.value}
{...getItemProps({ item, index })}
className={`px-4 py-1 cursor-pointer text-sm ${highlightedIndex === index ? "bg-black/15" : ""}`}
>
{item.label}
</li>
))}
</ul>
</div>
</div>
)}
<div className="relative w-full aspect-square">
{!permissionGranted ? (
<div className="absolute inset-0 flex flex-col items-center justify-center rounded-2xl border-2 border-amber-500 text-center p-8">
<p className="text-red-400 font-bold text-lg mb-2">Camera access denied</p>
<p className="text-gray-600">Please allow camera access in your browser settings to scan QR codes</p>
<button type="button" onClick={requestPermission} className="pill button text-xs mt-2 !py-0.5 !px-2">
Request Permission
</button>
</div>
) : (
<>
<Webcam
key={selectedDeviceId}
ref={webcamRef}
audio={false}
videoConstraints={{
deviceId: selectedDeviceId ? { exact: selectedDeviceId } : undefined,
...(selectedDeviceId ? {} : { facingMode: { ideal: "environment" } }),
}}
onUserMedia={async () => {
const newDevices = await navigator.mediaDevices.enumerateDevices();
const videoDevices = newDevices.filter((d) => d.kind === "videoinput");
setDevices(videoDevices);
}}
className="size-full object-cover rounded-2xl border-2 border-amber-500"
/>
<QrFinder />
<canvas ref={canvasRef} className="hidden" />
</>
)}
</div>
<div className="mt-4 flex justify-center">
<button type="button" onClick={close} className="pill button">
Cancel
</button>
{/* Dropdown menu */}
<ul
{...getMenuProps({}, { suppressRefError: true })}
className={`absolute z-50 w-full bg-orange-200 border-2 border-orange-400 rounded-lg mt-1 shadow-lg max-h-60 overflow-y-auto ${
isDropdownOpen ? "block" : "hidden"
}`}
>
{isDropdownOpen &&
cameraItems.map((item, index) => (
<li
key={item.value}
{...getItemProps({ item, index })}
className={`px-4 py-1 cursor-pointer text-sm ${
highlightedIndex === index ? "bg-black/15" : ""
}`}
>
{item.label}
</li>
))}
</ul>
</div>
</div>
)}
<div className="relative w-full aspect-square">
{!permissionGranted ? (
<div className="absolute inset-0 flex flex-col items-center justify-center rounded-2xl border-2 border-amber-500 text-center p-8">
<p className="text-red-400 font-bold text-lg mb-2">
Camera access denied
</p>
<p className="text-gray-600">
Please allow camera access in your browser settings to scan QR
codes
</p>
<button
type="button"
onClick={requestPermission}
className="pill button text-xs mt-2 py-0.5! px-2!"
>
Request Permission
</button>
</div>
) : (
<>
<Webcam
key={selectedDeviceId}
ref={webcamRef}
audio={false}
videoConstraints={{
deviceId: selectedDeviceId
? { exact: selectedDeviceId }
: undefined,
...(selectedDeviceId
? {}
: { facingMode: { ideal: "environment" } }),
}}
onUserMedia={async () => {
const newDevices =
await navigator.mediaDevices.enumerateDevices();
const videoDevices = newDevices.filter(
(d) => d.kind === "videoinput"
);
setDevices(videoDevices);
}}
className="size-full object-cover rounded-2xl border-2 border-amber-500"
/>
<QrFinder />
<canvas ref={canvasRef} className="hidden" />
</>
)}
</div>
)}
</>
<div className="mt-4 flex justify-center">
<button type="button" onClick={close} className="pill button">
Cancel
</button>
</div>
</div>
</div>
);
}

View file

@ -1,6 +1,6 @@
"use client";
import { useCallback, useRef } from "react";
import { useCallback, useRef, useState } from "react";
import { FileWithPath } from "react-dropzone";
import jsQR from "jsqr";
import Dropzone from "../dropzone";
@ -10,6 +10,7 @@ interface Props {
}
export default function QrUpload({ setQrBytesRaw }: Props) {
const [hasImage, setHasImage] = useState(false);
const canvasRef = useRef<HTMLCanvasElement>(null);
const handleDrop = useCallback(
@ -36,6 +37,7 @@ export default function QrUpload({ setQrBytesRaw }: Props) {
if (!code) return;
setQrBytesRaw(code.binaryData!);
setHasImage(true);
};
image.src = event.target!.result as string;
};
@ -48,9 +50,15 @@ export default function QrUpload({ setQrBytesRaw }: Props) {
<div className="max-w-md w-full">
<Dropzone onDrop={handleDrop} options={{ maxFiles: 1 }}>
<p className="text-center text-sm">
Drag and drop your QR code image here
<br />
or click to open
{!hasImage ? (
<>
Drag and drop your QR code image here
<br />
or click to open
</>
) : (
"Uploaded!"
)}
</p>
</Dropzone>

View file

@ -1,29 +1,46 @@
"use client";
import React, { useState } from "react";
import React, { useState, useRef } from "react";
import { useCombobox } from "downshift";
import { Icon } from "@iconify/react";
interface Props {
tags: string[];
setTags: React.Dispatch<React.SetStateAction<string[]>>;
showTagLimit?: boolean;
}
const tagRegex = /^[a-z0-9-_]*$/;
const predefinedTags = ["anime", "art", "cartoon", "celebrity", "games", "history", "meme", "movie", "oc", "tv"];
const predefinedTags = [
"anime",
"art",
"cartoon",
"celebrity",
"games",
"history",
"meme",
"movie",
"oc",
"tv",
];
export default function TagSelector({ tags, setTags }: Props) {
export default function TagSelector({ tags, setTags, showTagLimit }: Props) {
const [inputValue, setInputValue] = useState<string>("");
const inputRef = useRef<HTMLInputElement>(null);
const getFilteredItems = (): string[] =>
predefinedTags.filter((item) => item.toLowerCase().includes(inputValue?.toLowerCase() || "")).filter((item) => !tags.includes(item));
predefinedTags
.filter((item) =>
item.toLowerCase().includes(inputValue?.toLowerCase() || "")
)
.filter((item) => !tags.includes(item));
const filteredItems = getFilteredItems();
const isMaxItemsSelected = tags.length >= 8;
const hasSelectedItems = tags.length > 0;
const addTag = (tag: string) => {
if (!tags.includes(tag) && tags.length < 8) {
if (!tags.includes(tag) && tags.length < 8 && tag.length <= 20) {
setTags([...tags, tag]);
}
};
@ -32,7 +49,15 @@ export default function TagSelector({ tags, setTags }: Props) {
setTags(tags.filter((t) => t !== tag));
};
const { isOpen, getToggleButtonProps, getMenuProps, getInputProps, getItemProps, highlightedIndex } = useCombobox<string>({
const {
isOpen,
openMenu,
getToggleButtonProps,
getMenuProps,
getInputProps,
getItemProps,
highlightedIndex,
} = useCombobox<string>({
inputValue,
items: filteredItems,
onInputValueChange: ({ inputValue }) => {
@ -61,85 +86,129 @@ export default function TagSelector({ tags, setTags }: Props) {
}
};
const handleContainerClick = () => {
if (!isMaxItemsSelected) {
inputRef.current?.focus();
openMenu();
}
};
return (
<div
className={`col-span-2 !justify-between pill input relative focus-within:ring-[3px] ring-orange-400/50 transition ${
tags.length > 0 ? "!py-1.5" : ""
}`}
>
{/* Tags */}
<div className="flex flex-wrap gap-1.5 w-full">
{tags.map((tag) => (
<span key={tag} className="bg-orange-300 py-1 px-3 rounded-2xl flex items-center gap-1 text-sm">
{tag}
<div className="col-span-2 relative">
<div
className={`relative justify-between! pill input focus-within:ring-[3px] ring-orange-400/50 cursor-text transition ${
tags.length > 0 ? "py-1.5! px-2!" : ""
}`}
onClick={handleContainerClick}
>
{/* Tags */}
<div className="flex flex-wrap gap-1.5 w-full">
{tags.map((tag) => (
<span
key={tag}
className="bg-orange-300 py-1 px-3 rounded-2xl flex items-center gap-1 text-sm"
>
{tag}
<button
type="button"
aria-label="Delete Tag"
className="text-slate-800 cursor-pointer"
onClick={(e) => {
e.stopPropagation();
removeTag(tag);
}}
>
<Icon icon="mdi:close" className="text-xs" />
</button>
</span>
))}
{/* Input */}
<input
{...getInputProps({
ref: inputRef,
onKeyDown: handleKeyDown,
disabled: isMaxItemsSelected,
placeholder: tags.length > 0 ? "" : "Type or select a tag...",
maxLength: 20,
className: "w-full flex-1 outline-none placeholder:text-black/40",
})}
/>
</div>
{/* Control buttons */}
<div
className="flex items-center gap-1"
onClick={(e) => e.stopPropagation()}
>
{hasSelectedItems && (
<button
type="button"
aria-label="Delete Tag"
aria-label="Remove All Tags"
className="text-black cursor-pointer"
onClick={(e) => {
e.stopPropagation();
removeTag(tag);
}}
onClick={() => setTags([])}
>
<Icon icon="mdi:close" className="text-xs" />
<Icon icon="mdi:close" />
</button>
</span>
))}
)}
{/* Input */}
<input
{...getInputProps({
onKeyDown: handleKeyDown,
disabled: isMaxItemsSelected,
placeholder: tags.length > 0 ? "" : "Type or select a tag...",
className: "w-full flex-1 outline-none placeholder:text-black/40",
})}
/>
</div>
{/* Control buttons */}
<div className="flex items-center gap-1">
{hasSelectedItems && (
<button type="button" aria-label="Remove All Tags" className="text-black cursor-pointer" onClick={() => setTags([])}>
<Icon icon="mdi:close" />
<button
type="button"
aria-label="Toggle Tag Dropdown"
{...getToggleButtonProps()}
disabled={isMaxItemsSelected}
className="text-black cursor-pointer text-xl disabled:text-black/35"
>
<Icon icon="mdi:chevron-down" />
</button>
)}
</div>
<button type="button" aria-label="Toggle Tag Dropdown" {...getToggleButtonProps()} className="text-black cursor-pointer text-xl">
<Icon icon="mdi:chevron-down" />
</button>
</div>
{/* Dropdown menu */}
{!isMaxItemsSelected && (
<ul
{...getMenuProps()}
className={`absolute left-0 top-full mt-2 z-50 w-full bg-orange-200 border-2 border-orange-400 rounded-lg shadow-lg max-h-60 overflow-y-auto ${
isOpen ? "block" : "hidden"
}`}
>
{isOpen &&
filteredItems.map((item, index) => (
{/* Dropdown menu */}
{!isMaxItemsSelected && (
<ul
{...getMenuProps()}
onClick={(e) => e.stopPropagation()}
className={`absolute right-0 top-full mt-2 z-50 w-80 bg-orange-200/45 backdrop-blur-md border-2 border-orange-400 rounded-lg shadow-lg shadow-black/25 max-h-60 overflow-y-auto ${
isOpen ? "block" : "hidden"
}`}
>
{filteredItems.map((item, index) => (
<li
key={item}
{...getItemProps({ item, index })}
className={`px-4 py-1 cursor-pointer text-sm ${highlightedIndex === index ? "bg-black/15" : ""}`}
className={`px-4 py-1 cursor-pointer text-sm ${
highlightedIndex === index ? "bg-black/15" : ""
}`}
>
{item}
</li>
))}
{isOpen && inputValue && !filteredItems.includes(inputValue) && (
<li
className="px-4 py-1 cursor-pointer text-sm bg-black/15"
onClick={() => {
addTag(inputValue);
setInputValue("");
}}
>
Add &quot;{inputValue}&quot;
</li>
{inputValue && !filteredItems.includes(inputValue) && (
<li
className="px-4 py-1 cursor-pointer text-sm bg-black/15"
onClick={() => {
addTag(inputValue);
setInputValue("");
}}
>
Add &quot;{inputValue}&quot;
</li>
)}
</ul>
)}
</div>
{/* Tag limit message */}
{showTagLimit && (
<div className="mt-1.5 text-xs min-h-4">
{isMaxItemsSelected ? (
<span className="text-red-400 font-medium">
Maximum of 8 tags reached. Remove a tag to add more.
</span>
) : (
<span className="text-black/60">{tags.length}/8 tags</span>
)}
</ul>
</div>
)}
</div>
);

View file

@ -1,102 +1,59 @@
"use client";
import { useEffect, useState } from "react";
import { useState } from "react";
import { createPortal } from "react-dom";
import useEmblaCarousel from "embla-carousel-react";
import { Icon } from "@iconify/react";
import TutorialPage from "./page";
import Tutorial from ".";
export default function ThreeDsScanTutorialButton() {
const [isOpen, setIsOpen] = useState(false);
const [isVisible, setIsVisible] = useState(false);
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true });
const [selectedIndex, setSelectedIndex] = useState(0);
const close = () => {
setIsVisible(false);
setTimeout(() => {
setIsOpen(false);
setSelectedIndex(0);
}, 300);
};
useEffect(() => {
if (isOpen) {
// slight delay to trigger animation
setTimeout(() => setIsVisible(true), 10);
}
}, [isOpen]);
useEffect(() => {
if (!emblaApi) return;
emblaApi.on("select", () => setSelectedIndex(emblaApi.selectedScrollSnap()));
}, [emblaApi]);
return (
<>
<button aria-label="Tutorial" type="button" onClick={() => setIsOpen(true)} className="text-3xl cursor-pointer">
<button
aria-label="Tutorial"
type="button"
onClick={() => setIsOpen(true)}
className="text-3xl cursor-pointer"
>
<Icon icon="fa:question-circle" />
<span>Tutorial</span>
</button>
{isOpen &&
createPortal(
<div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-[var(--header-height)] flex items-center justify-center z-40">
<div
onClick={close}
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${
isVisible ? "opacity-100" : "opacity-0"
}`}
/>
<div
className={`z-50 bg-orange-50 border-2 border-amber-500 rounded-2xl shadow-lg w-full max-w-md h-[30rem] transition-discrete duration-300 flex flex-col ${
isVisible ? "scale-100 opacity-100" : "scale-75 opacity-0"
}`}
>
<div className="flex justify-between items-center mb-2 p-6 pb-0">
<h2 className="text-xl font-bold">Tutorial</h2>
<button onClick={close} aria-label="Close" className="text-red-400 hover:text-red-500 text-2xl cursor-pointer">
<Icon icon="material-symbols:close-rounded" />
</button>
</div>
<div className="flex flex-col min-h-0 h-full">
<div className="overflow-hidden h-full" ref={emblaRef}>
<div className="flex h-full">
<TutorialPage text="1. Enter the town hall" imageSrc="/tutorial/3ds/step1.png" />
<TutorialPage text="2. Go into 'QR Code'" imageSrc="/tutorial/3ds/adding-mii/step2.png" />
<TutorialPage text="3. Press 'Scan QR Code'" imageSrc="/tutorial/3ds/adding-mii/step3.png" />
<TutorialPage text="4. Click on the QR code below the Mii's image" imageSrc="/tutorial/3ds/adding-mii/step4.png" />
<TutorialPage text="5. Scan with your 3DS" imageSrc="/tutorial/3ds/adding-mii/step5.png" />
<TutorialPage carouselIndex={selectedIndex} finishIndex={5} />
</div>
</div>
<div className="flex justify-between items-center mt-2 px-6 pb-6">
<button
onClick={() => emblaApi?.scrollPrev()}
aria-label="Scroll Carousel Left"
className="pill button !p-1 aspect-square text-2xl"
>
<Icon icon="tabler:chevron-left" />
</button>
<span className="text-sm">Adding Mii to Island</span>
<button
onClick={() => emblaApi?.scrollNext()}
aria-label="Scroll Carousel Right"
className="pill button !p-1 aspect-square text-2xl"
>
<Icon icon="tabler:chevron-right" />
</button>
</div>
</div>
</div>
</div>,
<Tutorial
tutorials={[
{
title: "Adding Mii",
steps: [
{
text: "1. Enter the town hall",
imageSrc: "/tutorial/3ds/step1.png",
},
{
text: "2. Go into 'QR Code'",
imageSrc: "/tutorial/3ds/adding-mii/step2.png",
},
{
text: "3. Press 'Scan QR Code'",
imageSrc: "/tutorial/3ds/adding-mii/step3.png",
},
{
text: "4. Click on the QR code below the Mii's image",
imageSrc: "/tutorial/3ds/adding-mii/step4.png",
},
{
text: "5. Scan with your 3DS",
imageSrc: "/tutorial/3ds/adding-mii/step5.png",
},
{ type: "finish" },
],
},
]}
isOpen={isOpen}
setIsOpen={setIsOpen}
/>,
document.body
)}
</>

View file

@ -1,129 +1,99 @@
"use client";
import { useEffect, useState } from "react";
import { useState } from "react";
import { createPortal } from "react-dom";
import useEmblaCarousel from "embla-carousel-react";
import { Icon } from "@iconify/react";
import Tutorial from ".";
import TutorialPage from "./page";
import StartingPage from "./starting-page";
export default function ThreeDsSubmitTutorialButton() {
export default function SubmitTutorialButton() {
const [isOpen, setIsOpen] = useState(false);
const [isVisible, setIsVisible] = useState(false);
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true });
const [selectedIndex, setSelectedIndex] = useState(0);
const close = () => {
setIsVisible(false);
setTimeout(() => {
setIsOpen(false);
setSelectedIndex(0);
}, 300);
};
useEffect(() => {
if (isOpen) {
// slight delay to trigger animation
setTimeout(() => setIsVisible(true), 10);
}
}, [isOpen]);
useEffect(() => {
if (!emblaApi) return;
emblaApi.on("select", () => setSelectedIndex(emblaApi.selectedScrollSnap()));
}, [emblaApi]);
const isStartingPage = selectedIndex === 0 || selectedIndex === 9;
const inTutorialAllowCopying = selectedIndex && selectedIndex >= 1 && selectedIndex <= 9;
return (
<>
<button type="button" onClick={() => setIsOpen(true)} className="text-sm text-orange-400 cursor-pointer underline-offset-2 hover:underline">
<button
type="button"
onClick={() => setIsOpen(true)}
className="text-sm text-orange-400 cursor-pointer underline-offset-2 hover:underline"
>
How to?
</button>
{isOpen &&
createPortal(
<div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-[var(--header-height)] flex items-center justify-center z-40">
<div
onClick={close}
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${
isVisible ? "opacity-100" : "opacity-0"
}`}
/>
<div
className={`z-50 bg-orange-50 border-2 border-amber-500 rounded-2xl shadow-lg w-full max-w-md h-[30rem] transition-discrete duration-300 flex flex-col ${
isVisible ? "scale-100 opacity-100" : "scale-75 opacity-0"
}`}
>
<div className="flex justify-between items-center mb-2 p-6 pb-0">
<h2 className="text-xl font-bold">Tutorial</h2>
<button onClick={close} aria-label="Close" className="text-red-400 hover:text-red-500 text-2xl cursor-pointer">
<Icon icon="material-symbols:close-rounded" />
</button>
</div>
<div className="flex flex-col min-h-0 h-full">
<div className="overflow-hidden h-full" ref={emblaRef}>
<div className="flex h-full">
<StartingPage isSwitch emblaApi={emblaApi} />
{/* Allow Copying */}
<TutorialPage text="1. Enter the town hall" imageSrc="/tutorial/step1.png" />
<TutorialPage text="2. Go into 'Mii List'" imageSrc="/tutorial/allow-copying/step2.png" />
<TutorialPage text="3. Select and edit the Mii you wish to submit" imageSrc="/tutorial/allow-copying/step3.png" />
<TutorialPage text="4. Click 'Other Settings' in the information screen" imageSrc="/tutorial/allow-copying/step4.png" />
<TutorialPage text="5. Click on 'Don't Allow' under the 'Copying' text" imageSrc="/tutorial/allow-copying/step5.png" />
<TutorialPage text="6. Press 'Allow'" imageSrc="/tutorial/allow-copying/step6.png" />
<TutorialPage text="7. Confirm the edits to the Mii" imageSrc="/tutorial/allow-copying/step7.png" />
<TutorialPage carouselIndex={selectedIndex} finishIndex={8} />
<StartingPage emblaApi={emblaApi} />
{/* Create QR Code */}
<TutorialPage text="1. Enter the town hall" imageSrc="/tutorial/step1.png" />
<TutorialPage text="2. Go into 'QR Code'" imageSrc="/tutorial/create-qr-code/step2.png" />
<TutorialPage text="3. Press 'Create QR Code'" imageSrc="/tutorial/create-qr-code/step3.png" />
<TutorialPage text="4. Select and press 'OK' on the Mii you wish to submit" imageSrc="/tutorial/create-qr-code/step4.png" />
<TutorialPage
text="5. Pick any option; it doesn't matter since the QR code regenerates upon submission."
imageSrc="/tutorial/create-qr-code/step5.png"
/>
<TutorialPage
text="6. Exit the tutorial; Upload the QR code (scan with camera or upload file through SD card)."
imageSrc="/tutorial/create-qr-code/step6.png"
/>
<TutorialPage carouselIndex={selectedIndex} finishIndex={16} />
</div>
</div>
<div className={`flex justify-between items-center mt-2 px-6 pb-6 transition-opacity duration-300 ${isStartingPage && "opacity-0"}`}>
<button
onClick={() => emblaApi?.scrollPrev()}
disabled={isStartingPage}
className={`pill button !p-1 aspect-square text-2xl ${isStartingPage && "!cursor-auto"}`}
aria-label="Scroll Carousel Left"
>
<Icon icon="tabler:chevron-left" />
</button>
<span className="text-sm">{inTutorialAllowCopying ? "Allow Copying" : "Create QR Code"}</span>
<button
onClick={() => emblaApi?.scrollNext()}
disabled={isStartingPage}
className={`pill button !p-1 aspect-square text-2xl ${isStartingPage && "!cursor-auto"}`}
aria-label="Scroll Carousel Right"
>
<Icon icon="tabler:chevron-right" />
</button>
</div>
</div>
</div>
</div>,
<Tutorial
tutorials={[
{
title: "Allow Copying",
thumbnail: "/tutorial/3ds/allow-copying/thumbnail.png",
hint: "Suggested!",
steps: [
{ type: "start" },
{
text: "1. Enter the town hall",
imageSrc: "/tutorial/3ds/step1.png",
},
{
text: "2. Go into 'Mii List'",
imageSrc: "/tutorial/3ds/allow-copying/step2.png",
},
{
text: "3. Select and edit the Mii you wish to submit",
imageSrc: "/tutorial/3ds/allow-copying/step3.png",
},
{
text: "4. Click 'Other Settings' in the information screen",
imageSrc: "/tutorial/3ds/allow-copying/step4.png",
},
{
text: "5. Click on 'Don't Allow' under the 'Copying' text",
imageSrc: "/tutorial/3ds/allow-copying/step5.png",
},
{
text: "6. Press 'Allow'",
imageSrc: "/tutorial/3ds/allow-copying/step6.png",
},
{
text: "7. Confirm the edits to the Mii",
imageSrc: "/tutorial/3ds/allow-copying/step7.png",
},
{ type: "finish" },
],
},
{
title: "Create QR Code",
thumbnail: "/tutorial/3ds/create-qr-code/thumbnail.png",
steps: [
{ type: "start" },
{
text: "1. Enter the town hall",
imageSrc: "/tutorial/3ds/step1.png",
},
{
text: "2. Go into 'QR Code'",
imageSrc: "/tutorial/3ds/create-qr-code/step2.png",
},
{
text: "3. Press 'Create QR Code'",
imageSrc: "/tutorial/3ds/create-qr-code/step3.png",
},
{
text: "4. Select and press 'OK' on the Mii you wish to submit",
imageSrc: "/tutorial/3ds/create-qr-code/step4.png",
},
{
text: "5. Pick any option; it doesn't matter since the QR code regenerates upon submission.",
imageSrc: "/tutorial/3ds/create-qr-code/step5.png",
},
{
text: "6. Exit the tutorial; Upload the QR code (scan with camera or upload file through SD card).",
imageSrc: "/tutorial/3ds/create-qr-code/step6.png",
},
{ type: "finish" },
],
},
]}
isOpen={isOpen}
setIsOpen={setIsOpen}
/>,
document.body
)}
</>

View file

@ -0,0 +1,215 @@
"use client";
import Image from "next/image";
import { useEffect, useState } from "react";
import useEmblaCarousel from "embla-carousel-react";
import { Icon } from "@iconify/react";
import confetti from "canvas-confetti";
import ReturnToIsland from "../admin/return-to-island";
interface Slide {
// step is never used, undefined is assumed as a step
type?: "start" | "step" | "finish";
text?: string;
imageSrc?: string;
}
interface Tutorial {
title: string;
thumbnail?: string;
hint?: string;
steps: Slide[];
}
interface Props {
tutorials: Tutorial[];
isOpen: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
}
export default function Tutorial({ tutorials, isOpen, setIsOpen }: Props) {
const [isVisible, setIsVisible] = useState(false);
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true });
const [selectedIndex, setSelectedIndex] = useState(0);
// Build index map
const slides: Array<Slide & { tutorialTitle: string }> = [];
const startSlides: Record<string, number> = {};
tutorials.forEach((tutorial) => {
tutorial.steps.forEach((slide) => {
if (slide.type === "start") {
startSlides[tutorial.title] = slides.length;
}
slides.push({ ...slide, tutorialTitle: tutorial.title });
});
});
const currentSlide = slides[selectedIndex];
const isStartingPage = currentSlide?.type === "start";
useEffect(() => {
if (currentSlide.type !== "finish") return;
const defaults = { startVelocity: 30, spread: 360, ticks: 120, zIndex: 50 };
const randomInRange = (min: number, max: number) => Math.random() * (max - min) + min;
setTimeout(() => {
confetti({
...defaults,
particleCount: 500,
origin: { x: randomInRange(0.1, 0.3), y: Math.random() - 0.2 },
});
confetti({
...defaults,
particleCount: 500,
origin: { x: randomInRange(0.7, 0.9), y: Math.random() - 0.2 },
});
}, 300);
}, [currentSlide]);
const close = () => {
setIsVisible(false);
setTimeout(() => {
setIsOpen(false);
setSelectedIndex(0);
}, 300);
};
const goToTutorial = (tutorialTitle: string) => {
if (!emblaApi) return;
const index = startSlides[tutorialTitle];
// Jump to next starting slide then transition to actual tutorial
emblaApi.scrollTo(index, true);
emblaApi.scrollTo(index + 1);
};
useEffect(() => {
if (isOpen) {
// slight delay to trigger animation
setTimeout(() => setIsVisible(true), 10);
}
}, [isOpen]);
useEffect(() => {
if (!emblaApi) return;
emblaApi.on("select", () => setSelectedIndex(emblaApi.selectedScrollSnap()));
}, [emblaApi]);
return (
<div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-(--header-height) flex items-center justify-center z-40">
<div
onClick={close}
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${
isVisible ? "opacity-100" : "opacity-0"
}`}
/>
<div
className={`z-50 bg-orange-50 border-2 border-amber-500 rounded-2xl shadow-lg w-full max-w-md h-120 transition-discrete duration-300 flex flex-col ${
isVisible ? "scale-100 opacity-100" : "scale-75 opacity-0"
}`}
>
<div className="flex justify-between items-center mb-2 p-6 pb-0">
<h2 className="text-xl font-bold">Tutorial</h2>
<button onClick={close} aria-label="Close" className="text-red-400 hover:text-red-500 text-2xl cursor-pointer">
<Icon icon="material-symbols:close-rounded" />
</button>
</div>
<div className="flex flex-col min-h-0 h-full">
<div className="overflow-hidden h-full" ref={emblaRef}>
<div className="flex h-full">
{slides.map((slide, index) => (
<div key={index} className={`shrink-0 flex flex-col w-full px-6 ${slide.type === "start" && "py-6"}`}>
{slide.type === "start" ? (
<>
{/* Separator */}
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mb-2">
<hr className="grow border-zinc-300" />
<span>Pick a tutorial</span>
<hr className="grow border-zinc-300" />
</div>
<div className="grid grid-cols-2 gap-4 h-full">
{tutorials.map((tutorial, tutorialIndex) => (
<button
key={tutorialIndex}
onClick={() => goToTutorial(tutorial.title)}
aria-label={tutorial.title + " tutorial"}
className="flex flex-col justify-center items-center bg-zinc-50 rounded-xl p-4 shadow-md border-2 border-zinc-300 cursor-pointer text-center text-sm transition hover:scale-[1.03] hover:bg-cyan-100 hover:border-cyan-600"
>
<Image
src={tutorial.thumbnail!}
alt="tutorial thumbnail"
width={128}
height={128}
className="rounded-lg border-2 border-zinc-300"
/>
<p className="mt-2">{tutorial.title}</p>
{/* Set opacity to 0 to keep height the same with other tutorials */}
<p className={`text-[0.65rem] text-zinc-400 ${!tutorial.hint && "opacity-0"}`}>{tutorial.hint || "placeholder"}</p>
</button>
))}
</div>
</>
) : slide.type === "finish" ? (
<div className="h-full flex flex-col justify-center items-center">
<Icon icon="fxemoji:partypopper" className="text-9xl" />
<h1 className="font-medium text-xl mt-6 animate-bounce">Yatta! You did it!</h1>
</div>
) : (
<>
<p className="text-sm text-zinc-500 mb-2 text-center">{slide.text}</p>
<Image
src={slide.imageSrc ?? "/missing.svg"}
alt="step image"
width={396}
height={320}
loading="eager"
className="rounded-lg w-full h-full object-contain bg-black flex-1"
/>
</>
)}
</div>
))}
</div>
</div>
{/* Arrows */}
<div className={`flex justify-between items-center mt-2 px-6 pb-6 transition-opacity duration-300 ${isStartingPage && "opacity-0"}`}>
<button
onClick={() => emblaApi?.scrollPrev()}
disabled={isStartingPage}
className={`pill button p-1! aspect-square text-2xl ${isStartingPage && "cursor-auto!"}`}
aria-label="Scroll Carousel Left"
>
<Icon icon="tabler:chevron-left" />
</button>
{/* Only show tutorial name on step slides */}
<span
className={`text-sm transition-opacity duration-300 ${
(currentSlide.type === "finish" || currentSlide.type === "start") && "opacity-0"
}`}
>
{currentSlide?.tutorialTitle}
</span>
<button
onClick={() => emblaApi?.scrollNext()}
disabled={isStartingPage}
className={`pill button p-1! aspect-square text-2xl ${isStartingPage && "cursor-auto!"}`}
aria-label="Scroll Carousel Right"
>
<Icon icon="tabler:chevron-right" />
</button>
</div>
</div>
</div>
</div>
);
}

View file

@ -1,59 +0,0 @@
"use client";
import Image from "next/image";
import { Icon } from "@iconify/react";
import { useEffect } from "react";
import confetti from "canvas-confetti";
interface Props {
text?: string;
imageSrc?: string;
carouselIndex?: number;
finishIndex?: number;
}
export default function TutorialPage({ text, imageSrc, carouselIndex, finishIndex }: Props) {
useEffect(() => {
if (carouselIndex !== finishIndex || !carouselIndex || !finishIndex) return;
const defaults = { startVelocity: 30, spread: 360, ticks: 120, zIndex: 50 };
const randomInRange = (min: number, max: number) => Math.random() * (max - min) + min;
setTimeout(() => {
confetti({
...defaults,
particleCount: 500,
origin: { x: randomInRange(0.1, 0.3), y: Math.random() - 0.2 },
});
confetti({
...defaults,
particleCount: 500,
origin: { x: randomInRange(0.7, 0.9), y: Math.random() - 0.2 },
});
}, 300);
}, [carouselIndex, finishIndex]);
return (
<div className="flex-shrink-0 flex flex-col w-full px-6">
{!finishIndex ? (
<>
<p className="text-sm text-zinc-500 mb-2 text-center">{text}</p>
<Image
src={imageSrc ?? "/missing.svg"}
alt="step image"
width={396}
height={320}
className="rounded-lg w-full h-full object-contain bg-black flex-1"
/>
</>
) : (
<div className="h-full flex flex-col justify-center items-center">
<Icon icon="fxemoji:partypopper" className="text-9xl" />
<h1 className="font-medium text-xl mt-6 animate-bounce">Yatta! You did it!</h1>
</div>
)}
</div>
);
}

View file

@ -1,62 +0,0 @@
import Image from "next/image";
import { UseEmblaCarouselType } from "embla-carousel-react";
interface Props {
emblaApi: UseEmblaCarouselType[1] | undefined;
isSwitch?: boolean;
}
export default function StartingPage({ emblaApi, isSwitch }: Props) {
const goToTutorial = (index: number) => {
if (!emblaApi) return;
emblaApi.scrollTo(index - 1, true);
emblaApi.scrollTo(index);
};
return (
<div className="flex-shrink-0 flex flex-col w-full px-6 py-6">
{/* Separator */}
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mb-2">
<hr className="flex-grow border-zinc-300" />
<span>Pick a tutorial</span>
<hr className="flex-grow border-zinc-300" />
</div>
<div className="grid grid-cols-2 gap-4 h-full">
<button
onClick={() => goToTutorial(1)}
aria-label="Allow Copying Tutorial"
className="flex flex-col justify-center items-center bg-zinc-50 rounded-xl p-4 shadow-md border-2 border-zinc-300 cursor-pointer text-center text-sm transition hover:scale-[1.03] hover:bg-cyan-100 hover:border-cyan-600"
>
<Image
src={`/tutorial/${isSwitch ? "switch" : "3ds"}/allow-copying/thumbnail.png`}
alt="Allow Copying thumbnail"
width={128}
height={128}
className="rounded-lg border-2 border-zinc-300"
/>
<p className="mt-2">Allow Copying</p>
<p className="text-[0.65rem] text-zinc-400">Suggested!</p>
</button>
<button
onClick={() => goToTutorial(10)}
aria-label="Create QR Code Tutorial"
className="flex flex-col justify-center items-center bg-zinc-50 rounded-xl p-4 shadow-md border-2 border-zinc-300 cursor-pointer text-center text-sm transition hover:scale-[1.03] hover:bg-cyan-100 hover:border-cyan-600"
>
<Image
src={`/tutorial/${isSwitch ? "switch" : "3ds"}/create-qr-code/thumbnail.png`}
alt="Creating QR code thumbnail"
width={128}
height={128}
className="rounded-lg border-2 border-zinc-300"
/>
<p className="mt-2">Create QR Code</p>
{/* Add placeholder to keep height the same */}
<p className="text-[0.65rem] opacity-0">placeholder</p>
</button>
</div>
</div>
);
}

View file

@ -1,102 +1,59 @@
"use client";
import { useEffect, useState } from "react";
import { useState } from "react";
import { createPortal } from "react-dom";
import useEmblaCarousel from "embla-carousel-react";
import { Icon } from "@iconify/react";
import Tutorial from ".";
import TutorialPage from "./page";
export default function SwitchScanTutorialButton() {
export default function ThreeDsScanTutorialButton() {
const [isOpen, setIsOpen] = useState(false);
const [isVisible, setIsVisible] = useState(false);
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true });
const [selectedIndex, setSelectedIndex] = useState(0);
const close = () => {
setIsVisible(false);
setTimeout(() => {
setIsOpen(false);
setSelectedIndex(0);
}, 300);
};
useEffect(() => {
if (isOpen) {
// slight delay to trigger animation
setTimeout(() => setIsVisible(true), 10);
}
}, [isOpen]);
useEffect(() => {
if (!emblaApi) return;
emblaApi.on("select", () => setSelectedIndex(emblaApi.selectedScrollSnap()));
}, [emblaApi]);
return (
<>
<button aria-label="Tutorial" type="button" onClick={() => setIsOpen(true)} className="text-3xl cursor-pointer">
<button
aria-label="Tutorial"
type="button"
onClick={() => setIsOpen(true)}
className="text-3xl cursor-pointer"
>
<Icon icon="fa:question-circle" />
<span>Tutorial</span>
</button>
{isOpen &&
createPortal(
<div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-[var(--header-height)] flex items-center justify-center z-40">
<div
onClick={close}
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${
isVisible ? "opacity-100" : "opacity-0"
}`}
/>
<div
className={`z-50 bg-orange-50 border-2 border-amber-500 rounded-2xl shadow-lg w-full max-w-md h-[30rem] transition-discrete duration-300 flex flex-col ${
isVisible ? "scale-100 opacity-100" : "scale-75 opacity-0"
}`}
>
<div className="flex justify-between items-center mb-2 p-6 pb-0">
<h2 className="text-xl font-bold">Tutorial</h2>
<button onClick={close} aria-label="Close" className="text-red-400 hover:text-red-500 text-2xl cursor-pointer">
<Icon icon="material-symbols:close-rounded" />
</button>
</div>
<div className="flex flex-col min-h-0 h-full">
<div className="overflow-hidden h-full" ref={emblaRef}>
<div className="flex h-full">
<TutorialPage text="1. Enter the town hall" imageSrc="/tutorial/switch/step1.png" />
<TutorialPage text="2. Go into 'QR Code'" imageSrc="/tutorial/switch/adding-mii/step2.png" />
<TutorialPage text="3. Press 'Scan QR Code'" imageSrc="/tutorial/switch/adding-mii/step3.png" />
<TutorialPage text="4. Click on the QR code below the Mii's image" imageSrc="/tutorial/switch/adding-mii/step4.png" />
<TutorialPage text="5. Scan with your 3DS" imageSrc="/tutorial/switch/adding-mii/step5.png" />
<TutorialPage carouselIndex={selectedIndex} finishIndex={5} />
</div>
</div>
<div className="flex justify-between items-center mt-2 px-6 pb-6">
<button
onClick={() => emblaApi?.scrollPrev()}
aria-label="Scroll Carousel Left"
className="pill button !p-1 aspect-square text-2xl"
>
<Icon icon="tabler:chevron-left" />
</button>
<span className="text-sm">Adding Mii to Island</span>
<button
onClick={() => emblaApi?.scrollNext()}
aria-label="Scroll Carousel Right"
className="pill button !p-1 aspect-square text-2xl"
>
<Icon icon="tabler:chevron-right" />
</button>
</div>
</div>
</div>
</div>,
<Tutorial
tutorials={[
{
title: "Adding Mii",
steps: [
{
text: "1. Enter the town hall",
imageSrc: "/tutorial/switch/step1.png",
},
{
text: "2. Go into 'QR Code'",
imageSrc: "/tutorial/switch/adding-mii/step2.png",
},
{
text: "3. Press 'Scan QR Code'",
imageSrc: "/tutorial/switch/adding-mii/step3.png",
},
{
text: "4. Click on the QR code below the Mii's image",
imageSrc: "/tutorial/switch/adding-mii/step4.png",
},
{
text: "5. Scan with your 3DS",
imageSrc: "/tutorial/switch/adding-mii/step5.png",
},
{ type: "finish" },
],
},
]}
isOpen={isOpen}
setIsOpen={setIsOpen}
/>,
document.body
)}
</>

View file

@ -1,132 +1,99 @@
"use client";
import { useEffect, useState } from "react";
import { useState } from "react";
import { createPortal } from "react-dom";
import useEmblaCarousel from "embla-carousel-react";
import { Icon } from "@iconify/react";
import Tutorial from ".";
import TutorialPage from "./page";
import StartingPage from "./starting-page";
export default function SwitchSubmitTutorialButton() {
export default function SubmitTutorialButton() {
const [isOpen, setIsOpen] = useState(false);
const [isVisible, setIsVisible] = useState(false);
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true });
const [selectedIndex, setSelectedIndex] = useState(0);
const close = () => {
setIsVisible(false);
setTimeout(() => {
setIsOpen(false);
setSelectedIndex(0);
}, 300);
};
useEffect(() => {
if (isOpen) {
// slight delay to trigger animation
setTimeout(() => setIsVisible(true), 10);
}
}, [isOpen]);
useEffect(() => {
if (!emblaApi) return;
emblaApi.on("select", () => setSelectedIndex(emblaApi.selectedScrollSnap()));
}, [emblaApi]);
const isStartingPage = selectedIndex === 0 || selectedIndex === 9;
const inTutorialAllowCopying = selectedIndex && selectedIndex >= 1 && selectedIndex <= 9;
return (
<>
<button type="button" onClick={() => setIsOpen(true)} className="text-sm text-orange-400 cursor-pointer underline-offset-2 hover:underline">
<button
type="button"
onClick={() => setIsOpen(true)}
className="text-sm text-orange-400 cursor-pointer underline-offset-2 hover:underline"
>
How to?
</button>
{isOpen &&
createPortal(
<div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-[var(--header-height)] flex items-center justify-center z-40">
<div
onClick={close}
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${
isVisible ? "opacity-100" : "opacity-0"
}`}
/>
<div
className={`z-50 bg-orange-50 border-2 border-amber-500 rounded-2xl shadow-lg w-full max-w-md h-[30rem] transition-discrete duration-300 flex flex-col ${
isVisible ? "scale-100 opacity-100" : "scale-75 opacity-0"
}`}
>
<div className="flex justify-between items-center mb-2 p-6 pb-0">
<h2 className="text-xl font-bold">Tutorial</h2>
<button onClick={close} aria-label="Close" className="text-red-400 hover:text-red-500 text-2xl cursor-pointer">
<Icon icon="material-symbols:close-rounded" />
</button>
</div>
<div className="flex flex-col min-h-0 h-full">
<div className="overflow-hidden h-full" ref={emblaRef}>
<div className="flex h-full">
<StartingPage isSwitch emblaApi={emblaApi} />
{/* Allow Copying */}
<TutorialPage text="1. Enter the town hall" imageSrc="/tutorial/switch/step1.png" />
<TutorialPage text="2. Go into 'Mii List'" imageSrc="/tutorial/switch/allow-copying/step2.png" />
<TutorialPage text="3. Select and edit the Mii you wish to submit" imageSrc="/tutorial/switch/allow-copying/step3.png" />
<TutorialPage text="4. Click 'Other Settings' in the information screen" imageSrc="/tutorial/switch/allow-copying/step4.png" />
<TutorialPage text="5. Click on 'Don't Allow' under the 'Copying' text" imageSrc="/tutorial/switch/allow-copying/step5.png" />
<TutorialPage text="6. Press 'Allow'" imageSrc="/tutorial/switch/allow-copying/step6.png" />
<TutorialPage text="7. Confirm the edits to the Mii" imageSrc="/tutorial/switch/allow-copying/step7.png" />
<TutorialPage carouselIndex={selectedIndex} finishIndex={8} />
<StartingPage emblaApi={emblaApi} />
{/* Create QR Code */}
<TutorialPage text="1. Enter the town hall" imageSrc="/tutorial/switch/step1.png" />
<TutorialPage text="2. Go into 'QR Code'" imageSrc="/tutorial/switch/create-qr-code/step2.png" />
<TutorialPage text="3. Press 'Create QR Code'" imageSrc="/tutorial/switch/create-qr-code/step3.png" />
<TutorialPage
text="4. Select and press 'OK' on the Mii you wish to submit"
imageSrc="/tutorial/switch/create-qr-code/step4.png"
/>
<TutorialPage
text="5. Pick any option; it doesn't matter since the QR code regenerates upon submission."
imageSrc="/tutorial/switch/create-qr-code/step5.png"
/>
<TutorialPage
text="6. Exit the tutorial; Upload the QR code (scan with camera or upload file through SD card)."
imageSrc="/tutorial/switch/create-qr-code/step6.png"
/>
<TutorialPage carouselIndex={selectedIndex} finishIndex={16} />
</div>
</div>
<div className={`flex justify-between items-center mt-2 px-6 pb-6 transition-opacity duration-300 ${isStartingPage && "opacity-0"}`}>
<button
onClick={() => emblaApi?.scrollPrev()}
disabled={isStartingPage}
className={`pill button !p-1 aspect-square text-2xl ${isStartingPage && "!cursor-auto"}`}
aria-label="Scroll Carousel Left"
>
<Icon icon="tabler:chevron-left" />
</button>
<span className="text-sm">{inTutorialAllowCopying ? "Allow Copying" : "Create QR Code"}</span>
<button
onClick={() => emblaApi?.scrollNext()}
disabled={isStartingPage}
className={`pill button !p-1 aspect-square text-2xl ${isStartingPage && "!cursor-auto"}`}
aria-label="Scroll Carousel Right"
>
<Icon icon="tabler:chevron-right" />
</button>
</div>
</div>
</div>
</div>,
<Tutorial
tutorials={[
{
title: "Allow Copying",
thumbnail: "/tutorial/switch/allow-copying/thumbnail.png",
hint: "Suggested!",
steps: [
{ type: "start" },
{
text: "1. Enter the town hall",
imageSrc: "/tutorial/switch/step1.png",
},
{
text: "2. Go into 'Mii List'",
imageSrc: "/tutorial/switch/allow-copying/step2.png",
},
{
text: "3. Select and edit the Mii you wish to submit",
imageSrc: "/tutorial/switch/allow-copying/step3.png",
},
{
text: "4. Click 'Other Settings' in the information screen",
imageSrc: "/tutorial/switch/allow-copying/step4.png",
},
{
text: "5. Click on 'Don't Allow' under the 'Copying' text",
imageSrc: "/tutorial/switch/allow-copying/step5.png",
},
{
text: "6. Press 'Allow'",
imageSrc: "/tutorial/switch/allow-copying/step6.png",
},
{
text: "7. Confirm the edits to the Mii",
imageSrc: "/tutorial/switch/allow-copying/step7.png",
},
{ type: "finish" },
],
},
{
title: "Create QR Code",
thumbnail: "/tutorial/switch/create-qr-code/thumbnail.png",
steps: [
{ type: "start" },
{
text: "1. Enter the town hall",
imageSrc: "/tutorial/switch/step1.png",
},
{
text: "2. Go into 'QR Code'",
imageSrc: "/tutorial/switch/create-qr-code/step2.png",
},
{
text: "3. Press 'Create QR Code'",
imageSrc: "/tutorial/switch/create-qr-code/step3.png",
},
{
text: "4. Select and press 'OK' on the Mii you wish to submit",
imageSrc: "/tutorial/switch/create-qr-code/step4.png",
},
{
text: "5. Pick any option; it doesn't matter since the QR code regenerates upon submission.",
imageSrc: "/tutorial/switch/create-qr-code/step5.png",
},
{
text: "6. Exit the tutorial; Upload the QR code (scan with camera or upload file through SD card).",
imageSrc: "/tutorial/switch/create-qr-code/step6.png",
},
{ type: "finish" },
],
},
]}
isOpen={isOpen}
setIsOpen={setIsOpen}
/>,
document.body
)}
</>

View file

@ -14,7 +14,7 @@ import satori, { Font } from "satori";
import { Mii } from "@prisma/client";
const MIN_IMAGE_DIMENSIONS = [320, 240];
const MIN_IMAGE_DIMENSIONS = [128, 128];
const MAX_IMAGE_DIMENSIONS = [1920, 1080];
const MAX_IMAGE_SIZE = 4 * 1024 * 1024; // 4 MB
const ALLOWED_MIME_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"];
@ -22,7 +22,11 @@ const ALLOWED_MIME_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"
//#region Image validation
export async function validateImage(file: File): Promise<{ valid: boolean; error?: string; status?: number }> {
if (!file || file.size == 0) return { valid: false, error: "Empty image file" };
if (file.size > MAX_IMAGE_SIZE) return { valid: false, error: `Image too large. Maximum size is ${MAX_IMAGE_SIZE / (1024 * 1024)}MB` };
if (file.size > MAX_IMAGE_SIZE)
return {
valid: false,
error: `Image too large. Maximum size is ${MAX_IMAGE_SIZE / (1024 * 1024)}MB`,
};
try {
const buffer = Buffer.from(await file.arrayBuffer());
@ -30,7 +34,10 @@ export async function validateImage(file: File): Promise<{ valid: boolean; error
// Check mime type
const fileType = await fileTypeFromBuffer(buffer);
if (!fileType || !ALLOWED_MIME_TYPES.includes(fileType.mime))
return { valid: false, error: "Invalid image file type. Only .jpeg, .png, .gif, and .webp are allowed" };
return {
valid: false,
error: "Invalid image file type. Only .jpeg, .png, .gif, and .webp are allowed",
};
let metadata: sharp.Metadata;
try {
@ -48,7 +55,10 @@ 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 320x240 and 1920x1080" };
return {
valid: false,
error: "Image dimensions are invalid. Resolution must be between 128x128 and 1920x1080",
};
}
// Check for inappropriate content
@ -62,7 +72,11 @@ export async function validateImage(file: File): Promise<{ valid: boolean; error
if (!moderationResponse.ok) {
console.error("Moderation API error");
return { valid: false, error: "Content moderation check failed", status: 500 };
return {
valid: false,
error: "Content moderation check failed",
status: 500,
};
}
const result = await moderationResponse.json();
@ -77,7 +91,11 @@ export async function validateImage(file: File): Promise<{ valid: boolean; error
return { valid: true };
} catch (error) {
console.error("Error validating image:", error);
return { valid: false, error: "Failed to process image file.", status: 500 };
return {
valid: false,
error: "Failed to process image file.",
status: 500,
};
}
}
//#endregion
@ -146,13 +164,21 @@ export async function generateMetadataImage(mii: Mii, author: string): Promise<B
<div tw="w-full h-full bg-amber-50 border-2 border-amber-500 rounded-2xl p-4 flex flex-col">
<div tw="flex w-full">
{/* Mii image */}
<div tw="w-80 h-62 rounded-xl flex justify-center mr-2 px-2" style={{ backgroundImage: "linear-gradient(to bottom, #fef3c7, #fde68a);" }}>
<div
tw="w-80 h-62 rounded-xl flex justify-center mr-2 px-2"
style={{
backgroundImage: "linear-gradient(to bottom, #fef3c7, #fde68a);",
}}
>
<img
src={miiImage}
width={248}
height={248}
tw="w-full h-full"
style={{ objectFit: "contain", filter: "drop-shadow(0 10px 8px #00000024) drop-shadow(0 4px 3px #00000024)" }}
style={{
objectFit: "contain",
filter: "drop-shadow(0 10px 8px #00000024) drop-shadow(0 4px 3px #00000024)",
}}
/>
</div>
@ -168,22 +194,35 @@ export async function generateMetadataImage(mii: Mii, author: string): Promise<B
{mii.name}
</span>
{/* Tags */}
<div id="tags" tw="flex flex-wrap mt-1 w-full">
{mii.tags.map((tag) => (
<span key={tag} tw="mr-1 px-2 py-1 bg-orange-300 rounded-full text-sm">
{tag}
</span>
))}
<div id="tags" tw="relative flex mt-1 w-full overflow-hidden">
<div tw="flex">
{mii.tags.map((tag) => (
<span key={tag} tw="mr-1 px-2 py-1 bg-orange-300 rounded-full text-sm shrink-0">
{tag}
</span>
))}
</div>
<div
tw="absolute inset-0"
style={{
position: "absolute",
backgroundImage: "linear-gradient(to right, #fffbeb00 70%, #fffbeb);",
}}
></div>
</div>
{/* Author */}
<div tw="flex text-sm mt-2">
By: <span tw="ml-1.5 font-semibold">@{author}</span>
<div tw="flex mt-2 text-sm w-1/2">
By{" "}
<span tw="ml-1.5 font-semibold overflow-hidden" style={{ textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{author}
</span>
</div>
{/* Watermark */}
<div tw="absolute bottom-0 right-0 flex items-center">
<img src={`${process.env.NEXT_PUBLIC_BASE_URL}/logo.svg`} height={34} />
<img src={`${process.env.NEXT_PUBLIC_BASE_URL}/logo.svg`} height={32} />
{/* I tried using text-orange-400 but it wasn't correct..? */}
<span tw="ml-2 font-black text-xl" style={{ color: "#FF8904" }}>
TomodachiShare

View file

@ -1,3 +1,4 @@
import { MiiGender, MiiPlatform } from "@prisma/client";
import { z } from "zod";
// profanity censoring bypasses the regex in some of these but I think it's funny
@ -26,7 +27,7 @@ export const tagsSchema = z
z
.string()
.min(2, { error: "Tags must be at least 2 characters long" })
.max(64, { error: "Tags cannot be more than 20 characters long" })
.max(20, { error: "Tags cannot be more than 20 characters long" })
.regex(/^[a-z0-9-_]+$/, {
error: "Tags can only contain lowercase letters, numbers, dashes, and underscores.",
})
@ -39,6 +40,37 @@ export const idSchema = z.coerce
.int({ error: "ID must be an integer" })
.positive({ error: "ID must be valid" });
export const searchSchema = z.object({
q: querySchema.optional(),
sort: z.enum(["likes", "newest", "oldest", "random"], { error: "Sort must be either 'likes', 'newest', 'oldest', or 'random'" }).default("newest"),
tags: z
.string()
.optional()
.transform((value) =>
value
?.split(",")
.map((tag) => tag.trim())
.filter((tag) => tag.length > 0)
),
platform: z.enum(MiiPlatform, { error: "Platform must be either 'THREE_DS', or 'SWITCH'" }).optional(),
gender: z.enum(MiiGender, { error: "Gender must be either 'MALE', or 'FEMALE'" }).optional(),
// todo: incorporate tagsSchema
// Pages
limit: z.coerce
.number({ error: "Limit must be a number" })
.int({ error: "Limit must be an integer" })
.min(1, { error: "Limit must be at least 1" })
.max(100, { error: "Limit cannot be more than 100" })
.optional(),
page: z.coerce
.number({ error: "Page must be a number" })
.int({ error: "Page must be an integer" })
.min(1, { error: "Page must be at least 1" })
.optional(),
// Random sort
seed: z.coerce.number({ error: "Seed must be a number" }).int({ error: "Seed must be an integer" }).optional(),
});
// Account Info
export const usernameSchema = z
.string()

10
src/types.d.ts vendored
View file

@ -12,13 +12,3 @@ declare module "next-auth" {
username?: string;
}
}
type MiiWithUsername = Prisma.MiiGetPayload<{
include: {
user: {
select: {
username: true;
};
};
};
}>;