feat: vite test

This commit is contained in:
trafficlunar 2026-04-17 14:24:40 +01:00
parent d208565a61
commit 1d11cf3f99
122 changed files with 6922 additions and 16846 deletions

View file

@ -1,255 +1,255 @@
import { useCallback, useEffect, useRef, useState } from "react";
import jsQR from "jsqr";
import { Icon } from "@iconify/react";
import QrFinder from "./qr-finder";
import { useSelect } from "downshift";
interface Props {
isOpen: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
onCapture?: () => void;
setImage?: (value: string | undefined) => void;
setQrBytesRaw?: React.Dispatch<React.SetStateAction<number[]>>;
}
export default function Camera({ isOpen, setIsOpen, onCapture, setImage, setQrBytesRaw }: Props) {
const [isVisible, setIsVisible] = useState(false);
const [permissionGranted, setPermissionGranted] = useState<boolean | null>(null);
const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);
const [selectedDeviceId, setSelectedDeviceId] = useState<string | null>(null);
const videoRef = useRef<HTMLVideoElement>(null);
const streamRef = useRef<MediaStream | null>(null);
const requestRef = useRef<number>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const cameraItems = devices.map((device) => ({
value: device.deviceId,
label: device.label || `Camera ${devices.indexOf(device) + 1}`,
}));
const {
isOpen: isDropdownOpen,
getToggleButtonProps,
getMenuProps,
getItemProps,
highlightedIndex,
selectedItem,
} = useSelect({
items: cameraItems,
selectedItem: cameraItems.find((item) => item.value === selectedDeviceId) ?? null,
onSelectedItemChange: ({ selectedItem }) => {
setSelectedDeviceId(selectedItem?.value ?? null);
},
});
const takePicture = useCallback(() => {
if (!isOpen) return;
// Continue scanning in a loop
if (setQrBytesRaw) requestRef.current = requestAnimationFrame(takePicture);
const video = videoRef.current;
const canvas = canvasRef.current;
if (!video || video.videoWidth === 0 || video.videoHeight === 0 || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
ctx.drawImage(video, 0, 0, video.videoWidth, video.videoHeight);
if (setImage) {
setImage(canvas.toDataURL());
if (onCapture) onCapture();
close();
return;
}
if (!setQrBytesRaw) return;
const imageData = ctx.getImageData(0, 0, video.videoWidth, video.videoHeight);
const code = jsQR(imageData.data, imageData.width, imageData.height);
if (!code || !code.binaryData) return;
// Cancel animation frame to stop scanning
if (requestRef.current) {
cancelAnimationFrame(requestRef.current);
requestRef.current = null;
}
setQrBytesRaw(code.binaryData);
close();
}, [isOpen, setIsOpen, setQrBytesRaw]);
const requestPermission = () => {
if (!navigator.mediaDevices) return;
navigator.mediaDevices
.getUserMedia({ video: true, audio: false })
.then((stream) => {
// immediately stop this temp stream
stream.getTracks().forEach((track) => track.stop());
setPermissionGranted(true);
})
.catch((err) => {
setPermissionGranted(false);
console.error("An error occurred trying to access the camera", err);
});
};
const stopCamera = () => {
if (requestRef.current) {
cancelAnimationFrame(requestRef.current);
requestRef.current = null;
}
if (videoRef.current) {
videoRef.current.pause();
videoRef.current.srcObject = null;
}
if (streamRef.current) {
streamRef.current.getTracks().forEach((track) => track.stop());
streamRef.current = null;
}
};
const close = () => {
stopCamera();
setIsVisible(false);
setTimeout(() => {
setIsOpen(false);
}, 300);
};
useEffect(() => {
if (isOpen) {
// slight delay to trigger animation
setTimeout(() => setIsVisible(true), 10);
requestPermission();
}
}, [isOpen]);
useEffect(() => {
if (!isOpen || !permissionGranted) return;
navigator.mediaDevices
.enumerateDevices()
.then((devices) => {
const videoDevices = devices.filter((d) => d.kind === "videoinput");
setDevices(videoDevices);
const targetDeviceId = selectedDeviceId || videoDevices[0]?.deviceId;
if (!targetDeviceId) return;
setSelectedDeviceId(targetDeviceId);
// start camera stream
return navigator.mediaDevices.getUserMedia({
video: { deviceId: targetDeviceId },
audio: false,
});
})
.then((stream) => {
if (!stream || !videoRef.current) return;
streamRef.current = stream;
videoRef.current.srcObject = stream;
videoRef.current.play();
})
.catch((err) => console.error("Camera error", err));
if (setQrBytesRaw) requestRef.current = requestAnimationFrame(takePicture);
// cleanup
return () => {
stopCamera();
};
}, [isOpen, permissionGranted, selectedDeviceId]);
return (
<div className={`fixed inset-0 h-[calc(100%-var(--header-height))] top-(--header-height) flex items-center justify-center z-40 ${!isOpen ? "hidden" : ""}`}>
<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 ${
isVisible ? "scale-100 opacity-100" : "scale-75 opacity-0"
}`}
>
<div className="flex justify-between items-center mb-2">
<h2 className="text-xl font-bold">{setQrBytesRaw ? "Scan QR Code" : "Take Picture"}</h2>
<button type="button" aria-label="Close" onClick={close} className="text-red-400 hover:text-red-500 text-2xl cursor-pointer">
<Icon icon="material-symbols:close-rounded" />
</button>
</div>
<div className={`mb-4 flex flex-col gap-1 ${devices.length <= 1 ? "hidden" : ""}`}>
<label className="text-sm font-semibold">Camera:</label>
<div className="relative w-full">
{/* Toggle button to open the dropdown */}
<button
type="button"
aria-label="Select camera dropdown"
{...getToggleButtonProps({}, { suppressRefError: true })}
className="pill input w-full px-2! py-0.5! justify-between! text-sm"
>
{selectedItem?.label || "Select a camera"}
<Icon icon="tabler:chevron-down" className="ml-2 size-5" />
</button>
{/* Dropdown menu */}
<ul
{...getMenuProps({}, { suppressRefError: true })}
className={`absolute z-50 w-full bg-orange-200 border-2 border-orange-400 rounded-lg mt-1 shadow-lg max-h-60 overflow-y-auto ${
isDropdownOpen ? "block" : "hidden"
}`}
>
{isDropdownOpen &&
cameraItems.map((item, index) => (
<li
key={item.value}
{...getItemProps({ item, index })}
className={`px-4 py-1 cursor-pointer text-sm ${highlightedIndex === index ? "bg-black/15" : ""}`}
>
{item.label}
</li>
))}
</ul>
</div>
</div>
<div className={`relative w-full ${setQrBytesRaw ? "aspect-square" : ""}`}>
{!permissionGranted && (
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center rounded-2xl bg-amber-50 border-2 border-amber-500 text-center p-8">
<p className="text-red-400 font-bold text-lg mb-2">Camera access denied</p>
<p className="text-gray-600">Please allow camera access in your browser settings to {setQrBytesRaw ? "scan QR codes" : "take pictures"}</p>
<button type="button" onClick={requestPermission} className="pill button text-xs mt-2 py-0.5! px-2!">
Request Permission
</button>
</div>
)}
<div className="rounded-2xl border-2 border-amber-500 max-h-96 flex justify-center items-center overflow-hidden">
<img src="/loading.svg" alt="loading indicator" width={256} height={256} className="absolute" />
<video ref={videoRef} className={`size-full z-10 ${setQrBytesRaw ? "object-cover aspect-square" : ""}`} />
</div>
{setQrBytesRaw && <QrFinder />}
<canvas ref={canvasRef} className="hidden" />
</div>
<div className="mt-4 flex justify-center gap-2">
<button type="button" onClick={close} className="pill button">
Cancel
</button>
{setImage && (
<button type="button" onClick={takePicture} className="pill button">
Take Picture
</button>
)}
</div>
</div>
</div>
);
}
import { useCallback, useEffect, useRef, useState } from "react";
import jsQR from "jsqr";
import { Icon } from "@iconify/react";
import QrFinder from "./qr-finder";
import { useSelect } from "downshift";
interface Props {
isOpen: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
onCapture?: () => void;
setImage?: (value: string | undefined) => void;
setQrBytesRaw?: React.Dispatch<React.SetStateAction<number[]>>;
}
export default function Camera({ isOpen, setIsOpen, onCapture, setImage, setQrBytesRaw }: Props) {
const [isVisible, setIsVisible] = useState(false);
const [permissionGranted, setPermissionGranted] = useState<boolean | null>(null);
const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);
const [selectedDeviceId, setSelectedDeviceId] = useState<string | null>(null);
const videoRef = useRef<HTMLVideoElement>(null);
const streamRef = useRef<MediaStream | null>(null);
const requestRef = useRef<number>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const cameraItems = devices.map((device) => ({
value: device.deviceId,
label: device.label || `Camera ${devices.indexOf(device) + 1}`,
}));
const {
isOpen: isDropdownOpen,
getToggleButtonProps,
getMenuProps,
getItemProps,
highlightedIndex,
selectedItem,
} = useSelect({
items: cameraItems,
selectedItem: cameraItems.find((item) => item.value === selectedDeviceId) ?? null,
onSelectedItemChange: ({ selectedItem }) => {
setSelectedDeviceId(selectedItem?.value ?? null);
},
});
const takePicture = useCallback(() => {
if (!isOpen) return;
// Continue scanning in a loop
if (setQrBytesRaw) requestRef.current = requestAnimationFrame(takePicture);
const video = videoRef.current;
const canvas = canvasRef.current;
if (!video || video.videoWidth === 0 || video.videoHeight === 0 || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
ctx.drawImage(video, 0, 0, video.videoWidth, video.videoHeight);
if (setImage) {
setImage(canvas.toDataURL());
if (onCapture) onCapture();
close();
return;
}
if (!setQrBytesRaw) return;
const imageData = ctx.getImageData(0, 0, video.videoWidth, video.videoHeight);
const code = jsQR(imageData.data, imageData.width, imageData.height);
if (!code || !code.binaryData) return;
// Cancel animation frame to stop scanning
if (requestRef.current) {
cancelAnimationFrame(requestRef.current);
requestRef.current = null;
}
setQrBytesRaw(code.binaryData);
close();
}, [isOpen, setIsOpen, setQrBytesRaw]);
const requestPermission = () => {
if (!navigator.mediaDevices) return;
navigator.mediaDevices
.getUserMedia({ video: true, audio: false })
.then((stream) => {
// immediately stop this temp stream
stream.getTracks().forEach((track) => track.stop());
setPermissionGranted(true);
})
.catch((err) => {
setPermissionGranted(false);
console.error("An error occurred trying to access the camera", err);
});
};
const stopCamera = () => {
if (requestRef.current) {
cancelAnimationFrame(requestRef.current);
requestRef.current = null;
}
if (videoRef.current) {
videoRef.current.pause();
videoRef.current.srcObject = null;
}
if (streamRef.current) {
streamRef.current.getTracks().forEach((track) => track.stop());
streamRef.current = null;
}
};
const close = () => {
stopCamera();
setIsVisible(false);
setTimeout(() => {
setIsOpen(false);
}, 300);
};
useEffect(() => {
if (isOpen) {
// slight delay to trigger animation
setTimeout(() => setIsVisible(true), 10);
requestPermission();
}
}, [isOpen]);
useEffect(() => {
if (!isOpen || !permissionGranted) return;
navigator.mediaDevices
.enumerateDevices()
.then((devices) => {
const videoDevices = devices.filter((d) => d.kind === "videoinput");
setDevices(videoDevices);
const targetDeviceId = selectedDeviceId || videoDevices[0]?.deviceId;
if (!targetDeviceId) return;
setSelectedDeviceId(targetDeviceId);
// start camera stream
return navigator.mediaDevices.getUserMedia({
video: { deviceId: targetDeviceId },
audio: false,
});
})
.then((stream) => {
if (!stream || !videoRef.current) return;
streamRef.current = stream;
videoRef.current.srcObject = stream;
videoRef.current.play();
})
.catch((err) => console.error("Camera error", err));
if (setQrBytesRaw) requestRef.current = requestAnimationFrame(takePicture);
// cleanup
return () => {
stopCamera();
};
}, [isOpen, permissionGranted, selectedDeviceId]);
return (
<div className={`fixed inset-0 h-[calc(100%-var(--header-height))] top-(--header-height) flex items-center justify-center z-40 ${!isOpen ? "hidden" : ""}`}>
<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 ${
isVisible ? "scale-100 opacity-100" : "scale-75 opacity-0"
}`}
>
<div className="flex justify-between items-center mb-2">
<h2 className="text-xl font-bold">{setQrBytesRaw ? "Scan QR Code" : "Take Picture"}</h2>
<button type="button" aria-label="Close" onClick={close} className="text-red-400 hover:text-red-500 text-2xl cursor-pointer">
<Icon icon="material-symbols:close-rounded" />
</button>
</div>
<div className={`mb-4 flex flex-col gap-1 ${devices.length <= 1 ? "hidden" : ""}`}>
<label className="text-sm font-semibold">Camera:</label>
<div className="relative w-full">
{/* Toggle button to open the dropdown */}
<button
type="button"
aria-label="Select camera dropdown"
{...getToggleButtonProps({}, { suppressRefError: true })}
className="pill input w-full px-2! py-0.5! justify-between! text-sm"
>
{selectedItem?.label || "Select a camera"}
<Icon icon="tabler:chevron-down" className="ml-2 size-5" />
</button>
{/* Dropdown menu */}
<ul
{...getMenuProps({}, { suppressRefError: true })}
className={`absolute z-50 w-full bg-orange-200 border-2 border-orange-400 rounded-lg mt-1 shadow-lg max-h-60 overflow-y-auto ${
isDropdownOpen ? "block" : "hidden"
}`}
>
{isDropdownOpen &&
cameraItems.map((item, index) => (
<li
key={item.value}
{...getItemProps({ item, index })}
className={`px-4 py-1 cursor-pointer text-sm ${highlightedIndex === index ? "bg-black/15" : ""}`}
>
{item.label}
</li>
))}
</ul>
</div>
</div>
<div className={`relative w-full ${setQrBytesRaw ? "aspect-square" : ""}`}>
{!permissionGranted && (
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center rounded-2xl bg-amber-50 border-2 border-amber-500 text-center p-8">
<p className="text-red-400 font-bold text-lg mb-2">Camera access denied</p>
<p className="text-gray-600">Please allow camera access in your browser settings to {setQrBytesRaw ? "scan QR codes" : "take pictures"}</p>
<button type="button" onClick={requestPermission} className="pill button text-xs mt-2 py-0.5! px-2!">
Request Permission
</button>
</div>
)}
<div className="rounded-2xl border-2 border-amber-500 max-h-96 flex justify-center items-center overflow-hidden">
<img src="/loading.svg" alt="loading indicator" width={256} height={256} className="absolute" />
<video ref={videoRef} className={`size-full z-10 ${setQrBytesRaw ? "object-cover aspect-square" : ""}`} />
</div>
{setQrBytesRaw && <QrFinder />}
<canvas ref={canvasRef} className="hidden" />
</div>
<div className="mt-4 flex justify-center gap-2">
<button type="button" onClick={close} className="pill button">
Cancel
</button>
{setImage && (
<button type="button" onClick={takePicture} className="pill button">
Take Picture
</button>
)}
</div>
</div>
</div>
);
}

