mirror of
https://github.com/trafficlunar/tomodachi-share.git
synced 2026-06-28 14:44:15 +00:00
feat: view instructions on mii page
This commit is contained in:
parent
5995afe3db
commit
13941e849c
23 changed files with 625 additions and 311 deletions
38
src/components/mii/dating-preferences.tsx
Normal file
38
src/components/mii/dating-preferences.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { ChangeEvent } from "react";
|
||||
import { MiiGender } from "@prisma/client";
|
||||
import { SwitchMiiInstructions } from "@/types";
|
||||
|
||||
interface Props {
|
||||
data: SwitchMiiInstructions["datingPreferences"];
|
||||
onChecked?: (e: ChangeEvent<HTMLInputElement, HTMLInputElement>, gender: MiiGender) => void;
|
||||
}
|
||||
|
||||
const DATING_PREFERENCES = ["Male", "Female", "Nonbinary"];
|
||||
|
||||
export default function DatingPreferencesViewer({ data, onChecked }: Props) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{DATING_PREFERENCES.map((gender) => {
|
||||
const genderEnum = gender.toUpperCase() as MiiGender;
|
||||
|
||||
return (
|
||||
<div className="flex gap-1.5">
|
||||
<input
|
||||
key={gender}
|
||||
type="checkbox"
|
||||
id={gender}
|
||||
className="checkbox"
|
||||
checked={data.includes(genderEnum)}
|
||||
onChange={(e) => {
|
||||
if (onChecked) onChecked(e, genderEnum);
|
||||
}}
|
||||
/>
|
||||
<label htmlFor={gender} className="text-sm select-none">
|
||||
{gender}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
112
src/components/mii/delete-mii-button.tsx
Normal file
112
src/components/mii/delete-mii-button.tsx
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Icon } from "@iconify/react";
|
||||
|
||||
import LikeButton from "../like-button";
|
||||
import SubmitButton from "../submit-button";
|
||||
|
||||
interface Props {
|
||||
miiId: number;
|
||||
miiName: string;
|
||||
likes: number;
|
||||
inMiiPage?: boolean;
|
||||
}
|
||||
|
||||
export default function DeleteMiiButton({ miiId, miiName, likes, inMiiPage }: Props) {
|
||||
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/mii/${miiId}/delete`, { method: "DELETE" });
|
||||
if (!response.ok) {
|
||||
const { error } = await response.json();
|
||||
setError(error);
|
||||
return;
|
||||
}
|
||||
|
||||
close();
|
||||
window.location.reload(); // I would use router.refresh() here but the Mii list doesn't update
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
setIsVisible(false);
|
||||
setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
// slight delay to trigger animation
|
||||
setTimeout(() => setIsVisible(true), 10);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{inMiiPage ? (
|
||||
<button onClick={() => setIsOpen(true)} aria-label="Delete Mii" className="cursor-pointer">
|
||||
<Icon icon="mdi:trash" />
|
||||
<span>Delete</span>
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={() => setIsOpen(true)} aria-label="Delete Mii" title="Delete Mii" data-tooltip="Delete" className="cursor-pointer aspect-square">
|
||||
<Icon icon="mdi:trash" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isOpen &&
|
||||
createPortal(
|
||||
<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 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">Delete Mii</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 your Mii permanently. This action cannot be undone.</p>
|
||||
|
||||
<div className="bg-orange-100 rounded-xl border-2 border-orange-400 mt-4 flex">
|
||||
<Image src={`/mii/${miiId}/image?type=mii`} alt="mii image" width={128} height={128} />
|
||||
<div className="p-4">
|
||||
<p className="text-xl font-bold line-clamp-1" title={miiName}>
|
||||
{miiName}
|
||||
</p>
|
||||
<LikeButton likes={likes} isLiked={true} disabled />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{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} text="Delete" className="bg-red-400! border-red-500! hover:bg-red-500!" />
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
272
src/components/mii/instructions.tsx
Normal file
272
src/components/mii/instructions.tsx
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
import React from "react";
|
||||
|
||||
import DatingPreferencesViewer from "./dating-preferences";
|
||||
import VoiceViewer from "./voice-viewer";
|
||||
import PersonalityViewer from "./personality-viewer";
|
||||
|
||||
import { SwitchMiiInstructions } from "@/types";
|
||||
import { Icon } from "@iconify/react";
|
||||
import { COLORS } from "@/lib/switch";
|
||||
|
||||
interface Props {
|
||||
instructions: Partial<SwitchMiiInstructions>;
|
||||
}
|
||||
|
||||
interface SectionProps {
|
||||
name: string;
|
||||
instructions: Partial<SwitchMiiInstructions[keyof SwitchMiiInstructions]>;
|
||||
children?: React.ReactNode;
|
||||
isSubSection?: boolean;
|
||||
}
|
||||
|
||||
const ORDINAL_SUFFIXES: Record<string, string> = {
|
||||
one: "st",
|
||||
two: "nd",
|
||||
few: "rd",
|
||||
other: "th",
|
||||
};
|
||||
const ordinalRules = new Intl.PluralRules("en-US", { type: "ordinal" });
|
||||
|
||||
function GridPosition({ index, cols = 5 }: { index: number; cols?: number }) {
|
||||
const row = Math.floor(index / cols) + 1;
|
||||
const col = (index % cols) + 1;
|
||||
const rowSuffix = ORDINAL_SUFFIXES[ordinalRules.select(row)];
|
||||
const colSuffix = ORDINAL_SUFFIXES[ordinalRules.select(col)];
|
||||
|
||||
return `${row}${rowSuffix} row, ${col}${colSuffix} column`;
|
||||
}
|
||||
|
||||
function ColorPosition({ color }: { color: number }) {
|
||||
if (!color) return null;
|
||||
if (color <= 7) {
|
||||
return (
|
||||
<>
|
||||
Color menu on left, <GridPosition index={color} cols={1} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (color >= 108) {
|
||||
return (
|
||||
<>
|
||||
Outside color menu, <GridPosition index={color - 108} cols={2} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="flex items-center">
|
||||
<div className="size-5 rounded mr-1.5" style={{ backgroundColor: `#${COLORS[color]}` }}></div>
|
||||
Color menu on right, <GridPosition index={color - 8} cols={10} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface TableCellProps {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function TableCell({ label, children }: TableCellProps) {
|
||||
return (
|
||||
<tr className={"border-b border-orange-300/50 last:border-0"}>
|
||||
<td className={"py-0.5 pr-6 text-amber-700 font-semibold w-30 text-sm"}>{label}</td>
|
||||
<td className={"py-0.5 text-amber-950"}>{children}</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ name, instructions, children, isSubSection }: SectionProps) {
|
||||
if (typeof instructions !== "object") return null;
|
||||
|
||||
const type = "type" in instructions ? instructions.type : undefined;
|
||||
const color = "color" in instructions ? instructions.color : undefined;
|
||||
const height = "height" in instructions ? instructions.height : undefined;
|
||||
const distance = "distance" in instructions ? instructions.distance : undefined;
|
||||
const rotation = "rotation" in instructions ? instructions.rotation : undefined;
|
||||
const size = "size" in instructions ? instructions.size : undefined;
|
||||
const stretch = "stretch" in instructions ? instructions.stretch : undefined;
|
||||
|
||||
return (
|
||||
<div className={`p-3 ${isSubSection ? "mt-2" : "border-l-4 border-amber-400 bg-amber-100/50 rounded-r-lg py-2.5"}`}>
|
||||
<h3 className="font-semibold text-xl text-amber-800 mb-1">{name}</h3>
|
||||
|
||||
<table className="w-full">
|
||||
<tbody>
|
||||
{type && (
|
||||
<TableCell label="Type">
|
||||
<GridPosition index={type} />
|
||||
</TableCell>
|
||||
)}
|
||||
{color && (
|
||||
<TableCell label="Color">
|
||||
<ColorPosition color={color} />
|
||||
</TableCell>
|
||||
)}
|
||||
{height && <TableCell label="Height">{height}</TableCell>}
|
||||
{distance && <TableCell label="Distance">{distance}</TableCell>}
|
||||
{rotation && <TableCell label="Rotation">{rotation}</TableCell>}
|
||||
{size && <TableCell label="Size">{size}</TableCell>}
|
||||
{stretch && <TableCell label="Stretch">{stretch}</TableCell>}
|
||||
|
||||
{children}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MiiInstructions({ instructions }: Props) {
|
||||
if (Object.keys(instructions).length === 0) return null;
|
||||
const { head, hair, eyebrows, eyes, nose, lips, ears, glasses, other, height, weight, datingPreferences, voice, personality } = instructions;
|
||||
|
||||
return (
|
||||
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex flex-col gap-3 max-h-96 overflow-y-auto">
|
||||
<h2 className="text-xl font-semibold text-amber-700 flex items-center gap-2">
|
||||
<Icon icon="fa7-solid:list" />
|
||||
Instructions
|
||||
</h2>
|
||||
|
||||
{head && <Section name="Head" instructions={head}></Section>}
|
||||
{hair && (
|
||||
<Section name="Hair" instructions={hair}>
|
||||
{hair.setType && (
|
||||
<TableCell label="Set Type">
|
||||
<GridPosition index={hair.setType} />
|
||||
</TableCell>
|
||||
)}
|
||||
{hair.bangsType && (
|
||||
<TableCell label="Bangs Type">
|
||||
<GridPosition index={hair.bangsType} />
|
||||
</TableCell>
|
||||
)}
|
||||
{hair.backType && (
|
||||
<TableCell label="Back Type">
|
||||
<GridPosition index={hair.backType} />
|
||||
</TableCell>
|
||||
)}
|
||||
{hair.subColor && (
|
||||
<TableCell label="Sub Color">
|
||||
<ColorPosition color={hair.subColor} />
|
||||
</TableCell>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
{eyebrows && <Section name="Eyebrows" instructions={eyebrows}></Section>}
|
||||
{eyes && (
|
||||
<Section name="Eyes" instructions={eyes}>
|
||||
{eyes.eyesType && (
|
||||
<TableCell label="Eyes Type">
|
||||
<GridPosition index={eyes.eyesType} />
|
||||
</TableCell>
|
||||
)}
|
||||
{eyes.eyelashesTop && (
|
||||
<TableCell label="Eyelashes Top Type">
|
||||
<GridPosition index={eyes.eyelashesTop} />
|
||||
</TableCell>
|
||||
)}
|
||||
{eyes.eyelashesBottom && (
|
||||
<TableCell label="Eyelashes Bottom Type">
|
||||
<GridPosition index={eyes.eyelashesBottom} />
|
||||
</TableCell>
|
||||
)}
|
||||
{eyes.eyelidTop && (
|
||||
<TableCell label="Eyelid Top Type">
|
||||
<GridPosition index={eyes.eyelidTop} />
|
||||
</TableCell>
|
||||
)}
|
||||
{eyes.eyelidBottom && (
|
||||
<TableCell label="Eyelid Bottom Type">
|
||||
<GridPosition index={eyes.eyelidBottom} />
|
||||
</TableCell>
|
||||
)}
|
||||
{eyes.eyeliner && (
|
||||
<TableCell label="Eyeliner Type">
|
||||
<GridPosition index={eyes.eyeliner} />
|
||||
</TableCell>
|
||||
)}
|
||||
{eyes.pupil && (
|
||||
<TableCell label="Pupil Type">
|
||||
<GridPosition index={eyes.pupil} />
|
||||
</TableCell>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
{nose && <Section name="Nose" instructions={nose}></Section>}
|
||||
{lips && <Section name="Lips" instructions={lips}></Section>}
|
||||
{ears && <Section name="Ears" instructions={ears}></Section>}
|
||||
{glasses && (
|
||||
<Section name="Glasses" instructions={glasses}>
|
||||
{glasses.ringColor && (
|
||||
<TableCell label="Ring Color">
|
||||
<ColorPosition color={glasses.ringColor} />
|
||||
</TableCell>
|
||||
)}
|
||||
{glasses.shadesColor && (
|
||||
<TableCell label="Shades Color">
|
||||
<ColorPosition color={glasses.shadesColor} />
|
||||
</TableCell>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
{other && (
|
||||
<Section name="Other" instructions={other}>
|
||||
<Section isSubSection name="Wrinkles 1" instructions={other.wrinkles1} />
|
||||
<Section isSubSection name="Wrinkles 2" instructions={other.wrinkles2} />
|
||||
<Section isSubSection name="Beard" instructions={other.beard} />
|
||||
<Section isSubSection name="Moustache" instructions={other.moustache} />
|
||||
<Section isSubSection name="Goatee" instructions={other.goatee} />
|
||||
<Section isSubSection name="Mole" instructions={other.mole} />
|
||||
<Section isSubSection name="Eye Shadow" instructions={other.eyeShadow} />
|
||||
<Section isSubSection name="Blush" instructions={other.blush} />
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{(height || weight || datingPreferences || voice || personality) && (
|
||||
<div className="pl-3 text-sm border-l-4 border-amber-400 bg-amber-100/50 rounded-r-lg py-2.5 text-amber-950">
|
||||
<h3 className="font-semibold text-xl text-amber-800 mb-1">Misc</h3>
|
||||
|
||||
{height && (
|
||||
<div className="flex mb-1">
|
||||
<label htmlFor="height" className="w-16">
|
||||
Height
|
||||
</label>
|
||||
<input id="height" type="range" min={0} max={100} step={1} disabled value={height} />
|
||||
</div>
|
||||
)}
|
||||
{weight && (
|
||||
<div className="flex">
|
||||
<label htmlFor="weight" className="w-16">
|
||||
Weight
|
||||
</label>
|
||||
<input id="weight" type="range" min={0} max={100} step={1} disabled value={weight} />
|
||||
</div>
|
||||
)}
|
||||
{datingPreferences && (
|
||||
<div className="pl-2">
|
||||
<h4 className="text-lg font-semibold mt-4">Dating Preferences</h4>
|
||||
<div className="w-min">
|
||||
<DatingPreferencesViewer data={datingPreferences} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{voice && (
|
||||
<div className="pl-2">
|
||||
<h4 className="font-semibold text-xl text-amber-800 mb-1 mt-4">Voice</h4>
|
||||
<div className="w-min">
|
||||
<VoiceViewer data={voice} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{personality && (
|
||||
<div className="pl-2">
|
||||
<h4 className="font-semibold text-xl text-amber-800 mb-1 mt-4">Personality</h4>
|
||||
<div className="w-min">
|
||||
<PersonalityViewer data={personality} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
131
src/components/mii/list/filter-menu.tsx
Normal file
131
src/components/mii/list/filter-menu.tsx
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
"use client";
|
||||
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Icon } from "@iconify/react";
|
||||
|
||||
import { MiiGender, MiiPlatform } from "@prisma/client";
|
||||
|
||||
import PlatformSelect from "./platform-select";
|
||||
import TagFilter from "./tag-filter";
|
||||
import GenderSelect from "./gender-select";
|
||||
import OtherFilters from "./other-filters";
|
||||
|
||||
export default function FilterMenu() {
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
const platform = (searchParams.get("platform") as MiiPlatform) || undefined;
|
||||
const gender = (searchParams.get("gender") as MiiGender) || undefined;
|
||||
const rawTags = searchParams.get("tags") || "";
|
||||
const rawExclude = searchParams.get("exclude") || "";
|
||||
const allowCopying = (searchParams.get("allowCopying") as unknown as boolean) || false;
|
||||
|
||||
const tags = useMemo(
|
||||
() =>
|
||||
rawTags
|
||||
? rawTags
|
||||
.split(",")
|
||||
.map((tag) => tag.trim())
|
||||
.filter((tag) => tag.length > 0)
|
||||
: [],
|
||||
[rawTags],
|
||||
);
|
||||
const exclude = useMemo(
|
||||
() =>
|
||||
rawExclude
|
||||
? rawExclude
|
||||
.split(",")
|
||||
.map((tag) => tag.trim())
|
||||
.filter((tag) => tag.length > 0)
|
||||
: [],
|
||||
[rawExclude],
|
||||
);
|
||||
|
||||
const [filterCount, setFilterCount] = useState(tags.length);
|
||||
|
||||
// Filter menu button handler
|
||||
const handleClick = () => {
|
||||
if (!isOpen) {
|
||||
setIsOpen(true);
|
||||
// slight delay to trigger animation
|
||||
setTimeout(() => setIsVisible(true), 10);
|
||||
} else {
|
||||
setIsVisible(false);
|
||||
setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
}, 200);
|
||||
}
|
||||
};
|
||||
|
||||
// Count all active filters
|
||||
useEffect(() => {
|
||||
let count = tags.length + exclude.length;
|
||||
if (platform) count++;
|
||||
if (gender) count++;
|
||||
if (allowCopying) count++;
|
||||
|
||||
setFilterCount(count);
|
||||
}, [tags, exclude, platform, gender, allowCopying]);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button className="pill button gap-2" onClick={handleClick}>
|
||||
<Icon icon="mdi:filter" className="text-xl" />
|
||||
Filter
|
||||
<span className="w-5">({filterCount})</span>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div
|
||||
className={`absolute w-80 left-0 top-full mt-8 z-40 flex flex-col items-center bg-orange-50
|
||||
border-2 border-amber-500 rounded-2xl shadow-lg p-4 transition-discrete duration-200 ${isVisible ? "translate-y-0 opacity-100" : "-translate-y-2 opacity-0"}`}
|
||||
>
|
||||
{/* Arrow */}
|
||||
<div className="absolute bottom-full left-1/6 -translate-x-1/2 size-0 border-8 border-transparent border-b-amber-500"></div>
|
||||
|
||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium w-full mb-2">
|
||||
<hr className="grow border-zinc-300" />
|
||||
<span>Platform</span>
|
||||
<hr className="grow border-zinc-300" />
|
||||
</div>
|
||||
<PlatformSelect />
|
||||
|
||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium w-full mt-2 mb-1">
|
||||
<hr className="grow border-zinc-300" />
|
||||
<span>Gender</span>
|
||||
<hr className="grow border-zinc-300" />
|
||||
</div>
|
||||
<GenderSelect />
|
||||
|
||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium w-full mt-2 mb-2">
|
||||
<hr className="grow border-zinc-300" />
|
||||
<span>Tags Include</span>
|
||||
<hr className="grow border-zinc-300" />
|
||||
</div>
|
||||
<TagFilter />
|
||||
|
||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium w-full mt-2 mb-2">
|
||||
<hr className="grow border-zinc-300" />
|
||||
<span>Tags Exclude</span>
|
||||
<hr className="grow border-zinc-300" />
|
||||
</div>
|
||||
<TagFilter isExclude />
|
||||
|
||||
{platform !== "SWITCH" && (
|
||||
<>
|
||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium w-full mt-2 mb-1">
|
||||
<hr className="grow border-zinc-300" />
|
||||
<span>Other</span>
|
||||
<hr className="grow border-zinc-300" />
|
||||
</div>
|
||||
<OtherFilters />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
75
src/components/mii/list/gender-select.tsx
Normal file
75
src/components/mii/list/gender-select.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
"use client";
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useState, useTransition } from "react";
|
||||
import { Icon } from "@iconify/react";
|
||||
import { MiiGender, MiiPlatform } from "@prisma/client";
|
||||
|
||||
export default function GenderSelect() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const [selected, setSelected] = useState<MiiGender | null>((searchParams.get("gender") as MiiGender) ?? null);
|
||||
const platform = (searchParams.get("platform") as MiiPlatform) || undefined;
|
||||
|
||||
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 {
|
||||
params.delete("gender");
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
router.push(`?${params.toString()}`, { scroll: false });
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex gap-0.5 w-fit">
|
||||
<button
|
||||
onClick={() => handleClick("MALE")}
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
<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>
|
||||
|
||||
<button
|
||||
onClick={() => handleClick("FEMALE")}
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
<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>
|
||||
|
||||
{platform !== "THREE_DS" && (
|
||||
<button
|
||||
onClick={() => handleClick("NONBINARY")}
|
||||
aria-label="Filter for Nonbinary Miis"
|
||||
data-tooltip-span
|
||||
className={`cursor-pointer rounded-xl flex justify-center items-center size-13 text-5xl border-2 transition-all ${
|
||||
selected === "NONBINARY" ? "bg-purple-100 border-purple-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
|
||||
}`}
|
||||
>
|
||||
<div className="tooltip bg-purple-400! border-purple-400! before:border-b-purple-400!">Nonbinary</div>
|
||||
<Icon icon="mdi:gender-non-binary" className="text-purple-400" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
243
src/components/mii/list/index.tsx
Normal file
243
src/components/mii/list/index.tsx
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
import Link from "next/link";
|
||||
|
||||
import { MiiGender, MiiPlatform, Prisma } from "@prisma/client";
|
||||
import { Icon } from "@iconify/react";
|
||||
|
||||
import crypto from "crypto";
|
||||
import seedrandom from "seedrandom";
|
||||
|
||||
import { searchSchema } from "@/lib/schemas";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
import SortSelect from "./sort-select";
|
||||
import Carousel from "../../carousel";
|
||||
import LikeButton from "../../like-button";
|
||||
import DeleteMiiButton from "../delete-mii-button";
|
||||
import Pagination from "./pagination";
|
||||
import FilterMenu from "./filter-menu";
|
||||
|
||||
interface Props {
|
||||
searchParams: { [key: string]: string | string[] | undefined };
|
||||
userId?: number; // Profiles
|
||||
inLikesPage?: boolean; // Self-explanatory
|
||||
}
|
||||
|
||||
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, exclude, platform, gender, allowCopying, page = 1, limit = 24, seed } = parsed.data;
|
||||
|
||||
// My Likes page
|
||||
let miiIdsLiked: number[] | undefined = undefined;
|
||||
|
||||
if (inLikesPage && session?.user.id) {
|
||||
const likedMiis = await prisma.like.findMany({
|
||||
where: { userId: Number(session.user.id) },
|
||||
select: { miiId: true },
|
||||
});
|
||||
miiIdsLiked = likedMiis.map((like) => like.miiId);
|
||||
}
|
||||
|
||||
const where: Prisma.MiiWhereInput = {
|
||||
// Only show liked miis on likes page
|
||||
...(inLikesPage && miiIdsLiked && { id: { in: miiIdsLiked } }),
|
||||
// Searching
|
||||
...(query && {
|
||||
OR: [{ name: { contains: query, mode: "insensitive" } }, { tags: { has: query } }, { description: { contains: query, mode: "insensitive" } }],
|
||||
}),
|
||||
// Tag filtering
|
||||
...(tags && tags.length > 0 && { tags: { hasEvery: tags } }),
|
||||
...(exclude && exclude.length > 0 && { NOT: { tags: { hasSome: exclude } } }),
|
||||
// Platform
|
||||
...(platform && { platform: { equals: platform } }),
|
||||
// Gender
|
||||
...(gender && { gender: { equals: gender } }),
|
||||
// Allow Copying
|
||||
...(allowCopying && { allowedCopying: true }),
|
||||
// Profiles
|
||||
...(userId && { userId }),
|
||||
};
|
||||
|
||||
const select: Prisma.MiiSelect = {
|
||||
id: true,
|
||||
// Don't show when userId is specified
|
||||
...(!userId && {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
platform: true,
|
||||
name: true,
|
||||
imageCount: true,
|
||||
tags: true,
|
||||
createdAt: true,
|
||||
gender: true,
|
||||
allowedCopying: true,
|
||||
// Mii liked check
|
||||
...(session?.user?.id && {
|
||||
likedBy: {
|
||||
where: { userId: Number(session.user.id) },
|
||||
select: { userId: true },
|
||||
},
|
||||
}),
|
||||
// Like count
|
||||
_count: {
|
||||
select: { likedBy: true },
|
||||
},
|
||||
};
|
||||
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
let totalCount: number;
|
||||
let filteredCount: number;
|
||||
let list: Prisma.MiiGetPayload<{ select: typeof select }>[];
|
||||
|
||||
if (sort === "random") {
|
||||
// Get all IDs that match the where conditions
|
||||
const matchingIds = await prisma.mii.findMany({
|
||||
where,
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
totalCount = matchingIds.length;
|
||||
filteredCount = Math.max(0, Math.min(limit, totalCount - skip));
|
||||
|
||||
if (matchingIds.length === 0) return;
|
||||
|
||||
// Use seed for consistent random results
|
||||
const randomSeed = seed || crypto.randomInt(0, 1_000_000_000);
|
||||
const rng = seedrandom(randomSeed.toString());
|
||||
|
||||
// Randomize all IDs using the Durstenfeld algorithm
|
||||
for (let i = matchingIds.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(rng() * (i + 1));
|
||||
[matchingIds[i], matchingIds[j]] = [matchingIds[j], matchingIds[i]];
|
||||
}
|
||||
|
||||
// Convert to number[] array
|
||||
const selectedIds = matchingIds.slice(skip, skip + limit).map((i) => i.id);
|
||||
|
||||
list = await prisma.mii.findMany({
|
||||
where: {
|
||||
id: { in: selectedIds },
|
||||
},
|
||||
select,
|
||||
});
|
||||
} else {
|
||||
// Sorting by likes, newest, or oldest
|
||||
let orderBy: Prisma.MiiOrderByWithRelationInput[];
|
||||
|
||||
if (sort === "likes") {
|
||||
orderBy = [{ likedBy: { _count: "desc" } }, { name: "asc" }];
|
||||
} else if (sort === "oldest") {
|
||||
orderBy = [{ createdAt: "asc" }, { name: "asc" }];
|
||||
} else {
|
||||
// default to newest
|
||||
orderBy = [{ createdAt: "desc" }, { name: "asc" }];
|
||||
}
|
||||
|
||||
[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,
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
const lastPage = Math.ceil(totalCount / limit);
|
||||
const miis = list.map(({ _count, likedBy, ...rest }) => ({
|
||||
...rest,
|
||||
likes: _count.likedBy,
|
||||
isLiked: session?.user?.id ? likedBy.length > 0 : false,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex justify-between items-center gap-2 mb-2 max-md:flex-col">
|
||||
<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">{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 text-amber-700">Miis</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative flex items-center justify-end gap-2 w-full md:max-w-2/3 max-md:justify-center">
|
||||
<FilterMenu />
|
||||
<SortSelect />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-4 max-lg:grid-cols-3 max-md:grid-cols-2 max-[30rem]:grid-cols-1">
|
||||
{miis.map((mii) => (
|
||||
<div
|
||||
key={mii.id}
|
||||
className="flex flex-col bg-zinc-50 rounded-3xl border-2 border-zinc-300 shadow-lg p-[0.8rem] transition hover:scale-105 hover:bg-cyan-100 hover:border-cyan-600"
|
||||
>
|
||||
<Carousel
|
||||
images={[
|
||||
`/mii/${mii.id}/image?type=mii`,
|
||||
...(platform === "THREE_DS" ? `/mii/${mii.id}/image?type=qr-code` : ""),
|
||||
...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}>
|
||||
{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">
|
||||
{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 />
|
||||
|
||||
{!userId && (
|
||||
<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">
|
||||
<Icon icon="mdi:pencil" />
|
||||
</Link>
|
||||
<DeleteMiiButton miiId={mii.id} miiName={mii.name} likes={mii.likes} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Pagination lastPage={lastPage} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
src/components/mii/list/other-filters.tsx
Normal file
38
src/components/mii/list/other-filters.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
"use client";
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import React, { ChangeEvent, ChangeEventHandler, useState, useTransition } from "react";
|
||||
|
||||
export default function OtherFilters() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const [allowCopying, setAllowCopying] = useState<boolean>((searchParams.get("allowCopying") as unknown as boolean) ?? false);
|
||||
|
||||
const handleChangeAllowCopying = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setAllowCopying(e.target.checked);
|
||||
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set("page", "1");
|
||||
|
||||
if (!allowCopying) {
|
||||
params.set("allowCopying", "true");
|
||||
} else {
|
||||
params.delete("allowCopying");
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
router.push(`?${params.toString()}`, { scroll: false });
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex justify-between items-center w-full">
|
||||
<label htmlFor="allowCopying" className="text-sm">
|
||||
Allow Copying
|
||||
</label>
|
||||
<input type="checkbox" id="allowCopying" className="checkbox-alt" checked={allowCopying} onChange={handleChangeAllowCopying} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
101
src/components/mii/list/pagination.tsx
Normal file
101
src/components/mii/list/pagination.tsx
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
"use client";
|
||||
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Icon } from "@iconify/react";
|
||||
|
||||
interface Props {
|
||||
lastPage: number;
|
||||
}
|
||||
|
||||
export default function Pagination({ lastPage }: Props) {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const page = Number(searchParams.get("page") ?? 1);
|
||||
|
||||
const createPageUrl = useCallback(
|
||||
(pageNumber: number) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set("page", pageNumber.toString());
|
||||
return `${pathname}?${params.toString()}`;
|
||||
},
|
||||
[searchParams, pathname],
|
||||
);
|
||||
|
||||
const numbers = useMemo(() => {
|
||||
const result = [];
|
||||
|
||||
// Always show 5 pages, centering around the current page when possible
|
||||
const start = Math.max(1, Math.min(page - 2, lastPage - 4));
|
||||
const end = Math.min(lastPage, start + 4);
|
||||
|
||||
for (let i = start; i <= end; i++) result.push(i);
|
||||
|
||||
return result;
|
||||
}, [page, lastPage]);
|
||||
|
||||
return (
|
||||
<div className="flex justify-center items-center w-full mt-8">
|
||||
{/* Go to first page */}
|
||||
<Link
|
||||
href={page === 1 ? "#" : createPageUrl(1)}
|
||||
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!"}`}
|
||||
>
|
||||
<Icon icon="stash:chevron-double-left" />
|
||||
</Link>
|
||||
|
||||
{/* Previous page */}
|
||||
<Link
|
||||
href={page === 1 ? "#" : createPageUrl(page - 1)}
|
||||
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!"}`}
|
||||
>
|
||||
<Icon icon="stash:chevron-left" />
|
||||
</Link>
|
||||
|
||||
{/* Page numbers */}
|
||||
<div className="flex mx-2">
|
||||
{numbers.map((number) => (
|
||||
<Link
|
||||
key={number}
|
||||
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!"}`}
|
||||
>
|
||||
{number}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Next page */}
|
||||
<Link
|
||||
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!"}`}
|
||||
>
|
||||
<Icon icon="stash:chevron-right" />
|
||||
</Link>
|
||||
|
||||
{/* Go to last page */}
|
||||
<Link
|
||||
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!"}`}
|
||||
>
|
||||
<Icon icon="stash:chevron-double-right" />
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
58
src/components/mii/list/platform-select.tsx
Normal file
58
src/components/mii/list/platform-select.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
"use client";
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useState, useTransition } from "react";
|
||||
import { Icon } from "@iconify/react";
|
||||
import { MiiPlatform } from "@prisma/client";
|
||||
|
||||
export default function PlatformSelect() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const [selected, setSelected] = useState<MiiPlatform | null>((searchParams.get("platform") as MiiPlatform) ?? null);
|
||||
|
||||
const handleClick = (platform: MiiPlatform) => {
|
||||
const filter = selected === platform ? null : platform;
|
||||
setSelected(filter);
|
||||
|
||||
const params = new URLSearchParams(searchParams);
|
||||
if (filter) {
|
||||
params.set("platform", filter);
|
||||
} else {
|
||||
params.delete("platform");
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
router.push(`?${params.toString()}`);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-0.5 w-fit">
|
||||
<button
|
||||
onClick={() => handleClick("THREE_DS")}
|
||||
aria-label="Filter for 3DS Miis"
|
||||
data-tooltip-span
|
||||
className={`cursor-pointer rounded-xl flex justify-center items-center size-13 text-3xl border-2 transition-all ${
|
||||
selected === "THREE_DS" ? "bg-sky-100 border-sky-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
|
||||
}`}
|
||||
>
|
||||
<div className="tooltip bg-sky-400! border-sky-400! before:border-b-sky-400!">3DS</div>
|
||||
<Icon icon="cib:nintendo-3ds" className="text-sky-400" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleClick("SWITCH")}
|
||||
aria-label="Filter for Switch Miis"
|
||||
data-tooltip-span
|
||||
className={`cursor-pointer rounded-xl flex justify-center items-center size-13 text-3xl border-2 transition-all ${
|
||||
selected === "SWITCH" ? "bg-red-100 border-red-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
|
||||
}`}
|
||||
>
|
||||
<div className="tooltip bg-red-400! border-red-400! before:border-b-red-400!">Switch</div>
|
||||
<Icon icon="cib:nintendo-switch" className="text-red-400" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
53
src/components/mii/list/skeleton.tsx
Normal file
53
src/components/mii/list/skeleton.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import FilterSelect from "./tag-filter";
|
||||
import SortSelect from "./sort-select";
|
||||
import Pagination from "./pagination";
|
||||
|
||||
export default function Skeleton() {
|
||||
return (
|
||||
<div className="w-full animate-pulse">
|
||||
<div className="flex justify-between items-end mb-2 max-[32rem]:flex-col max-[32rem]:items-center">
|
||||
<p className="text-lg">
|
||||
<span className="font-extrabold">???</span> Miis
|
||||
</p>
|
||||
|
||||
<div className="flex gap-2 pointer-events-none">
|
||||
<FilterSelect />
|
||||
<SortSelect />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-4 max-lg:grid-cols-3 max-md:grid-cols-2 max-[30rem]:grid-cols-1">
|
||||
{[...Array(24)].map((_, index) => (
|
||||
<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>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4 flex flex-col gap-1 h-full">
|
||||
{/* Name */}
|
||||
<div className="h-7 bg-zinc-300 rounded w-2/3 mb-0.5" />
|
||||
|
||||
{/* Tags */}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<div className="px-4 py-2 bg-orange-200 rounded-full w-14 h-6" />
|
||||
<div className="px-4 py-2 bg-orange-200 rounded-full w-10 h-6" />
|
||||
</div>
|
||||
|
||||
{/* Bottom row */}
|
||||
<div className="mt-0.5 grid grid-cols-2 items-center">
|
||||
<div className="h-6 w-12 bg-red-200 rounded" />
|
||||
<div className="h-4 w-24 bg-zinc-200 rounded justify-self-end" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="pointer-events-none">
|
||||
<Pagination lastPage={10} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
64
src/components/mii/list/sort-select.tsx
Normal file
64
src/components/mii/list/sort-select.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
"use client";
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useTransition } from "react";
|
||||
import { useSelect } from "downshift";
|
||||
import { Icon } from "@iconify/react";
|
||||
|
||||
type Sort = "likes" | "newest" | "oldest" | "random";
|
||||
|
||||
const items = ["likes", "newest", "oldest", "random"];
|
||||
|
||||
export default function SortSelect() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const currentSort = (searchParams.get("sort") as Sort) || "newest";
|
||||
|
||||
const { isOpen, getToggleButtonProps, getMenuProps, getItemProps, highlightedIndex, selectedItem } = useSelect({
|
||||
items,
|
||||
selectedItem: currentSort,
|
||||
onSelectedItemChange: ({ selectedItem }) => {
|
||||
if (!selectedItem) return;
|
||||
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set("page", "1");
|
||||
params.set("sort", selectedItem);
|
||||
|
||||
if (selectedItem == "random") {
|
||||
params.set("seed", Math.floor(Math.random() * 1_000_000_000).toString());
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
router.push(`?${params.toString()}`, { scroll: false });
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
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">
|
||||
<span>Sort by </span>
|
||||
{selectedItem || "Select a way to sort"}
|
||||
<Icon icon="tabler:chevron-down" className="ml-2 size-5" />
|
||||
</button>
|
||||
|
||||
{/* Dropdown menu */}
|
||||
<ul
|
||||
{...getMenuProps()}
|
||||
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 ${
|
||||
isOpen ? "block" : "hidden"
|
||||
}`}
|
||||
>
|
||||
{isOpen &&
|
||||
items.map((item, index) => (
|
||||
<li key={item} {...getItemProps({ item, index })} className={`px-4 py-1 cursor-pointer text-sm ${highlightedIndex === index ? "bg-black/15" : ""}`}>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
62
src/components/mii/list/tag-filter.tsx
Normal file
62
src/components/mii/list/tag-filter.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
"use client";
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useMemo, useState, useTransition } from "react";
|
||||
import TagSelector from "../../tag-selector";
|
||||
|
||||
interface Props {
|
||||
isExclude?: boolean;
|
||||
}
|
||||
|
||||
export default function TagFilter({ isExclude }: Props) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const rawTags = searchParams.get(isExclude ? "exclude" : "tags") || "";
|
||||
const preexistingTags = useMemo(
|
||||
() =>
|
||||
rawTags
|
||||
? rawTags
|
||||
.split(",")
|
||||
.map((tag) => tag.trim())
|
||||
.filter((tag) => tag.length > 0)
|
||||
: [],
|
||||
[rawTags],
|
||||
);
|
||||
|
||||
const [tags, setTags] = useState<string[]>(preexistingTags);
|
||||
|
||||
// Sync state if the URL tags change (e.g. via navigation)
|
||||
useEffect(() => {
|
||||
setTags(preexistingTags);
|
||||
}, [preexistingTags]);
|
||||
|
||||
// Redirect automatically on tags change
|
||||
useEffect(() => {
|
||||
const urlTags = preexistingTags.join(",");
|
||||
const stateTags = tags.join(",");
|
||||
|
||||
if (urlTags === stateTags) return;
|
||||
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set("page", "1");
|
||||
|
||||
if (tags.length > 0) {
|
||||
params.set(isExclude ? "exclude" : "tags", stateTags);
|
||||
} else {
|
||||
params.delete(isExclude ? "exclude" : "tags");
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
router.push(`?${params.toString()}`, { scroll: false });
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tags]);
|
||||
|
||||
return (
|
||||
<div className="w-72">
|
||||
<TagSelector tags={tags} setTags={setTags} isExclude={isExclude} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
49
src/components/mii/personality-viewer.tsx
Normal file
49
src/components/mii/personality-viewer.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
"use client";
|
||||
|
||||
import { SwitchMiiInstructions } from "@/types";
|
||||
|
||||
interface Props {
|
||||
data: SwitchMiiInstructions["personality"];
|
||||
onClick?: (key: string, i: number) => void;
|
||||
}
|
||||
|
||||
const PERSONALITY_SETTINGS: { label: string; left: string; right: string }[] = [
|
||||
{ label: "Movement", left: "Slow", right: "Quick" },
|
||||
{ label: "Speech", left: "Polite", right: "Honest" },
|
||||
{ label: "Energy", left: "Flat", right: "Varied" },
|
||||
{ label: "Thinking", left: "Serious", right: "Chill" },
|
||||
{ label: "Overall", left: "Normal", right: "Quirky" },
|
||||
];
|
||||
|
||||
export default function PersonalityViewer({ data, onClick }: Props) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 mb-3">
|
||||
{PERSONALITY_SETTINGS.map(({ label, left, right }) => {
|
||||
const key = label.toLowerCase() as keyof typeof data;
|
||||
return (
|
||||
<div key={label} className="flex justify-center items-center gap-2">
|
||||
<span className="text-sm font-semibold w-24 shrink-0">{label}</span>
|
||||
<span className="text-sm text-zinc-500 w-14 text-right">{left}</span>
|
||||
<div className="flex gap-0.5">
|
||||
{Array.from({ length: 6 }).map((_, i) => {
|
||||
const colors = ["bg-green-400", "bg-green-300", "bg-teal-200", "bg-orange-200", "bg-orange-300", "bg-orange-400"];
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (onClick) onClick(key, i);
|
||||
}}
|
||||
className={`size-7 rounded-lg transition-opacity duration-100 border-orange-500
|
||||
${colors[i]} ${data[key] === i ? "border-2 opacity-100" : "opacity-70"} ${onClick ? "cursor-pointer" : ""}`}
|
||||
></button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<span className="text-sm text-zinc-500 w-12 shrink-0">{right}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
174
src/components/mii/share-mii-button.tsx
Normal file
174
src/components/mii/share-mii-button.tsx
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Icon } from "@iconify/react";
|
||||
|
||||
interface Props {
|
||||
miiId: number;
|
||||
}
|
||||
|
||||
export default function ShareMiiButton({ miiId }: Props) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
const [hasCopiedUrl, setHasCopiedUrl] = useState(false);
|
||||
const [hasCopiedImage, setHasCopiedImage] = useState(false);
|
||||
|
||||
const url = `${process.env.NEXT_PUBLIC_BASE_URL}/mii/${miiId}`;
|
||||
|
||||
const handleCopyUrl = async () => {
|
||||
await navigator.clipboard.writeText(url);
|
||||
|
||||
setHasCopiedUrl(true);
|
||||
|
||||
// Reset to trigger exit animation
|
||||
setTimeout(() => {
|
||||
setHasCopiedUrl(false);
|
||||
}, 750);
|
||||
};
|
||||
|
||||
const handleCopyImage = async () => {
|
||||
const response = await fetch(`/mii/${miiId}/image?type=metadata`);
|
||||
const blob = await response.blob();
|
||||
|
||||
await navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })]);
|
||||
|
||||
setHasCopiedImage(true);
|
||||
|
||||
// Reset to trigger exit animation
|
||||
setTimeout(() => {
|
||||
setHasCopiedImage(false);
|
||||
}, 750);
|
||||
};
|
||||
|
||||
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)} aria-label="Share" className="cursor-pointer">
|
||||
<Icon icon="material-symbols:share" />
|
||||
<span>Share</span>
|
||||
</button>
|
||||
|
||||
{isOpen &&
|
||||
createPortal(
|
||||
<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 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">Share Mii</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="relative">
|
||||
<input type="text" disabled className="pill input w-full text-sm" value={url} />
|
||||
|
||||
{/* Copy button */}
|
||||
<button className="absolute! top-2.5 right-2.5 cursor-pointer" data-tooltip={hasCopiedUrl ? "Copied!" : "Copy URL"} onClick={handleCopyUrl}>
|
||||
<div className="relative text-xl">
|
||||
{/* Copy icon */}
|
||||
<Icon
|
||||
icon="solar:copy-bold"
|
||||
className={`text-zinc-400 transition-all duration-300 ${
|
||||
hasCopiedUrl ? "opacity-0 scale-75 rotate-12" : "opacity-100 scale-100 rotate-0"
|
||||
}`}
|
||||
/>
|
||||
|
||||
{/* Check icon */}
|
||||
<Icon
|
||||
icon="heroicons-solid:check"
|
||||
className={`absolute inset-0 text-green-600 transition-all duration-300 ${
|
||||
hasCopiedUrl ? "opacity-100 scale-100 rotate-0" : "opacity-0 scale-75 -rotate-12"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium my-4">
|
||||
<hr className="grow border-zinc-300" />
|
||||
<span>or</span>
|
||||
<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">
|
||||
<Image src={`/mii/${miiId}/image?type=metadata`} alt="mii 'metadata' image" width={248} height={248} unoptimized className="drop-shadow-md" />
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 mt-4">
|
||||
<div className="flex gap-2 w-full">
|
||||
{/* Save button */}
|
||||
<a
|
||||
href={`/mii/${miiId}/image?type=metadata`}
|
||||
className="pill button p-0! aspect-square size-11 cursor-pointer text-xl"
|
||||
aria-label="Save Image"
|
||||
data-tooltip="Save Image"
|
||||
download={"hello.png"}
|
||||
>
|
||||
<Icon icon="material-symbols:save-rounded" />
|
||||
</a>
|
||||
|
||||
{/* Copy button */}
|
||||
<button
|
||||
className="pill button p-0! aspect-square size-11 cursor-pointer"
|
||||
aria-label="Copy Image"
|
||||
data-tooltip={hasCopiedImage ? "Copied!" : "Copy Image"}
|
||||
onClick={handleCopyImage}
|
||||
>
|
||||
<div className="relative text-xl">
|
||||
{/* Copy icon */}
|
||||
<Icon
|
||||
icon="solar:copy-bold"
|
||||
className={` transition-all duration-300 ${hasCopiedImage ? "opacity-0 scale-75 rotate-12" : "opacity-100 scale-100 rotate-0"}`}
|
||||
/>
|
||||
|
||||
{/* Check icon */}
|
||||
<Icon
|
||||
icon="heroicons-solid:check"
|
||||
className={`absolute inset-0 text-black/60 transition-all duration-300 ${
|
||||
hasCopiedImage ? "opacity-100 scale-100 rotate-0" : "opacity-0 scale-75 -rotate-12"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button onClick={close} className="pill button">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
59
src/components/mii/voice-viewer.tsx
Normal file
59
src/components/mii/voice-viewer.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
"use client";
|
||||
|
||||
import { SwitchMiiInstructions } from "@/types";
|
||||
import { ChangeEvent } from "react";
|
||||
|
||||
interface Props {
|
||||
data: SwitchMiiInstructions["voice"];
|
||||
onClick?: (e: ChangeEvent<HTMLInputElement, HTMLInputElement>, label: string) => void;
|
||||
onClickTone?: (i: number) => void;
|
||||
}
|
||||
|
||||
const VOICE_SETTINGS: string[] = ["Speed", "Pitch", "Depth", "Delivery"];
|
||||
|
||||
export default function VoiceViewer({ data, onClick, onClickTone }: Props) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{VOICE_SETTINGS.map((label) => (
|
||||
<div key={label} className="flex gap-3">
|
||||
<label htmlFor={label} className="text-sm w-14">
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
name={label}
|
||||
className="grow"
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
value={data[label as keyof typeof data]}
|
||||
disabled={!onClick}
|
||||
onChange={(e) => {
|
||||
if (onClick) onClick(e, label);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<label htmlFor="delivery" className="text-sm w-14">
|
||||
Tone
|
||||
</label>
|
||||
<div className="grid grid-cols-6 gap-1 grow">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<button
|
||||
type="button"
|
||||
key={i}
|
||||
onClick={() => {
|
||||
if (onClickTone) onClickTone(i);
|
||||
}}
|
||||
className={`transition-colors duration-100 rounded-xl ${data.tone === i ? "bg-orange-400!" : ""} ${onClick ? "hover:bg-orange-300 cursor-pointer" : ""}`}
|
||||
>
|
||||
{i + 1}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue