feat: .ltd files
no automatic instructions
|
Before Width: | Height: | Size: 233 KiB After Width: | Height: | Size: 233 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 229 KiB After Width: | Height: | Size: 229 KiB |
|
Before Width: | Height: | Size: 151 KiB After Width: | Height: | Size: 151 KiB |
BIN
frontend/public/tutorial/switch/adding-mii/manual/thumbnail.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
frontend/public/tutorial/switch/adding-mii/modded/step1.jpg
Normal file
|
After Width: | Height: | Size: 126 KiB |
BIN
frontend/public/tutorial/switch/adding-mii/modded/step2.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
frontend/public/tutorial/switch/adding-mii/modded/step3.jpg
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
frontend/public/tutorial/switch/adding-mii/modded/thumbnail.png
Normal file
|
After Width: | Height: | Size: 7.5 KiB |
|
|
@ -1,49 +1,48 @@
|
|||
import { type ReactNode, useState } from "react";
|
||||
import { type DropzoneOptions, type FileWithPath, useDropzone } from "react-dropzone";
|
||||
import { Icon } from "@iconify/react";
|
||||
|
||||
interface Props {
|
||||
onDrop: (acceptedFiles: FileWithPath[]) => void;
|
||||
options?: DropzoneOptions;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export default function Dropzone({ onDrop, options, children }: Props) {
|
||||
const [isDraggingOver, setIsDraggingOver] = useState(false);
|
||||
|
||||
const handleDrop = (acceptedFiles: FileWithPath[]) => {
|
||||
setIsDraggingOver(false);
|
||||
onDrop(acceptedFiles);
|
||||
};
|
||||
|
||||
const { getRootProps, getInputProps } = useDropzone({
|
||||
onDrop: handleDrop,
|
||||
maxFiles: 3,
|
||||
accept: {
|
||||
"image/*": [".png", ".jpg", ".jpeg", ".bmp", ".png", ".heic"],
|
||||
},
|
||||
...options,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
{...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 size-full transition-all duration-200 ${
|
||||
isDraggingOver && "scale-105 brightness-90 shadow-xl"
|
||||
}`}
|
||||
>
|
||||
{/* Used to transition from border-dashed to border-solid */}
|
||||
<div
|
||||
className={`absolute inset-0 rounded-[10px] outline-2 outline-amber-500 transition-opacity duration-300 ${
|
||||
isDraggingOver ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
></div>
|
||||
|
||||
<input {...getInputProps({ multiple: options?.maxFiles ? options.maxFiles > 1 : false })} />
|
||||
<Icon icon="material-symbols:upload" fontSize={48} />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { type ReactNode, useState } from "react";
|
||||
import { type DropzoneOptions, type FileWithPath, useDropzone } from "react-dropzone";
|
||||
import { Icon } from "@iconify/react";
|
||||
|
||||
interface Props {
|
||||
type?: "file" | "image";
|
||||
onDrop: (acceptedFiles: FileWithPath[]) => void;
|
||||
options?: DropzoneOptions;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export default function Dropzone({ type = "image", onDrop, options, children }: Props) {
|
||||
const [isDraggingOver, setIsDraggingOver] = useState(false);
|
||||
|
||||
const handleDrop = (acceptedFiles: FileWithPath[]) => {
|
||||
setIsDraggingOver(false);
|
||||
onDrop(acceptedFiles);
|
||||
};
|
||||
|
||||
const { getRootProps, getInputProps } = useDropzone({
|
||||
onDrop: handleDrop,
|
||||
maxFiles: 3,
|
||||
accept: type === "image" ? { "image/*": [".png", ".jpg", ".jpeg", ".bmp", ".png", ".heic"] } : { "application/octet-stream": [".ltd"] },
|
||||
...options,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
{...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 size-full transition-all duration-200 ${
|
||||
isDraggingOver && "scale-105 brightness-90 shadow-xl"
|
||||
}`}
|
||||
>
|
||||
{/* Used to transition from border-dashed to border-solid */}
|
||||
<div
|
||||
className={`absolute inset-0 rounded-[10px] outline-2 outline-amber-500 transition-opacity duration-300 ${
|
||||
isDraggingOver ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
></div>
|
||||
|
||||
<input {...getInputProps({ multiple: options?.maxFiles ? options.maxFiles > 1 : false })} />
|
||||
<Icon icon="material-symbols:upload" fontSize={48} />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,165 +1,165 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import useEmblaCarousel from "embla-carousel-react";
|
||||
import { Icon } from "@iconify/react";
|
||||
|
||||
interface Props {
|
||||
src: string;
|
||||
alt: string;
|
||||
width: number;
|
||||
height: number;
|
||||
className?: string;
|
||||
images?: string[];
|
||||
}
|
||||
|
||||
export default function ImageViewer({ src, alt, width, height, className, images = [] }: Props) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true, duration: 15 });
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [scrollSnaps, setScrollSnaps] = useState<number[]>([]);
|
||||
|
||||
const close = () => {
|
||||
setIsVisible(false);
|
||||
setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
// slight delay to trigger animation
|
||||
setTimeout(() => setIsVisible(true), 10);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!emblaApi) return;
|
||||
|
||||
// Keep order of images whilst opening at src prop
|
||||
const index = images.indexOf(src);
|
||||
if (index !== -1) {
|
||||
emblaApi.scrollTo(index, true);
|
||||
setSelectedIndex(index);
|
||||
}
|
||||
|
||||
setScrollSnaps(emblaApi.scrollSnapList());
|
||||
emblaApi.on("select", () => setSelectedIndex(emblaApi.selectedScrollSnap()));
|
||||
}, [emblaApi, images, src]);
|
||||
|
||||
// Handle keyboard events
|
||||
useEffect(() => {
|
||||
if (!isOpen || !emblaApi) return;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "ArrowLeft") emblaApi.scrollPrev();
|
||||
else if (event.key === "ArrowRight") emblaApi.scrollNext();
|
||||
else if (event.key === "Escape") close();
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [isOpen, emblaApi]);
|
||||
|
||||
const imagesMap = images.length === 0 ? [src] : images;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* not inserting pixelated image-rendering here because i thought it looked a bit weird */}
|
||||
<img src={src} alt={alt} width={width} height={height} loading="lazy" onClick={() => setIsOpen(true)} className={`cursor-pointer ${className}`} />
|
||||
|
||||
{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={`absolute inset-0 backdrop-brightness-40 backdrop-contrast-125 backdrop-blur-sm transition-opacity duration-300 ${isVisible ? "opacity-100" : "opacity-0"}`}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
onClick={close}
|
||||
className={`pill button p-2! size-11 aspect-square text-2xl absolute top-4 right-4 shrink-0 ${isVisible ? "opacity-100" : "opacity-0"}`}
|
||||
>
|
||||
<Icon icon="material-symbols:close-rounded" />
|
||||
</button>
|
||||
|
||||
<div
|
||||
className={`overflow-hidden max-w-4xl h-[75vh] max-md:h-[55vh] transition-discrete duration-300 ${isVisible ? "scale-100 opacity-100" : "scale-90 opacity-0"}`}
|
||||
ref={emblaRef}
|
||||
>
|
||||
<div className="flex h-full">
|
||||
{imagesMap.map((image, index) => (
|
||||
<div key={index} className="flex-[0_0_100%] h-full flex items-center px-4">
|
||||
<img
|
||||
src={image}
|
||||
alt={alt}
|
||||
width={896}
|
||||
height={896}
|
||||
loading={Math.abs(index - selectedIndex) <= 1 ? "eager" : "lazy"}
|
||||
className="max-w-full max-h-full object-contain drop-shadow-lg"
|
||||
style={{ imageRendering: image.includes("qr-code") ? "pixelated" : "auto" }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{images.length > 1 && (
|
||||
<>
|
||||
{/* Carousel counter */}
|
||||
<div
|
||||
className={`flex justify-center gap-2 bg-orange-300 w-15 font-semibold text-sm py-1 rounded-full border-2 border-orange-400 absolute top-4 left-4 transition-opacity duration-300 ${
|
||||
isVisible ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
>
|
||||
{selectedIndex + 1} / {images.length}
|
||||
</div>
|
||||
|
||||
{/* Carousel buttons */}
|
||||
{/* Prev button */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Scroll Carousel Left"
|
||||
onClick={() => emblaApi?.scrollPrev()}
|
||||
className={`absolute left-2 top-1/2 -translate-y-1/2 pill button p-0.5! aspect-square text-4xl transition-opacity duration-300 ${isVisible ? "opacity-100" : "opacity-0"}`}
|
||||
>
|
||||
<Icon icon="ic:round-chevron-left" />
|
||||
</button>
|
||||
{/* Next button */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Scroll Carousel Right"
|
||||
onClick={() => emblaApi?.scrollNext()}
|
||||
className={`absolute right-2 top-1/2 -translate-y-1/2 pill button p-0.5! aspect-square text-4xl transition-opacity duration-300 ${isVisible ? "opacity-100" : "opacity-0"}`}
|
||||
>
|
||||
<Icon icon="ic:round-chevron-right" />
|
||||
</button>
|
||||
|
||||
{/* Carousel snaps */}
|
||||
<div
|
||||
className={`flex justify-center gap-2 bg-orange-300 p-2.5 rounded-full border-2 border-orange-400 absolute left-1/2 -translate-x-1/2 bottom-4 transition-opacity duration-300 ${
|
||||
isVisible ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
>
|
||||
{scrollSnaps.map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
aria-label={`Go to ${index} in Carousel`}
|
||||
onClick={() => emblaApi?.scrollTo(index)}
|
||||
className={`size-2 cursor-pointer rounded-full transition-all duration-300 ${index === selectedIndex ? "bg-slate-800 w-8" : "bg-slate-800/30"}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import useEmblaCarousel from "embla-carousel-react";
|
||||
import { Icon } from "@iconify/react";
|
||||
|
||||
interface Props {
|
||||
src: string;
|
||||
alt: string;
|
||||
width: number;
|
||||
height: number;
|
||||
className?: string;
|
||||
images?: string[];
|
||||
}
|
||||
|
||||
export default function ImageViewer({ src, alt, width, height, className, images = [] }: Props) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true, duration: 15 });
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [scrollSnaps, setScrollSnaps] = useState<number[]>([]);
|
||||
|
||||
const close = () => {
|
||||
setIsVisible(false);
|
||||
setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
// slight delay to trigger animation
|
||||
setTimeout(() => setIsVisible(true), 10);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!emblaApi) return;
|
||||
|
||||
// Keep order of images whilst opening at src prop
|
||||
const index = images.indexOf(src);
|
||||
if (index !== -1) {
|
||||
emblaApi.scrollTo(index, true);
|
||||
setSelectedIndex(index);
|
||||
}
|
||||
|
||||
setScrollSnaps(emblaApi.scrollSnapList());
|
||||
emblaApi.on("select", () => setSelectedIndex(emblaApi.selectedScrollSnap()));
|
||||
}, [emblaApi, images, src]);
|
||||
|
||||
// Handle keyboard events
|
||||
useEffect(() => {
|
||||
if (!isOpen || !emblaApi) return;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "ArrowLeft") emblaApi.scrollPrev();
|
||||
else if (event.key === "ArrowRight") emblaApi.scrollNext();
|
||||
else if (event.key === "Escape") close();
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [isOpen, emblaApi]);
|
||||
|
||||
const imagesMap = images.length === 0 ? [src] : images;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* not inserting pixelated image-rendering here because i thought it looked a bit weird */}
|
||||
<img src={src} alt={alt} width={width} height={height} loading="lazy" onClick={() => setIsOpen(true)} className={`cursor-pointer ${className}`} />
|
||||
|
||||
{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={`absolute inset-0 backdrop-brightness-40 backdrop-contrast-125 backdrop-blur-sm transition-opacity duration-300 ${isVisible ? "opacity-100" : "opacity-0"}`}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
onClick={close}
|
||||
className={`pill button p-2! size-11 aspect-square text-2xl absolute top-4 right-4 shrink-0 ${isVisible ? "opacity-100" : "opacity-0"}`}
|
||||
>
|
||||
<Icon icon="material-symbols:close-rounded" />
|
||||
</button>
|
||||
|
||||
<div
|
||||
className={`overflow-hidden max-w-4xl h-[75vh] max-md:h-[55vh] transition-discrete duration-300 ${isVisible ? "scale-100 opacity-100" : "scale-90 opacity-0"}`}
|
||||
ref={emblaRef}
|
||||
>
|
||||
<div className="flex h-full">
|
||||
{imagesMap.map((image, index) => (
|
||||
<div key={index} className="flex-[0_0_100%] h-full flex items-center px-4">
|
||||
<img
|
||||
src={image}
|
||||
alt={alt}
|
||||
width={896}
|
||||
height={896}
|
||||
loading={Math.abs(index - selectedIndex) <= 1 ? "eager" : "lazy"}
|
||||
className="max-w-full max-h-full object-contain drop-shadow-lg"
|
||||
style={{ imageRendering: image.includes("qr-code") ? "pixelated" : "auto" }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{images.length > 1 && (
|
||||
<>
|
||||
{/* Carousel counter */}
|
||||
<div
|
||||
className={`flex justify-center gap-2 bg-orange-300 w-15 font-semibold text-sm py-1 rounded-full border-2 border-orange-400 absolute top-4 left-4 transition-opacity duration-300 ${
|
||||
isVisible ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
>
|
||||
{selectedIndex + 1} / {images.length}
|
||||
</div>
|
||||
|
||||
{/* Carousel buttons */}
|
||||
{/* Prev button */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Scroll Carousel Left"
|
||||
onClick={() => emblaApi?.scrollPrev()}
|
||||
className={`absolute left-2 top-1/2 -translate-y-1/2 pill button p-0.5! aspect-square text-4xl transition-opacity duration-300 ${isVisible ? "opacity-100" : "opacity-0"}`}
|
||||
>
|
||||
<Icon icon="ic:round-chevron-left" />
|
||||
</button>
|
||||
{/* Next button */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Scroll Carousel Right"
|
||||
onClick={() => emblaApi?.scrollNext()}
|
||||
className={`absolute right-2 top-1/2 -translate-y-1/2 pill button p-0.5! aspect-square text-4xl transition-opacity duration-300 ${isVisible ? "opacity-100" : "opacity-0"}`}
|
||||
>
|
||||
<Icon icon="ic:round-chevron-right" />
|
||||
</button>
|
||||
|
||||
{/* Carousel snaps */}
|
||||
<div
|
||||
className={`flex justify-center gap-2 bg-orange-300 p-2.5 rounded-full border-2 border-orange-400 absolute left-1/2 -translate-x-1/2 bottom-4 transition-opacity duration-300 ${
|
||||
isVisible ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
>
|
||||
{scrollSnaps.map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
aria-label={`Go to ${index} in Carousel`}
|
||||
onClick={() => emblaApi?.scrollTo(index)}
|
||||
className={`size-2 cursor-pointer rounded-full transition-all duration-300 ${index === selectedIndex ? "bg-slate-800 w-8" : "bg-slate-800/30"}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,254 +1,254 @@
|
|||
import type { ReactNode } from "react";
|
||||
import DatingPreferencesViewer from "./dating-preferences";
|
||||
import PersonalityViewer from "./personality-viewer";
|
||||
|
||||
import { type SwitchMiiInstructions, COLORS } from "@tomodachi-share/shared";
|
||||
|
||||
interface Props {
|
||||
instructions: Partial<SwitchMiiInstructions>;
|
||||
}
|
||||
|
||||
interface SectionProps {
|
||||
name: string;
|
||||
instructions: Partial<SwitchMiiInstructions[keyof SwitchMiiInstructions]>;
|
||||
children?: 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 not(value: any) {
|
||||
return value !== undefined && value !== null;
|
||||
}
|
||||
|
||||
function numberValue(value: number, cutoff: number = 25) {
|
||||
return value === cutoff ? "0" : value > cutoff ? `+${value - cutoff}` : `${value - cutoff}`;
|
||||
}
|
||||
|
||||
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 | undefined | null }) {
|
||||
if (color === undefined || color === null) return null;
|
||||
if (color <= 7) {
|
||||
return (
|
||||
<span className="flex items-center">
|
||||
<div className="size-5 rounded mr-1.5 shrink-0" style={{ backgroundColor: `#${COLORS[color]}` }}></div>
|
||||
Color menu on left, <GridPosition index={color} cols={1} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (color >= 108) {
|
||||
return (
|
||||
<span className="flex items-center">
|
||||
<div className="size-5 rounded mr-1.5 shrink-0" style={{ backgroundColor: `#${COLORS[color]}` }}></div>
|
||||
Outside color menu, <GridPosition index={color - 108} cols={2} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="flex items-center">
|
||||
<div className="size-5 rounded mr-1.5 shrink-0" 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" || !instructions) return null;
|
||||
|
||||
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 w-max ${isSubSection ? "not-first:mt-2 pt-0!" : "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>
|
||||
{not(color) && (
|
||||
<TableCell label="Color">
|
||||
<ColorPosition color={color} />
|
||||
</TableCell>
|
||||
)}
|
||||
{not(height) && <TableCell label="Height">{numberValue(height!, 0)}</TableCell>}
|
||||
{not(distance) && <TableCell label="Distance">{numberValue(distance!, 0)}</TableCell>}
|
||||
{not(rotation) && <TableCell label="Rotation">{numberValue(rotation!, 0)}</TableCell>}
|
||||
{not(size) && <TableCell label="Size">{numberValue(size!, 0)}</TableCell>}
|
||||
{not(stretch) && <TableCell label="Stretch">{numberValue(stretch!, 0)}</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, birthday, datingPreferences, voice, personality } = instructions;
|
||||
|
||||
return (
|
||||
<>
|
||||
{head && (
|
||||
<Section name="Head" instructions={head}>
|
||||
{not(head.skinColor) && (
|
||||
<TableCell label="Skin Color">
|
||||
<ColorPosition color={head.skinColor} />
|
||||
</TableCell>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
{hair && (
|
||||
<Section name="Hair" instructions={hair}>
|
||||
{not(hair.subColor) && (
|
||||
<TableCell label="Sub Color">
|
||||
<ColorPosition color={hair.subColor} />
|
||||
</TableCell>
|
||||
)}
|
||||
{not(hair.subColor2) && (
|
||||
<TableCell label="Sub Color (Back)">
|
||||
<ColorPosition color={hair.subColor2} />
|
||||
</TableCell>
|
||||
)}
|
||||
{not(hair.style) && <TableCell label="Tying Style">{hair.style}</TableCell>}
|
||||
{not(hair.isFlipped) && <TableCell label="Flipped">{hair.isFlipped ? "Yes" : "No"}</TableCell>}
|
||||
</Section>
|
||||
)}
|
||||
{eyebrows && <Section name="Eyebrows" instructions={eyebrows}></Section>}
|
||||
{eyes && (
|
||||
<Section name="Eyes" instructions={eyes}>
|
||||
<Section isSubSection name="Tab 1" instructions={eyes.main} />
|
||||
<Section isSubSection name="Tab 2" instructions={eyes.eyelashesTop} />
|
||||
<Section isSubSection name="Tab 3" instructions={eyes.eyelashesBottom} />
|
||||
<Section isSubSection name="Tab 4" instructions={eyes.eyelidTop} />
|
||||
<Section isSubSection name="Tab 5" instructions={eyes.eyelidBottom} />
|
||||
<Section isSubSection name="Tab 6" instructions={eyes.eyeliner} />
|
||||
<Section isSubSection name="Tab 7" instructions={eyes.pupil} />
|
||||
</Section>
|
||||
)}
|
||||
{nose && <Section name="Nose" instructions={nose}></Section>}
|
||||
{lips && (
|
||||
<Section name="Lips" instructions={lips}>
|
||||
{not(lips.hasLipstick) && <TableCell label="Lipstick">{lips.hasLipstick ? "Yes" : "No"}</TableCell>}
|
||||
</Section>
|
||||
)}
|
||||
{ears && <Section name="Ears" instructions={ears}></Section>}
|
||||
{glasses && (
|
||||
<Section name="Glasses" instructions={glasses}>
|
||||
{not(glasses.ringColor) && (
|
||||
<TableCell label="Ring Color">
|
||||
<ColorPosition color={glasses.ringColor} />
|
||||
</TableCell>
|
||||
)}
|
||||
{not(glasses.shadesColor) && (
|
||||
<TableCell label="Shades Color">
|
||||
<ColorPosition color={glasses.shadesColor} />
|
||||
</TableCell>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
{other && (
|
||||
<Section name="Other" instructions={other}>
|
||||
<Section isSubSection name="Tab 1" instructions={other.wrinkles1} />
|
||||
<Section isSubSection name="Tab 2" instructions={other.wrinkles2} />
|
||||
<Section isSubSection name="Tab 3" instructions={other.beard} />
|
||||
<Section isSubSection name="Tab 4" instructions={other.moustache}>
|
||||
{other.moustache && other.moustache.isFlipped && <TableCell label="Flipped">{other.moustache.isFlipped ? "Yes" : "No"}</TableCell>}
|
||||
</Section>
|
||||
<Section isSubSection name="Tab 5" instructions={other.goatee} />
|
||||
<Section isSubSection name="Tab 6" instructions={other.mole} />
|
||||
<Section isSubSection name="Tab 7" instructions={other.eyeShadow} />
|
||||
<Section isSubSection name="Tab 8" instructions={other.blush} />
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{(height || weight || datingPreferences || voice || personality) && (
|
||||
<div className="p-3 border-l-4 border-amber-400 bg-amber-100/50 rounded-r-lg py-2.5 text-amber-950 w-max">
|
||||
<h3 className="font-semibold text-xl text-amber-800 mb-1">Misc</h3>
|
||||
|
||||
<table className="w-full">
|
||||
<tbody>
|
||||
{not(height) && <TableCell label="Height">{numberValue(height!, 64)}</TableCell>}
|
||||
{not(weight) && <TableCell label="Weight">{numberValue(weight!, 64)}</TableCell>}
|
||||
</tbody>
|
||||
</table>
|
||||
{birthday && (
|
||||
<div className="pl-2 not-nth-2:mt-4">
|
||||
<h4 className="font-semibold text-xl text-amber-800 mb-1">Birthday</h4>
|
||||
<table className="w-full">
|
||||
<tbody>
|
||||
{not(birthday.day) && <TableCell label="Day">{birthday.day}</TableCell>}
|
||||
{not(birthday.month) && <TableCell label="Month">{birthday.month}</TableCell>}
|
||||
{not(birthday.age) && <TableCell label="Age">{birthday.age}</TableCell>}
|
||||
{not(birthday.dontAge) && <TableCell label="Don't Age">{birthday.dontAge ? "Yes" : "No"}</TableCell>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{voice && (
|
||||
<div className="pl-2 not-nth-2:mt-4">
|
||||
<h4 className="font-semibold text-xl text-amber-800 mb-1">Voice</h4>
|
||||
<table className="w-full">
|
||||
<tbody>
|
||||
{not(voice.speed) && <TableCell label="Speed">{numberValue(voice.speed!, 25)}</TableCell>}
|
||||
{not(voice.pitch) && <TableCell label="Pitch">{numberValue(voice.pitch!, 25)}</TableCell>}
|
||||
{not(voice.depth) && <TableCell label="Depth">{numberValue(voice.depth!, 25)}</TableCell>}
|
||||
{not(voice.delivery) && <TableCell label="Delivery">{numberValue(voice.delivery!, 25)}</TableCell>}
|
||||
{not(voice.tone) && <TableCell label="Tone">{voice.tone}</TableCell>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{datingPreferences && (
|
||||
<div className="pl-2 not-nth-2:mt-4">
|
||||
<h4 className="font-semibold text-xl text-amber-800 mb-1">Dating Preferences</h4>
|
||||
<div className="w-min">
|
||||
<DatingPreferencesViewer data={datingPreferences} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{personality && (
|
||||
<div className="pl-2 not-nth-2:mt-4">
|
||||
<h4 className="font-semibold text-xl text-amber-800 mb-1">Personality</h4>
|
||||
<div className="w-min">
|
||||
<PersonalityViewer data={personality} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
import type { ReactNode } from "react";
|
||||
import DatingPreferencesViewer from "./dating-preferences";
|
||||
import PersonalityViewer from "./personality-viewer";
|
||||
|
||||
import { type SwitchMiiInstructions, COLORS } from "@tomodachi-share/shared";
|
||||
|
||||
interface Props {
|
||||
instructions: Partial<SwitchMiiInstructions>;
|
||||
}
|
||||
|
||||
interface SectionProps {
|
||||
name: string;
|
||||
instructions: Partial<SwitchMiiInstructions[keyof SwitchMiiInstructions]>;
|
||||
children?: 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 not(value: any) {
|
||||
return value !== undefined && value !== null;
|
||||
}
|
||||
|
||||
function numberValue(value: number, cutoff: number = 25) {
|
||||
return value === cutoff ? "0" : value > cutoff ? `+${value - cutoff}` : `${value - cutoff}`;
|
||||
}
|
||||
|
||||
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 | undefined | null }) {
|
||||
if (color === undefined || color === null) return null;
|
||||
if (color <= 7) {
|
||||
return (
|
||||
<span className="flex items-center">
|
||||
<div className="size-5 rounded mr-1.5 shrink-0" style={{ backgroundColor: `#${COLORS[color]}` }}></div>
|
||||
Color menu on left, <GridPosition index={color} cols={1} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (color >= 108) {
|
||||
return (
|
||||
<span className="flex items-center">
|
||||
<div className="size-5 rounded mr-1.5 shrink-0" style={{ backgroundColor: `#${COLORS[color]}` }}></div>
|
||||
Outside color menu, <GridPosition index={color - 108} cols={2} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="flex items-center">
|
||||
<div className="size-5 rounded mr-1.5 shrink-0" 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" || !instructions) return null;
|
||||
|
||||
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 w-max not-last:mb-3 ${isSubSection ? "not-first:mt-2 pt-0!" : "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>
|
||||
{not(color) && (
|
||||
<TableCell label="Color">
|
||||
<ColorPosition color={color} />
|
||||
</TableCell>
|
||||
)}
|
||||
{not(height) && <TableCell label="Height">{numberValue(height!, 0)}</TableCell>}
|
||||
{not(distance) && <TableCell label="Distance">{numberValue(distance!, 0)}</TableCell>}
|
||||
{not(rotation) && <TableCell label="Rotation">{numberValue(rotation!, 0)}</TableCell>}
|
||||
{not(size) && <TableCell label="Size">{numberValue(size!, 0)}</TableCell>}
|
||||
{not(stretch) && <TableCell label="Stretch">{numberValue(stretch!, 0)}</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, birthday, datingPreferences, voice, personality } = instructions;
|
||||
|
||||
return (
|
||||
<>
|
||||
{head && (
|
||||
<Section name="Head" instructions={head}>
|
||||
{not(head.skinColor) && (
|
||||
<TableCell label="Skin Color">
|
||||
<ColorPosition color={head.skinColor} />
|
||||
</TableCell>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
{hair && (
|
||||
<Section name="Hair" instructions={hair}>
|
||||
{not(hair.subColor) && (
|
||||
<TableCell label="Sub Color">
|
||||
<ColorPosition color={hair.subColor} />
|
||||
</TableCell>
|
||||
)}
|
||||
{not(hair.subColor2) && (
|
||||
<TableCell label="Sub Color (Back)">
|
||||
<ColorPosition color={hair.subColor2} />
|
||||
</TableCell>
|
||||
)}
|
||||
{not(hair.style) && <TableCell label="Tying Style">{hair.style}</TableCell>}
|
||||
{not(hair.isFlipped) && <TableCell label="Flipped">{hair.isFlipped ? "Yes" : "No"}</TableCell>}
|
||||
</Section>
|
||||
)}
|
||||
{eyebrows && <Section name="Eyebrows" instructions={eyebrows}></Section>}
|
||||
{eyes && (
|
||||
<Section name="Eyes" instructions={eyes}>
|
||||
<Section isSubSection name="Tab 1" instructions={eyes.main} />
|
||||
<Section isSubSection name="Tab 2" instructions={eyes.eyelashesTop} />
|
||||
<Section isSubSection name="Tab 3" instructions={eyes.eyelashesBottom} />
|
||||
<Section isSubSection name="Tab 4" instructions={eyes.eyelidTop} />
|
||||
<Section isSubSection name="Tab 5" instructions={eyes.eyelidBottom} />
|
||||
<Section isSubSection name="Tab 6" instructions={eyes.eyeliner} />
|
||||
<Section isSubSection name="Tab 7" instructions={eyes.pupil} />
|
||||
</Section>
|
||||
)}
|
||||
{nose && <Section name="Nose" instructions={nose}></Section>}
|
||||
{lips && (
|
||||
<Section name="Lips" instructions={lips}>
|
||||
{not(lips.hasLipstick) && <TableCell label="Lipstick">{lips.hasLipstick ? "Yes" : "No"}</TableCell>}
|
||||
</Section>
|
||||
)}
|
||||
{ears && <Section name="Ears" instructions={ears}></Section>}
|
||||
{glasses && (
|
||||
<Section name="Glasses" instructions={glasses}>
|
||||
{not(glasses.ringColor) && (
|
||||
<TableCell label="Ring Color">
|
||||
<ColorPosition color={glasses.ringColor} />
|
||||
</TableCell>
|
||||
)}
|
||||
{not(glasses.shadesColor) && (
|
||||
<TableCell label="Shades Color">
|
||||
<ColorPosition color={glasses.shadesColor} />
|
||||
</TableCell>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
{other && (
|
||||
<Section name="Other" instructions={other}>
|
||||
<Section isSubSection name="Tab 1" instructions={other.wrinkles1} />
|
||||
<Section isSubSection name="Tab 2" instructions={other.wrinkles2} />
|
||||
<Section isSubSection name="Tab 3" instructions={other.beard} />
|
||||
<Section isSubSection name="Tab 4" instructions={other.moustache}>
|
||||
{other.moustache && other.moustache.isFlipped && <TableCell label="Flipped">{other.moustache.isFlipped ? "Yes" : "No"}</TableCell>}
|
||||
</Section>
|
||||
<Section isSubSection name="Tab 5" instructions={other.goatee} />
|
||||
<Section isSubSection name="Tab 6" instructions={other.mole as any} />
|
||||
<Section isSubSection name="Tab 7" instructions={other.eyeShadow} />
|
||||
<Section isSubSection name="Tab 8" instructions={other.blush} />
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{(height || weight || datingPreferences || voice || personality) && (
|
||||
<div className="p-3 border-l-4 border-amber-400 bg-amber-100/50 rounded-r-lg py-2.5 text-amber-950 w-max">
|
||||
<h3 className="font-semibold text-xl text-amber-800 mb-1">Misc</h3>
|
||||
|
||||
<table className="w-full">
|
||||
<tbody>
|
||||
{not(height) && <TableCell label="Height">{numberValue(height!, 64)}</TableCell>}
|
||||
{not(weight) && <TableCell label="Weight">{numberValue(weight!, 64)}</TableCell>}
|
||||
</tbody>
|
||||
</table>
|
||||
{birthday && (
|
||||
<div className="pl-2 not-nth-2:mt-4">
|
||||
<h4 className="font-semibold text-xl text-amber-800 mb-1">Birthday</h4>
|
||||
<table className="w-full">
|
||||
<tbody>
|
||||
{not(birthday.day) && <TableCell label="Day">{birthday.day}</TableCell>}
|
||||
{not(birthday.month) && <TableCell label="Month">{birthday.month}</TableCell>}
|
||||
{not(birthday.age) && <TableCell label="Age">{birthday.age}</TableCell>}
|
||||
{not(birthday.dontAge) && <TableCell label="Don't Age">{birthday.dontAge ? "Yes" : "No"}</TableCell>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{voice && (
|
||||
<div className="pl-2 not-nth-2:mt-4">
|
||||
<h4 className="font-semibold text-xl text-amber-800 mb-1">Voice</h4>
|
||||
<table className="w-full">
|
||||
<tbody>
|
||||
{not(voice.speed) && <TableCell label="Speed">{numberValue(voice.speed!, 25)}</TableCell>}
|
||||
{not(voice.pitch) && <TableCell label="Pitch">{numberValue(voice.pitch!, 25)}</TableCell>}
|
||||
{not(voice.depth) && <TableCell label="Depth">{numberValue(voice.depth!, 25)}</TableCell>}
|
||||
{not(voice.delivery) && <TableCell label="Delivery">{numberValue(voice.delivery!, 25)}</TableCell>}
|
||||
{not(voice.tone) && <TableCell label="Tone">{voice.tone}</TableCell>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{datingPreferences && (
|
||||
<div className="pl-2 not-nth-2:mt-4">
|
||||
<h4 className="font-semibold text-xl text-amber-800 mb-1">Dating Preferences</h4>
|
||||
<div className="w-min">
|
||||
<DatingPreferencesViewer data={datingPreferences} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{personality && (
|
||||
<div className="pl-2 not-nth-2:mt-4">
|
||||
<h4 className="font-semibold text-xl text-amber-800 mb-1">Personality</h4>
|
||||
<div className="w-min">
|
||||
<PersonalityViewer data={personality} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,9 +9,27 @@ export default function OtherFilters() {
|
|||
const [, startTransition] = useTransition();
|
||||
|
||||
const platform = (searchParams.get("platform") as MiiPlatform) || undefined;
|
||||
const [hasShareMiiFile, setHasShareMiiFile] = useState<boolean>((searchParams.get("sharemii") as unknown as boolean) ?? false);
|
||||
const [allowCopying, setAllowCopying] = useState<boolean>((searchParams.get("allowCopying") as unknown as boolean) ?? false);
|
||||
const [quarantined, setQuarantined] = useState<boolean>((searchParams.get("quarantined") as unknown as boolean) ?? false);
|
||||
|
||||
const handleChangeHasShareMiiFile = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setHasShareMiiFile(e.target.checked);
|
||||
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set("page", "1");
|
||||
|
||||
if (!hasShareMiiFile) {
|
||||
params.set("isFromSaveFile", "true");
|
||||
} else {
|
||||
params.delete("isFromSaveFile");
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
navigate(`?${params.toString()}`);
|
||||
});
|
||||
};
|
||||
|
||||
const handleChangeAllowCopying = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setAllowCopying(e.target.checked);
|
||||
|
||||
|
|
@ -59,6 +77,14 @@ export default function OtherFilters() {
|
|||
<hr className="grow border-zinc-300" />
|
||||
</div>
|
||||
|
||||
{platform !== "THREE_DS" && (
|
||||
<div className="flex justify-between items-center w-full mb-1">
|
||||
<label htmlFor="ltdfile" className="text-sm">
|
||||
Has ShareMii File
|
||||
</label>
|
||||
<input type="checkbox" id="ltdfile" className="checkbox-alt" checked={hasShareMiiFile} onChange={handleChangeHasShareMiiFile} />
|
||||
</div>
|
||||
)}
|
||||
{showAllowCopying && (
|
||||
<div className="flex justify-between items-center w-full mb-1">
|
||||
<label htmlFor="allowCopying" className="text-sm">
|
||||
|
|
|
|||
|
|
@ -1,73 +1,83 @@
|
|||
import { useCallback, useState } from "react";
|
||||
import { type FileWithPath } from "react-dropzone";
|
||||
import { Icon } from "@iconify/react";
|
||||
import Dropzone from "../dropzone";
|
||||
import Camera from "./camera";
|
||||
import ImageEditorPortrait from "./image-editor";
|
||||
|
||||
interface Props {
|
||||
text: string;
|
||||
forceCrop?: boolean;
|
||||
image?: string | undefined;
|
||||
setImage: (value: string | undefined) => void;
|
||||
}
|
||||
|
||||
export default function SwitchFileUpload({ text, forceCrop, image, setImage }: Props) {
|
||||
const [isCameraOpen, setIsCameraOpen] = useState(false);
|
||||
const [isCropOpen, setIsCropOpen] = useState(false);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(acceptedFiles: FileWithPath[]) => {
|
||||
const file = acceptedFiles[0];
|
||||
// Convert to Data URI
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (event) => {
|
||||
setImage(event.target!.result as string);
|
||||
if (forceCrop) setIsCropOpen(true);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
},
|
||||
[setImage],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="max-w-md w-full flex flex-col items-center gap-2">
|
||||
<Dropzone onDrop={handleDrop} options={{ maxFiles: 1 }}>
|
||||
<p className="text-center text-sm">
|
||||
{!image ? (
|
||||
<>
|
||||
Drag and drop {text}
|
||||
<br />
|
||||
or click to open
|
||||
</>
|
||||
) : (
|
||||
"Uploaded!"
|
||||
)}
|
||||
</p>
|
||||
</Dropzone>
|
||||
|
||||
<span>or</span>
|
||||
|
||||
<div className="flex gap-2 max-sm:flex-col">
|
||||
<button type="button" aria-label="Use your camera" onClick={() => setIsCameraOpen(true)} className="pill button gap-2">
|
||||
<Icon icon="mdi:camera" fontSize={20} />
|
||||
Use your camera
|
||||
</button>
|
||||
<button type="button" aria-label="Crop image" onClick={() => setIsCropOpen(true)} className="pill button gap-2">
|
||||
<Icon icon="mdi:image-edit" fontSize={20} />
|
||||
Edit Image
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Camera
|
||||
isOpen={isCameraOpen}
|
||||
setIsOpen={setIsCameraOpen}
|
||||
setImage={setImage}
|
||||
onCapture={() => {
|
||||
if (forceCrop) setIsCropOpen(true);
|
||||
}}
|
||||
/>
|
||||
<ImageEditorPortrait isOpen={isCropOpen} setIsOpen={setIsCropOpen} image={image} setImage={setImage} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { useCallback, useState } from "react";
|
||||
import { type FileWithPath } from "react-dropzone";
|
||||
import { Icon } from "@iconify/react";
|
||||
import Dropzone from "../dropzone";
|
||||
import Camera from "./camera";
|
||||
import ImageEditorPortrait from "./image-editor";
|
||||
|
||||
interface Props {
|
||||
text: string;
|
||||
type?: "file" | "image";
|
||||
forceCrop?: boolean;
|
||||
file?: string | File | undefined;
|
||||
setFile?: (value: File | undefined) => void;
|
||||
image?: string | undefined;
|
||||
setImage?: (value: string | undefined) => void;
|
||||
}
|
||||
|
||||
export default function SwitchFileUpload({ text, type = "image", forceCrop, file, setFile, image, setImage }: Props) {
|
||||
const [isCameraOpen, setIsCameraOpen] = useState(false);
|
||||
const [isCropOpen, setIsCropOpen] = useState(false);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(acceptedFiles: FileWithPath[]) => {
|
||||
const file = acceptedFiles[0];
|
||||
if (type === "file") {
|
||||
setFile!(file);
|
||||
} else {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
setImage!(event.target!.result as string);
|
||||
if (forceCrop) setIsCropOpen(true);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
},
|
||||
[setFile, setImage],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="max-w-md w-full flex flex-col items-center gap-2">
|
||||
<Dropzone type={type} onDrop={handleDrop} options={{ maxFiles: 1 }}>
|
||||
<p className="text-center text-sm">
|
||||
{!file && !image ? (
|
||||
<>
|
||||
Drag and drop {text}
|
||||
<br />
|
||||
or click to open
|
||||
</>
|
||||
) : (
|
||||
"Uploaded!"
|
||||
)}
|
||||
</p>
|
||||
</Dropzone>
|
||||
|
||||
{type === "image" && (
|
||||
<>
|
||||
<span>or</span>
|
||||
|
||||
<div className="flex gap-2 max-sm:flex-col">
|
||||
<button type="button" aria-label="Use your camera" onClick={() => setIsCameraOpen(true)} className="pill button gap-2">
|
||||
<Icon icon="mdi:camera" fontSize={20} />
|
||||
Use your camera
|
||||
</button>
|
||||
<button type="button" aria-label="Crop image" onClick={() => setIsCropOpen(true)} className="pill button gap-2">
|
||||
<Icon icon="mdi:image-edit" fontSize={20} />
|
||||
Edit Image
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Camera
|
||||
isOpen={isCameraOpen}
|
||||
setIsOpen={setIsCameraOpen}
|
||||
setImage={setImage}
|
||||
onCapture={() => {
|
||||
if (forceCrop) setIsCropOpen(true);
|
||||
}}
|
||||
/>
|
||||
<ImageEditorPortrait isOpen={isCropOpen} setIsOpen={setIsCropOpen} image={image} setImage={setImage!} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,205 +1,211 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import useEmblaCarousel from "embla-carousel-react";
|
||||
import { Icon } from "@iconify/react";
|
||||
import confetti from "canvas-confetti";
|
||||
|
||||
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, duration: 15 });
|
||||
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-xl 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"
|
||||
>
|
||||
<img
|
||||
src={tutorial.thumbnail!}
|
||||
alt="tutorial thumbnail"
|
||||
width={128}
|
||||
height={128}
|
||||
className="rounded-lg border-2 border-zinc-300 object-cover"
|
||||
/>
|
||||
<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>
|
||||
|
||||
<img
|
||||
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>
|
||||
);
|
||||
}
|
||||
import { useEffect, useState } from "react";
|
||||
import useEmblaCarousel from "embla-carousel-react";
|
||||
import { Icon } from "@iconify/react";
|
||||
import confetti from "canvas-confetti";
|
||||
|
||||
interface Slide {
|
||||
type?: "start" | "step" | "finish"; // step is never specified, undefined is assumed as step
|
||||
text?: string;
|
||||
link?: 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, duration: 15 });
|
||||
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-xl 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"
|
||||
>
|
||||
<img
|
||||
src={tutorial.thumbnail!}
|
||||
alt="tutorial thumbnail"
|
||||
width={128}
|
||||
height={128}
|
||||
className="rounded-lg border-2 border-zinc-300 object-cover"
|
||||
/>
|
||||
<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>
|
||||
) : (
|
||||
<>
|
||||
{slide.link ? (
|
||||
<a href={slide.link} className="text-sm text-blue-600 mb-2 text-center">
|
||||
{slide.text}
|
||||
</a>
|
||||
) : (
|
||||
<p className="text-sm text-zinc-500 mb-2 text-center">{slide.text}</p>
|
||||
)}
|
||||
|
||||
<img
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,54 +1,78 @@
|
|||
import { useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Icon } from "@iconify/react";
|
||||
import Tutorial from ".";
|
||||
|
||||
export default function SwitchAddMiiTutorialButton() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<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(
|
||||
<Tutorial
|
||||
tutorials={[
|
||||
{
|
||||
title: "Adding Mii",
|
||||
steps: [
|
||||
{
|
||||
text: "1. Press X to open the menu, then select 'Add a Mii'",
|
||||
imageSrc: "/tutorial/switch/adding-mii/step1.jpg",
|
||||
},
|
||||
{
|
||||
text: "2. Press 'From scratch' and choose the Male template",
|
||||
imageSrc: "/tutorial/switch/adding-mii/step2.jpg",
|
||||
},
|
||||
{
|
||||
text: "3. Click on the features image on this page to zoom it in and add all features on the mii editor",
|
||||
imageSrc: "/tutorial/switch/adding-mii/step3.png",
|
||||
},
|
||||
{
|
||||
text: "4. If the author added instructions, follow them (not all instructions will be there, check next slide for more)",
|
||||
imageSrc: "/tutorial/switch/adding-mii/step4.jpg",
|
||||
},
|
||||
{
|
||||
text: "5. For instructions like height or distance, use the number of button clicks (positive for buttons on right, negative for buttons on left)",
|
||||
imageSrc: "/tutorial/switch/step4.jpg",
|
||||
},
|
||||
{ type: "finish" },
|
||||
],
|
||||
},
|
||||
]}
|
||||
isOpen={isOpen}
|
||||
setIsOpen={setIsOpen}
|
||||
/>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
import { useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Icon } from "@iconify/react";
|
||||
import Tutorial from ".";
|
||||
|
||||
export default function SwitchAddMiiTutorialButton() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<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(
|
||||
<Tutorial
|
||||
tutorials={[
|
||||
{
|
||||
title: "ShareMii (Modded)",
|
||||
thumbnail: "/tutorial/switch/adding-mii/modded/thumbnail.png",
|
||||
steps: [
|
||||
{ type: "start" },
|
||||
{
|
||||
text: "1. Download ShareMii - click here for link",
|
||||
link: "https://gamebanana.com/tools/22305",
|
||||
imageSrc: "/tutorial/switch/adding-mii/modded/step1.jpg",
|
||||
},
|
||||
{
|
||||
text: "2. Download the .ltd file, it is above the instructions next to all the other buttons",
|
||||
imageSrc: "/tutorial/switch/adding-mii/modded/step2.png",
|
||||
},
|
||||
{
|
||||
text: "3. Follow the instructions by the creator (scroll down to importing) - click here for link",
|
||||
link: "https://docs.google.com/document/d/e/2PACX-1vRSaPbTe0pijDSETzdeGhvQ7zYHlx9Qnxn7WdUqG9cveZYyk405A0LSbYnl8ygTNI_ZZqMrIZLeHenr/pub",
|
||||
imageSrc: "/tutorial/switch/adding-mii/modded/step3.jpg",
|
||||
},
|
||||
{ type: "finish" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Manual",
|
||||
thumbnail: "/tutorial/switch/adding-mii/manual/thumbnail.png",
|
||||
steps: [
|
||||
{ type: "start" },
|
||||
{
|
||||
text: "1. Press X to open the menu, then select 'Add a Mii'",
|
||||
imageSrc: "/tutorial/switch/adding-mii/manual/step1.jpg",
|
||||
},
|
||||
{
|
||||
text: "2. Press 'From scratch' and choose the Male template",
|
||||
imageSrc: "/tutorial/switch/adding-mii/manual/step2.jpg",
|
||||
},
|
||||
{
|
||||
text: "3. Click on the features image on this page to zoom it in and add all features on the mii editor (This won't work if the Mii is from a save file! You can see the icons in the instructions)",
|
||||
imageSrc: "/tutorial/switch/adding-mii/manual/step3.png",
|
||||
},
|
||||
{
|
||||
text: "4. If the Mii has instructions, follow them (not all instructions will be there if not from save data, check next slide for more)",
|
||||
imageSrc: "/tutorial/switch/adding-mii/manual/step4.jpg",
|
||||
},
|
||||
{
|
||||
text: "5. For instructions like height or distance, use the number of button clicks (positive for buttons on right, negative for buttons on left)",
|
||||
imageSrc: "/tutorial/switch/step4.jpg",
|
||||
},
|
||||
{ type: "finish" },
|
||||
],
|
||||
},
|
||||
]}
|
||||
isOpen={isOpen}
|
||||
setIsOpen={setIsOpen}
|
||||
/>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,48 +1,67 @@
|
|||
import { useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import Tutorial from ".";
|
||||
|
||||
export default function SwitchSubmitTutorialButton() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={() => setIsOpen(true)} className="text-sm text-orange-400 cursor-pointer underline-offset-2 hover:underline">
|
||||
How to?
|
||||
</button>
|
||||
|
||||
{isOpen &&
|
||||
createPortal(
|
||||
<Tutorial
|
||||
tutorials={[
|
||||
{
|
||||
title: "Submitting",
|
||||
steps: [
|
||||
{
|
||||
text: "1. Press X to open the menu, then select 'Residents'",
|
||||
imageSrc: "/tutorial/switch/submitting/step1.jpg",
|
||||
},
|
||||
{
|
||||
text: "2. Find the Mii you want to submit and edit it",
|
||||
imageSrc: "/tutorial/switch/submitting/step2.jpg",
|
||||
},
|
||||
{
|
||||
text: "3. Press Y to open the features list, then take a screenshot and upload to this submit form",
|
||||
imageSrc: "/tutorial/switch/submitting/step3.jpg",
|
||||
},
|
||||
{
|
||||
text: "4. Adding Mii colors and settings is recommended. All instructions are optional; for values like height or distance, use the number of button clicks (positive for buttons on right, negative for buttons on left)",
|
||||
imageSrc: "/tutorial/switch/step4.jpg",
|
||||
},
|
||||
{ type: "finish" },
|
||||
],
|
||||
},
|
||||
]}
|
||||
isOpen={isOpen}
|
||||
setIsOpen={setIsOpen}
|
||||
/>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
import { useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import Tutorial from ".";
|
||||
|
||||
interface Props {
|
||||
type: "savedata" | "manual";
|
||||
}
|
||||
|
||||
export default function SwitchSubmitTutorialButton({ type }: Props) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={() => setIsOpen(true)} className="text-sm text-orange-400 cursor-pointer underline-offset-2 hover:underline">
|
||||
How to?
|
||||
</button>
|
||||
|
||||
{isOpen &&
|
||||
createPortal(
|
||||
<Tutorial
|
||||
tutorials={[
|
||||
{
|
||||
title: "Submitting",
|
||||
steps:
|
||||
type === "savedata"
|
||||
? [
|
||||
{
|
||||
text: "1. Download ShareMii - click here for link",
|
||||
link: "https://gamebanana.com/tools/22305",
|
||||
imageSrc: "/tutorial/switch/adding-mii/modded/step1.jpg",
|
||||
},
|
||||
{
|
||||
text: "2. Follow the instructions by the creator (scroll down to exporting) - click here for link",
|
||||
link: "https://docs.google.com/document/d/e/2PACX-1vRSaPbTe0pijDSETzdeGhvQ7zYHlx9Qnxn7WdUqG9cveZYyk405A0LSbYnl8ygTNI_ZZqMrIZLeHenr/pub",
|
||||
imageSrc: "/tutorial/switch/adding-mii/modded/step3.jpg",
|
||||
},
|
||||
{ type: "finish" },
|
||||
]
|
||||
: [
|
||||
{
|
||||
text: "1. Press X to open the menu, then select 'Residents'",
|
||||
imageSrc: "/tutorial/switch/submitting/step1.jpg",
|
||||
},
|
||||
{
|
||||
text: "2. Find the Mii you want to submit and edit it",
|
||||
imageSrc: "/tutorial/switch/submitting/step2.jpg",
|
||||
},
|
||||
{
|
||||
text: "3. Press Y to open the features list, then take a screenshot and upload to this submit form",
|
||||
imageSrc: "/tutorial/switch/submitting/step3.jpg",
|
||||
},
|
||||
{
|
||||
text: "4. Adding Mii colors and settings is recommended. All instructions are optional; for values like height or distance, use the number of button clicks (positive for buttons on right, negative for buttons on left)",
|
||||
imageSrc: "/tutorial/switch/step4.jpg",
|
||||
},
|
||||
{ type: "finish" },
|
||||
],
|
||||
},
|
||||
]}
|
||||
isOpen={isOpen}
|
||||
setIsOpen={setIsOpen}
|
||||
/>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -423,7 +423,7 @@ export default function EditMiiPage() {
|
|||
<div className="flex flex-col items-center gap-2">
|
||||
<SwitchFileUpload text="a screenshot of your Mii here" image={miiPortraitUri} setImage={handleMiiPortraitChange} forceCrop />
|
||||
<SwitchFileUpload text="a screenshot of your Mii's features here" image={miiFeaturesUri} setImage={handleMiiFeaturesChange} />
|
||||
<SwitchSubmitTutorialButton />
|
||||
<SwitchSubmitTutorialButton type="manual" />
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-zinc-400 text-center mt-2">You must upload a screenshot of the features, check tutorial on how.</p>
|
||||
|
|
@ -457,7 +457,7 @@ export default function EditMiiPage() {
|
|||
</div>
|
||||
|
||||
<MiiEditor instructions={instructions} />
|
||||
<SwitchSubmitTutorialButton />
|
||||
<SwitchSubmitTutorialButton type="manual" />
|
||||
</>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -135,13 +135,47 @@ export default function MiiPage() {
|
|||
/>
|
||||
</div>
|
||||
) : (
|
||||
<ImageViewer
|
||||
src={`${API_URL}/mii/${mii.id}/image?type=features`}
|
||||
alt="mii features"
|
||||
width={300}
|
||||
height={300}
|
||||
className="rounded-lg hover:brightness-90 mb-4 transition-all"
|
||||
/>
|
||||
<>
|
||||
<ImageViewer
|
||||
src={`${API_URL}/mii/${mii.id}/image?type=features`}
|
||||
alt="mii features"
|
||||
width={300}
|
||||
height={300}
|
||||
className="rounded-lg hover:brightness-90 mb-4 transition-all"
|
||||
/>
|
||||
|
||||
{mii.isFromSaveFile && (
|
||||
<>
|
||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mb-4 w-full">
|
||||
<hr className="grow border-zinc-300" />
|
||||
<span>Face Paint Texture</span>
|
||||
<hr className="grow border-zinc-300" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="rounded-lg mb-4 overflow-hidden"
|
||||
style={{
|
||||
backgroundImage: `
|
||||
linear-gradient(45deg, #ccc 25%, transparent 25%),
|
||||
linear-gradient(-45deg, #ccc 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, #ccc 75%),
|
||||
linear-gradient(-45deg, transparent 75%, #ccc 75%)
|
||||
`,
|
||||
backgroundSize: "16px 16px",
|
||||
backgroundPosition: "0 0, 0 8px, 8px -8px, -8px 0px",
|
||||
}}
|
||||
>
|
||||
<ImageViewer
|
||||
src={`${API_URL}/mii/${mii.id}/image?type=facepaint`}
|
||||
alt="mii facepaint"
|
||||
width={300}
|
||||
height={300}
|
||||
className="rounded-lg hover:brightness-90 transition-all"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<hr className="w-full border-t-2 border-t-amber-400" />
|
||||
|
||||
|
|
@ -340,9 +374,15 @@ export default function MiiPage() {
|
|||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex gap-3 w-fit bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 text-3xl text-orange-400 max-md:place-self-center *:size-12 *:flex *:flex-col *:items-center *:gap-1 **:transition-discrete **:duration-150 *:hover:brightness-75 *:hover:scale-[1.08] *:[&_span]:text-xs">
|
||||
<div className="flex gap-4 w-fit bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 text-3xl text-orange-400 max-md:place-self-center *:size-12 *:flex *:flex-col *:items-center *:gap-1 **:transition-discrete **:duration-150 *:hover:brightness-75 *:hover:scale-[1.08] *:[&_span]:text-xs">
|
||||
<AuthorButtons mii={mii} />
|
||||
|
||||
{mii.isFromSaveFile && (
|
||||
<a aria-label="Download Mii" href={`${API_URL}/mii/${mii.id}/download`} download>
|
||||
<Icon icon="material-symbols:download" />
|
||||
<span>Download</span>
|
||||
</a>
|
||||
)}
|
||||
<ShareMiiButton miiId={mii.id} />
|
||||
<Link aria-label="Report Mii" to={`/report/mii/${mii.id}`}>
|
||||
<Icon icon="material-symbols:flag-rounded" />
|
||||
|
|
@ -353,11 +393,16 @@ export default function MiiPage() {
|
|||
|
||||
{/* Instructions */}
|
||||
{mii.platform === "SWITCH" && (
|
||||
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex flex-col gap-3 max-h-96 overflow-y-auto">
|
||||
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex flex-col 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>
|
||||
<p className="text-xs text-amber-800 mb-3">
|
||||
All instructions are based off of the default Male Mii.
|
||||
<br />
|
||||
{mii.isFromSaveFile && "If you're on modded/emulator, you can download the .ltd file above."}
|
||||
</p>
|
||||
|
||||
{mii.youtubeId && (
|
||||
<iframe
|
||||
|
|
|
|||
|
|
@ -58,13 +58,14 @@ export default function SubmitPage() {
|
|||
const [platform, setPlatform] = useState<MiiPlatform>("SWITCH");
|
||||
const [gender, setGender] = useState<MiiGender>("MALE");
|
||||
const [makeup, setMakeup] = useState<MiiMakeup>("PARTIAL");
|
||||
const [way, setWay] = useState<"savedata" | "manual" | null>(null);
|
||||
const [miiDataFile, setMiiDataFile] = useState<File | undefined>();
|
||||
const [youtubeId, setYouTubeId] = useState("");
|
||||
const instructions = useRef<SwitchMiiInstructions>(defaultInstructions);
|
||||
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
// Validate before sending request
|
||||
const nameValidation = nameSchema.safeParse(name);
|
||||
if (!nameValidation.success) {
|
||||
setError(nameValidation.error.issues[0].message);
|
||||
|
|
@ -76,32 +77,26 @@ export default function SubmitPage() {
|
|||
return;
|
||||
}
|
||||
|
||||
// Send request to server
|
||||
const formData = new FormData();
|
||||
formData.append("platform", platform);
|
||||
formData.append("name", name);
|
||||
formData.append("tags", JSON.stringify(tags));
|
||||
formData.append("description", description);
|
||||
formData.append("youtubeId", youtubeId);
|
||||
files.forEach((file, index) => {
|
||||
// image1, image2, etc.
|
||||
formData.append(`image${index + 1}`, file);
|
||||
});
|
||||
|
||||
if (platform === "THREE_DS") {
|
||||
formData.append("qrBytesRaw", JSON.stringify(qrBytesRaw));
|
||||
} else if (platform === "SWITCH") {
|
||||
} else if (platform === "SWITCH" && way) {
|
||||
const portraitResponse = await fetch(miiPortraitUri!);
|
||||
const featuresResponse = await fetch(miiFeaturesUri!);
|
||||
|
||||
if (!portraitResponse.ok || !featuresResponse.ok) {
|
||||
setError("Failed to get Mii portrait/features screenshot. Did you upload one?");
|
||||
if (!portraitResponse.ok) {
|
||||
setError("Failed to get Mii portrait screenshot. Did you upload one?");
|
||||
return;
|
||||
}
|
||||
|
||||
const portraitBlob = await portraitResponse.blob();
|
||||
const featuresBlob = await featuresResponse.blob();
|
||||
if (!portraitBlob.type.startsWith("image/") || !featuresBlob.type.startsWith("image/")) {
|
||||
if (!portraitBlob.type.startsWith("image/")) {
|
||||
setError("Invalid image file found");
|
||||
return;
|
||||
}
|
||||
|
|
@ -109,8 +104,32 @@ export default function SubmitPage() {
|
|||
formData.append("gender", gender);
|
||||
formData.append("makeup", makeup);
|
||||
formData.append("miiPortraitImage", portraitBlob);
|
||||
formData.append("way", way);
|
||||
|
||||
const featuresResponse = await fetch(miiFeaturesUri!);
|
||||
if (!featuresResponse.ok) {
|
||||
setError("Failed to get Mii features screenshot. Did you upload one?");
|
||||
return;
|
||||
}
|
||||
|
||||
const featuresBlob = await featuresResponse.blob();
|
||||
if (!featuresBlob.type.startsWith("image/")) {
|
||||
setError("Invalid image file found");
|
||||
return;
|
||||
}
|
||||
|
||||
if (way === "savedata") {
|
||||
if (!miiDataFile) {
|
||||
setError("Failed to find Mii data file, did you upload one?");
|
||||
return;
|
||||
}
|
||||
formData.append("miiDataFile", miiDataFile);
|
||||
}
|
||||
|
||||
formData.append("miiFeaturesImage", featuresBlob);
|
||||
formData.append("instructions", JSON.stringify(instructions.current));
|
||||
|
||||
formData.append("youtubeId", youtubeId);
|
||||
}
|
||||
|
||||
const response = await fetch(`${import.meta.env.VITE_API_URL}/api/submit`, {
|
||||
|
|
@ -121,7 +140,7 @@ export default function SubmitPage() {
|
|||
const { id, error } = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
setError(String(error)); // app can crash if error message is not a string
|
||||
setError(String(error));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -129,19 +148,17 @@ export default function SubmitPage() {
|
|||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (platform === "SWITCH" || qrBytesRaw.length == 0) return;
|
||||
if (platform !== "THREE_DS" || qrBytesRaw.length === 0) return;
|
||||
const qrBytes = new Uint8Array(qrBytesRaw);
|
||||
|
||||
const preview = async () => {
|
||||
setError("");
|
||||
|
||||
// Validate QR code size
|
||||
if (qrBytesRaw.length !== 372) {
|
||||
setError("QR code size is not a valid Tomodachi Life QR code");
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert QR code to JS (3DS)
|
||||
let conversion: { mii: Mii; tomodachiLifeMii: ThreeDsTomodachiLifeMii };
|
||||
try {
|
||||
conversion = convertQrCode(qrBytes);
|
||||
|
|
@ -151,13 +168,11 @@ export default function SubmitPage() {
|
|||
return;
|
||||
}
|
||||
|
||||
// Generate a new QR code for aesthetic reasons
|
||||
try {
|
||||
const byteString = String.fromCharCode(...qrBytes);
|
||||
const generatedCode = qrcode(0, "L");
|
||||
generatedCode.addData(byteString, "Byte");
|
||||
generatedCode.make();
|
||||
|
||||
setGeneratedQrCodeUri(generatedCode.createDataURL());
|
||||
} catch {
|
||||
setError("Failed to regenerate QR code");
|
||||
|
|
@ -194,7 +209,6 @@ export default function SubmitPage() {
|
|||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-auto">
|
||||
<LikeButton likes={0} isLiked={false} disabled />
|
||||
</div>
|
||||
|
|
@ -209,7 +223,6 @@ export default function SubmitPage() {
|
|||
<p className="text-sm text-zinc-500">Share your creation for others to see.</p>
|
||||
</div>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium my-1">
|
||||
<hr className="grow border-zinc-300" />
|
||||
<span>Info</span>
|
||||
|
|
@ -222,33 +235,23 @@ export default function SubmitPage() {
|
|||
Platform
|
||||
</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"
|
||||
}`}
|
||||
></div>
|
||||
|
||||
{/* Switch button */}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPlatform("SWITCH")}
|
||||
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!"
|
||||
}`}
|
||||
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" />
|
||||
Switch
|
||||
</button>
|
||||
|
||||
{/* 3DS button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPlatform("THREE_DS")}
|
||||
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!"
|
||||
}`}
|
||||
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" />
|
||||
3DS
|
||||
|
|
@ -307,43 +310,34 @@ export default function SubmitPage() {
|
|||
onClick={() => setGender("MALE")}
|
||||
aria-label="Filter for Male Miis"
|
||||
data-tooltip="Male"
|
||||
className={`cursor-pointer rounded-xl flex justify-center items-center size-11 text-4xl border-2 transition-all after:bg-blue-400! after:border-blue-400! before:border-b-blue-400! ${
|
||||
gender === "MALE" ? "bg-blue-100 border-blue-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
|
||||
}`}
|
||||
className={`cursor-pointer rounded-xl flex justify-center items-center size-11 text-4xl border-2 transition-all after:bg-blue-400! after:border-blue-400! before:border-b-blue-400! ${gender === "MALE" ? "bg-blue-100 border-blue-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"}`}
|
||||
>
|
||||
<Icon icon="foundation:male" className="text-blue-400" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setGender("FEMALE")}
|
||||
aria-label="Filter for Female Miis"
|
||||
data-tooltip="Female"
|
||||
className={`cursor-pointer rounded-xl flex justify-center items-center size-11 text-4xl border-2 transition-all after:bg-pink-400! after:border-pink-400! before:border-b-pink-400! ${
|
||||
gender === "FEMALE" ? "bg-pink-100 border-pink-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
|
||||
}`}
|
||||
className={`cursor-pointer rounded-xl flex justify-center items-center size-11 text-4xl border-2 transition-all after:bg-pink-400! after:border-pink-400! before:border-b-pink-400! ${gender === "FEMALE" ? "bg-pink-100 border-pink-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"}`}
|
||||
>
|
||||
<Icon icon="foundation:female" className="text-pink-400" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setGender("NONBINARY")}
|
||||
aria-label="Filter for Nonbinary Miis"
|
||||
data-tooltip="Nonbinary"
|
||||
className={`cursor-pointer rounded-xl flex justify-center items-center size-11 text-4xl border-2 transition-all after:bg-purple-400! after:border-purple-400! before:border-b-purple-400! ${
|
||||
gender === "NONBINARY" ? "bg-purple-100 border-purple-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
|
||||
}`}
|
||||
className={`cursor-pointer rounded-xl flex justify-center items-center size-11 text-4xl border-2 transition-all after:bg-purple-400! after:border-purple-400! before:border-b-purple-400! ${gender === "NONBINARY" ? "bg-purple-100 border-purple-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"}`}
|
||||
>
|
||||
<Icon icon="mdi:gender-non-binary" className="text-purple-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Makeup (switch only) */}
|
||||
{/* Makeup (switch only) — unchanged from base */}
|
||||
<div className={`w-full grid grid-cols-3 items-start ${platform === "SWITCH" ? "" : "hidden"}`}>
|
||||
<label className="font-semibold py-2">Face Paint</label>
|
||||
|
||||
<div className="col-span-2 flex flex-col gap-1.5">
|
||||
{[
|
||||
{ value: "FULL", label: "Full", desc: "Most of the face/features are covered", color: "pink" },
|
||||
|
|
@ -365,9 +359,34 @@ export default function SubmitPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* (Switch Only) Mii Screenshots */}
|
||||
{/* (Switch only) Choose a Way */}
|
||||
<div className={`${platform === "SWITCH" ? "" : "hidden"}`}>
|
||||
{/* Separator */}
|
||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-8 mb-2">
|
||||
<hr className="grow border-zinc-300" />
|
||||
<span>Choose a Way</span>
|
||||
<hr className="grow border-zinc-300" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 w-full">
|
||||
<button
|
||||
onClick={() => setWay("savedata")}
|
||||
type="button"
|
||||
className={`flex flex-col justify-center items-center rounded-xl p-4 shadow-md border-2 cursor-pointer text-center text-sm transition hover:scale-[1.03] ${way === "savedata" ? "bg-cyan-100 border-cyan-600" : "bg-zinc-50 border-zinc-300 hover:bg-cyan-100 hover:border-cyan-600"}`}
|
||||
>
|
||||
ShareMii file (.ltd) (Modded)
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setWay("manual")}
|
||||
type="button"
|
||||
className={`flex flex-col justify-center items-center rounded-xl p-4 shadow-md border-2 cursor-pointer text-center text-sm transition hover:scale-[1.03] ${way === "manual" ? "bg-cyan-100 border-cyan-600" : "bg-zinc-50 border-zinc-300 hover:bg-cyan-100 hover:border-cyan-600"}`}
|
||||
>
|
||||
Manual
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-zinc-400 text-center mt-2">To see a tutorial, select a method above and click the 'How to?' buttons that appear.</p>
|
||||
</div>
|
||||
|
||||
{/* (Switch only) Mii Screenshots */}
|
||||
<div className={`${platform === "SWITCH" && way ? "" : "hidden"}`}>
|
||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-8 mb-2">
|
||||
<hr className="grow border-zinc-300" />
|
||||
<span>Mii Screenshots</span>
|
||||
|
|
@ -396,7 +415,7 @@ export default function SubmitPage() {
|
|||
</div>
|
||||
|
||||
{/* Step 2 - Features */}
|
||||
<div className="flex flex-col items-center gap-2 w-full">
|
||||
<div className="flex flex-col items-center gap-2 w-full mt-4">
|
||||
<div className="flex items-center gap-2 self-start">
|
||||
<span className="bg-orange-400 text-white text-xs font-bold rounded-full size-5 flex items-center justify-center shrink-0">2</span>
|
||||
<span className="text-sm font-semibold text-zinc-600">
|
||||
|
|
@ -415,12 +434,27 @@ export default function SubmitPage() {
|
|||
</div>
|
||||
<SwitchFileUpload text="a screenshot of your Mii's features here" image={miiFeaturesUri} setImage={setMiiFeaturesUri} forceCrop />
|
||||
</div>
|
||||
<SwitchSubmitTutorialButton type="manual" />
|
||||
<p className="text-xs text-zinc-400 text-center">A tutorial on how to screenshot the features is above.</p>
|
||||
</div>
|
||||
|
||||
<SwitchSubmitTutorialButton />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-zinc-400 text-center mt-2">A tutorial on how to screenshot the features is above.</p>
|
||||
{/* (Switch only) ShareMii file upload */}
|
||||
<div className={`${platform === "SWITCH" && way === "savedata" ? "" : "hidden"}`}>
|
||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-8 mb-2">
|
||||
<hr className="grow border-zinc-300" />
|
||||
<span>ShareMii File</span>
|
||||
<hr className="grow border-zinc-300" />
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<SwitchFileUpload type="file" text="your Mii's .ltd file" file={miiDataFile} setFile={setMiiDataFile} />
|
||||
<SwitchSubmitTutorialButton type="savedata" />
|
||||
<p className="text-xs text-zinc-400 text-center">Only the v3 format is supported, please make sure ShareMii is up to date.</p>
|
||||
<p className="text-xs text-zinc-400 text-center mb-2">
|
||||
Unfortunately, at this time we can't automatically generate instructions from a .ltd file.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* (3DS only) QR code scanning */}
|
||||
|
|
@ -430,33 +464,27 @@ export default function SubmitPage() {
|
|||
<span>QR Code</span>
|
||||
<hr className="grow border-zinc-300" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<QrUpload setQrBytesRaw={setQrBytesRaw} />
|
||||
<span>or</span>
|
||||
|
||||
<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>
|
||||
|
||||
<Camera isOpen={isQrScannerOpen} setIsOpen={setIsQrScannerOpen} setQrBytesRaw={setQrBytesRaw} />
|
||||
<ThreeDsScanTutorialButton />
|
||||
|
||||
<span className="text-xs text-zinc-400">For emulators, aes_keys.txt is required.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* (Switch only) Mii instructions */}
|
||||
<div className={`${platform === "SWITCH" ? "" : "hidden"}`}>
|
||||
{/* (Switch only) Mii Instructions */}
|
||||
<div className={`${platform === "SWITCH" && way ? "" : "hidden"}`}>
|
||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-8 mb-2">
|
||||
<hr className="grow border-zinc-300" />
|
||||
<span>Mii Instructions</span>
|
||||
<hr className="grow border-zinc-300" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
{/* YouTube */}
|
||||
<div className="w-full grid grid-cols-3 items-center">
|
||||
<label htmlFor="youtube" className="font-semibold">
|
||||
YouTube Video
|
||||
|
|
@ -476,40 +504,37 @@ export default function SubmitPage() {
|
|||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<MiiEditor instructions={instructions} />
|
||||
<SwitchSubmitTutorialButton />
|
||||
<SwitchSubmitTutorialButton type="manual" />
|
||||
<span className="text-xs text-zinc-400 text-center px-32 max-sm:px-8">
|
||||
Mii editor may be inaccurate. Instructions are REALLY recommended, but you do not have to add every instruction.
|
||||
Mii editor may be inaccurate. Instructions are recommended, but not required - you do not have to add every instruction.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom images selector */}
|
||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-6 mb-2">
|
||||
<hr className="grow border-zinc-300" />
|
||||
<span>Custom images</span>
|
||||
<hr className="grow border-zinc-300" />
|
||||
<div className={`${platform === "THREE_DS" || way ? "" : "hidden"} flex flex-col justify-center`}>
|
||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-6 mb-2">
|
||||
<hr className="grow border-zinc-300" />
|
||||
<span>Custom images</span>
|
||||
<hr className="grow border-zinc-300" />
|
||||
</div>
|
||||
<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
|
||||
<br />
|
||||
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} />
|
||||
</div>
|
||||
|
||||
<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
|
||||
<br />
|
||||
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} />
|
||||
|
||||
<hr className="border-zinc-300 my-2" />
|
||||
<div className="flex justify-between items-center">
|
||||
{error && <span className="text-red-400 font-bold">Error: {error}</span>}
|
||||
|
||||
<SubmitButton onClick={handleSubmit} className="ml-auto" />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||