View file

@ -1,456 +1,456 @@
// import { redirect } from "next/navigation";
// import { useCallback, useEffect, useRef, useState } from "react";
// import { FileWithPath } from "react-dropzone";
// import { Mii, MiiGender, MiiMakeup } from "@prisma/client";
// import { useSession } from "next-auth/react";
// import { nameSchema, tagsSchema } from "@tomodachi-share/shared/schemas";
// import { defaultInstructions, minifyInstructions } from "@/lib/switch";
// import { SwitchMiiInstructions } from "@tomodachi-share/shared";
// import TagSelector from "../tag-selector";
// import ImageList from "./image-list";
// import LikeButton from "../like-button";
// import Carousel from "../carousel";
// import SubmitButton from "../submit-button";
// import Dropzone from "../dropzone";
// import MiiEditor from "./mii-editor";
// import SwitchSubmitTutorialButton from "../tutorial/switch-submit";
// import { Icon } from "@iconify/react";
// import SwitchFileUpload from "./switch-file-upload";
// interface Props {
// mii: Mii;
// likes: number;
// }
// function deepMerge<T>(target: T, source: Partial<T>): T {
// const output = structuredClone(target);
// if (typeof source !== "object" || source === null) return output;
// for (const key in source) {
// const sourceValue = source[key];
// const targetValue = (output as any)[key];
// if (typeof sourceValue === "object" && sourceValue !== null && !Array.isArray(sourceValue)) {
// (output as any)[key] = deepMerge(targetValue, sourceValue);
// } else {
// (output as any)[key] = sourceValue;
// }
// }
// return output;
// }
// export default function EditForm({ mii, likes }: Props) {
// const session = useSession();
// const [files, setFiles] = useState<FileWithPath[]>([]);
// const handleFilesChange: React.Dispatch<React.SetStateAction<FileWithPath[]>> = (updater) => {
// hasCustomImagesChanged.current = true;
// setFiles(updater);
// };
// const handleDrop = useCallback(
// (acceptedFiles: FileWithPath[]) => {
// if (files.length >= 3) return;
// hasCustomImagesChanged.current = true;
// setFiles((prev) => [...prev, ...acceptedFiles]);
// },
// [files.length],
// );
// const [error, setError] = useState<string | undefined>(undefined);
// const [name, setName] = useState(mii.name);
// const [tags, setTags] = useState(mii.tags);
// const [description, setDescription] = useState(mii.description);
// const [gender, setGender] = useState<MiiGender>(mii.gender ?? "MALE");
// const [makeup, setMakeup] = useState<MiiMakeup>(mii.makeup ?? "PARTIAL");
// const [miiPortraitUri, setMiiPortraitUri] = useState<string | undefined>(`/mii/${mii.id}/image?type=mii`);
// const [miiFeaturesUri, setMiiFeaturesUri] = useState<string | undefined>(`/mii/${mii.id}/image?type=features`);
// const [youtubeId, setYouTubeId] = useState(mii.youtubeId ?? "");
// const instructions = useRef<SwitchMiiInstructions>(deepMerge(defaultInstructions, (mii.instructions as object) ?? {}));
// const [quarantined, setQuarantined] = useState(mii.quarantined);
// const hasCustomImagesChanged = useRef(false);
// const hasMiiPortraitChanged = useRef(false);
// const hasMiiFeaturesChanged = useRef(false);
// const handleSubmit = async () => {
// // Validate before sending request
// const nameValidation = nameSchema.safeParse(name);
// if (!nameValidation.success) {
// setError(nameValidation.error.issues[0].message);
// return;
// }
// const tagsValidation = tagsSchema.safeParse(tags);
// if (!tagsValidation.success) {
// setError(tagsValidation.error.issues[0].message);
// return;
// }
// // Send request to server
// const formData = new FormData();
// if (name != mii.name) formData.append("name", name);
// if (tags != mii.tags) formData.append("tags", JSON.stringify(tags));
// if (description && description != mii.description) formData.append("description", description);
// if (gender != mii.gender) formData.append("gender", gender);
// if (makeup != mii.makeup) formData.append("makeup", makeup);
// if (miiPortraitUri) formData.append("miiPortraitUri", miiPortraitUri);
// if (quarantined != mii.quarantined) formData.append("quarantined", JSON.stringify(quarantined));
// if (youtubeId != mii.youtubeId) formData.append("youtubeId", youtubeId);
// if (minifyInstructions(structuredClone(instructions.current)) !== (mii.instructions as object))
// formData.append("instructions", JSON.stringify(instructions.current));
// if (hasCustomImagesChanged.current) {
// files.forEach((file, index) => {
// // image1, image2, etc.
// formData.append(`image${index + 1}`, file);
// });
// }
// // Switch pictures
// async function getBlob(uri: string): Promise<Blob | null> {
// const response = await fetch(uri);
// if (!response.ok) {
// setError("Failed to get Mii portrait/features screenshot. Did you upload one?");
// return null;
// }
// const blob = await response.blob();
// if (!blob.type.startsWith("image/")) {
// setError("Invalid image file found");
// return null;
// }
// return blob;
// }
// if (miiPortraitUri && hasMiiPortraitChanged.current) {
// const blob = await getBlob(miiPortraitUri);
// if (blob) formData.append("miiPortraitImage", blob);
// }
// if (miiFeaturesUri && hasMiiFeaturesChanged.current) {
// const blob = await getBlob(miiFeaturesUri);
// if (blob) formData.append("miiFeaturesImage", blob);
// }
// const response = await fetch(`/api/mii/${mii.id}/edit`, {
// method: "PATCH",
// body: formData,
// });
// const { error } = await response.json();
// if (!response.ok) {
// setError(error);
// return;
// }
// redirect(`/mii/${mii.id}`);
// };
// const handleMiiPortraitChange = (uri: string | undefined) => {
// hasMiiPortraitChanged.current = true;
// setMiiPortraitUri(uri);
// };
// const handleMiiFeaturesChange = (uri: string | undefined) => {
// hasMiiFeaturesChanged.current = true;
// setMiiFeaturesUri(uri);
// };
// // Load existing images - converts image URLs to File objects
// useEffect(() => {
// const loadExistingImages = async () => {
// try {
// const existing = await Promise.all(
// Array.from({ length: mii.imageCount }, async (_, index) => {
// const path = `/mii/${mii.id}/image?type=image${index}`;
// const response = await fetch(path);
// const blob = await response.blob();
// return Object.assign(new File([blob], `image${index}.png`, { type: "image/png" }), { path });
// }),
// );
// setFiles(existing);
// } catch (error) {
// console.error("Error loading existing images:", error);
// }
// };
// loadExistingImages();
// }, [mii.id, mii.imageCount]);
// return (
// <div className="flex justify-center gap-4 w-full max-lg:flex-col max-lg:items-center">
// <div className="flex justify-center">
// <div className="w-75 h-min flex flex-col bg-zinc-50 rounded-3xl border-2 border-zinc-300 shadow-lg p-3">
// <Carousel
// images={[
// miiPortraitUri ?? `/mii/${mii.id}/image?type=mii`,
// ...(mii.platform === "THREE_DS" ? [`/mii/${mii.id}/image?type=qr-code`] : [miiFeaturesUri ?? `/mii/${mii.id}/image?type=features`]),
// ...files.map((file) => URL.createObjectURL(file)),
// ]}
// />
// <div className="p-4 flex flex-col gap-1 h-full">
// <h1 className="font-bold text-2xl line-clamp-1" title={name}>
// {name || "Mii name"}
// </h1>
// <div id="tags" className="flex flex-wrap gap-1">
// {tags.length == 0 && <span className="px-2 py-1 bg-orange-300 rounded-full text-xs">tag</span>}
// {tags.map((tag) => (
// <span key={tag} className="px-2 py-1 bg-orange-300 rounded-full text-xs">
// {tag}
// </span>
// ))}
// </div>
// <div className="mt-auto">
// <LikeButton likes={likes} isLiked={false} abbreviate disabled />
// </div>
// </div>
// </div>
// </div>
// <div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex flex-col gap-2 max-w-2xl w-full">
// <div>
// <h2 className="text-2xl font-bold">Edit your Mii</h2>
// <p className="text-sm text-zinc-500">Make changes to your existing Mii.</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>
// <hr className="grow border-zinc-300" />
// </div>
// <div className="w-full grid grid-cols-3 items-center">
// <label htmlFor="name" className="font-semibold">
// Name
// </label>
// <input
// id="name"
// type="text"
// className="pill input w-full col-span-2"
// minLength={2}
// maxLength={64}
// placeholder="Type your mii's name here..."
// value={name}
// onChange={(e) => setName(e.target.value)}
// />
// </div>
// <div className="w-full grid grid-cols-3 items-center">
// <label htmlFor="tags" className="font-semibold">
// Tags
// </label>
// <TagSelector tags={tags} setTags={setTags} showTagLimit />
// </div>
// <div className="w-full grid grid-cols-3 items-start">
// <label htmlFor="reason-note" className="font-semibold py-2">
// Description
// </label>
// <textarea
// rows={5}
// maxLength={512}
// placeholder="(optional) Type a description..."
// className="pill input rounded-xl! resize-none col-span-2 text-sm"
// value={description ?? ""}
// onChange={(e) => setDescription(e.target.value)}
// />
// </div>
// {session.data?.user?.id == import.meta.env.NEXT_PUBLIC_ADMIN_USER_ID && (
// <>
// <div className="w-full grid grid-cols-3 items-center">
// <label htmlFor="quarantined" className="font-semibold py-2">
// Quarantined
// </label>
// <div className="col-span-2 flex gap-1">
// <input type="checkbox" id="quarantined" className="checkbox-alt" checked={quarantined} onChange={(e) => setQuarantined(e.target.checked)} />
// </div>
// </div>
// </>
// )}
// {/* Makeup/Images/Instructions (Switch only) */}
// {mii.platform === "SWITCH" && (
// <>
// <div className="w-full grid grid-cols-3 items-start z-20">
// <label htmlFor="gender" className="font-semibold py-2">
// Gender
// </label>
// <div className="col-span-2 flex gap-1">
// <button
// type="button"
// 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"
// }`}
// >
// <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"
// }`}
// >
// <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"
// }`}
// >
// <Icon icon="mdi:gender-non-binary" className="text-purple-400" />
// </button>
// </div>
// </div>
// <div className="w-full grid grid-cols-3 items-start">
// <label htmlFor="makeup" className="font-semibold py-2">
// Face Paint
// </label>
// <div className="col-span-2 flex gap-1">
// {/* Full Makeup */}
// <button
// type="button"
// onClick={() => setMakeup("FULL")}
// aria-label="Full Face Paint"
// data-tooltip="Full Face Paint"
// className={`cursor-pointer rounded-xl flex justify-center items-center size-11 text-4xl border-2 transition-all after:bg-pink-400! after:border-pink-400! before:border-b-pink-400! ${
// makeup === "FULL" ? "bg-pink-100 border-pink-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
// }`}
// >
// <Icon icon="mdi:palette" className="text-pink-400" />
// </button>
// {/* Partial Makeup */}
// <button
// type="button"
// onClick={() => setMakeup("PARTIAL")}
// aria-label="Partial Face Paint"
// data-tooltip="Partial Face Paint"
// className={`cursor-pointer rounded-xl flex justify-center items-center size-11 text-4xl border-2 transition-all after:bg-purple-400! after:border-purple-400! before:border-b-purple-400! ${
// makeup === "PARTIAL" ? "bg-purple-100 border-purple-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
// }`}
// >
// <Icon icon="mdi:lipstick" className="text-purple-400" />
// </button>
// {/* No Makeup */}
// <button
// type="button"
// onClick={() => setMakeup("NONE")}
// aria-label="No Face Paint"
// data-tooltip="No Face Paint"
// className={`cursor-pointer rounded-xl flex justify-center items-center size-11 text-4xl border-2 transition-all after:bg-gray-400! after:border-gray-400! before:border-b-gray-400! ${
// makeup === "NONE" ? "bg-gray-200 border-gray-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
// }`}
// >
// <Icon icon="codex:cross" className="text-gray-400" />
// </button>
// </div>
// </div>
// {/* (Switch Only) Mii Portrait */}
// <div>
// {/* 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>Mii Portrait</span>
// <hr className="grow border-zinc-300" />
// </div>
// <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 />
// </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>
// </div>
// <div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-8">
// <hr className="grow border-zinc-300" />
// <span>Instructions</span>
// <hr className="grow border-zinc-300" />
// </div>
// {/* YouTube */}
// <div className="w-full grid grid-cols-3 items-center">
// <label htmlFor="youtube" className="font-semibold">
// YouTube Video
// </label>
// <input
// id="youtube"
// type="text"
// className="pill input w-full col-span-2"
// minLength={2}
// maxLength={64}
// placeholder="Paste a URL or video ID..."
// value={youtubeId}
// onChange={(e) => {
// const val = e.target.value;
// const match = val.match(/(?:youtube\.com\/(?:watch\?v=|shorts\/|embed\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/);
// setYouTubeId(match ? match[1] : val);
// }}
// />
// </div>
// <MiiEditor instructions={instructions} />
// <SwitchSubmitTutorialButton />
// </>
// )}
// {/* Separator */}
// <div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-8">
// <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">
// <Dropzone onDrop={handleDrop}>
// <p className="text-center text-sm">
// Drag and drop your images here
// <br />
// or click to open
// </p>
// </Dropzone>
// </div>
// <ImageList files={files} setFiles={handleFilesChange} />
// <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} text="Edit" className="ml-auto" />
// </div>
// </div>
// </div>
// );
// }
// import { redirect } from "next/navigation";
// import { useCallback, useEffect, useRef, useState } from "react";
// import { FileWithPath } from "react-dropzone";
// import { Mii, MiiGender, MiiMakeup } from "@prisma/client";
// import { useSession } from "next-auth/react";
// import { nameSchema, tagsSchema } from "@tomodachi-share/shared/schemas";
// import { defaultInstructions, minifyInstructions } from "@/lib/switch";
// import { SwitchMiiInstructions } from "@tomodachi-share/shared";
// import TagSelector from "../tag-selector";
// import ImageList from "./image-list";
// import LikeButton from "../like-button";
// import Carousel from "../carousel";
// import SubmitButton from "../submit-button";
// import Dropzone from "../dropzone";
// import MiiEditor from "./mii-editor";
// import SwitchSubmitTutorialButton from "../tutorial/switch-submit";
// import { Icon } from "@iconify/react";
// import SwitchFileUpload from "./switch-file-upload";
// interface Props {
// mii: Mii;
// likes: number;
// }
// function deepMerge<T>(target: T, source: Partial<T>): T {
// const output = structuredClone(target);
// if (typeof source !== "object" || source === null) return output;
// for (const key in source) {
// const sourceValue = source[key];
// const targetValue = (output as any)[key];
// if (typeof sourceValue === "object" && sourceValue !== null && !Array.isArray(sourceValue)) {
// (output as any)[key] = deepMerge(targetValue, sourceValue);
// } else {
// (output as any)[key] = sourceValue;
// }
// }
// return output;
// }
// export default function EditForm({ mii, likes }: Props) {
// const session = useSession();
// const [files, setFiles] = useState<FileWithPath[]>([]);
// const handleFilesChange: React.Dispatch<React.SetStateAction<FileWithPath[]>> = (updater) => {
// hasCustomImagesChanged.current = true;
// setFiles(updater);
// };
// const handleDrop = useCallback(
// (acceptedFiles: FileWithPath[]) => {
// if (files.length >= 3) return;
// hasCustomImagesChanged.current = true;
// setFiles((prev) => [...prev, ...acceptedFiles]);
// },
// [files.length],
// );
// const [error, setError] = useState<string | undefined>(undefined);
// const [name, setName] = useState(mii.name);
// const [tags, setTags] = useState(mii.tags);
// const [description, setDescription] = useState(mii.description);
// const [gender, setGender] = useState<MiiGender>(mii.gender ?? "MALE");
// const [makeup, setMakeup] = useState<MiiMakeup>(mii.makeup ?? "PARTIAL");
// const [miiPortraitUri, setMiiPortraitUri] = useState<string | undefined>(`/mii/${mii.id}/image?type=mii`);
// const [miiFeaturesUri, setMiiFeaturesUri] = useState<string | undefined>(`/mii/${mii.id}/image?type=features`);
// const [youtubeId, setYouTubeId] = useState(mii.youtubeId ?? "");
// const instructions = useRef<SwitchMiiInstructions>(deepMerge(defaultInstructions, (mii.instructions as object) ?? {}));
// const [quarantined, setQuarantined] = useState(mii.quarantined);
// const hasCustomImagesChanged = useRef(false);
// const hasMiiPortraitChanged = useRef(false);
// const hasMiiFeaturesChanged = useRef(false);
// const handleSubmit = async () => {
// // Validate before sending request
// const nameValidation = nameSchema.safeParse(name);
// if (!nameValidation.success) {
// setError(nameValidation.error.issues[0].message);
// return;
// }
// const tagsValidation = tagsSchema.safeParse(tags);
// if (!tagsValidation.success) {
// setError(tagsValidation.error.issues[0].message);
// return;
// }
// // Send request to server
// const formData = new FormData();
// if (name != mii.name) formData.append("name", name);
// if (tags != mii.tags) formData.append("tags", JSON.stringify(tags));
// if (description && description != mii.description) formData.append("description", description);
// if (gender != mii.gender) formData.append("gender", gender);
// if (makeup != mii.makeup) formData.append("makeup", makeup);
// if (miiPortraitUri) formData.append("miiPortraitUri", miiPortraitUri);
// if (quarantined != mii.quarantined) formData.append("quarantined", JSON.stringify(quarantined));
// if (youtubeId != mii.youtubeId) formData.append("youtubeId", youtubeId);
// if (minifyInstructions(structuredClone(instructions.current)) !== (mii.instructions as object))
// formData.append("instructions", JSON.stringify(instructions.current));
// if (hasCustomImagesChanged.current) {
// files.forEach((file, index) => {
// // image1, image2, etc.
// formData.append(`image${index + 1}`, file);
// });
// }
// // Switch pictures
// async function getBlob(uri: string): Promise<Blob | null> {
// const response = await fetch(uri);
// if (!response.ok) {
// setError("Failed to get Mii portrait/features screenshot. Did you upload one?");
// return null;
// }
// const blob = await response.blob();
// if (!blob.type.startsWith("image/")) {
// setError("Invalid image file found");
// return null;
// }
// return blob;
// }
// if (miiPortraitUri && hasMiiPortraitChanged.current) {
// const blob = await getBlob(miiPortraitUri);
// if (blob) formData.append("miiPortraitImage", blob);
// }
// if (miiFeaturesUri && hasMiiFeaturesChanged.current) {
// const blob = await getBlob(miiFeaturesUri);
// if (blob) formData.append("miiFeaturesImage", blob);
// }
// const response = await fetch(`/api/mii/${mii.id}/edit`, {
// method: "PATCH",
// body: formData,
// });
// const { error } = await response.json();
// if (!response.ok) {
// setError(error);
// return;
// }
// redirect(`/mii/${mii.id}`);
// };
// const handleMiiPortraitChange = (uri: string | undefined) => {
// hasMiiPortraitChanged.current = true;
// setMiiPortraitUri(uri);
// };
// const handleMiiFeaturesChange = (uri: string | undefined) => {
// hasMiiFeaturesChanged.current = true;
// setMiiFeaturesUri(uri);
// };
// // Load existing images - converts image URLs to File objects
// useEffect(() => {
// const loadExistingImages = async () => {
// try {
// const existing = await Promise.all(
// Array.from({ length: mii.imageCount }, async (_, index) => {
// const path = `/mii/${mii.id}/image?type=image${index}`;
// const response = await fetch(path);
// const blob = await response.blob();
// return Object.assign(new File([blob], `image${index}.png`, { type: "image/png" }), { path });
// }),
// );
// setFiles(existing);
// } catch (error) {
// console.error("Error loading existing images:", error);
// }
// };
// loadExistingImages();
// }, [mii.id, mii.imageCount]);
// return (
// <div className="flex justify-center gap-4 w-full max-lg:flex-col max-lg:items-center">
// <div className="flex justify-center">
// <div className="w-75 h-min flex flex-col bg-zinc-50 rounded-3xl border-2 border-zinc-300 shadow-lg p-3">
// <Carousel
// images={[
// miiPortraitUri ?? `/mii/${mii.id}/image?type=mii`,
// ...(mii.platform === "THREE_DS" ? [`/mii/${mii.id}/image?type=qr-code`] : [miiFeaturesUri ?? `/mii/${mii.id}/image?type=features`]),
// ...files.map((file) => URL.createObjectURL(file)),
// ]}
// />
// <div className="p-4 flex flex-col gap-1 h-full">
// <h1 className="font-bold text-2xl line-clamp-1" title={name}>
// {name || "Mii name"}
// </h1>
// <div id="tags" className="flex flex-wrap gap-1">
// {tags.length == 0 && <span className="px-2 py-1 bg-orange-300 rounded-full text-xs">tag</span>}
// {tags.map((tag) => (
// <span key={tag} className="px-2 py-1 bg-orange-300 rounded-full text-xs">
// {tag}
// </span>
// ))}
// </div>
// <div className="mt-auto">
// <LikeButton likes={likes} isLiked={false} abbreviate disabled />
// </div>
// </div>
// </div>
// </div>
// <div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex flex-col gap-2 max-w-2xl w-full">
// <div>
// <h2 className="text-2xl font-bold">Edit your Mii</h2>
// <p className="text-sm text-zinc-500">Make changes to your existing Mii.</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>
// <hr className="grow border-zinc-300" />
// </div>
// <div className="w-full grid grid-cols-3 items-center">
// <label htmlFor="name" className="font-semibold">
// Name
// </label>
// <input
// id="name"
// type="text"
// className="pill input w-full col-span-2"
// minLength={2}
// maxLength={64}
// placeholder="Type your mii's name here..."
// value={name}
// onChange={(e) => setName(e.target.value)}
// />
// </div>
// <div className="w-full grid grid-cols-3 items-center">
// <label htmlFor="tags" className="font-semibold">
// Tags
// </label>
// <TagSelector tags={tags} setTags={setTags} showTagLimit />
// </div>
// <div className="w-full grid grid-cols-3 items-start">
// <label htmlFor="reason-note" className="font-semibold py-2">
// Description
// </label>
// <textarea
// rows={5}
// maxLength={512}
// placeholder="(optional) Type a description..."
// className="pill input rounded-xl! resize-none col-span-2 text-sm"
// value={description ?? ""}
// onChange={(e) => setDescription(e.target.value)}
// />
// </div>
// {session.data?.user?.id == import.meta.env.NEXT_PUBLIC_ADMIN_USER_ID && (
// <>
// <div className="w-full grid grid-cols-3 items-center">
// <label htmlFor="quarantined" className="font-semibold py-2">
// Quarantined
// </label>
// <div className="col-span-2 flex gap-1">
// <input type="checkbox" id="quarantined" className="checkbox-alt" checked={quarantined} onChange={(e) => setQuarantined(e.target.checked)} />
// </div>
// </div>
// </>
// )}
// {/* Makeup/Images/Instructions (Switch only) */}
// {mii.platform === "SWITCH" && (
// <>
// <div className="w-full grid grid-cols-3 items-start z-20">
// <label htmlFor="gender" className="font-semibold py-2">
// Gender
// </label>
// <div className="col-span-2 flex gap-1">
// <button
// type="button"
// 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"
// }`}
// >
// <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"
// }`}
// >
// <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"
// }`}
// >
// <Icon icon="mdi:gender-non-binary" className="text-purple-400" />
// </button>
// </div>
// </div>
// <div className="w-full grid grid-cols-3 items-start">
// <label htmlFor="makeup" className="font-semibold py-2">
// Face Paint
// </label>
// <div className="col-span-2 flex gap-1">
// {/* Full Makeup */}
// <button
// type="button"
// onClick={() => setMakeup("FULL")}
// aria-label="Full Face Paint"
// data-tooltip="Full Face Paint"
// className={`cursor-pointer rounded-xl flex justify-center items-center size-11 text-4xl border-2 transition-all after:bg-pink-400! after:border-pink-400! before:border-b-pink-400! ${
// makeup === "FULL" ? "bg-pink-100 border-pink-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
// }`}
// >
// <Icon icon="mdi:palette" className="text-pink-400" />
// </button>
// {/* Partial Makeup */}
// <button
// type="button"
// onClick={() => setMakeup("PARTIAL")}
// aria-label="Partial Face Paint"
// data-tooltip="Partial Face Paint"
// className={`cursor-pointer rounded-xl flex justify-center items-center size-11 text-4xl border-2 transition-all after:bg-purple-400! after:border-purple-400! before:border-b-purple-400! ${
// makeup === "PARTIAL" ? "bg-purple-100 border-purple-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
// }`}
// >
// <Icon icon="mdi:lipstick" className="text-purple-400" />
// </button>
// {/* No Makeup */}
// <button
// type="button"
// onClick={() => setMakeup("NONE")}
// aria-label="No Face Paint"
// data-tooltip="No Face Paint"
// className={`cursor-pointer rounded-xl flex justify-center items-center size-11 text-4xl border-2 transition-all after:bg-gray-400! after:border-gray-400! before:border-b-gray-400! ${
// makeup === "NONE" ? "bg-gray-200 border-gray-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
// }`}
// >
// <Icon icon="codex:cross" className="text-gray-400" />
// </button>
// </div>
// </div>
// {/* (Switch Only) Mii Portrait */}
// <div>
// {/* 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>Mii Portrait</span>
// <hr className="grow border-zinc-300" />
// </div>
// <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 />
// </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>
// </div>
// <div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-8">
// <hr className="grow border-zinc-300" />
// <span>Instructions</span>
// <hr className="grow border-zinc-300" />
// </div>
// {/* YouTube */}
// <div className="w-full grid grid-cols-3 items-center">
// <label htmlFor="youtube" className="font-semibold">
// YouTube Video
// </label>
// <input
// id="youtube"
// type="text"
// className="pill input w-full col-span-2"
// minLength={2}
// maxLength={64}
// placeholder="Paste a URL or video ID..."
// value={youtubeId}
// onChange={(e) => {
// const val = e.target.value;
// const match = val.match(/(?:youtube\.com\/(?:watch\?v=|shorts\/|embed\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/);
// setYouTubeId(match ? match[1] : val);
// }}
// />
// </div>
// <MiiEditor instructions={instructions} />
// <SwitchSubmitTutorialButton />
// </>
// )}
// {/* Separator */}
// <div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-8">
// <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">
// <Dropzone onDrop={handleDrop}>
// <p className="text-center text-sm">
// Drag and drop your images here
// <br />
// or click to open
// </p>
// </Dropzone>
// </div>
// <ImageList files={files} setFiles={handleFilesChange} />
// <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} text="Edit" className="ml-auto" />
// </div>
// </div>
// </div>
// );
// }

