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

@ -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
)}
</>