View file

@ -1,114 +1,114 @@
import { useCallback, useEffect, useRef, useState } from "react";
import ReactCrop, { type Crop } from "react-image-crop";
import { Icon } from "@iconify/react";
interface Props {
isOpen: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
image: string | undefined;
setImage: (value: string | undefined) => void;
}
export default function ImageEditorPortrait({ isOpen, setIsOpen, image, setImage }: Props) {
const [isVisible, setIsVisible] = useState(false);
const [crop, setCrop] = useState<Crop>();
const imageRef = useRef<HTMLImageElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const applyCrop = useCallback(() => {
if (!imageRef.current || !canvasRef.current || !crop) return;
const image = imageRef.current;
const canvas = canvasRef.current;
if (!crop.width || !crop.height || image.naturalWidth === 0 || image.naturalHeight === 0) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const scaleX = image.naturalWidth / image.width;
const scaleY = image.naturalHeight / image.height;
canvas.width = crop.width * scaleX;
canvas.height = crop.height * scaleY;
ctx.drawImage(image, crop.x * scaleX, crop.y * scaleY, crop.width * scaleX, crop.height * scaleY, 0, 0, crop.width * scaleX, crop.height * scaleY);
setImage(canvas.toDataURL());
setCrop(undefined);
}, [crop, setImage]);
const rotate = () => {
if (!imageRef.current || !canvasRef.current) return;
const image = imageRef.current;
const canvas = canvasRef.current;
const ctx = canvas.getContext("2d");
if (!ctx) return;
canvas.width = image.naturalHeight;
canvas.height = image.naturalWidth;
ctx.translate(canvas.width / 2, canvas.height / 2);
ctx.rotate(Math.PI / 2);
ctx.drawImage(image, -image.naturalWidth / 2, -image.naturalHeight / 2);
setImage(canvas.toDataURL());
};
const close = () => {
setIsVisible(false);
setTimeout(() => {
setIsOpen(false);
}, 300);
};
useEffect(() => {
if (isOpen) {
// slight delay to trigger animation
setTimeout(() => setIsVisible(true), 10);
}
}, [isOpen]);
return (
<div className={`fixed inset-0 h-[calc(100%-var(--header-height))] top-(--header-height) flex items-center justify-center z-40 ${!isOpen ? "hidden" : ""}`}>
<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 ${
isVisible ? "scale-100 opacity-100" : "scale-75 opacity-0"
}`}
>
<div className="flex justify-between items-center mb-2">
<h2 className="text-xl font-bold">Edit Image</h2>
<button type="button" aria-label="Close" onClick={close} className="text-red-400 hover:text-red-500 text-2xl cursor-pointer">
<Icon icon="material-symbols:close-rounded" />
</button>
</div>
<div className="relative w-full flex justify-center">
<ReactCrop crop={crop} onChange={(c) => setCrop(c)} className="rounded-2xl border-2 border-amber-500 overflow-hidden max-h-96">
<img ref={imageRef} src={image} />
</ReactCrop>
<canvas ref={canvasRef} className="hidden" />
</div>
<div className="mt-4 flex justify-center gap-2">
<button type="button" onClick={close} className="pill button">
Done
</button>
<button type="button" onClick={applyCrop} className="pill button">
Crop
</button>
<button type="button" onClick={rotate} className="pill button">
Rotate
</button>
</div>
</div>
</div>
);
}
import { useCallback, useEffect, useRef, useState } from "react";
import ReactCrop, { type Crop } from "react-image-crop";
import { Icon } from "@iconify/react";
interface Props {
isOpen: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
image: string | undefined;
setImage: (value: string | undefined) => void;
}
export default function ImageEditorPortrait({ isOpen, setIsOpen, image, setImage }: Props) {
const [isVisible, setIsVisible] = useState(false);
const [crop, setCrop] = useState<Crop>();
const imageRef = useRef<HTMLImageElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const applyCrop = useCallback(() => {
if (!imageRef.current || !canvasRef.current || !crop) return;
const image = imageRef.current;
const canvas = canvasRef.current;
if (!crop.width || !crop.height || image.naturalWidth === 0 || image.naturalHeight === 0) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const scaleX = image.naturalWidth / image.width;
const scaleY = image.naturalHeight / image.height;
canvas.width = crop.width * scaleX;
canvas.height = crop.height * scaleY;
ctx.drawImage(image, crop.x * scaleX, crop.y * scaleY, crop.width * scaleX, crop.height * scaleY, 0, 0, crop.width * scaleX, crop.height * scaleY);
setImage(canvas.toDataURL());
setCrop(undefined);
}, [crop, setImage]);
const rotate = () => {
if (!imageRef.current || !canvasRef.current) return;
const image = imageRef.current;
const canvas = canvasRef.current;
const ctx = canvas.getContext("2d");
if (!ctx) return;
canvas.width = image.naturalHeight;
canvas.height = image.naturalWidth;
ctx.translate(canvas.width / 2, canvas.height / 2);
ctx.rotate(Math.PI / 2);
ctx.drawImage(image, -image.naturalWidth / 2, -image.naturalHeight / 2);
setImage(canvas.toDataURL());
};
const close = () => {
setIsVisible(false);
setTimeout(() => {
setIsOpen(false);
}, 300);
};
useEffect(() => {
if (isOpen) {
// slight delay to trigger animation
setTimeout(() => setIsVisible(true), 10);
}
}, [isOpen]);
return (
<div className={`fixed inset-0 h-[calc(100%-var(--header-height))] top-(--header-height) flex items-center justify-center z-40 ${!isOpen ? "hidden" : ""}`}>
<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 ${
isVisible ? "scale-100 opacity-100" : "scale-75 opacity-0"
}`}
>
<div className="flex justify-between items-center mb-2">
<h2 className="text-xl font-bold">Edit Image</h2>
<button type="button" aria-label="Close" onClick={close} className="text-red-400 hover:text-red-500 text-2xl cursor-pointer">
<Icon icon="material-symbols:close-rounded" />
</button>
</div>
<div className="relative w-full flex justify-center">
<ReactCrop crop={crop} onChange={(c) => setCrop(c)} className="rounded-2xl border-2 border-amber-500 overflow-hidden max-h-96">
<img ref={imageRef} src={image} />
</ReactCrop>
<canvas ref={canvasRef} className="hidden" />
</div>
<div className="mt-4 flex justify-center gap-2">
<button type="button" onClick={close} className="pill button">
Done
</button>
<button type="button" onClick={applyCrop} className="pill button">
Crop
</button>
<button type="button" onClick={rotate} className="pill button">
Rotate
</button>
</div>
</div>
</div>
);
}

View file

@ -1,73 +1,73 @@
import { type FileWithPath } from "react-dropzone";
import { DragDropContext, Draggable, Droppable, type DropResult } from "@hello-pangea/dnd";
import { Icon } from "@iconify/react";
interface Props {
files: readonly FileWithPath[];
setFiles: React.Dispatch<React.SetStateAction<FileWithPath[]>>;
}
export default function ImageList({ files, setFiles }: Props) {
const handleDelete = (index: number) => {
const newFiles = [...files];
newFiles.splice(index, 1);
setFiles(newFiles);
};
const handleDragEnd = (result: DropResult) => {
if (!result.destination) return;
const items = Array.from(files);
const [reorderedItem] = items.splice(result.source.index, 1);
items.splice(result.destination.index, 0, reorderedItem);
setFiles(items);
};
return (
<DragDropContext onDragEnd={handleDragEnd}>
<Droppable droppableId="imageDroppable">
{(provided) => (
<div ref={provided.innerRef} {...provided.droppableProps} className="flex flex-col px-12 max-lg:px-0 max-md:px-12 max-[32rem]:px-0">
{files.map((file, index) => (
<Draggable key={file.name} draggableId={file.name} index={index}>
{(provided) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
className="w-full p-4 rounded-xl bg-orange-100 border-2 border-amber-500 flex gap-2 shadow-md my-1"
>
<img
src={URL.createObjectURL(file)}
alt={file.name}
width={96}
height={96}
className="aspect-3/2 object-contain w-24 rounded-md bg-orange-300 border-2 border-orange-400"
/>
<div className="flex flex-col justify-center w-full min-w-0">
<span className="font-semibold overflow-hidden text-ellipsis">{file.name}</span>
<button
onClick={() => handleDelete(index)}
className="pill button text-xs w-min px-3! py-1! bg-red-300! border-red-400! hover:bg-red-400!"
>
Delete
</button>
</div>
<div
{...provided.dragHandleProps}
className="h-full w-11 px-1 cursor-grab flex items-center justify-center rounded transition-colors hover:bg-black/10"
>
<Icon icon="tabler:grip-horizontal" className="size-6 text-black/50" />
</div>
</div>
)}
</Draggable>
))}
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
);
}
import { type FileWithPath } from "react-dropzone";
import { DragDropContext, Draggable, Droppable, type DropResult } from "@hello-pangea/dnd";
import { Icon } from "@iconify/react";
interface Props {
files: readonly FileWithPath[];
setFiles: React.Dispatch<React.SetStateAction<FileWithPath[]>>;
}
export default function ImageList({ files, setFiles }: Props) {
const handleDelete = (index: number) => {
const newFiles = [...files];
newFiles.splice(index, 1);
setFiles(newFiles);
};
const handleDragEnd = (result: DropResult) => {
if (!result.destination) return;
const items = Array.from(files);
const [reorderedItem] = items.splice(result.source.index, 1);
items.splice(result.destination.index, 0, reorderedItem);
setFiles(items);
};
return (
<DragDropContext onDragEnd={handleDragEnd}>
<Droppable droppableId="imageDroppable">
{(provided) => (
<div ref={provided.innerRef} {...provided.droppableProps} className="flex flex-col px-12 max-lg:px-0 max-md:px-12 max-[32rem]:px-0">
{files.map((file, index) => (
<Draggable key={file.name} draggableId={file.name} index={index}>
{(provided) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
className="w-full p-4 rounded-xl bg-orange-100 border-2 border-amber-500 flex gap-2 shadow-md my-1"
>
<img
src={URL.createObjectURL(file)}
alt={file.name}
width={96}
height={96}
className="aspect-3/2 object-contain w-24 rounded-md bg-orange-300 border-2 border-orange-400"
/>
<div className="flex flex-col justify-center w-full min-w-0">
<span className="font-semibold overflow-hidden text-ellipsis">{file.name}</span>
<button
onClick={() => handleDelete(index)}
className="pill button text-xs w-min px-3! py-1! bg-red-300! border-red-400! hover:bg-red-400!"
>
Delete
</button>
</div>
<div
{...provided.dragHandleProps}
className="h-full w-11 px-1 cursor-grab flex items-center justify-center rounded transition-colors hover:bg-black/10"
>
<Icon icon="tabler:grip-horizontal" className="size-6 text-black/50" />
</div>
</div>
)}
</Draggable>
))}
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
);
}

View file

@ -101,7 +101,7 @@ export default function SubmitForm() {
formData.append("instructions", JSON.stringify(instructions.current));
}
const response = await fetch(`${import.meta.env.PUBLIC_API_URL}/api/submit`, {
const response = await fetch(`${import.meta.env.VITE_API_URL}/api/submit`, {
method: "POST",
body: formData,
credentials: "include",

View file

@ -1,124 +1,124 @@
import { useEffect, useState } from "react";
import { Icon } from "@iconify/react";
import { COLORS } from "@tomodachi-share/shared";
interface Props {
disabled?: boolean;
color: number;
setColor: (color: number) => void;
tab?: "hair" | "eyes" | "lips" | "glasses" | "eyeliner";
}
export default function ColorPicker({ disabled, color, setColor, tab = "hair" }: Props) {
const [isOpen, setIsOpen] = useState(false);
const [isVisible, setIsVisible] = useState(false);
const getExtraSlice = () => {
switch (tab) {
case "hair":
return { start: 0, end: 8 };
case "eyes":
return { start: 122, end: 128 };
case "lips":
return { start: 128, end: 133 };
case "glasses":
return { start: 133, end: 139 };
case "eyeliner":
return { start: 139, end: 152 };
default:
return { start: 108, end: 122 };
}
};
const close = () => {
setIsVisible(false);
setTimeout(() => {
setIsOpen(false);
}, 300);
};
useEffect(() => {
if (isOpen) {
// slight delay to trigger animation
setTimeout(() => setIsVisible(true), 10);
}
}, [isOpen]);
return (
<>
<button
type="button"
onClick={() => {
if (isOpen) {
close();
} else {
setIsOpen(true);
}
}}
disabled={disabled}
className={`w-20 flex gap-1.5 mb-2 p-2 rounded-xl shadow ${disabled ? "bg-zinc-300 opacity-50 cursor-not-allowed" : "bg-zinc-100 cursor-pointer"}`}
>
<Icon icon={"material-symbols:palette"} className="text-xl" />
<div className="grow rounded" style={{ backgroundColor: `#${COLORS[color]}` }}></div>
</button>
{isOpen && (
<div
className={`absolute inset-0 z-10 w-full p-0.5 bg-orange-100 rounded-lg transition-transform duration-500 overflow-x-auto flex
${isVisible ? "opacity-100" : "opacity-0"}`}
style={{
transition: isVisible
? "transform 500ms cubic-bezier(0.34, 1.28, 0.64, 1), opacity 300ms"
: "transform 1000ms cubic-bezier(0.55, 0, 0.45, 1), opacity 300ms",
}}
>
<div className="w-max flex items-center justify-center grow shrink-0">
<div className="mr-8 flex flex-col gap-0.5">
{COLORS.slice(getExtraSlice().start, getExtraSlice().end).map((c, i) => {
const actualIndex = i + getExtraSlice().start;
return (
<button
type="button"
key={actualIndex}
onClick={() => setColor(actualIndex)}
className={`size-7.5 cursor-pointer rounded-md ring-orange-500 ring-offset-2 ${color === actualIndex ? "ring-2 z-10" : ""}`}
style={{
backgroundColor: `#${c}`,
opacity: isVisible ? 1 : 0,
transform: isVisible ? "scale(1)" : "scale(0.7)",
transition: `opacity 250ms ease, transform 320ms cubic-bezier(0.34, 1.4, 0.64, 1)`,
// stagger by column then row for a wave effect
transitionDelay: isVisible ? `${120 + (i % 10) * 18 + Math.floor(i / 10) * 10}ms` : "0ms",
}}
></button>
);
})}
</div>
<div className="grid grid-cols-10 gap-0.5 overflow-x-auto">
{COLORS.slice(8, 108).map((c, i) => (
<button
type="button"
key={i + 8}
onClick={() => setColor(i + 8)}
className={`size-7.5 cursor-pointer rounded-md ring-orange-500 ring-offset-2 ${color === i + 8 ? "ring-2 z-10" : ""}`}
style={{
backgroundColor: `#${c}`,
opacity: isVisible ? 1 : 0,
transform: isVisible ? "scale(1)" : "scale(0.7)",
transition: `opacity 250ms ease, transform 320ms cubic-bezier(0.34, 1.4, 0.64, 1)`,
transitionDelay: isVisible ? `${120 + (i % 10) * 18 + Math.floor(i / 10) * 10}ms` : "0ms",
}}
></button>
))}
</div>
<button type="button" onClick={close} className="h-4/5 w-16 ml-4 cursor-pointer transition-transform hover:scale-115 active:scale-90">
<Icon icon={"tabler:chevron-right"} className="text-4xl" />
</button>
</div>
</div>
)}
</>
);
}
import { useEffect, useState } from "react";
import { Icon } from "@iconify/react";
import { COLORS } from "@tomodachi-share/shared";
interface Props {
disabled?: boolean;
color: number;
setColor: (color: number) => void;
tab?: "hair" | "eyes" | "lips" | "glasses" | "eyeliner";
}
export default function ColorPicker({ disabled, color, setColor, tab = "hair" }: Props) {
const [isOpen, setIsOpen] = useState(false);
const [isVisible, setIsVisible] = useState(false);
const getExtraSlice = () => {
switch (tab) {
case "hair":
return { start: 0, end: 8 };
case "eyes":
return { start: 122, end: 128 };
case "lips":
return { start: 128, end: 133 };
case "glasses":
return { start: 133, end: 139 };
case "eyeliner":
return { start: 139, end: 152 };
default:
return { start: 108, end: 122 };
}
};
const close = () => {
setIsVisible(false);
setTimeout(() => {
setIsOpen(false);
}, 300);
};
useEffect(() => {
if (isOpen) {
// slight delay to trigger animation
setTimeout(() => setIsVisible(true), 10);
}
}, [isOpen]);
return (
<>
<button
type="button"
onClick={() => {
if (isOpen) {
close();
} else {
setIsOpen(true);
}
}}
disabled={disabled}
className={`w-20 flex gap-1.5 mb-2 p-2 rounded-xl shadow ${disabled ? "bg-zinc-300 opacity-50 cursor-not-allowed" : "bg-zinc-100 cursor-pointer"}`}
>
<Icon icon={"material-symbols:palette"} className="text-xl" />
<div className="grow rounded" style={{ backgroundColor: `#${COLORS[color]}` }}></div>
</button>
{isOpen && (
<div
className={`absolute inset-0 z-10 w-full p-0.5 bg-orange-100 rounded-lg transition-transform duration-500 overflow-x-auto flex
${isVisible ? "opacity-100" : "opacity-0"}`}
style={{
transition: isVisible
? "transform 500ms cubic-bezier(0.34, 1.28, 0.64, 1), opacity 300ms"
: "transform 1000ms cubic-bezier(0.55, 0, 0.45, 1), opacity 300ms",
}}
>
<div className="w-max flex items-center justify-center grow shrink-0">
<div className="mr-8 flex flex-col gap-0.5">
{COLORS.slice(getExtraSlice().start, getExtraSlice().end).map((c, i) => {
const actualIndex = i + getExtraSlice().start;
return (
<button
type="button"
key={actualIndex}
onClick={() => setColor(actualIndex)}
className={`size-7.5 cursor-pointer rounded-md ring-orange-500 ring-offset-2 ${color === actualIndex ? "ring-2 z-10" : ""}`}
style={{
backgroundColor: `#${c}`,
opacity: isVisible ? 1 : 0,
transform: isVisible ? "scale(1)" : "scale(0.7)",
transition: `opacity 250ms ease, transform 320ms cubic-bezier(0.34, 1.4, 0.64, 1)`,
// stagger by column then row for a wave effect
transitionDelay: isVisible ? `${120 + (i % 10) * 18 + Math.floor(i / 10) * 10}ms` : "0ms",
}}
></button>
);
})}
</div>
<div className="grid grid-cols-10 gap-0.5 overflow-x-auto">
{COLORS.slice(8, 108).map((c, i) => (
<button
type="button"
key={i + 8}
onClick={() => setColor(i + 8)}
className={`size-7.5 cursor-pointer rounded-md ring-orange-500 ring-offset-2 ${color === i + 8 ? "ring-2 z-10" : ""}`}
style={{
backgroundColor: `#${c}`,
opacity: isVisible ? 1 : 0,
transform: isVisible ? "scale(1)" : "scale(0.7)",
transition: `opacity 250ms ease, transform 320ms cubic-bezier(0.34, 1.4, 0.64, 1)`,
transitionDelay: isVisible ? `${120 + (i % 10) * 18 + Math.floor(i / 10) * 10}ms` : "0ms",
}}
></button>
))}
</div>
<button type="button" onClick={close} className="h-4/5 w-16 ml-4 cursor-pointer transition-transform hover:scale-115 active:scale-90">
<Icon icon={"tabler:chevron-right"} className="text-4xl" />
</button>
</div>
</div>
)}
</>
);
}

View file

@ -1,78 +1,78 @@
import { Icon } from "@iconify/react";
interface SliderProps {
label: string;
value: number;
onChange: (value: number) => void;
min?: number;
max?: number;
mid?: number;
step?: number;
className?: string;
}
export default function EnhancedSlider({ label, value, onChange, min = 0, max = 128, mid = 64, step = 1, className = "" }: SliderProps) {
const handleChange = (newValue: number) => {
const clampedValue = Math.min(max, Math.max(min, newValue));
onChange(clampedValue);
};
const nudge = (direction: number) => {
const newValue = value + direction * step;
handleChange(newValue);
};
const displayValue = value - mid;
const displayText = displayValue > 0 ? `+${displayValue}` : displayValue.toString();
const percentage = ((value - min) / (max - min)) * 100;
return (
<div className={`w-full ${className}`}>
<div className="flex justify-between items-center my-1 relative">
<h3 className="text-sm font-semibold">{label}</h3>
<span className="absolute left-1/2 transform -translate-x-1/2 text-xs font-bold text-orange-600 bg-orange-50 border-2 border-orange-400 px-2 py-1 rounded-full shadow-sm">
{displayText}
</span>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => nudge(-1)}
disabled={value <= min}
className="bg-orange-50 border-2 border-orange-400 text-orange-400 font-bold size-7 rounded-lg cursor-pointer flex items-center justify-center shrink-0 transition-transform not-disabled:active:scale-95 disabled:opacity-30 disabled:cursor-not-allowed hover:bg-orange-50"
aria-label={`Decrease ${label}`}
>
<Icon icon="mdi:chevron-left" width="16" height="16" />
</button>
<div className="relative flex-1 h-8 flex items-center">
{/* Tick mark at center */}
<div className="absolute left-1/2 top-1/2 transform -translate-x-1/2 -translate-y-1/2 w-0.5 h-3 bg-orange-400 rounded z-10 opacity-60"></div>
<input
type="range"
min={min}
max={max}
step={step}
value={value}
onChange={(e) => handleChange(e.target.valueAsNumber)}
className="w-full px-0.5 h-2 bg-orange-200 rounded-lg appearance-none cursor-pointer focus:outline-0"
style={{
background: `linear-gradient(to right, #fb923c 0%, #fb923c ${percentage}%, #fed7aa ${percentage}%, #fed7aa 100%)`,
}}
/>
</div>
<button
type="button"
onClick={() => nudge(1)}
disabled={value >= max}
className="bg-orange-50 border-2 border-orange-400 text-orange-400 font-bold size-7 rounded-lg cursor-pointer flex items-center justify-center shrink-0 transition-transform not-disabled:active:scale-95 disabled:opacity-30 disabled:cursor-not-allowed hover:bg-orange-50"
aria-label={`Increase ${label}`}
>
<Icon icon="mdi:chevron-right" width="16" height="16" />
</button>
</div>
</div>
);
}
import { Icon } from "@iconify/react";
interface SliderProps {
label: string;
value: number;
onChange: (value: number) => void;
min?: number;
max?: number;
mid?: number;
step?: number;
className?: string;
}
export default function EnhancedSlider({ label, value, onChange, min = 0, max = 128, mid = 64, step = 1, className = "" }: SliderProps) {
const handleChange = (newValue: number) => {
const clampedValue = Math.min(max, Math.max(min, newValue));
onChange(clampedValue);
};
const nudge = (direction: number) => {
const newValue = value + direction * step;
handleChange(newValue);
};
const displayValue = value - mid;
const displayText = displayValue > 0 ? `+${displayValue}` : displayValue.toString();
const percentage = ((value - min) / (max - min)) * 100;
return (
<div className={`w-full ${className}`}>
<div className="flex justify-between items-center my-1 relative">
<h3 className="text-sm font-semibold">{label}</h3>
<span className="absolute left-1/2 transform -translate-x-1/2 text-xs font-bold text-orange-600 bg-orange-50 border-2 border-orange-400 px-2 py-1 rounded-full shadow-sm">
{displayText}
</span>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => nudge(-1)}
disabled={value <= min}
className="bg-orange-50 border-2 border-orange-400 text-orange-400 font-bold size-7 rounded-lg cursor-pointer flex items-center justify-center shrink-0 transition-transform not-disabled:active:scale-95 disabled:opacity-30 disabled:cursor-not-allowed hover:bg-orange-50"
aria-label={`Decrease ${label}`}
>
<Icon icon="mdi:chevron-left" width="16" height="16" />
</button>
<div className="relative flex-1 h-8 flex items-center">
{/* Tick mark at center */}
<div className="absolute left-1/2 top-1/2 transform -translate-x-1/2 -translate-y-1/2 w-0.5 h-3 bg-orange-400 rounded z-10 opacity-60"></div>
<input
type="range"
min={min}
max={max}
step={step}
value={value}
onChange={(e) => handleChange(e.target.valueAsNumber)}
className="w-full px-0.5 h-2 bg-orange-200 rounded-lg appearance-none cursor-pointer focus:outline-0"
style={{
background: `linear-gradient(to right, #fb923c 0%, #fb923c ${percentage}%, #fed7aa ${percentage}%, #fed7aa 100%)`,
}}
/>
</div>
<button
type="button"
onClick={() => nudge(1)}
disabled={value >= max}
className="bg-orange-50 border-2 border-orange-400 text-orange-400 font-bold size-7 rounded-lg cursor-pointer flex items-center justify-center shrink-0 transition-transform not-disabled:active:scale-95 disabled:opacity-30 disabled:cursor-not-allowed hover:bg-orange-50"
aria-label={`Increase ${label}`}
>
<Icon icon="mdi:chevron-right" width="16" height="16" />
</button>
</div>
</div>
);
}

View file

@ -1,80 +1,80 @@
import { type SwitchMiiInstructions } from "@tomodachi-share/shared";
import React, { useState } from "react";
import { Icon } from "@iconify/react";
import HeadTab from "./tabs/head";
import HairTab from "./tabs/hair";
import EyebrowsTab from "./tabs/eyebrows";
import EyesTab from "./tabs/eyes";
import NoseTab from "./tabs/nose";
import LipsTab from "./tabs/lips";
import EarsTab from "./tabs/ears";
import GlassesTab from "./tabs/glasses";
import OtherTab from "./tabs/other";
import MiscTab from "./tabs/misc";
interface Props {
instructions: React.RefObject<SwitchMiiInstructions>;
}
type Tab = "head" | "hair" | "eyebrows" | "eyes" | "nose" | "lips" | "ears" | "glasses" | "other" | "misc";
export const TAB_ICONS: Record<Tab, string> = {
head: "mingcute:head-fill",
hair: "mingcute:hair-fill",
eyebrows: "material-symbols:eyebrow",
eyes: "mdi:eye",
nose: "mingcute:nose-fill",
lips: "material-symbols-light:lips",
ears: "ion:ear",
glasses: "solar:glasses-bold",
other: "mdi:sparkles",
misc: "material-symbols:settings",
};
export const TAB_COMPONENTS: Record<Tab, React.ComponentType<any>> = {
head: HeadTab,
hair: HairTab,
eyebrows: EyebrowsTab,
eyes: EyesTab,
nose: NoseTab,
lips: LipsTab,
ears: EarsTab,
glasses: GlassesTab,
other: OtherTab,
misc: MiscTab,
};
export default function MiiEditor({ instructions }: Props) {
const [tab, setTab] = useState<Tab>("head");
return (
<>
<div className="w-full h-91 flex flex-col sm:flex-row bg-orange-100 border-2 border-orange-200 rounded-xl overflow-hidden">
<div className="w-full flex flex-row sm:flex-col max-sm:max-h-9 sm:max-w-9">
{(Object.keys(TAB_COMPONENTS) as Tab[]).map((t) => (
<button
key={t}
type="button"
onClick={() => setTab(t)}
className={`size-full aspect-square flex justify-center items-center text-[1.35rem] cursor-pointer bg-orange-200 hover:bg-orange-300 transition-colors duration-75 ${tab === t ? "bg-orange-100!" : ""}`}
>
{/* ml because of border on left causing icons to look miscentered */}
<Icon icon={TAB_ICONS[t]} className="-ml-0.5" />
</button>
))}
</div>
{/* Keep all tabs loaded to avoid flickering */}
{(Object.keys(TAB_COMPONENTS) as Tab[]).map((t) => {
const TabComponent = TAB_COMPONENTS[t];
return (
<div key={t} className={t === tab ? "grow relative p-3" : "hidden"}>
<TabComponent instructions={instructions} />
</div>
);
})}
</div>
</>
);
}
import { type SwitchMiiInstructions } from "@tomodachi-share/shared";
import React, { useState } from "react";
import { Icon } from "@iconify/react";
import HeadTab from "./tabs/head";
import HairTab from "./tabs/hair";
import EyebrowsTab from "./tabs/eyebrows";
import EyesTab from "./tabs/eyes";
import NoseTab from "./tabs/nose";
import LipsTab from "./tabs/lips";
import EarsTab from "./tabs/ears";
import GlassesTab from "./tabs/glasses";
import OtherTab from "./tabs/other";
import MiscTab from "./tabs/misc";
interface Props {
instructions: React.RefObject<SwitchMiiInstructions>;
}
type Tab = "head" | "hair" | "eyebrows" | "eyes" | "nose" | "lips" | "ears" | "glasses" | "other" | "misc";
export const TAB_ICONS: Record<Tab, string> = {
head: "mingcute:head-fill",
hair: "mingcute:hair-fill",
eyebrows: "material-symbols:eyebrow",
eyes: "mdi:eye",
nose: "mingcute:nose-fill",
lips: "material-symbols-light:lips",
ears: "ion:ear",
glasses: "solar:glasses-bold",
other: "mdi:sparkles",
misc: "material-symbols:settings",
};
export const TAB_COMPONENTS: Record<Tab, React.ComponentType<any>> = {
head: HeadTab,
hair: HairTab,
eyebrows: EyebrowsTab,
eyes: EyesTab,
nose: NoseTab,
lips: LipsTab,
ears: EarsTab,
glasses: GlassesTab,
other: OtherTab,
misc: MiscTab,
};
export default function MiiEditor({ instructions }: Props) {
const [tab, setTab] = useState<Tab>("head");
return (
<>
<div className="w-full h-91 flex flex-col sm:flex-row bg-orange-100 border-2 border-orange-200 rounded-xl overflow-hidden">
<div className="w-full flex flex-row sm:flex-col max-sm:max-h-9 sm:max-w-9">
{(Object.keys(TAB_COMPONENTS) as Tab[]).map((t) => (
<button
key={t}
type="button"
onClick={() => setTab(t)}
className={`size-full aspect-square flex justify-center items-center text-[1.35rem] cursor-pointer bg-orange-200 hover:bg-orange-300 transition-colors duration-75 ${tab === t ? "bg-orange-100!" : ""}`}
>
{/* ml because of border on left causing icons to look miscentered */}
<Icon icon={TAB_ICONS[t]} className="-ml-0.5" />
</button>
))}
</div>
{/* Keep all tabs loaded to avoid flickering */}
{(Object.keys(TAB_COMPONENTS) as Tab[]).map((t) => {
const TabComponent = TAB_COMPONENTS[t];
return (
<div key={t} className={t === tab ? "grow relative p-3" : "hidden"}>
<TabComponent instructions={instructions} />
</div>
);
})}
</div>
</>
);
}

View file

@ -1,92 +1,92 @@
import { Icon } from "@iconify/react";
import { useState } from "react";
interface Props {
target: { height?: number; distance?: number; rotation?: number; size?: number; stretch?: number } | any;
}
export default function NumberInputs({ target }: Props) {
const [values, setValues] = useState<Record<string, number>>({
height: target?.height ?? 0,
distance: target?.distance ?? 0,
rotation: target?.rotation ?? 0,
size: target?.size ?? 0,
stretch: target?.stretch ?? 0,
});
if (!target) return null;
return (
<div className="grid grid-cols-2 gap-x-4 h-min w-fit">
{["Height", "Distance", "Rotation", "Size", "Stretch"].map(
(label) =>
target[label.toLowerCase()] !== undefined && (
<NumberField
key={label}
label={label}
value={values[label.toLowerCase()]}
onChange={(value) => {
const field = label.toLowerCase();
setValues((prev) => ({ ...prev, [field]: value }));
target[field] = value;
}}
/>
),
)}
</div>
);
}
interface NumberFieldProps {
label: string;
value: number;
onChange: (value: number) => void;
}
function NumberField({ label, value, onChange }: NumberFieldProps) {
const MIN = -100;
const MAX = 100;
const decrement = () => onChange(Math.max(MIN, value - 1));
const increment = () => onChange(Math.min(MAX, value + 1));
return (
<div>
<label htmlFor={label} className="text-xs">
{label}
</label>
<div className="pill input text-sm py-1! px-2! w-full flex items-center gap-1">
<button
type="button"
onClick={decrement}
disabled={value <= MIN}
className="cursor-pointer flex items-center justify-center shrink-0 disabled:opacity-30"
aria-label={`Decrease ${label}`}
>
<Icon icon="mdi:minus" width="16" height="16" />
</button>
<input
type="number"
id={label}
min={MIN}
max={MAX}
value={value}
onChange={(e) => {
const val = Math.min(MAX, Math.max(MIN, Number(e.target.value)));
onChange(val);
}}
className="w-full text-center bg-transparent outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
<button
type="button"
onClick={increment}
disabled={value >= MAX}
className="cursor-pointer flex items-center justify-center shrink-0 disabled:opacity-30"
aria-label={`Increase ${label}`}
>
<Icon icon="mdi:plus" width="16" height="16" />
</button>
</div>
</div>
);
}
import { Icon } from "@iconify/react";
import { useState } from "react";
interface Props {
target: { height?: number; distance?: number; rotation?: number; size?: number; stretch?: number } | any;
}
export default function NumberInputs({ target }: Props) {
const [values, setValues] = useState<Record<string, number>>({
height: target?.height ?? 0,
distance: target?.distance ?? 0,
rotation: target?.rotation ?? 0,
size: target?.size ?? 0,
stretch: target?.stretch ?? 0,
});
if (!target) return null;
return (
<div className="grid grid-cols-2 gap-x-4 h-min w-fit">
{["Height", "Distance", "Rotation", "Size", "Stretch"].map(
(label) =>
target[label.toLowerCase()] !== undefined && (
<NumberField
key={label}
label={label}
value={values[label.toLowerCase()]}
onChange={(value) => {
const field = label.toLowerCase();
setValues((prev) => ({ ...prev, [field]: value }));
target[field] = value;
}}
/>
),
)}
</div>
);
}
interface NumberFieldProps {
label: string;
value: number;
onChange: (value: number) => void;
}
function NumberField({ label, value, onChange }: NumberFieldProps) {
const MIN = -100;
const MAX = 100;
const decrement = () => onChange(Math.max(MIN, value - 1));
const increment = () => onChange(Math.min(MAX, value + 1));
return (
<div>
<label htmlFor={label} className="text-xs">
{label}
</label>
<div className="pill input text-sm py-1! px-2! w-full flex items-center gap-1">
<button
type="button"
onClick={decrement}
disabled={value <= MIN}
className="cursor-pointer flex items-center justify-center shrink-0 disabled:opacity-30"
aria-label={`Decrease ${label}`}
>
<Icon icon="mdi:minus" width="16" height="16" />
</button>
<input
type="number"
id={label}
min={MIN}
max={MAX}
value={value}
onChange={(e) => {
const val = Math.min(MAX, Math.max(MIN, Number(e.target.value)));
onChange(val);
}}
className="w-full text-center bg-transparent outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
<button
type="button"
onClick={increment}
disabled={value >= MAX}
className="cursor-pointer flex items-center justify-center shrink-0 disabled:opacity-30"
aria-label={`Increase ${label}`}
>
<Icon icon="mdi:plus" width="16" height="16" />
</button>
</div>
</div>
);
}

View file

@ -1,18 +1,18 @@
import { type SwitchMiiInstructions } from "@tomodachi-share/shared";
import NumberInputs from "../number-inputs";
interface EarsProps {
instructions: React.RefObject<SwitchMiiInstructions>;
}
export default function EarsTab({ instructions }: EarsProps) {
return (
<>
<h1 className="absolute font-bold text-xl">Ears</h1>
<div className="size-full flex flex-col justify-center items-center">
<NumberInputs target={instructions.current.ears} />
</div>
</>
);
}
import { type SwitchMiiInstructions } from "@tomodachi-share/shared";
import NumberInputs from "../number-inputs";
interface EarsProps {
instructions: React.RefObject<SwitchMiiInstructions>;
}
export default function EarsTab({ instructions }: EarsProps) {
return (
<>
<h1 className="absolute font-bold text-xl">Ears</h1>
<div className="size-full flex flex-col justify-center items-center">
<NumberInputs target={instructions.current.ears} />
</div>
</>
);
}

View file

@ -1,29 +1,29 @@
import { useState } from "react";
import { type SwitchMiiInstructions } from "@tomodachi-share/shared";
import ColorPicker from "../color-picker";
import NumberInputs from "../number-inputs";
interface Props {
instructions: React.RefObject<SwitchMiiInstructions>;
}
export default function EyebrowsTab({ instructions }: Props) {
const [color, setColor] = useState(instructions.current.eyebrows.color ?? 3);
return (
<>
<h1 className="absolute font-bold text-xl">Eyebrows</h1>
<div className="size-full flex flex-col justify-center items-center">
<ColorPicker
color={color}
setColor={(i) => {
setColor(i);
instructions.current.eyebrows.color = i;
}}
/>
<NumberInputs target={instructions.current.eyebrows} />
</div>
</>
);
}
import { useState } from "react";
import { type SwitchMiiInstructions } from "@tomodachi-share/shared";
import ColorPicker from "../color-picker";
import NumberInputs from "../number-inputs";
interface Props {
instructions: React.RefObject<SwitchMiiInstructions>;
}
export default function EyebrowsTab({ instructions }: Props) {
const [color, setColor] = useState(instructions.current.eyebrows.color ?? 3);
return (
<>
<h1 className="absolute font-bold text-xl">Eyebrows</h1>
<div className="size-full flex flex-col justify-center items-center">
<ColorPicker
color={color}
setColor={(i) => {
setColor(i);
instructions.current.eyebrows.color = i;
}}
/>
<NumberInputs target={instructions.current.eyebrows} />
</div>
</>
);
}

View file

@ -1,67 +1,67 @@
import { type SwitchMiiInstructions } from "@tomodachi-share/shared";
import { useState } from "react";
import ColorPicker from "../color-picker";
import NumberInputs from "../number-inputs";
interface Props {
instructions: React.RefObject<SwitchMiiInstructions>;
}
const TABS: { name: keyof SwitchMiiInstructions["eyes"]; colorsDisabled?: boolean }[] = [
{ name: "main" },
{ name: "eyelashesTop", colorsDisabled: true },
{ name: "eyelashesBottom", colorsDisabled: true },
{ name: "eyelidTop", colorsDisabled: true },
{ name: "eyelidBottom", colorsDisabled: true },
{ name: "eyeliner" },
{ name: "pupil", colorsDisabled: true },
];
export default function EyesTab({ instructions }: Props) {
const [tab, setTab] = useState(0);
const [colors, setColors] = useState<number[]>(() =>
TABS.map((t) => {
const entry = instructions.current.eyes[t.name] ?? {};
const color = entry && "color" in entry ? entry.color : null;
return color ?? 122;
}),
);
const currentTab = TABS[tab];
const setColor = (value: number) => {
setColors((prev) => {
const copy = [...prev];
copy[tab] = value;
return copy;
});
if (!currentTab.colorsDisabled) (instructions.current.eyes[currentTab.name] as { color: number }).color = value;
};
return (
<>
<h1 className="absolute font-bold text-xl">Eyes</h1>
<div className="absolute right-3 z-10 flex justify-end">
<div className="rounded-2xl bg-orange-200">
{TABS.map((_, i) => (
<button
key={i}
type="button"
onClick={() => setTab(i)}
className={`px-3 py-1 rounded-2xl cursor-pointer hover:bg-orange-300/50 transition-colors duration-75 ${tab === i ? "bg-orange-300!" : "orange-200"}`}
>
{i + 1}
</button>
))}
</div>
</div>
<div className="absolute inset-0 flex flex-col justify-center items-center">
<ColorPicker disabled={currentTab.colorsDisabled} color={colors[tab]} setColor={setColor} tab={tab === 5 ? "eyeliner" : "eyes"} />
<NumberInputs key={tab} target={instructions.current.eyes[currentTab.name]} />
</div>
</>
);
}
import { type SwitchMiiInstructions } from "@tomodachi-share/shared";
import { useState } from "react";
import ColorPicker from "../color-picker";
import NumberInputs from "../number-inputs";
interface Props {
instructions: React.RefObject<SwitchMiiInstructions>;
}
const TABS: { name: keyof SwitchMiiInstructions["eyes"]; colorsDisabled?: boolean }[] = [
{ name: "main" },
{ name: "eyelashesTop", colorsDisabled: true },
{ name: "eyelashesBottom", colorsDisabled: true },
{ name: "eyelidTop", colorsDisabled: true },
{ name: "eyelidBottom", colorsDisabled: true },
{ name: "eyeliner" },
{ name: "pupil", colorsDisabled: true },
];
export default function EyesTab({ instructions }: Props) {
const [tab, setTab] = useState(0);
const [colors, setColors] = useState<number[]>(() =>
TABS.map((t) => {
const entry = instructions.current.eyes[t.name] ?? {};
const color = entry && "color" in entry ? entry.color : null;
return color ?? 122;
}),
);
const currentTab = TABS[tab];
const setColor = (value: number) => {
setColors((prev) => {
const copy = [...prev];
copy[tab] = value;
return copy;
});
if (!currentTab.colorsDisabled) (instructions.current.eyes[currentTab.name] as { color: number }).color = value;
};
return (
<>
<h1 className="absolute font-bold text-xl">Eyes</h1>
<div className="absolute right-3 z-10 flex justify-end">
<div className="rounded-2xl bg-orange-200">
{TABS.map((_, i) => (
<button
key={i}
type="button"
onClick={() => setTab(i)}
className={`px-3 py-1 rounded-2xl cursor-pointer hover:bg-orange-300/50 transition-colors duration-75 ${tab === i ? "bg-orange-300!" : "orange-200"}`}
>
{i + 1}
</button>
))}
</div>
</div>
<div className="absolute inset-0 flex flex-col justify-center items-center">
<ColorPicker disabled={currentTab.colorsDisabled} color={colors[tab]} setColor={setColor} tab={tab === 5 ? "eyeliner" : "eyes"} />
<NumberInputs key={tab} target={instructions.current.eyes[currentTab.name]} />
</div>
</>
);
}

View file

@ -1,39 +1,39 @@
import { useState } from "react";
import { type SwitchMiiInstructions } from "@tomodachi-share/shared";
import ColorPicker from "../color-picker";
import NumberInputs from "../number-inputs";
interface Props {
instructions: React.RefObject<SwitchMiiInstructions>;
}
export default function GlassesTab({ instructions }: Props) {
const [ringColor, setRingColor] = useState(instructions.current.glasses.ringColor ?? 133);
const [shadesColor, setShadesColor] = useState(instructions.current.glasses.shadesColor ?? 133);
return (
<>
<h1 className="absolute font-bold text-xl">Glasses</h1>
<div className="size-full flex flex-col justify-center items-center">
<ColorPicker
color={ringColor}
setColor={(i) => {
setRingColor(i);
instructions.current.glasses.ringColor = i;
}}
tab="glasses"
/>
<ColorPicker
color={shadesColor}
setColor={(i) => {
setShadesColor(i);
instructions.current.glasses.shadesColor = i;
}}
tab="glasses"
/>
<NumberInputs target={instructions.current.glasses} />
</div>
</>
);
}
import { useState } from "react";
import { type SwitchMiiInstructions } from "@tomodachi-share/shared";
import ColorPicker from "../color-picker";
import NumberInputs from "../number-inputs";
interface Props {
instructions: React.RefObject<SwitchMiiInstructions>;
}
export default function GlassesTab({ instructions }: Props) {
const [ringColor, setRingColor] = useState(instructions.current.glasses.ringColor ?? 133);
const [shadesColor, setShadesColor] = useState(instructions.current.glasses.shadesColor ?? 133);
return (
<>
<h1 className="absolute font-bold text-xl">Glasses</h1>
<div className="size-full flex flex-col justify-center items-center">
<ColorPicker
color={ringColor}
setColor={(i) => {
setRingColor(i);
instructions.current.glasses.ringColor = i;
}}
tab="glasses"
/>
<ColorPicker
color={shadesColor}
setColor={(i) => {
setShadesColor(i);
instructions.current.glasses.shadesColor = i;
}}
tab="glasses"
/>
<NumberInputs target={instructions.current.glasses} />
</div>
</>
);
}

View file

@ -1,129 +1,129 @@
import { type SwitchMiiInstructions } from "@tomodachi-share/shared";
import { useState } from "react";
import ColorPicker from "../color-picker";
interface Props {
instructions: React.RefObject<SwitchMiiInstructions>;
}
type Tab = "sets" | "bangs" | "back";
export default function HairTab({ instructions }: Props) {
const [tab, setTab] = useState<Tab>("sets");
const [color, setColor] = useState(instructions.current.hair.color ?? 3);
const [subColor, setSubColor] = useState<number | null>(instructions.current.hair.subColor);
const [subColor2, setSubColor2] = useState<number | null>(instructions.current.hair.subColor2);
const [style, setStyle] = useState<number | null>(instructions.current.hair.style);
const [isFlipped, setIsFlipped] = useState(instructions.current.hair.isFlipped);
return (
<>
<h1 className="absolute font-bold text-xl">Hair</h1>
<div className="absolute right-3 z-10 flex justify-end">
<button
type="button"
onClick={() => setTab("sets")}
className={`px-3 py-1 rounded-2xl bg-orange-200 mr-1 cursor-pointer hover:bg-orange-300/50 transition-colors duration-75 ${tab === "sets" ? "bg-orange-300!" : "orange-200"}`}
>
Sets
</button>
<div className="rounded-2xl bg-orange-200 flex">
<button
type="button"
onClick={() => setTab("bangs")}
className={`px-3 py-1 rounded-2xl cursor-pointer hover:bg-orange-300/50 transition-colors duration-75 ${tab === "bangs" ? "bg-orange-300!" : "orange-200"}`}
>
Bangs
</button>
<button
type="button"
onClick={() => setTab("back")}
className={`px-3 py-1 rounded-2xl cursor-pointer hover:bg-orange-300/50 transition-colors duration-75 ${tab === "back" ? "bg-orange-300!" : "orange-200"}`}
>
Back
</button>
</div>
</div>
<div className="absolute inset-0 flex flex-col justify-center items-center">
<ColorPicker
color={color}
setColor={(i) => {
setColor(i);
instructions.current.hair.color = i;
}}
/>
<div className="flex gap-1.5 items-center mb-2">
<input
type="checkbox"
id="subcolor"
className="checkbox"
checked={tab === "back" ? subColor2 !== null : subColor !== null}
onChange={(e) => {
if (tab === "back") {
setSubColor2(e.target.checked ? 0 : null);
instructions.current.hair.subColor2 = e.target.checked ? 0 : null;
} else {
setSubColor(e.target.checked ? 0 : null);
instructions.current.hair.subColor = e.target.checked ? 0 : null;
}
}}
/>
<label htmlFor="subcolor" className="text-xs">
Sub color
</label>
</div>
<ColorPicker
disabled={tab === "back" ? subColor2 === null : subColor === null}
color={tab === "back" ? (subColor2 ?? 0) : (subColor ?? 0)}
setColor={(i) => {
if (tab === "back") {
setSubColor2(i);
instructions.current.hair.subColor2 = i;
} else {
setSubColor(i);
instructions.current.hair.subColor = i;
}
}}
/>
<p className="text-sm mb-1">Tying style</p>
<div className="grid grid-cols-3 gap-0.5">
{Array.from({ length: 3 }).map((_, i) => (
<button
type="button"
key={i}
onClick={() => {
setStyle(i + 1);
instructions.current.hair.style = i + 1;
}}
className={`size-full aspect-square cursor-pointer hover:bg-orange-300 transition-colors duration-100 rounded-lg ${style === i + 1 ? "bg-orange-400!" : ""}`}
>
{i + 1}
</button>
))}
</div>
<div className="flex gap-1.5 items-center mt-4">
<input
type="checkbox"
id="subcolor"
className="checkbox"
checked={isFlipped}
onChange={(e) => {
setIsFlipped(e.target.checked);
instructions.current.hair.isFlipped = e.target.checked;
}}
/>
<label htmlFor="subcolor" className="text-xs">
Flip
</label>
</div>
</div>
</>
);
}
import { type SwitchMiiInstructions } from "@tomodachi-share/shared";
import { useState } from "react";
import ColorPicker from "../color-picker";
interface Props {
instructions: React.RefObject<SwitchMiiInstructions>;
}
type Tab = "sets" | "bangs" | "back";
export default function HairTab({ instructions }: Props) {
const [tab, setTab] = useState<Tab>("sets");
const [color, setColor] = useState(instructions.current.hair.color ?? 3);
const [subColor, setSubColor] = useState<number | null>(instructions.current.hair.subColor);
const [subColor2, setSubColor2] = useState<number | null>(instructions.current.hair.subColor2);
const [style, setStyle] = useState<number | null>(instructions.current.hair.style);
const [isFlipped, setIsFlipped] = useState(instructions.current.hair.isFlipped);
return (
<>
<h1 className="absolute font-bold text-xl">Hair</h1>
<div className="absolute right-3 z-10 flex justify-end">
<button
type="button"
onClick={() => setTab("sets")}
className={`px-3 py-1 rounded-2xl bg-orange-200 mr-1 cursor-pointer hover:bg-orange-300/50 transition-colors duration-75 ${tab === "sets" ? "bg-orange-300!" : "orange-200"}`}
>
Sets
</button>
<div className="rounded-2xl bg-orange-200 flex">
<button
type="button"
onClick={() => setTab("bangs")}
className={`px-3 py-1 rounded-2xl cursor-pointer hover:bg-orange-300/50 transition-colors duration-75 ${tab === "bangs" ? "bg-orange-300!" : "orange-200"}`}
>
Bangs
</button>
<button
type="button"
onClick={() => setTab("back")}
className={`px-3 py-1 rounded-2xl cursor-pointer hover:bg-orange-300/50 transition-colors duration-75 ${tab === "back" ? "bg-orange-300!" : "orange-200"}`}
>
Back
</button>
</div>
</div>
<div className="absolute inset-0 flex flex-col justify-center items-center">
<ColorPicker
color={color}
setColor={(i) => {
setColor(i);
instructions.current.hair.color = i;
}}
/>
<div className="flex gap-1.5 items-center mb-2">
<input
type="checkbox"
id="subcolor"
className="checkbox"
checked={tab === "back" ? subColor2 !== null : subColor !== null}
onChange={(e) => {
if (tab === "back") {
setSubColor2(e.target.checked ? 0 : null);
instructions.current.hair.subColor2 = e.target.checked ? 0 : null;
} else {
setSubColor(e.target.checked ? 0 : null);
instructions.current.hair.subColor = e.target.checked ? 0 : null;
}
}}
/>
<label htmlFor="subcolor" className="text-xs">
Sub color
</label>
</div>
<ColorPicker
disabled={tab === "back" ? subColor2 === null : subColor === null}
color={tab === "back" ? (subColor2 ?? 0) : (subColor ?? 0)}
setColor={(i) => {
if (tab === "back") {
setSubColor2(i);
instructions.current.hair.subColor2 = i;
} else {
setSubColor(i);
instructions.current.hair.subColor = i;
}
}}
/>
<p className="text-sm mb-1">Tying style</p>
<div className="grid grid-cols-3 gap-0.5">
{Array.from({ length: 3 }).map((_, i) => (
<button
type="button"
key={i}
onClick={() => {
setStyle(i + 1);
instructions.current.hair.style = i + 1;
}}
className={`size-full aspect-square cursor-pointer hover:bg-orange-300 transition-colors duration-100 rounded-lg ${style === i + 1 ? "bg-orange-400!" : ""}`}
>
{i + 1}
</button>
))}
</div>
<div className="flex gap-1.5 items-center mt-4">
<input
type="checkbox"
id="subcolor"
className="checkbox"
checked={isFlipped}
onChange={(e) => {
setIsFlipped(e.target.checked);
instructions.current.hair.isFlipped = e.target.checked;
}}
/>
<label htmlFor="subcolor" className="text-xs">
Flip
</label>
</div>
</div>
</>
);
}

View file

@ -1,44 +1,44 @@
import { useState } from "react";
import ColorPicker from "../color-picker";
import { type SwitchMiiInstructions } from "@tomodachi-share/shared";
interface Props {
instructions: React.RefObject<SwitchMiiInstructions>;
}
const COLORS = ["FFD8BA", "FFD5AC", "FEC1A4", "FEC68F", "FEB089", "FEBA6B", "F39866", "E89854", "E37E3F", "B45627", "914220", "59371F", "662D16", "392D1E"];
export default function HeadTab({ instructions }: Props) {
const [color, setColor] = useState(instructions.current.head.skinColor ?? 109);
return (
<>
<h1 className="absolute font-bold text-xl">Head</h1>
<div className="size-full flex flex-col justify-center items-center">
<ColorPicker
color={color}
setColor={(i) => {
setColor(i);
instructions.current.head.skinColor = i;
}}
/>
<div className="grid grid-cols-7 gap-1">
{COLORS.map((hex, i) => (
<button
type="button"
key={i + 108}
onClick={() => {
setColor(i + 108);
instructions.current.head.skinColor = i + 108;
}}
className={`size-9 rounded-lg cursor-pointer ring-offset-2 ring-orange-500 ${color === i + 108 ? "ring-2" : ""}`}
style={{ backgroundColor: `#${hex}` }}
></button>
))}
</div>
</div>
</>
);
}
import { useState } from "react";
import ColorPicker from "../color-picker";
import { type SwitchMiiInstructions } from "@tomodachi-share/shared";
interface Props {
instructions: React.RefObject<SwitchMiiInstructions>;
}
const COLORS = ["FFD8BA", "FFD5AC", "FEC1A4", "FEC68F", "FEB089", "FEBA6B", "F39866", "E89854", "E37E3F", "B45627", "914220", "59371F", "662D16", "392D1E"];
export default function HeadTab({ instructions }: Props) {
const [color, setColor] = useState(instructions.current.head.skinColor ?? 109);
return (
<>
<h1 className="absolute font-bold text-xl">Head</h1>
<div className="size-full flex flex-col justify-center items-center">
<ColorPicker
color={color}
setColor={(i) => {
setColor(i);
instructions.current.head.skinColor = i;
}}
/>
<div className="grid grid-cols-7 gap-1">
{COLORS.map((hex, i) => (
<button
type="button"
key={i + 108}
onClick={() => {
setColor(i + 108);
instructions.current.head.skinColor = i + 108;
}}
className={`size-9 rounded-lg cursor-pointer ring-offset-2 ring-orange-500 ${color === i + 108 ? "ring-2" : ""}`}
style={{ backgroundColor: `#${hex}` }}
></button>
))}
</div>
</div>
</>
);
}

View file

@ -1,47 +1,47 @@
import { type SwitchMiiInstructions } from "@tomodachi-share/shared";
import ColorPicker from "../color-picker";
import NumberInputs from "../number-inputs";
import { useState } from "react";
interface Props {
instructions: React.RefObject<SwitchMiiInstructions>;
}
export default function LipsTab({ instructions }: Props) {
const [color, setColor] = useState(instructions.current.lips.color ?? 128);
const [hasLipstick, setHasLipstick] = useState(instructions.current.lips.hasLipstick);
return (
<>
<h1 className="absolute font-bold text-xl">Lips</h1>
<div className="size-full flex flex-col justify-center items-center">
<ColorPicker
color={color}
setColor={(i) => {
setColor(i);
instructions.current.lips.color = i;
}}
tab="lips"
/>
<NumberInputs target={instructions.current.lips} />
<div className="flex gap-1.5 items-center mt-4">
<input
type="checkbox"
id="subcolor"
className="checkbox"
checked={hasLipstick}
onChange={(e) => {
setHasLipstick(e.target.checked);
instructions.current.lips.hasLipstick = e.target.checked;
}}
/>
<label htmlFor="subcolor" className="text-xs">
Lipstick
</label>
</div>
</div>
</>
);
}
import { type SwitchMiiInstructions } from "@tomodachi-share/shared";
import ColorPicker from "../color-picker";
import NumberInputs from "../number-inputs";
import { useState } from "react";
interface Props {
instructions: React.RefObject<SwitchMiiInstructions>;
}
export default function LipsTab({ instructions }: Props) {
const [color, setColor] = useState(instructions.current.lips.color ?? 128);
const [hasLipstick, setHasLipstick] = useState(instructions.current.lips.hasLipstick);
return (
<>
<h1 className="absolute font-bold text-xl">Lips</h1>
<div className="size-full flex flex-col justify-center items-center">
<ColorPicker
color={color}
setColor={(i) => {
setColor(i);
instructions.current.lips.color = i;
}}
tab="lips"
/>
<NumberInputs target={instructions.current.lips} />
<div className="flex gap-1.5 items-center mt-4">
<input
type="checkbox"
id="subcolor"
className="checkbox"
checked={hasLipstick}
onChange={(e) => {
setHasLipstick(e.target.checked);
instructions.current.lips.hasLipstick = e.target.checked;
}}
/>
<label htmlFor="subcolor" className="text-xs">
Lipstick
</label>
</div>
</div>
</>
);
}

View file

@ -1,213 +1,213 @@
import { useState } from "react";
import type { MiiGender, SwitchMiiInstructions } from "@tomodachi-share/shared";
import EnhancedSlider from "../enhanced-slider";
import DatingPreferencesViewer from "../../../mii/dating-preferences";
import VoiceViewer from "../../../mii/voice-viewer";
import PersonalityViewer from "../../../mii/personality-viewer";
interface Props {
instructions: React.RefObject<SwitchMiiInstructions>;
}
export default function MiscTab({ instructions }: Props) {
const [height, setHeight] = useState(instructions.current.height ?? 64);
const [weight, setWeight] = useState(instructions.current.weight ?? 64);
const [datingPreferences, setDatingPreferences] = useState<MiiGender[]>(instructions.current.datingPreferences ?? []);
const [voice, setVoice] = useState({
speed: instructions.current.voice.speed ?? 25,
pitch: instructions.current.voice.pitch ?? 25,
depth: instructions.current.voice.depth ?? 25,
delivery: instructions.current.voice.delivery ?? 25,
tone: instructions.current.voice.tone ?? 0,
});
const [birthday, setBirthday] = useState({
day: instructions.current.birthday.day ?? (null as number | null),
month: instructions.current.birthday.month ?? (null as number | null),
age: instructions.current.birthday.age ?? (null as number | null),
dontAge: instructions.current.birthday.dontAge,
});
const [personality, setPersonality] = useState({
movement: instructions.current.personality.movement ?? -1,
speech: instructions.current.personality.speech ?? -1,
energy: instructions.current.personality.energy ?? -1,
thinking: instructions.current.personality.thinking ?? -1,
overall: instructions.current.personality.overall ?? -1,
});
return (
<>
<h1 className="font-bold text-xl">Misc</h1>
<div className="grow h-full overflow-y-auto pb-3">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium">
<hr className="grow border-zinc-300" />
<span>Body</span>
<hr className="grow border-zinc-300" />
</div>
<div className="flex flex-col">
<EnhancedSlider
label="Height"
value={height}
onChange={(v) => {
setHeight(v);
instructions.current.height = v;
}}
min={0}
max={128}
mid={64}
/>
</div>
<div className="flex flex-col">
<EnhancedSlider
label="Weight"
value={weight}
onChange={(v) => {
setWeight(v);
instructions.current.weight = v;
}}
min={0}
max={128}
mid={64}
/>
</div>
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-1.5 mb-2">
<hr className="grow border-zinc-300" />
<span>Dating Preferences</span>
<hr className="grow border-zinc-300" />
</div>
<div className="flex flex-col gap-1.5">
<DatingPreferencesViewer
data={datingPreferences}
onChecked={(e, gender) => {
setDatingPreferences((prev) => {
const updated = e.target.checked ? (prev.includes(gender) ? prev : [...prev, gender]) : prev.filter((p) => p !== gender);
instructions.current.datingPreferences = updated;
return updated;
});
}}
/>
</div>
</div>
<div>
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium">
<hr className="grow border-zinc-300" />
<span>Voice</span>
<hr className="grow border-zinc-300" />
</div>
<VoiceViewer
data={voice}
onChange={(v, label) => {
setVoice((p) => ({ ...p, [label]: v }));
instructions.current.voice[label as keyof typeof voice] = v;
}}
onClickTone={(i) => {
setVoice((p) => ({ ...p, tone: i }));
instructions.current.voice.tone = i;
}}
/>
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-1.5 mb-2">
<hr className="grow border-zinc-300" />
<span>Birthday</span>
<hr className="grow border-zinc-300" />
</div>
<div className="grid grid-cols-3 gap-2">
<div>
<label htmlFor="day" className="text-xs">
Day
</label>
<input
type="number"
id="day"
min={1}
max={31}
className="pill input text-sm py-1! px-3! w-full"
value={birthday.day ?? undefined}
onChange={(e) => {
setBirthday((p) => ({ ...p, day: e.target.valueAsNumber }));
instructions.current.birthday.day = e.target.valueAsNumber;
}}
/>
</div>
<div>
<label htmlFor="month" className="text-xs">
Month
</label>
<input
type="number"
id="month"
min={1}
max={12}
className="pill input text-sm py-1! px-3! w-full"
value={birthday.month ?? undefined}
onChange={(e) => {
setBirthday((p) => ({ ...p, month: e.target.valueAsNumber }));
instructions.current.birthday.month = e.target.valueAsNumber;
}}
/>
</div>
<div>
<label htmlFor="age" className="text-xs">
Age
</label>
<input
type="number"
id="age"
min={1}
max={1000}
className="pill input text-sm py-1! px-3! w-full"
value={birthday.age ?? undefined}
onChange={(e) => {
setBirthday((p) => ({ ...p, age: e.target.valueAsNumber }));
instructions.current.birthday.age = e.target.valueAsNumber;
}}
/>
</div>
<div className="flex gap-1.5 col-span-2">
<input
type="checkbox"
id="dontAge"
className="checkbox"
checked={birthday.dontAge}
onChange={(e) => {
setBirthday((p) => ({ ...p, dontAge: e.target.checked }));
instructions.current.birthday.dontAge = e.target.checked;
}}
/>
<label htmlFor="dontAge" className="text-sm select-none">
Don't Age
</label>
</div>
</div>
</div>
</div>
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-2 mb-2">
<hr className="grow border-zinc-300" />
<span>Personality</span>
<hr className="grow border-zinc-300" />
</div>
<PersonalityViewer
data={personality}
onClick={(key, i) => {
setPersonality((p) => {
const updated = { ...p, [key]: i };
instructions.current.personality = updated;
return updated;
});
}}
/>
</div>
</>
);
}
import { useState } from "react";
import type { MiiGender, SwitchMiiInstructions } from "@tomodachi-share/shared";
import EnhancedSlider from "../enhanced-slider";
import DatingPreferencesViewer from "../../../mii/dating-preferences";
import VoiceViewer from "../../../mii/voice-viewer";
import PersonalityViewer from "../../../mii/personality-viewer";
interface Props {
instructions: React.RefObject<SwitchMiiInstructions>;
}
export default function MiscTab({ instructions }: Props) {
const [height, setHeight] = useState(instructions.current.height ?? 64);
const [weight, setWeight] = useState(instructions.current.weight ?? 64);
const [datingPreferences, setDatingPreferences] = useState<MiiGender[]>(instructions.current.datingPreferences ?? []);
const [voice, setVoice] = useState({
speed: instructions.current.voice.speed ?? 25,
pitch: instructions.current.voice.pitch ?? 25,
depth: instructions.current.voice.depth ?? 25,
delivery: instructions.current.voice.delivery ?? 25,
tone: instructions.current.voice.tone ?? 0,
});
const [birthday, setBirthday] = useState({
day: instructions.current.birthday.day ?? (null as number | null),
month: instructions.current.birthday.month ?? (null as number | null),
age: instructions.current.birthday.age ?? (null as number | null),
dontAge: instructions.current.birthday.dontAge,
});
const [personality, setPersonality] = useState({
movement: instructions.current.personality.movement ?? -1,
speech: instructions.current.personality.speech ?? -1,
energy: instructions.current.personality.energy ?? -1,
thinking: instructions.current.personality.thinking ?? -1,
overall: instructions.current.personality.overall ?? -1,
});
return (
<>
<h1 className="font-bold text-xl">Misc</h1>
<div className="grow h-full overflow-y-auto pb-3">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium">
<hr className="grow border-zinc-300" />
<span>Body</span>
<hr className="grow border-zinc-300" />
</div>
<div className="flex flex-col">
<EnhancedSlider
label="Height"
value={height}
onChange={(v) => {
setHeight(v);
instructions.current.height = v;
}}
min={0}
max={128}
mid={64}
/>
</div>
<div className="flex flex-col">
<EnhancedSlider
label="Weight"
value={weight}
onChange={(v) => {
setWeight(v);
instructions.current.weight = v;
}}
min={0}
max={128}
mid={64}
/>
</div>
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-1.5 mb-2">
<hr className="grow border-zinc-300" />
<span>Dating Preferences</span>
<hr className="grow border-zinc-300" />
</div>
<div className="flex flex-col gap-1.5">
<DatingPreferencesViewer
data={datingPreferences}
onChecked={(e, gender) => {
setDatingPreferences((prev) => {
const updated = e.target.checked ? (prev.includes(gender) ? prev : [...prev, gender]) : prev.filter((p) => p !== gender);
instructions.current.datingPreferences = updated;
return updated;
});
}}
/>
</div>
</div>
<div>
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium">
<hr className="grow border-zinc-300" />
<span>Voice</span>
<hr className="grow border-zinc-300" />
</div>
<VoiceViewer
data={voice}
onChange={(v, label) => {
setVoice((p) => ({ ...p, [label]: v }));
instructions.current.voice[label as keyof typeof voice] = v;
}}
onClickTone={(i) => {
setVoice((p) => ({ ...p, tone: i }));
instructions.current.voice.tone = i;
}}
/>
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-1.5 mb-2">
<hr className="grow border-zinc-300" />
<span>Birthday</span>
<hr className="grow border-zinc-300" />
</div>
<div className="grid grid-cols-3 gap-2">
<div>
<label htmlFor="day" className="text-xs">
Day
</label>
<input
type="number"
id="day"
min={1}
max={31}
className="pill input text-sm py-1! px-3! w-full"
value={birthday.day ?? undefined}
onChange={(e) => {
setBirthday((p) => ({ ...p, day: e.target.valueAsNumber }));
instructions.current.birthday.day = e.target.valueAsNumber;
}}
/>
</div>
<div>
<label htmlFor="month" className="text-xs">
Month
</label>
<input
type="number"
id="month"
min={1}
max={12}
className="pill input text-sm py-1! px-3! w-full"
value={birthday.month ?? undefined}
onChange={(e) => {
setBirthday((p) => ({ ...p, month: e.target.valueAsNumber }));
instructions.current.birthday.month = e.target.valueAsNumber;
}}
/>
</div>
<div>
<label htmlFor="age" className="text-xs">
Age
</label>
<input
type="number"
id="age"
min={1}
max={1000}
className="pill input text-sm py-1! px-3! w-full"
value={birthday.age ?? undefined}
onChange={(e) => {
setBirthday((p) => ({ ...p, age: e.target.valueAsNumber }));
instructions.current.birthday.age = e.target.valueAsNumber;
}}
/>
</div>
<div className="flex gap-1.5 col-span-2">
<input
type="checkbox"
id="dontAge"
className="checkbox"
checked={birthday.dontAge}
onChange={(e) => {
setBirthday((p) => ({ ...p, dontAge: e.target.checked }));
instructions.current.birthday.dontAge = e.target.checked;
}}
/>
<label htmlFor="dontAge" className="text-sm select-none">
Don't Age
</label>
</div>
</div>
</div>
</div>
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-2 mb-2">
<hr className="grow border-zinc-300" />
<span>Personality</span>
<hr className="grow border-zinc-300" />
</div>
<PersonalityViewer
data={personality}
onClick={(key, i) => {
setPersonality((p) => {
const updated = { ...p, [key]: i };
instructions.current.personality = updated;
return updated;
});
}}
/>
</div>
</>
);
}

View file

@ -1,18 +1,18 @@
import { type SwitchMiiInstructions } from "@tomodachi-share/shared";
import NumberInputs from "../number-inputs";
interface Props {
instructions: React.RefObject<SwitchMiiInstructions>;
}
export default function NoseTab({ instructions }: Props) {
return (
<>
<h1 className="absolute font-bold text-xl">Nose</h1>
<div className="size-full flex flex-col justify-center items-center">
<NumberInputs target={instructions.current.nose} />
</div>
</>
);
}
import { type SwitchMiiInstructions } from "@tomodachi-share/shared";
import NumberInputs from "../number-inputs";
interface Props {
instructions: React.RefObject<SwitchMiiInstructions>;
}
export default function NoseTab({ instructions }: Props) {
return (
<>
<h1 className="absolute font-bold text-xl">Nose</h1>
<div className="size-full flex flex-col justify-center items-center">
<NumberInputs target={instructions.current.nose} />
</div>
</>
);
}

View file

@ -1,91 +1,91 @@
import { type SwitchMiiInstructions } from "@tomodachi-share/shared";
import { useState } from "react";
import ColorPicker from "../color-picker";
import NumberInputs from "../number-inputs";
interface Props {
instructions: React.RefObject<SwitchMiiInstructions>;
}
const TABS: { name: keyof SwitchMiiInstructions["other"]; defaultColor?: number }[] = [
{ name: "wrinkles1" },
{ name: "wrinkles2" },
{ name: "beard" },
{ name: "moustache" },
{ name: "goatee" },
{ name: "mole" },
{ name: "eyeShadow", defaultColor: 139 },
{ name: "blush" },
];
export default function OtherTab({ instructions }: Props) {
const [tab, setTab] = useState(0);
const [isFlipped, setIsFlipped] = useState(false);
const [colors, setColors] = useState<number[]>(() =>
TABS.map((t) => {
const entry = instructions.current.other[t.name] ?? {};
const color = entry && "color" in entry ? entry.color : null;
return color ?? t.defaultColor ?? 0;
}),
);
const currentTab = TABS[tab];
const setColor = (value: number) => {
setColors((prev) => {
const copy = [...prev];
copy[tab] = value;
return copy;
});
const target = instructions.current.other[currentTab.name];
if ("color" in target) {
target.color = value;
}
};
return (
<>
<h1 className="absolute font-bold text-xl">Other</h1>
<div className="absolute right-3 z-10 flex justify-end">
<div className="rounded-2xl bg-orange-200">
{TABS.map((_, i) => (
<button
key={i}
type="button"
onClick={() => setTab(i)}
className={`px-3 py-1 rounded-2xl cursor-pointer hover:bg-orange-300/50 transition-colors duration-75 ${tab === i ? "bg-orange-300!" : "orange-200"}`}
>
{i + 1}
</button>
))}
</div>
</div>
<div className="absolute inset-0 flex flex-col justify-center items-center">
<ColorPicker disabled={tab === 0 || tab === 1} color={colors[tab]} setColor={setColor} tab={tab === 6 ? "eyeliner" : "hair"} />
<NumberInputs key={tab} target={instructions.current.other[currentTab.name]} />
{tab === 3 && (
<div className="flex gap-1.5 items-center mt-4">
<input
type="checkbox"
id="subcolor"
className="checkbox"
checked={isFlipped}
onChange={(e) => {
setIsFlipped(e.target.checked);
instructions.current.other.moustache.isFlipped = e.target.checked;
}}
/>
<label htmlFor="subcolor" className="text-xs">
Flip
</label>
</div>
)}
</div>
</>
);
}
import { type SwitchMiiInstructions } from "@tomodachi-share/shared";
import { useState } from "react";
import ColorPicker from "../color-picker";
import NumberInputs from "../number-inputs";
interface Props {
instructions: React.RefObject<SwitchMiiInstructions>;
}
const TABS: { name: keyof SwitchMiiInstructions["other"]; defaultColor?: number }[] = [
{ name: "wrinkles1" },
{ name: "wrinkles2" },
{ name: "beard" },
{ name: "moustache" },
{ name: "goatee" },
{ name: "mole" },
{ name: "eyeShadow", defaultColor: 139 },
{ name: "blush" },
];
export default function OtherTab({ instructions }: Props) {
const [tab, setTab] = useState(0);
const [isFlipped, setIsFlipped] = useState(false);
const [colors, setColors] = useState<number[]>(() =>
TABS.map((t) => {
const entry = instructions.current.other[t.name] ?? {};
const color = entry && "color" in entry ? entry.color : null;
return color ?? t.defaultColor ?? 0;
}),
);
const currentTab = TABS[tab];
const setColor = (value: number) => {
setColors((prev) => {
const copy = [...prev];
copy[tab] = value;
return copy;
});
const target = instructions.current.other[currentTab.name];
if ("color" in target) {
target.color = value;
}
};
return (
<>
<h1 className="absolute font-bold text-xl">Other</h1>
<div className="absolute right-3 z-10 flex justify-end">
<div className="rounded-2xl bg-orange-200">
{TABS.map((_, i) => (
<button
key={i}
type="button"
onClick={() => setTab(i)}
className={`px-3 py-1 rounded-2xl cursor-pointer hover:bg-orange-300/50 transition-colors duration-75 ${tab === i ? "bg-orange-300!" : "orange-200"}`}
>
{i + 1}
</button>
))}
</div>
</div>
<div className="absolute inset-0 flex flex-col justify-center items-center">
<ColorPicker disabled={tab === 0 || tab === 1} color={colors[tab]} setColor={setColor} tab={tab === 6 ? "eyeliner" : "hair"} />
<NumberInputs key={tab} target={instructions.current.other[currentTab.name]} />
{tab === 3 && (
<div className="flex gap-1.5 items-center mt-4">
<input
type="checkbox"
id="subcolor"
className="checkbox"
checked={isFlipped}
onChange={(e) => {
setIsFlipped(e.target.checked);
instructions.current.other.moustache.isFlipped = e.target.checked;
}}
/>
<label htmlFor="subcolor" className="text-xs">
Flip
</label>
</div>
)}
</div>
</>
);
}

View file

@ -1,20 +1,20 @@
export default function QrFinder() {
return (
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none size-72 z-10">
{/* Top-left corner */}
<div className="absolute top-0 left-0 size-6 border-t-3 border-l-3 border-amber-500 rounded-tl-lg" />
{/* Top-right corner */}
<div className="absolute top-0 right-0 size-6 border-t-3 border-r-3 border-amber-500 rounded-tr-lg" />
{/* Bottom-left corner */}
<div className="absolute bottom-0 left-0 size-6 border-b-3 border-l-3 border-amber-500 rounded-bl-lg" />
{/* Bottom-right corner */}
<div className="absolute bottom-0 right-0 size-6 border-b-3 border-r-3 border-amber-500 rounded-br-lg" />
{/* Center point */}
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 size-5 bg-amber-500/70 rounded-full" />
</div>
);
}
export default function QrFinder() {
return (
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none size-72 z-10">
{/* Top-left corner */}
<div className="absolute top-0 left-0 size-6 border-t-3 border-l-3 border-amber-500 rounded-tl-lg" />
{/* Top-right corner */}
<div className="absolute top-0 right-0 size-6 border-t-3 border-r-3 border-amber-500 rounded-tr-lg" />
{/* Bottom-left corner */}
<div className="absolute bottom-0 left-0 size-6 border-b-3 border-l-3 border-amber-500 rounded-bl-lg" />
{/* Bottom-right corner */}
<div className="absolute bottom-0 right-0 size-6 border-b-3 border-r-3 border-amber-500 rounded-br-lg" />
{/* Center point */}
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 size-5 bg-amber-500/70 rounded-full" />
</div>
);
}

View file

@ -1,67 +1,67 @@
import { useCallback, useRef, useState } from "react";
import { type FileWithPath } from "react-dropzone";
import jsQR from "jsqr";
import Dropzone from "../dropzone";
interface Props {
setQrBytesRaw: React.Dispatch<React.SetStateAction<number[]>>;
}
export default function QrUpload({ setQrBytesRaw }: Props) {
const [hasImage, setHasImage] = useState(false);
const canvasRef = useRef<HTMLCanvasElement>(null);
const handleDrop = useCallback(
(acceptedFiles: FileWithPath[]) => {
const file = acceptedFiles[0];
// Scan QR code
const reader = new FileReader();
reader.onload = async (event) => {
const image = new Image();
image.onload = () => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
canvas.width = image.width;
canvas.height = image.height;
ctx.drawImage(image, 0, 0, image.width, image.height);
const imageData = ctx.getImageData(0, 0, image.width, image.height);
const code = jsQR(imageData.data, image.width, image.height);
if (!code) return;
setQrBytesRaw(code.binaryData!);
setHasImage(true);
};
image.src = event.target!.result as string;
};
reader.readAsDataURL(file);
},
[setQrBytesRaw],
);
return (
<div className="max-w-md w-full">
<Dropzone onDrop={handleDrop} options={{ maxFiles: 1 }}>
<p className="text-center text-sm">
{!hasImage ? (
<>
Drag and drop your QR code image here
<br />
or click to open
</>
) : (
"Uploaded!"
)}
</p>
</Dropzone>
{/* Canvas is used to scan the QR code */}
<canvas ref={canvasRef} className="hidden" />
</div>
);
}
import { useCallback, useRef, useState } from "react";
import { type FileWithPath } from "react-dropzone";
import jsQR from "jsqr";
import Dropzone from "../dropzone";
interface Props {
setQrBytesRaw: React.Dispatch<React.SetStateAction<number[]>>;
}
export default function QrUpload({ setQrBytesRaw }: Props) {
const [hasImage, setHasImage] = useState(false);
const canvasRef = useRef<HTMLCanvasElement>(null);
const handleDrop = useCallback(
(acceptedFiles: FileWithPath[]) => {
const file = acceptedFiles[0];
// Scan QR code
const reader = new FileReader();
reader.onload = async (event) => {
const image = new Image();
image.onload = () => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
canvas.width = image.width;
canvas.height = image.height;
ctx.drawImage(image, 0, 0, image.width, image.height);
const imageData = ctx.getImageData(0, 0, image.width, image.height);
const code = jsQR(imageData.data, image.width, image.height);
if (!code) return;
setQrBytesRaw(code.binaryData!);
setHasImage(true);
};
image.src = event.target!.result as string;
};
reader.readAsDataURL(file);
},
[setQrBytesRaw],
);
return (
<div className="max-w-md w-full">
<Dropzone onDrop={handleDrop} options={{ maxFiles: 1 }}>
<p className="text-center text-sm">
{!hasImage ? (
<>
Drag and drop your QR code image here
<br />
or click to open
</>
) : (
"Uploaded!"
)}
</p>
</Dropzone>
{/* Canvas is used to scan the QR code */}
<canvas ref={canvasRef} className="hidden" />
</div>
);
}

View file

@ -1,73 +1,73 @@
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;
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>
);
}