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

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View file

@ -1,25 +1,25 @@
import { useState } from "react";
export default function BannerForm() {
const [message, setMessage] = useState("");
const onClickClear = async () => {
await fetch("/api/admin/banner", { method: "DELETE" });
};
const onClickSet = async () => {
await fetch("/api/admin/banner", { method: "POST", body: message });
};
return (
<div className="bg-orange-100 rounded-xl border-2 border-orange-400 p-2 flex gap-2">
<input type="text" className="pill input w-full" placeholder="Enter banner text" value={message} onChange={(e) => setMessage(e.target.value)} />
<button type="button" className="pill button" onClick={onClickClear}>
Clear
</button>
<button type="submit" className="pill button" onClick={onClickSet}>
Set
</button>
</div>
);
}
import { useState } from "react";
export default function BannerForm() {
const [message, setMessage] = useState("");
const onClickClear = async () => {
await fetch("/api/admin/banner", { method: "DELETE" });
};
const onClickSet = async () => {
await fetch("/api/admin/banner", { method: "POST", body: message });
};
return (
<div className="bg-orange-100 rounded-xl border-2 border-orange-400 p-2 flex gap-2">
<input type="text" className="pill input w-full" placeholder="Enter banner text" value={message} onChange={(e) => setMessage(e.target.value)} />
<button type="button" className="pill button" onClick={onClickClear}>
Clear
</button>
<button type="submit" className="pill button" onClick={onClickSet}>
Set
</button>
</div>
);
}

View file

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

View file

@ -1,45 +1,45 @@
// import { settings } from "@/lib/settings";
// import { useState } from "react";
// export default function ControlCenter() {
// const [canSubmit, setCanSubmit] = useState(settings.canSubmit);
// const [isQueueEnabled, setIsQeueueEnabled] = useState(settings.queueEnabled);
// const onClickSet = async () => {
// await fetch("/api/admin/can-submit", { method: "PATCH", body: JSON.stringify(canSubmit) });
// await fetch("/api/admin/queue", { method: "PATCH", body: JSON.stringify(isQueueEnabled) });
// };
// return (
// <div className="bg-orange-100 rounded-xl border-2 border-orange-400 p-2 flex flex-col gap-2">
// <div className="flex items-center gap-2">
// <input
// id="submit"
// type="checkbox"
// className="checkbox size-6!"
// placeholder="Enter banner text"
// checked={canSubmit}
// onChange={(e) => setCanSubmit(e.target.checked)}
// />
// <label htmlFor="submit">Enable Submissions</label>
// </div>
// <div className="flex items-center gap-2">
// <input
// id="queue"
// type="checkbox"
// className="checkbox size-6!"
// placeholder="Enter banner text"
// checked={isQueueEnabled}
// onChange={(e) => setIsQeueueEnabled(e.target.checked)}
// />
// <label htmlFor="queue">Enable Queue</label>
// </div>
// <div className="flex gap-2 self-end">
// <button type="submit" className="pill button" onClick={onClickSet}>
// Set
// </button>
// </div>
// </div>
// );
// }
// import { settings } from "@/lib/settings";
// import { useState } from "react";
// export default function ControlCenter() {
// const [canSubmit, setCanSubmit] = useState(settings.canSubmit);
// const [isQueueEnabled, setIsQeueueEnabled] = useState(settings.queueEnabled);
// const onClickSet = async () => {
// await fetch("/api/admin/can-submit", { method: "PATCH", body: JSON.stringify(canSubmit) });
// await fetch("/api/admin/queue", { method: "PATCH", body: JSON.stringify(isQueueEnabled) });
// };
// return (
// <div className="bg-orange-100 rounded-xl border-2 border-orange-400 p-2 flex flex-col gap-2">
// <div className="flex items-center gap-2">
// <input
// id="submit"
// type="checkbox"
// className="checkbox size-6!"
// placeholder="Enter banner text"
// checked={canSubmit}
// onChange={(e) => setCanSubmit(e.target.checked)}
// />
// <label htmlFor="submit">Enable Submissions</label>
// </div>
// <div className="flex items-center gap-2">
// <input
// id="queue"
// type="checkbox"
// className="checkbox size-6!"
// placeholder="Enter banner text"
// checked={isQueueEnabled}
// onChange={(e) => setIsQeueueEnabled(e.target.checked)}
// />
// <label htmlFor="queue">Enable Queue</label>
// </div>
// <div className="flex gap-2 self-end">
// <button type="submit" className="pill button" onClick={onClickSet}>
// Set
// </button>
// </div>
// </div>
// );
// }

View file

@ -1,92 +1,92 @@
import { useRouter } from "next/navigation";
// import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
// import { useEffect, useState } from "react";
// import { createPortal } from "react-dom";
import { Icon } from "@iconify/react";
import SubmitButton from "../submit-button";
// import { Icon } from "@iconify/react";
// import SubmitButton from "../submit-button";
interface Props {
punishmentId: number;
}
// interface Props {
// punishmentId: number;
// }
export default function PunishmentDeletionDialog({ punishmentId }: Props) {
const router = useRouter();
// export default function PunishmentDeletionDialog({ punishmentId }: Props) {
// const router = useRouter();
const [isOpen, setIsOpen] = useState(false);
const [isVisible, setIsVisible] = useState(false);
// const [isOpen, setIsOpen] = useState(false);
// const [isVisible, setIsVisible] = useState(false);
const [error, setError] = useState<string | undefined>(undefined);
// const [error, setError] = useState<string | undefined>(undefined);
const handleSubmit = async () => {
const response = await fetch(`/api/admin/punish?id=${punishmentId}`, { method: "DELETE" });
// const handleSubmit = async () => {
// const response = await fetch(`/api/admin/punish?id=${punishmentId}`, { method: "DELETE" });
if (!response.ok) {
const data = await response.json();
setError(data.error);
// if (!response.ok) {
// const data = await response.json();
// setError(data.error);
return;
}
// return;
// }
router.refresh();
};
// router.refresh();
// };
const close = () => {
setIsVisible(false);
setTimeout(() => {
setIsOpen(false);
}, 300);
};
// const close = () => {
// setIsVisible(false);
// setTimeout(() => {
// setIsOpen(false);
// }, 300);
// };
useEffect(() => {
if (isOpen) {
// slight delay to trigger animation
setTimeout(() => setIsVisible(true), 10);
}
}, [isOpen]);
// useEffect(() => {
// if (isOpen) {
// // slight delay to trigger animation
// setTimeout(() => setIsVisible(true), 10);
// }
// }, [isOpen]);
return (
<>
<button onClick={() => setIsOpen(true)} aria-label="Delete Punishment" className="text-red-500 cursor-pointer hover:text-red-600 text-lg">
<Icon icon="material-symbols:close-rounded" />
</button>
// return (
// <>
// <button onClick={() => setIsOpen(true)} aria-label="Delete Punishment" className="text-red-500 cursor-pointer hover:text-red-600 text-lg">
// <Icon icon="material-symbols:close-rounded" />
// </button>
{isOpen &&
createPortal(
<div className="fixed inset-0 w-full h-[calc(100%-var(--header-height))] top-(--header-height) flex items-center justify-center z-40">
<div
onClick={close}
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${
isVisible ? "opacity-100" : "opacity-0"
}`}
/>
// {isOpen &&
// createPortal(
// <div className="fixed inset-0 w-full h-[calc(100%-var(--header-height))] top-(--header-height) flex items-center justify-center z-40">
// <div
// onClick={close}
// className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${
// isVisible ? "opacity-100" : "opacity-0"
// }`}
// />
<div
className={`z-50 bg-orange-50 border-2 border-amber-500 rounded-2xl shadow-lg p-6 w-full max-w-md transition-discrete duration-300 flex flex-col ${
isVisible ? "scale-100 opacity-100" : "scale-75 opacity-0"
}`}
>
<div className="flex justify-between items-center mb-2">
<h2 className="text-xl font-bold">Punishment Deletion</h2>
<button onClick={close} aria-label="Close" className="text-red-400 hover:text-red-500 text-2xl cursor-pointer">
<Icon icon="material-symbols:close-rounded" />
</button>
</div>
// <div
// className={`z-50 bg-orange-50 border-2 border-amber-500 rounded-2xl shadow-lg p-6 w-full max-w-md transition-discrete duration-300 flex flex-col ${
// isVisible ? "scale-100 opacity-100" : "scale-75 opacity-0"
// }`}
// >
// <div className="flex justify-between items-center mb-2">
// <h2 className="text-xl font-bold">Punishment Deletion</h2>
// <button onClick={close} aria-label="Close" className="text-red-400 hover:text-red-500 text-2xl cursor-pointer">
// <Icon icon="material-symbols:close-rounded" />
// </button>
// </div>
<p className="text-sm text-zinc-500">Are you sure? This will delete the user&lsquo;s punishment and they will be able to come back.</p>
// <p className="text-sm text-zinc-500">Are you sure? This will delete the user&lsquo;s punishment and they will be able to come back.</p>
{error && <span className="text-red-400 font-bold mt-2">Error: {error}</span>}
// {error && <span className="text-red-400 font-bold mt-2">Error: {error}</span>}
<div className="flex justify-end gap-2 mt-4">
<button onClick={close} className="pill button">
Cancel
</button>
<SubmitButton onClick={handleSubmit} />
</div>
</div>
</div>,
document.body,
)}
</>
);
}
// <div className="flex justify-end gap-2 mt-4">
// <button onClick={close} className="pill button">
// Cancel
// </button>
// <SubmitButton onClick={handleSubmit} />
// </div>
// </div>
// </div>,
// document.body,
// )}
// </>
// );
// }

View file

@ -1,166 +1,166 @@
// import { Prisma } from "@prisma/client";
// import { useMemo, useRef, useState } from "react";
// import Carousel from "../carousel";
// import Link from "next/link";
// import { Icon } from "@iconify/react";
// interface Props {
// miis: Prisma.MiiGetPayload<{ include: { user: { select: { id: true; name: true } }; _count: { select: { likedBy: true } } } }>[];
// }
// type Decision = "accept" | "reject" | null;
// export default function Queue({ miis }: Props) {
// const [currentIndex, setCurrentIndex] = useState(4); // Current index in the miis array, not visible
// const [visibleMiis, setVisibleMiis] = useState(miis.slice(0, 4));
// const [decision, setDecision] = useState<Decision>(null);
// const [isAnimating, setIsAnimating] = useState(false);
// const [dragOffset, setDragOffset] = useState(0);
// const dragStart = useRef<number | null>(null);
// const isDragging = useRef(false);
// const rotations = useMemo(() => {
// const map: Record<string, number> = {};
// miis.forEach((mii) => {
// map[mii.id] = Math.random() * 15 - 5;
// });
// return map;
// }, [miis]);
// const handleDecision = (decision: Decision) => {
// if (isAnimating) return;
// setDecision(decision);
// setIsAnimating(true);
// setDragOffset(decision === "accept" ? -300 : 300);
// setTimeout(() => {
// setVisibleMiis((prev) => {
// const newQueue = prev.slice(1); // Remove first Mii
// if (miis[currentIndex]) newQueue.push(miis[currentIndex]); // Add a new Mii to the end of the list
// return newQueue;
// });
// setCurrentIndex((prev) => prev + 1);
// setDecision(null);
// setIsAnimating(false);
// setDragOffset(0);
// }, 500);
// };
// const onDragStart = (clientX: number) => {
// if (isAnimating) return;
// dragStart.current = clientX;
// isDragging.current = true;
// };
// const onDragMove = (clientX: number) => {
// if (!isDragging.current || !dragStart.current) return;
// setDragOffset(clientX - dragStart.current);
// };
// const onDragEnd = () => {
// if (!isDragging.current) return;
// isDragging.current = false;
// if (dragOffset < -80) handleDecision("accept");
// else if (dragOffset > 80) handleDecision("reject");
// else setDragOffset(0);
// dragStart.current = null;
// };
// return (
// <div className="w-full flex justify-center items-center gap-8 relative h-100 mt-4 mb-8">
// <button
// onClick={() => handleDecision("accept")}
// className="pointer-coarse:hidden aspect-square cursor-pointer size-12 bg-zinc-50 border-2 border-zinc-300 rounded-full flex justify-center items-center text-2xl text-zinc-500 shadow-xs"
// >
// <Icon icon="material-symbols:check-rounded" />
// </button>
// <div className="relative w-full max-w-96 h-96 aspect-square">
// {visibleMiis.map((mii, i) => {
// const isTopCard = i === 0;
// // Calculate rotation/opacity based on drag distance
// const dragRotation = isTopCard ? dragOffset / 10 : 0;
// const dragOpacity = isTopCard ? 1 - Math.min(Math.abs(dragOffset) / 300, 1) : undefined;
// return (
// <div
// key={mii.id}
// className={`absolute inset-0 flex flex-col bg-zinc-50 rounded-3xl border-2 shadow-lg p-[0.8rem] border-zinc-300 *:select-none
// ${!isDragging.current ? "transition-all duration-500" : "transition-none"}
// ${isTopCard ? "cursor-grab active:cursor-grabbing" : "pointer-events-none"}`}
// style={{
// transform: isTopCard
// ? `translate(${dragOffset}px, ${Math.abs(dragOffset) * 0.1}px) rotate(${rotations[mii.id] + dragRotation}deg)`
// : `translateY(${i * 10}px) rotate(${rotations[mii.id]}deg)`,
// zIndex: (visibleMiis.length - i) * 10,
// opacity: dragOpacity,
// }}
// onMouseDown={(e) => isTopCard && onDragStart(e.clientX)}
// onMouseMove={(e) => isTopCard && onDragMove(e.clientX)}
// onMouseUp={() => isTopCard && onDragEnd()}
// onMouseLeave={() => isTopCard && isDragging.current && onDragEnd()}
// onTouchStart={(e) => isTopCard && onDragStart(e.touches[0].clientX)}
// onTouchMove={(e) => isTopCard && onDragMove(e.touches[0].clientX)}
// onTouchEnd={() => isTopCard && onDragEnd()}
// >
// <Carousel
// images={[
// `/mii/${mii.id}/image?type=mii`,
// ...(mii.platform === "THREE_DS" ? [`/mii/${mii.id}/image?type=qr-code`] : [`/mii/${mii.id}/image?type=features`]),
// ...Array.from({ length: mii.imageCount }, (_, index) => `/mii/${mii.id}/image?type=image${index}`),
// ]}
// onlyButtons
// />
// <div className="p-4 flex flex-col gap-1 h-full">
// <div className="flex justify-between items-center">
// <Link
// href={`/mii/${mii.id}`}
// draggable={false}
// className="relative font-bold text-2xl line-clamp-1 w-full text-ellipsis wrap-break-word"
// title={mii.name}
// >
// {mii.name}
// </Link>
// <div title={mii.platform === "SWITCH" ? "Switch" : "3DS"} className="-mr-3 text-[1.25rem] opacity-25">
// {mii.platform === "SWITCH" ? (
// <Icon icon="cib:nintendo-switch" className="text-red-400" />
// ) : (
// <Icon icon="cib:nintendo-3ds" className="text-sky-400" />
// )}
// </div>
// </div>
// <div id="tags" className="flex flex-wrap gap-1">
// {mii.tags.map((tag) => (
// <Link href={{ query: { tags: tag } }} draggable={false} key={tag} className="px-2 py-1 bg-orange-300 rounded-full text-xs">
// {tag}
// </Link>
// ))}
// </div>
// <div className="mt-auto grid grid-cols-2 gap-4 items-center">
// <p className="text-sm">{mii.createdAt.toLocaleString("en-GB", { timeZone: "UTC" })}</p>
// <Link href={`/profile/${mii.user.id}`} draggable={false} className="text-sm text-right overflow-hidden text-ellipsis whitespace-nowrap">
// @{mii.user?.name}
// </Link>
// </div>
// </div>
// </div>
// );
// })}
// </div>
// <button
// onClick={() => handleDecision("reject")}
// className="pointer-coarse:hidden aspect-square cursor-pointer size-12 bg-zinc-50 border-2 border-zinc-300 rounded-full flex justify-center items-center text-2xl text-zinc-500 shadow-xs"
// >
// <Icon icon="material-symbols:close-rounded" />
// </button>
// </div>
// );
// }
// import { Prisma } from "@prisma/client";
// import { useMemo, useRef, useState } from "react";
// import Carousel from "../carousel";
// import Link from "next/link";
// import { Icon } from "@iconify/react";
// interface Props {
// miis: Prisma.MiiGetPayload<{ include: { user: { select: { id: true; name: true } }; _count: { select: { likedBy: true } } } }>[];
// }
// type Decision = "accept" | "reject" | null;
// export default function Queue({ miis }: Props) {
// const [currentIndex, setCurrentIndex] = useState(4); // Current index in the miis array, not visible
// const [visibleMiis, setVisibleMiis] = useState(miis.slice(0, 4));
// const [decision, setDecision] = useState<Decision>(null);
// const [isAnimating, setIsAnimating] = useState(false);
// const [dragOffset, setDragOffset] = useState(0);
// const dragStart = useRef<number | null>(null);
// const isDragging = useRef(false);
// const rotations = useMemo(() => {
// const map: Record<string, number> = {};
// miis.forEach((mii) => {
// map[mii.id] = Math.random() * 15 - 5;
// });
// return map;
// }, [miis]);
// const handleDecision = (decision: Decision) => {
// if (isAnimating) return;
// setDecision(decision);
// setIsAnimating(true);
// setDragOffset(decision === "accept" ? -300 : 300);
// setTimeout(() => {
// setVisibleMiis((prev) => {
// const newQueue = prev.slice(1); // Remove first Mii
// if (miis[currentIndex]) newQueue.push(miis[currentIndex]); // Add a new Mii to the end of the list
// return newQueue;
// });
// setCurrentIndex((prev) => prev + 1);
// setDecision(null);
// setIsAnimating(false);
// setDragOffset(0);
// }, 500);
// };
// const onDragStart = (clientX: number) => {
// if (isAnimating) return;
// dragStart.current = clientX;
// isDragging.current = true;
// };
// const onDragMove = (clientX: number) => {
// if (!isDragging.current || !dragStart.current) return;
// setDragOffset(clientX - dragStart.current);
// };
// const onDragEnd = () => {
// if (!isDragging.current) return;
// isDragging.current = false;
// if (dragOffset < -80) handleDecision("accept");
// else if (dragOffset > 80) handleDecision("reject");
// else setDragOffset(0);
// dragStart.current = null;
// };
// return (
// <div className="w-full flex justify-center items-center gap-8 relative h-100 mt-4 mb-8">
// <button
// onClick={() => handleDecision("accept")}
// className="pointer-coarse:hidden aspect-square cursor-pointer size-12 bg-zinc-50 border-2 border-zinc-300 rounded-full flex justify-center items-center text-2xl text-zinc-500 shadow-xs"
// >
// <Icon icon="material-symbols:check-rounded" />
// </button>
// <div className="relative w-full max-w-96 h-96 aspect-square">
// {visibleMiis.map((mii, i) => {
// const isTopCard = i === 0;
// // Calculate rotation/opacity based on drag distance
// const dragRotation = isTopCard ? dragOffset / 10 : 0;
// const dragOpacity = isTopCard ? 1 - Math.min(Math.abs(dragOffset) / 300, 1) : undefined;
// return (
// <div
// key={mii.id}
// className={`absolute inset-0 flex flex-col bg-zinc-50 rounded-3xl border-2 shadow-lg p-[0.8rem] border-zinc-300 *:select-none
// ${!isDragging.current ? "transition-all duration-500" : "transition-none"}
// ${isTopCard ? "cursor-grab active:cursor-grabbing" : "pointer-events-none"}`}
// style={{
// transform: isTopCard
// ? `translate(${dragOffset}px, ${Math.abs(dragOffset) * 0.1}px) rotate(${rotations[mii.id] + dragRotation}deg)`
// : `translateY(${i * 10}px) rotate(${rotations[mii.id]}deg)`,
// zIndex: (visibleMiis.length - i) * 10,
// opacity: dragOpacity,
// }}
// onMouseDown={(e) => isTopCard && onDragStart(e.clientX)}
// onMouseMove={(e) => isTopCard && onDragMove(e.clientX)}
// onMouseUp={() => isTopCard && onDragEnd()}
// onMouseLeave={() => isTopCard && isDragging.current && onDragEnd()}
// onTouchStart={(e) => isTopCard && onDragStart(e.touches[0].clientX)}
// onTouchMove={(e) => isTopCard && onDragMove(e.touches[0].clientX)}
// onTouchEnd={() => isTopCard && onDragEnd()}
// >
// <Carousel
// images={[
// `/mii/${mii.id}/image?type=mii`,
// ...(mii.platform === "THREE_DS" ? [`/mii/${mii.id}/image?type=qr-code`] : [`/mii/${mii.id}/image?type=features`]),
// ...Array.from({ length: mii.imageCount }, (_, index) => `/mii/${mii.id}/image?type=image${index}`),
// ]}
// onlyButtons
// />
// <div className="p-4 flex flex-col gap-1 h-full">
// <div className="flex justify-between items-center">
// <Link
// href={`/mii/${mii.id}`}
// draggable={false}
// className="relative font-bold text-2xl line-clamp-1 w-full text-ellipsis wrap-break-word"
// title={mii.name}
// >
// {mii.name}
// </Link>
// <div title={mii.platform === "SWITCH" ? "Switch" : "3DS"} className="-mr-3 text-[1.25rem] opacity-25">
// {mii.platform === "SWITCH" ? (
// <Icon icon="cib:nintendo-switch" className="text-red-400" />
// ) : (
// <Icon icon="cib:nintendo-3ds" className="text-sky-400" />
// )}
// </div>
// </div>
// <div id="tags" className="flex flex-wrap gap-1">
// {mii.tags.map((tag) => (
// <Link href={{ query: { tags: tag } }} draggable={false} key={tag} className="px-2 py-1 bg-orange-300 rounded-full text-xs">
// {tag}
// </Link>
// ))}
// </div>
// <div className="mt-auto grid grid-cols-2 gap-4 items-center">
// <p className="text-sm">{mii.createdAt.toLocaleString("en-GB", { timeZone: "UTC" })}</p>
// <Link href={`/profile/${mii.user.id}`} draggable={false} className="text-sm text-right overflow-hidden text-ellipsis whitespace-nowrap">
// @{mii.user?.name}
// </Link>
// </div>
// </div>
// </div>
// );
// })}
// </div>
// <button
// onClick={() => handleDecision("reject")}
// className="pointer-coarse:hidden aspect-square cursor-pointer size-12 bg-zinc-50 border-2 border-zinc-300 rounded-full flex justify-center items-center text-2xl text-zinc-500 shadow-xs"
// >
// <Icon icon="material-symbols:close-rounded" />
// </button>
// </div>
// );
// }

View file

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

View file

@ -1,30 +1,30 @@
import { useRouter } from "next/navigation";
import { useTransition } from "react";
import { ReportStatus } from "@prisma/client";
// import { useRouter } from "next/navigation";
// import { useTransition } from "react";
// import { ReportStatus } from "@prisma/client";
export default function ReportTabs({ status }: { status?: ReportStatus }) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
// export default function ReportTabs({ status }: { status?: ReportStatus }) {
// const router = useRouter();
// const [isPending, startTransition] = useTransition();
return (
<div className={`flex gap-2 p-3 border-b border-orange-300 transition-opacity ${isPending ? "opacity-50" : ""}`}>
{["ALL", "OPEN", "RESOLVED", "DISMISSED"].map((s) => (
<button
key={s}
onClick={() =>
startTransition(() => {
router.push(s === "ALL" ? "/admin" : `/admin?status=${s}`, { scroll: false });
})
}
className={`text-sm px-3 py-1 rounded-full font-medium cursor-pointer border transition-colors ${
(s === "ALL" && !status) || s === status
? "bg-orange-400 text-white border-orange-400"
: "bg-white text-orange-700 border-orange-300 hover:bg-orange-50"
}`}
>
{s}
</button>
))}
</div>
);
}
// return (
// <div className={`flex gap-2 p-3 border-b border-orange-300 transition-opacity ${isPending ? "opacity-50" : ""}`}>
// {["ALL", "OPEN", "RESOLVED", "DISMISSED"].map((s) => (
// <button
// key={s}
// onClick={() =>
// startTransition(() => {
// router.push(s === "ALL" ? "/admin" : `/admin?status=${s}`, { scroll: false });
// })
// }
// className={`text-sm px-3 py-1 rounded-full font-medium cursor-pointer border transition-colors ${
// (s === "ALL" && !status) || s === status
// ? "bg-orange-400 text-white border-orange-400"
// : "bg-white text-orange-700 border-orange-300 hover:bg-orange-50"
// }`}
// >
// {s}
// </button>
// ))}
// </div>
// );
// }

View file

@ -1,202 +1,202 @@
// import { revalidatePath } from "next/cache";
// import { Icon } from "@iconify/react";
// import { ReportStatus } from "@prisma/client";
// import { prisma } from "@/lib/prisma";
// import ReportTabs from "./report-tabs";
// const PAGE_SIZE = 20;
// export default async function Reports({ searchParams }: { searchParams: { status?: string; page?: string } }) {
// const status = searchParams.status as ReportStatus | undefined;
// const page = Number(searchParams.page ?? 1);
// const [reports, total] = await Promise.all([
// prisma.report.findMany({
// where: status ? { status } : undefined,
// orderBy: { createdAt: "desc" },
// skip: (page - 1) * PAGE_SIZE,
// take: PAGE_SIZE,
// }),
// prisma.report.count({
// where: status ? { status } : undefined,
// }),
// ]);
// const totalPages = Math.ceil(total / PAGE_SIZE);
// const updateStatus = async (formData: FormData) => {
// "use server";
// const id = Number(formData.get("id"));
// const status = formData.get("status") as ReportStatus;
// await prisma.report.update({
// where: { id },
// data: { status },
// });
// revalidatePath("/admin");
// };
// return (
// <div className="bg-orange-100 rounded-xl border-2 border-orange-400">
// <ReportTabs status={status} />
// {/* Grid */}
// <div className="grid grid-cols-2 gap-2 p-2 max-lg:grid-cols-1">
// {reports.map((report) => (
// <div key={report.id} className="p-4 bg-white border border-orange-300 shadow-sm rounded-md">
// <div className="w-full overflow-x-scroll">
// <div className="flex gap-1 w-max">
// <span
// className={`text-xs font-semibold px-2 py-1 rounded-full border ${
// report.reportType == "USER" ? "bg-red-200 text-red-800 border-red-400" : "bg-cyan-200 text-cyan-800 border-cyan-400"
// }`}
// >
// {report.reportType}
// </span>
// <span
// className={`text-xs font-semibold px-2 py-1 rounded-full border ${
// report.status == "OPEN"
// ? "bg-orange-200 text-orange-800 border-orange-400"
// : report.status == "RESOLVED"
// ? "bg-green-200 text-green-800 border-green-400"
// : "bg-zinc-200 text-zinc-800 border-zinc-400"
// }`}
// >
// {report.status}
// </span>
// <span className="ml-2 flex items-center gap-1 text-sm text-zinc-500">
// <Icon icon="lucide:calendar" className="text-base" />
// {report.createdAt.toLocaleString("en-GB", {
// day: "2-digit",
// month: "long",
// year: "numeric",
// hour: "2-digit",
// minute: "2-digit",
// second: "2-digit",
// timeZone: "UTC",
// })}{" "}
// UTC
// </span>
// </div>
// </div>
// <div className="grid grid-cols-4 text-xs text-zinc-600 mt-4 max-sm:grid-cols-2">
// <div>
// <p>Target ID</p>
// <a href={report.reportType === "MII" ? `/mii/${report.targetId}` : `/profile/${report.targetId}`} className="text-blue-600 text-sm">
// {report.targetId}
// </a>
// </div>
// <div>
// <p>Creator ID</p>
// <a href={`/profile/${report.creatorId}`} className="text-blue-600 text-sm">
// {report.creatorId}
// </a>
// </div>
// <div>
// <p>Reporter</p>
// <a href={`/profile/${report.authorId}`} className="text-blue-600 text-sm">
// {report.authorId}
// </a>
// </div>
// <div>
// <p>Reason</p>
// <p className="font-medium text-black text-sm">{report.reason}</p>
// </div>
// </div>
// <div className="mt-4 border border-orange-200 bg-orange-100/50 rounded-md p-2">
// <p className="text-zinc-600 text-xs">Notes</p>
// <p>{report.reasonNotes}</p>
// </div>
// <div className="mt-4 flex gap-4">
// <form action={updateStatus}>
// <input type="hidden" name="id" value={report.id} />
// <input type="hidden" name="status" value={"OPEN"} />
// <button
// type="submit"
// aria-label="Open"
// className="cursor-pointer text-orange-400 flex items-center gap-1 p-1.5 rounded-lg transition-colors hover:bg-orange-400/15"
// >
// <Icon icon="mdi:alert-circle" className="text-xl" />
// <span className="text-sm">Open</span>
// </button>
// </form>
// <form action={updateStatus}>
// <input type="hidden" name="id" value={report.id} />
// <input type="hidden" name="status" value={"RESOLVED"} />
// <button
// type="submit"
// aria-label="Resolve"
// className="cursor-pointer text-green-500 flex items-center gap-1 p-1.5 rounded-lg transition-colors hover:bg-green-500/15"
// >
// <Icon icon="mdi:check-circle" className="text-xl" />
// <span className="text-sm">Resolve</span>
// </button>
// </form>
// <form action={updateStatus}>
// <input type="hidden" name="id" value={report.id} />
// <input type="hidden" name="status" value={"DISMISSED"} />
// <button
// type="submit"
// aria-label="Dismiss"
// className="cursor-pointer text-zinc-400 flex items-center gap-1 p-1.5 rounded-lg transition-colors hover:bg-zinc-400/15"
// >
// <Icon icon="mdi:close-circle" className="text-xl" />
// <span className="text-sm">Dismiss</span>
// </button>
// </form>
// </div>
// </div>
// ))}
// </div>
// {reports.length === 0 && (
// <div className="text-center py-12 text-gray-500">
// <p className="text-lg font-medium">No reports to display</p>
// <p className="text-sm">Reports will appear here when users submit them</p>
// </div>
// )}
// {/* Pagination */}
// {totalPages > 1 && (
// <div className="flex justify-between items-center p-3 border-t border-orange-300">
// <span className="text-sm text-orange-700">{total} total</span>
// <div className="flex items-center gap-3">
// {page > 1 && (
// <a
// href={`/admin?${new URLSearchParams({ ...(status && { status }), page: String(page - 1) })}`}
// className="text-sm px-3 py-1 rounded-full font-medium border bg-white text-orange-700 border-orange-300 hover:bg-orange-50 transition-colors"
// >
// Previous
// </a>
// )}
// <span className="text-sm text-orange-700">
// Page {page} of {totalPages}
// </span>
// {page < totalPages && (
// <a
// href={`/admin?${new URLSearchParams({ ...(status && { status }), page: String(page + 1) })}`}
// className="text-sm px-3 py-1 rounded-full font-medium border bg-white text-orange-700 border-orange-300 hover:bg-orange-50 transition-colors"
// >
// Next
// </a>
// )}
// </div>
// </div>
// )}
// </div>
// );
// }
// import { revalidatePath } from "next/cache";
// import { Icon } from "@iconify/react";
// import { ReportStatus } from "@prisma/client";
// import { prisma } from "@/lib/prisma";
// import ReportTabs from "./report-tabs";
// const PAGE_SIZE = 20;
// export default async function Reports({ searchParams }: { searchParams: { status?: string; page?: string } }) {
// const status = searchParams.status as ReportStatus | undefined;
// const page = Number(searchParams.page ?? 1);
// const [reports, total] = await Promise.all([
// prisma.report.findMany({
// where: status ? { status } : undefined,
// orderBy: { createdAt: "desc" },
// skip: (page - 1) * PAGE_SIZE,
// take: PAGE_SIZE,
// }),
// prisma.report.count({
// where: status ? { status } : undefined,
// }),
// ]);
// const totalPages = Math.ceil(total / PAGE_SIZE);
// const updateStatus = async (formData: FormData) => {
// "use server";
// const id = Number(formData.get("id"));
// const status = formData.get("status") as ReportStatus;
// await prisma.report.update({
// where: { id },
// data: { status },
// });
// revalidatePath("/admin");
// };
// return (
// <div className="bg-orange-100 rounded-xl border-2 border-orange-400">
// <ReportTabs status={status} />
// {/* Grid */}
// <div className="grid grid-cols-2 gap-2 p-2 max-lg:grid-cols-1">
// {reports.map((report) => (
// <div key={report.id} className="p-4 bg-white border border-orange-300 shadow-sm rounded-md">
// <div className="w-full overflow-x-scroll">
// <div className="flex gap-1 w-max">
// <span
// className={`text-xs font-semibold px-2 py-1 rounded-full border ${
// report.reportType == "USER" ? "bg-red-200 text-red-800 border-red-400" : "bg-cyan-200 text-cyan-800 border-cyan-400"
// }`}
// >
// {report.reportType}
// </span>
// <span
// className={`text-xs font-semibold px-2 py-1 rounded-full border ${
// report.status == "OPEN"
// ? "bg-orange-200 text-orange-800 border-orange-400"
// : report.status == "RESOLVED"
// ? "bg-green-200 text-green-800 border-green-400"
// : "bg-zinc-200 text-zinc-800 border-zinc-400"
// }`}
// >
// {report.status}
// </span>
// <span className="ml-2 flex items-center gap-1 text-sm text-zinc-500">
// <Icon icon="lucide:calendar" className="text-base" />
// {report.createdAt.toLocaleString("en-GB", {
// day: "2-digit",
// month: "long",
// year: "numeric",
// hour: "2-digit",
// minute: "2-digit",
// second: "2-digit",
// timeZone: "UTC",
// })}{" "}
// UTC
// </span>
// </div>
// </div>
// <div className="grid grid-cols-4 text-xs text-zinc-600 mt-4 max-sm:grid-cols-2">
// <div>
// <p>Target ID</p>
// <a href={report.reportType === "MII" ? `/mii/${report.targetId}` : `/profile/${report.targetId}`} className="text-blue-600 text-sm">
// {report.targetId}
// </a>
// </div>
// <div>
// <p>Creator ID</p>
// <a href={`/profile/${report.creatorId}`} className="text-blue-600 text-sm">
// {report.creatorId}
// </a>
// </div>
// <div>
// <p>Reporter</p>
// <a href={`/profile/${report.authorId}`} className="text-blue-600 text-sm">
// {report.authorId}
// </a>
// </div>
// <div>
// <p>Reason</p>
// <p className="font-medium text-black text-sm">{report.reason}</p>
// </div>
// </div>
// <div className="mt-4 border border-orange-200 bg-orange-100/50 rounded-md p-2">
// <p className="text-zinc-600 text-xs">Notes</p>
// <p>{report.reasonNotes}</p>
// </div>
// <div className="mt-4 flex gap-4">
// <form action={updateStatus}>
// <input type="hidden" name="id" value={report.id} />
// <input type="hidden" name="status" value={"OPEN"} />
// <button
// type="submit"
// aria-label="Open"
// className="cursor-pointer text-orange-400 flex items-center gap-1 p-1.5 rounded-lg transition-colors hover:bg-orange-400/15"
// >
// <Icon icon="mdi:alert-circle" className="text-xl" />
// <span className="text-sm">Open</span>
// </button>
// </form>
// <form action={updateStatus}>
// <input type="hidden" name="id" value={report.id} />
// <input type="hidden" name="status" value={"RESOLVED"} />
// <button
// type="submit"
// aria-label="Resolve"
// className="cursor-pointer text-green-500 flex items-center gap-1 p-1.5 rounded-lg transition-colors hover:bg-green-500/15"
// >
// <Icon icon="mdi:check-circle" className="text-xl" />
// <span className="text-sm">Resolve</span>
// </button>
// </form>
// <form action={updateStatus}>
// <input type="hidden" name="id" value={report.id} />
// <input type="hidden" name="status" value={"DISMISSED"} />
// <button
// type="submit"
// aria-label="Dismiss"
// className="cursor-pointer text-zinc-400 flex items-center gap-1 p-1.5 rounded-lg transition-colors hover:bg-zinc-400/15"
// >
// <Icon icon="mdi:close-circle" className="text-xl" />
// <span className="text-sm">Dismiss</span>
// </button>
// </form>
// </div>
// </div>
// ))}
// </div>
// {reports.length === 0 && (
// <div className="text-center py-12 text-gray-500">
// <p className="text-lg font-medium">No reports to display</p>
// <p className="text-sm">Reports will appear here when users submit them</p>
// </div>
// )}
// {/* Pagination */}
// {totalPages > 1 && (
// <div className="flex justify-between items-center p-3 border-t border-orange-300">
// <span className="text-sm text-orange-700">{total} total</span>
// <div className="flex items-center gap-3">
// {page > 1 && (
// <a
// href={`/admin?${new URLSearchParams({ ...(status && { status }), page: String(page - 1) })}`}
// className="text-sm px-3 py-1 rounded-full font-medium border bg-white text-orange-700 border-orange-300 hover:bg-orange-50 transition-colors"
// >
// Previous
// </a>
// )}
// <span className="text-sm text-orange-700">
// Page {page} of {totalPages}
// </span>
// {page < totalPages && (
// <a
// href={`/admin?${new URLSearchParams({ ...(status && { status }), page: String(page + 1) })}`}
// className="text-sm px-3 py-1 rounded-full font-medium border bg-white text-orange-700 border-orange-300 hover:bg-orange-50 transition-colors"
// >
// Next
// </a>
// )}
// </div>
// </div>
// )}
// </div>
// );
// }

View file

@ -1,51 +1,51 @@
import { useState } from "react";
import { Icon } from "@iconify/react";
import { redirect } from "next/navigation";
// import { useState } from "react";
// import { Icon } from "@iconify/react";
// import { redirect } from "next/navigation";
interface Props {
hasExpired: boolean;
}
// interface Props {
// hasExpired: boolean;
// }
export default function ReturnToIsland({ hasExpired }: Props) {
const [isChecked, setIsChecked] = useState(false);
const [error, setError] = useState<string | undefined>(undefined);
// export default function ReturnToIsland({ hasExpired }: Props) {
// const [isChecked, setIsChecked] = useState(false);
// const [error, setError] = useState<string | undefined>(undefined);
const handleClick = async () => {
const response = await fetch("/api/return", { method: "DELETE" });
// const handleClick = async () => {
// const response = await fetch("/api/return", { method: "DELETE" });
if (!response.ok) {
const data = await response.json();
setError(data.error);
// if (!response.ok) {
// const data = await response.json();
// setError(data.error);
return;
}
// return;
// }
redirect("/");
};
// redirect("/");
// };
return (
<>
<div className="flex justify-center items-center gap-2">
<input
type="checkbox"
id="agreement"
disabled={hasExpired}
checked={isChecked}
onChange={(e) => setIsChecked(e.target.checked)}
className={`checkbox ${hasExpired && "text-zinc-600 bg-zinc-100! border-zinc-300!"}`}
/>
<label htmlFor="agreement" className={`${hasExpired && "text-zinc-500"}`}>
I Agree
</label>
</div>
// return (
// <>
// <div className="flex justify-center items-center gap-2">
// <input
// type="checkbox"
// id="agreement"
// disabled={hasExpired}
// checked={isChecked}
// onChange={(e) => setIsChecked(e.target.checked)}
// className={`checkbox ${hasExpired && "text-zinc-600 bg-zinc-100! border-zinc-300!"}`}
// />
// <label htmlFor="agreement" className={`${hasExpired && "text-zinc-500"}`}>
// I Agree
// </label>
// </div>
<hr className="border-zinc-300 mt-3 mb-4" />
// <hr className="border-zinc-300 mt-3 mb-4" />
{error && <span className="text-red-400 font-bold mb-2.5">Error: {error}</span>}
<button disabled={!isChecked} aria-label="Travel Back Home" onClick={handleClick} className="pill button gap-2 w-fit self-center">
<Icon icon="ic:round-home" fontSize={24} />
Travel Back
</button>
</>
);
}
// {error && <span className="text-red-400 font-bold mb-2.5">Error: {error}</span>}
// <button disabled={!isChecked} aria-label="Travel Back Home" onClick={handleClick} className="pill button gap-2 w-fit self-center">
// <Icon icon="ic:round-home" fontSize={24} />
// Travel Back
// </button>
// </>
// );
// }

View file

@ -1,316 +1,316 @@
// WARNING: this code is quite trash
// // WARNING: this code is quite trash
import { useState } from "react";
// import { useState } from "react";
import { Icon } from "@iconify/react";
import { Prisma, PunishmentType } from "@prisma/client";
// import { Icon } from "@iconify/react";
// import { Prisma, PunishmentType } from "@prisma/client";
import ProfilePicture from "../profile-picture";
import SubmitButton from "../submit-button";
import PunishmentDeletionDialog from "./punishment-deletion-dialog";
// import ProfilePicture from "../profile-picture";
// import SubmitButton from "../submit-button";
// import PunishmentDeletionDialog from "./punishment-deletion-dialog";
interface ApiResponse {
success: boolean;
name: string;
image: string;
createdAt: string;
punishments: Prisma.PunishmentGetPayload<{
include: {
violatingMiis: true;
};
}>[];
}
// interface ApiResponse {
// success: boolean;
// name: string;
// image: string;
// createdAt: string;
// punishments: Prisma.PunishmentGetPayload<{
// include: {
// violatingMiis: true;
// };
// }>[];
// }
interface MiiList {
id: number;
reason: string;
}
// interface MiiList {
// id: number;
// reason: string;
// }
export default function Punishments() {
const [userId, setUserId] = useState(-1);
const [user, setUser] = useState<ApiResponse | undefined>();
// export default function Punishments() {
// const [userId, setUserId] = useState(-1);
// const [user, setUser] = useState<ApiResponse | undefined>();
const [type, setType] = useState<PunishmentType>("WARNING");
const [duration, setDuration] = useState(1);
const [notes, setNotes] = useState("");
const [reasons, setReasons] = useState("");
// const [type, setType] = useState<PunishmentType>("WARNING");
// const [duration, setDuration] = useState(1);
// const [notes, setNotes] = useState("");
// const [reasons, setReasons] = useState("");
const [miiList, setMiiList] = useState<MiiList[]>([]);
const [newMii, setNewMii] = useState<MiiList>({
id: 0,
reason: "",
});
// const [miiList, setMiiList] = useState<MiiList[]>([]);
// const [newMii, setNewMii] = useState<MiiList>({
// id: 0,
// reason: "",
// });
const [error, setError] = useState<string | undefined>(undefined);
// const [error, setError] = useState<string | undefined>(undefined);
const addMiiToList = () => {
if (newMii.id && newMii.reason) {
setMiiList([...miiList, { ...newMii, id: Number(newMii.id) }]);
setNewMii({ id: 0, reason: "" });
}
};
// const addMiiToList = () => {
// if (newMii.id && newMii.reason) {
// setMiiList([...miiList, { ...newMii, id: Number(newMii.id) }]);
// setNewMii({ id: 0, reason: "" });
// }
// };
const removeMiiFromList = (index: number) => {
setMiiList(miiList.filter((_, i) => i !== index));
};
// const removeMiiFromList = (index: number) => {
// setMiiList(miiList.filter((_, i) => i !== index));
// };
const handleLookup = async () => {
const response = await fetch(`/api/admin/lookup?id=${userId}`);
const data = await response.json();
setUser(data);
};
// const handleLookup = async () => {
// const response = await fetch(`/api/admin/lookup?id=${userId}`);
// const data = await response.json();
// setUser(data);
// };
const handleSubmit = async () => {
const response = await fetch(`/api/admin/punish?id=${userId}`, {
method: "POST",
body: JSON.stringify({
type,
duration,
notes,
reasons: reasons.split(","),
miiReasons: miiList,
}),
});
// const handleSubmit = async () => {
// const response = await fetch(`/api/admin/punish?id=${userId}`, {
// method: "POST",
// body: JSON.stringify({
// type,
// duration,
// notes,
// reasons: reasons.split(","),
// miiReasons: miiList,
// }),
// });
if (!response.ok) {
const { error } = await response.json();
setError(error);
}
// if (!response.ok) {
// const { error } = await response.json();
// setError(error);
// }
// Set all inputs to empty/default
setType("WARNING");
setDuration(1);
setNotes("");
setReasons("");
setMiiList([]);
setNewMii({ id: 0, reason: "" });
setError("");
// // Set all inputs to empty/default
// setType("WARNING");
// setDuration(1);
// setNotes("");
// setReasons("");
// setMiiList([]);
// setNewMii({ id: 0, reason: "" });
// setError("");
await handleLookup();
};
// await handleLookup();
// };
return (
<div className="bg-orange-100 rounded-xl border-2 border-orange-400 p-2 gap-2">
<div className="flex justify-center items-center gap-2">
<input
type="number"
placeholder="Enter user ID to lookup..."
name="user-id"
value={userId !== -1 ? userId : ""}
onChange={(e) => setUserId(Number(e.target.value))}
className="pill input w-full max-w-lg"
/>
<button onClick={handleLookup} className="pill button">
Lookup User
</button>
</div>
// return (
// <div className="bg-orange-100 rounded-xl border-2 border-orange-400 p-2 gap-2">
// <div className="flex justify-center items-center gap-2">
// <input
// type="number"
// placeholder="Enter user ID to lookup..."
// name="user-id"
// value={userId !== -1 ? userId : ""}
// onChange={(e) => setUserId(Number(e.target.value))}
// className="pill input w-full max-w-lg"
// />
// <button onClick={handleLookup} className="pill button">
// Lookup User
// </button>
// </div>
{user && (
<div className="grid grid-cols-2 gap-2 mt-2 max-lg:grid-cols-1">
<div className="p-4 bg-orange-50 border border-orange-300 rounded-md shadow-sm">
<div className="flex gap-1">
<ProfilePicture src={user.image} width={96} height={96} className="rounded-full border-2 border-orange-400" />
<div className="p-2 flex flex-col">
<p className="text-xl font-bold">{user.name}</p>
<p className="text-black/60 text-sm font-medium">@{user.name}</p>
<p className="text-sm mt-auto">
<span className="font-medium">Created:</span>{" "}
{new Date(user.createdAt).toLocaleString("en-GB", {
day: "2-digit",
month: "long",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
timeZone: "UTC",
})}{" "}
UTC
</p>
</div>
</div>
// {user && (
// <div className="grid grid-cols-2 gap-2 mt-2 max-lg:grid-cols-1">
// <div className="p-4 bg-orange-50 border border-orange-300 rounded-md shadow-sm">
// <div className="flex gap-1">
// <ProfilePicture src={user.image} width={96} height={96} className="rounded-full border-2 border-orange-400" />
// <div className="p-2 flex flex-col">
// <p className="text-xl font-bold">{user.name}</p>
// <p className="text-black/60 text-sm font-medium">@{user.name}</p>
// <p className="text-sm mt-auto">
// <span className="font-medium">Created:</span>{" "}
// {new Date(user.createdAt).toLocaleString("en-GB", {
// day: "2-digit",
// month: "long",
// year: "numeric",
// hour: "2-digit",
// minute: "2-digit",
// second: "2-digit",
// timeZone: "UTC",
// })}{" "}
// UTC
// </p>
// </div>
// </div>
<hr className="border-zinc-300 my-3" />
// <hr className="border-zinc-300 my-3" />
<div className="flex flex-col gap-2">
{user.punishments.length === 0 ? (
<p className="text-center text-zinc-500 my-2">No punishments found.</p>
) : (
<>
{user.punishments.map((punishment) => (
<div
key={punishment.id}
className={`border rounded-lg p-3 space-y-1 ${
punishment.type === "WARNING"
? "bg-yellow-50 border-yellow-400"
: punishment.type === "TEMP_EXILE"
? "bg-orange-100 border-orange-200"
: "bg-red-50 border-red-200"
}`}
>
<div className="flex items-center justify-between mb-2">
<span
className={`border px-2 py-1 rounded text-xs font-semibold ${
punishment.type === "WARNING"
? "bg-yellow-200 text-yellow-800 border-yellow-500"
: punishment.type === "TEMP_EXILE"
? "bg-orange-200 text-orange-800 border-orange-500"
: "bg-red-200 text-red-800 border-red-500"
}`}
>
{punishment.type}
</span>
// <div className="flex flex-col gap-2">
// {user.punishments.length === 0 ? (
// <p className="text-center text-zinc-500 my-2">No punishments found.</p>
// ) : (
// <>
// {user.punishments.map((punishment) => (
// <div
// key={punishment.id}
// className={`border rounded-lg p-3 space-y-1 ${
// punishment.type === "WARNING"
// ? "bg-yellow-50 border-yellow-400"
// : punishment.type === "TEMP_EXILE"
// ? "bg-orange-100 border-orange-200"
// : "bg-red-50 border-red-200"
// }`}
// >
// <div className="flex items-center justify-between mb-2">
// <span
// className={`border px-2 py-1 rounded text-xs font-semibold ${
// punishment.type === "WARNING"
// ? "bg-yellow-200 text-yellow-800 border-yellow-500"
// : punishment.type === "TEMP_EXILE"
// ? "bg-orange-200 text-orange-800 border-orange-500"
// : "bg-red-200 text-red-800 border-red-500"
// }`}
// >
// {punishment.type}
// </span>
<div className="flex items-center gap-2">
<span className="text-sm text-zinc-600">
{new Date(punishment.createdAt).toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" })}
</span>
<PunishmentDeletionDialog punishmentId={punishment.id} />
</div>
</div>
<p className="text-sm text-zinc-600">
<strong>Notes:</strong> {punishment.notes}
</p>
{punishment.type !== "WARNING" && (
<p className="text-sm text-zinc-600">
<strong>Expires:</strong>{" "}
{punishment.expiresAt
? new Date(punishment.expiresAt).toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" })
: "Never"}
</p>
)}
{punishment.type !== "PERM_EXILE" && (
<p className="text-sm text-zinc-600">
<strong>Returned:</strong> {JSON.stringify(punishment.returned)}
</p>
)}
<p className="text-sm text-zinc-600">
<strong>Reasons:</strong>
</p>
<ul className="ml-8 list-disc text-sm text-zinc-600">
{punishment.reasons.map((reason, index) => (
<li key={index}>{reason}</li>
))}
</ul>
<p className="text-sm text-zinc-600">
<strong>Mii Reasons:</strong>
</p>
<ul className="ml-8 list-disc text-sm text-zinc-600">
{punishment.violatingMiis.map((mii) => (
<li key={mii.miiId}>
{mii.miiId}: {mii.reason}
</li>
))}
</ul>
</div>
))}
</>
)}
</div>
</div>
// <div className="flex items-center gap-2">
// <span className="text-sm text-zinc-600">
// {new Date(punishment.createdAt).toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" })}
// </span>
// <PunishmentDeletionDialog punishmentId={punishment.id} />
// </div>
// </div>
// <p className="text-sm text-zinc-600">
// <strong>Notes:</strong> {punishment.notes}
// </p>
// {punishment.type !== "WARNING" && (
// <p className="text-sm text-zinc-600">
// <strong>Expires:</strong>{" "}
// {punishment.expiresAt
// ? new Date(punishment.expiresAt).toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" })
// : "Never"}
// </p>
// )}
// {punishment.type !== "PERM_EXILE" && (
// <p className="text-sm text-zinc-600">
// <strong>Returned:</strong> {JSON.stringify(punishment.returned)}
// </p>
// )}
// <p className="text-sm text-zinc-600">
// <strong>Reasons:</strong>
// </p>
// <ul className="ml-8 list-disc text-sm text-zinc-600">
// {punishment.reasons.map((reason, index) => (
// <li key={index}>{reason}</li>
// ))}
// </ul>
// <p className="text-sm text-zinc-600">
// <strong>Mii Reasons:</strong>
// </p>
// <ul className="ml-8 list-disc text-sm text-zinc-600">
// {punishment.violatingMiis.map((mii) => (
// <li key={mii.miiId}>
// {mii.miiId}: {mii.reason}
// </li>
// ))}
// </ul>
// </div>
// ))}
// </>
// )}
// </div>
// </div>
<div className="p-4 bg-orange-50 border border-orange-300 rounded-md shadow-sm flex flex-col gap-1">
{/* Punishment type */}
<p className="text-sm">Punishment Type</p>
<select name="punishment-type" value={type} onChange={(e) => setType(e.target.value as PunishmentType)} className="pill input">
<option value="WARNING">Warning</option>
<option value="TEMP_EXILE">Temporary Exile</option>
<option value="PERM_EXILE">Permanent Exile</option>
</select>
// <div className="p-4 bg-orange-50 border border-orange-300 rounded-md shadow-sm flex flex-col gap-1">
// {/* Punishment type */}
// <p className="text-sm">Punishment Type</p>
// <select name="punishment-type" value={type} onChange={(e) => setType(e.target.value as PunishmentType)} className="pill input">
// <option value="WARNING">Warning</option>
// <option value="TEMP_EXILE">Temporary Exile</option>
// <option value="PERM_EXILE">Permanent Exile</option>
// </select>
{/* Punishment duration */}
{type === "TEMP_EXILE" && (
<>
<p className="text-sm">Duration</p>
<select name="punishment-duration" value={duration} onChange={(e) => setDuration(Number(e.target.value))} className="pill input">
<option value="1">1 Day</option>
<option value="7">7 Days</option>
<option value="30">30 Days</option>
</select>
</>
)}
// {/* Punishment duration */}
// {type === "TEMP_EXILE" && (
// <>
// <p className="text-sm">Duration</p>
// <select name="punishment-duration" value={duration} onChange={(e) => setDuration(Number(e.target.value))} className="pill input">
// <option value="1">1 Day</option>
// <option value="7">7 Days</option>
// <option value="30">30 Days</option>
// </select>
// </>
// )}
{/* Punishment notes */}
<p className="text-sm">Notes</p>
<textarea
rows={2}
maxLength={256}
placeholder="Type notes here for the punishment..."
className="pill input rounded-xl! resize-none"
value={notes}
onChange={(e) => setNotes(e.target.value)}
/>
// {/* Punishment notes */}
// <p className="text-sm">Notes</p>
// <textarea
// rows={2}
// maxLength={256}
// placeholder="Type notes here for the punishment..."
// className="pill input rounded-xl! resize-none"
// value={notes}
// onChange={(e) => setNotes(e.target.value)}
// />
{/* Punishment profile-related reasons */}
<p className="text-sm">Profile-related reasons (split by comma)</p>
<textarea
rows={2}
maxLength={256}
placeholder="Type profile-related reasons here for the punishment..."
className="pill input rounded-xl! resize-none"
value={reasons}
onChange={(e) => setReasons(e.target.value)}
/>
// {/* Punishment profile-related reasons */}
// <p className="text-sm">Profile-related reasons (split by comma)</p>
// <textarea
// rows={2}
// maxLength={256}
// placeholder="Type profile-related reasons here for the punishment..."
// className="pill input rounded-xl! resize-none"
// value={reasons}
// onChange={(e) => setReasons(e.target.value)}
// />
{/* Punishment mii-related reasons */}
<p className="text-sm">Mii-related reasons</p>
<div className="bg-orange-100 border border-orange-300 rounded-lg p-4">
{/* Add Mii Form */}
<div className="flex gap-2">
<input
type="number"
placeholder="Mii ID"
className="pill input w-24 text-sm"
value={newMii.id}
onChange={(e) => setNewMii({ ...newMii, id: Number(e.target.value) })}
/>
<input
type="text"
placeholder="Reason for this Mii..."
className="pill input flex-1 text-sm"
value={newMii.reason}
onChange={(e) => setNewMii({ ...newMii, reason: e.target.value })}
/>
<button type="button" aria-label="Add Mii" onClick={addMiiToList} className="pill button aspect-square p-2.5!">
<Icon icon="ic:baseline-plus" className="size-4" />
</button>
</div>
// {/* Punishment mii-related reasons */}
// <p className="text-sm">Mii-related reasons</p>
// <div className="bg-orange-100 border border-orange-300 rounded-lg p-4">
// {/* Add Mii Form */}
// <div className="flex gap-2">
// <input
// type="number"
// placeholder="Mii ID"
// className="pill input w-24 text-sm"
// value={newMii.id}
// onChange={(e) => setNewMii({ ...newMii, id: Number(e.target.value) })}
// />
// <input
// type="text"
// placeholder="Reason for this Mii..."
// className="pill input flex-1 text-sm"
// value={newMii.reason}
// onChange={(e) => setNewMii({ ...newMii, reason: e.target.value })}
// />
// <button type="button" aria-label="Add Mii" onClick={addMiiToList} className="pill button aspect-square p-2.5!">
// <Icon icon="ic:baseline-plus" className="size-4" />
// </button>
// </div>
{/* Mii List */}
{miiList.length > 0 && (
<div className="mt-2 space-y-1">
<p className="text-sm font-medium text-black/50">Violating Miis ({miiList.length})</p>
{miiList.map((mii, index) => (
<div key={index} className="bg-white border border-orange-200 rounded-md p-3 flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="bg-orange-200 text-orange-800 border border-orange-400 px-2 py-1 rounded text-xs font-semibold">ID: {mii.id}</span>
<span className="text-sm text-gray-500">{mii.reason}</span>
</div>
</div>
<button
type="button"
aria-label="Remove Mii"
onClick={() => removeMiiFromList(index)}
className="cursor-pointer text-red-500 hover:text-red-700 transition-colors"
>
<Icon icon="iconamoon:trash" className="size-4" />
</button>
</div>
))}
</div>
)}
// {/* Mii List */}
// {miiList.length > 0 && (
// <div className="mt-2 space-y-1">
// <p className="text-sm font-medium text-black/50">Violating Miis ({miiList.length})</p>
// {miiList.map((mii, index) => (
// <div key={index} className="bg-white border border-orange-200 rounded-md p-3 flex items-center justify-between">
// <div className="flex-1">
// <div className="flex items-center gap-2">
// <span className="bg-orange-200 text-orange-800 border border-orange-400 px-2 py-1 rounded text-xs font-semibold">ID: {mii.id}</span>
// <span className="text-sm text-gray-500">{mii.reason}</span>
// </div>
// </div>
// <button
// type="button"
// aria-label="Remove Mii"
// onClick={() => removeMiiFromList(index)}
// className="cursor-pointer text-red-500 hover:text-red-700 transition-colors"
// >
// <Icon icon="iconamoon:trash" className="size-4" />
// </button>
// </div>
// ))}
// </div>
// )}
{miiList.length === 0 && <p className="text-center text-zinc-500 text-sm my-4">No Miis added yet</p>}
</div>
// {miiList.length === 0 && <p className="text-center text-zinc-500 text-sm my-4">No Miis added yet</p>}
// </div>
<div className="flex justify-between items-center mt-2">
{error && <span className="text-red-400 font-bold">Error: {error}</span>}
// <div className="flex justify-between items-center mt-2">
// {error && <span className="text-red-400 font-bold">Error: {error}</span>}
<SubmitButton onClick={handleSubmit} className="ml-auto" />
</div>
</div>
</div>
)}
</div>
);
}
// <SubmitButton onClick={handleSubmit} className="ml-auto" />
// </div>
// </div>
// </div>
// )}
// </div>
// );
// }

View file

@ -1,93 +1,93 @@
import { useEffect, useState } from "react";
import useEmblaCarousel from "embla-carousel-react";
import { Icon } from "@iconify/react";
import ImageViewer from "./image-viewer";
interface Props {
images: string[];
className?: string;
}
export default function Carousel({ images, className }: Props) {
const [emblaRef, emblaApi] = useEmblaCarousel({ duration: 15 });
const [selectedIndex, setSelectedIndex] = useState(0);
const [scrollSnaps, setScrollSnaps] = useState<number[]>([]);
const [isFocused, setIsFocused] = useState(false);
useEffect(() => {
if (!emblaApi) return;
emblaApi.reInit();
setScrollSnaps(emblaApi.scrollSnapList());
setSelectedIndex(0);
emblaApi.on("select", () => setSelectedIndex(emblaApi.selectedScrollSnap()));
}, [images, emblaApi]);
// Handle keyboard events
useEffect(() => {
if (!isFocused || !emblaApi) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "ArrowLeft") emblaApi.scrollPrev();
else if (event.key === "ArrowRight") emblaApi.scrollNext();
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [isFocused, emblaApi]);
return (
<div className="relative w-full h-fit" tabIndex={0} onMouseEnter={() => setIsFocused(true)} onMouseLeave={() => setIsFocused(false)}>
<div className={`overflow-hidden rounded-xl bg-zinc-300 ${className ?? ""}`} ref={emblaRef}>
<div className="flex">
{images.map((src, index) => (
<div key={index} className="shrink-0 w-full">
<ImageViewer src={src} alt="mii image" width={240} height={160} className="w-full h-auto aspect-3/2 object-contain" images={images} />
</div>
))}
</div>
</div>
{images.length > 1 && (
<>
<button
type="button"
aria-label="Scroll Carousel Left"
onClick={() => emblaApi?.scrollPrev()}
disabled={!emblaApi?.canScrollPrev()}
className={`absolute left-2 top-1/2 -translate-y-1/2 bg-white p-1 rounded-full shadow text-xl transition-opacity ${
emblaApi?.canScrollPrev() ? "opacity-100 cursor-pointer" : "opacity-50"
}`}
>
<Icon icon="ic:round-chevron-left" />
</button>
<button
type="button"
aria-label="Scroll Carousel Right"
onClick={() => emblaApi?.scrollNext()}
disabled={!emblaApi?.canScrollNext()}
className={`absolute right-2 top-1/2 -translate-y-1/2 bg-white p-1 rounded-full shadow text-xl transition-opacity ${
emblaApi?.canScrollNext() ? "opacity-100 cursor-pointer" : "opacity-50"
}`}
>
<Icon icon="ic:round-chevron-right" />
</button>
</>
)}
<div className="flex justify-center p-2 gap-2 absolute right-0">
{scrollSnaps.map((_, index) => (
<button
key={index}
type="button"
aria-label={`Go to ${index} in Carousel`}
onClick={() => emblaApi?.scrollTo(index)}
className={`size-1.5 cursor-pointer rounded-full ${index === selectedIndex ? "bg-black" : "bg-black/25"}`}
/>
))}
</div>
</div>
);
}
import { useEffect, useState } from "react";
import useEmblaCarousel from "embla-carousel-react";
import { Icon } from "@iconify/react";
import ImageViewer from "./image-viewer";
interface Props {
images: string[];
className?: string;
}
export default function Carousel({ images, className }: Props) {
const [emblaRef, emblaApi] = useEmblaCarousel({ duration: 15 });
const [selectedIndex, setSelectedIndex] = useState(0);
const [scrollSnaps, setScrollSnaps] = useState<number[]>([]);
const [isFocused, setIsFocused] = useState(false);
useEffect(() => {
if (!emblaApi) return;
emblaApi.reInit();
setScrollSnaps(emblaApi.scrollSnapList());
setSelectedIndex(0);
emblaApi.on("select", () => setSelectedIndex(emblaApi.selectedScrollSnap()));
}, [images, emblaApi]);
// Handle keyboard events
useEffect(() => {
if (!isFocused || !emblaApi) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "ArrowLeft") emblaApi.scrollPrev();
else if (event.key === "ArrowRight") emblaApi.scrollNext();
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [isFocused, emblaApi]);
return (
<div className="relative w-full h-fit" tabIndex={0} onMouseEnter={() => setIsFocused(true)} onMouseLeave={() => setIsFocused(false)}>
<div className={`overflow-hidden rounded-xl bg-zinc-300 ${className ?? ""}`} ref={emblaRef}>
<div className="flex">
{images.map((src, index) => (
<div key={index} className="shrink-0 w-full">
<ImageViewer src={src} alt="mii image" width={240} height={160} className="w-full h-auto aspect-3/2 object-contain" images={images} />
</div>
))}
</div>
</div>
{images.length > 1 && (
<>
<button
type="button"
aria-label="Scroll Carousel Left"
onClick={() => emblaApi?.scrollPrev()}
disabled={!emblaApi?.canScrollPrev()}
className={`absolute left-2 top-1/2 -translate-y-1/2 bg-white p-1 rounded-full shadow text-xl transition-opacity ${
emblaApi?.canScrollPrev() ? "opacity-100 cursor-pointer" : "opacity-50"
}`}
>
<Icon icon="ic:round-chevron-left" />
</button>
<button
type="button"
aria-label="Scroll Carousel Right"
onClick={() => emblaApi?.scrollNext()}
disabled={!emblaApi?.canScrollNext()}
className={`absolute right-2 top-1/2 -translate-y-1/2 bg-white p-1 rounded-full shadow text-xl transition-opacity ${
emblaApi?.canScrollNext() ? "opacity-100 cursor-pointer" : "opacity-50"
}`}
>
<Icon icon="ic:round-chevron-right" />
</button>
</>
)}
<div className="flex justify-center p-2 gap-2 absolute right-0">
{scrollSnaps.map((_, index) => (
<button
key={index}
type="button"
aria-label={`Go to ${index} in Carousel`}
onClick={() => emblaApi?.scrollTo(index)}
className={`size-1.5 cursor-pointer rounded-full ${index === selectedIndex ? "bg-black" : "bg-black/25"}`}
/>
))}
</div>
</div>
);
}

View file

@ -1,42 +1,42 @@
import { Icon } from "@iconify/react";
interface Props {
text: string;
className?: string;
}
// Adds fancy formatting to links
export default function Description({ text, className }: Props) {
const urlRegex = /(https?:\/\/[^\s]+)/g;
const parts = text.split(urlRegex);
return (
<p className={`text-sm mt-2 bg-white/50 p-3 rounded-lg border border-orange-200 whitespace-break-spaces max-h-54 overflow-y-auto ${className}`}>
{parts.map(async (part, index) => {
try {
// Check if it's a URL
if (!urlRegex.test(part)) throw new Error("Not a URL");
const url = new URL(part);
return (
<a
key={index}
href={`/out?url=${encodeURIComponent(part)}`}
target="_blank"
className="text-blue-700 underline break-all ml-1 inline-flex items-center group"
title={`Go to ${url.hostname}`}
>
{url.hostname}
{url.pathname !== "/" ? url.pathname : ""}
{url.search}
<Icon icon="mi:arrow-right-up" fontSize={16} className="transition group-hover:translate-x-0.5 group-hover:-translate-y-0.5" />
</a>
);
} catch {
// Normal text/Invalid URL fallback
return <span key={index}>{part}</span>;
}
})}
</p>
);
}
import { Icon } from "@iconify/react";
interface Props {
text: string;
className?: string;
}
// Adds fancy formatting to links
export default function Description({ text, className }: Props) {
const urlRegex = /(https?:\/\/[^\s]+)/g;
const parts = text.split(urlRegex);
return (
<p className={`text-sm mt-2 bg-white/50 p-3 rounded-lg border border-orange-200 whitespace-break-spaces max-h-54 overflow-y-auto ${className}`}>
{parts.map(async (part, index) => {
try {
// Check if it's a URL
if (!urlRegex.test(part)) throw new Error("Not a URL");
const url = new URL(part);
return (
<a
key={index}
href={`/out?url=${encodeURIComponent(part)}`}
target="_blank"
className="text-blue-700 underline break-all ml-1 inline-flex items-center group"
title={`Go to ${url.hostname}`}
>
{url.hostname}
{url.pathname !== "/" ? url.pathname : ""}
{url.search}
<Icon icon="mi:arrow-right-up" fontSize={16} className="transition group-hover:translate-x-0.5 group-hover:-translate-y-0.5" />
</a>
);
} catch {
// Normal text/Invalid URL fallback
return <span key={index}>{part}</span>;
}
})}
</p>
);
}

View file

@ -1,49 +1,49 @@
import { type ReactNode, useState } from "react";
import { type DropzoneOptions, type FileWithPath, useDropzone } from "react-dropzone";
import { Icon } from "@iconify/react";
interface Props {
onDrop: (acceptedFiles: FileWithPath[]) => void;
options?: DropzoneOptions;
children?: ReactNode;
}
export default function Dropzone({ onDrop, options, children }: Props) {
const [isDraggingOver, setIsDraggingOver] = useState(false);
const handleDrop = (acceptedFiles: FileWithPath[]) => {
setIsDraggingOver(false);
onDrop(acceptedFiles);
};
const { getRootProps, getInputProps } = useDropzone({
onDrop: handleDrop,
maxFiles: 3,
accept: {
"image/*": [".png", ".jpg", ".jpeg", ".bmp", ".png", ".heic"],
},
...options,
});
return (
<div
{...getRootProps()}
onDragOver={() => setIsDraggingOver(true)}
onDragLeave={() => setIsDraggingOver(false)}
className={`relative bg-orange-200 flex flex-col justify-center items-center gap-2 p-4 rounded-xl border-2 border-dashed border-amber-500 select-none size-full transition-all duration-200 ${
isDraggingOver && "scale-105 brightness-90 shadow-xl"
}`}
>
{/* Used to transition from border-dashed to border-solid */}
<div
className={`absolute inset-0 rounded-[10px] outline-2 outline-amber-500 transition-opacity duration-300 ${
isDraggingOver ? "opacity-100" : "opacity-0"
}`}
></div>
<input {...getInputProps({ multiple: options?.maxFiles ? options.maxFiles > 1 : false })} />
<Icon icon="material-symbols:upload" fontSize={48} />
{children}
</div>
);
}
import { type ReactNode, useState } from "react";
import { type DropzoneOptions, type FileWithPath, useDropzone } from "react-dropzone";
import { Icon } from "@iconify/react";
interface Props {
onDrop: (acceptedFiles: FileWithPath[]) => void;
options?: DropzoneOptions;
children?: ReactNode;
}
export default function Dropzone({ onDrop, options, children }: Props) {
const [isDraggingOver, setIsDraggingOver] = useState(false);
const handleDrop = (acceptedFiles: FileWithPath[]) => {
setIsDraggingOver(false);
onDrop(acceptedFiles);
};
const { getRootProps, getInputProps } = useDropzone({
onDrop: handleDrop,
maxFiles: 3,
accept: {
"image/*": [".png", ".jpg", ".jpeg", ".bmp", ".png", ".heic"],
},
...options,
});
return (
<div
{...getRootProps()}
onDragOver={() => setIsDraggingOver(true)}
onDragLeave={() => setIsDraggingOver(false)}
className={`relative bg-orange-200 flex flex-col justify-center items-center gap-2 p-4 rounded-xl border-2 border-dashed border-amber-500 select-none size-full transition-all duration-200 ${
isDraggingOver && "scale-105 brightness-90 shadow-xl"
}`}
>
{/* Used to transition from border-dashed to border-solid */}
<div
className={`absolute inset-0 rounded-[10px] outline-2 outline-amber-500 transition-opacity duration-300 ${
isDraggingOver ? "opacity-100" : "opacity-0"
}`}
></div>
<input {...getInputProps({ multiple: options?.maxFiles ? options.maxFiles > 1 : false })} />
<Icon icon="material-symbols:upload" fontSize={48} />
{children}
</div>
);
}

View file

@ -1,43 +0,0 @@
---
import { Icon } from "astro-icon/components";
---
<footer class="mt-auto">
<div class="max-w-4xl mx-auto px-4 py-4">
{/* Main disclaimer */}
<div class="text-center mb-2">
<p class="text-sm text-zinc-600 font-medium">TomodachiShare is not affiliated with Nintendo</p>
</div>
{/* Links section */}
<div class="flex flex-wrap justify-center items-center gap-x-4 text-sm max-sm:gap-x-12">
<a href="/terms-of-service" class="text-zinc-500 hover:text-zinc-700 transition-colors duration-200 hover:underline"> Terms of Service </a>
<span class="text-zinc-400 hidden sm:inline" aria-hidden="true">•</span>
<a href="/privacy" class="text-zinc-500 hover:text-zinc-700 transition-colors duration-200 hover:underline"> Privacy Policy </a>
<span class="text-zinc-400 hidden sm:inline" aria-hidden="true">•</span>
<a
href="https://discord.gg/48cXBFKvWQ"
target="_blank"
class="text-[#5865F2] hover:text-[#454FBF] transition-colors duration-200 hover:underline inline-flex items-end gap-1"
>
<Icon name="ic:baseline-discord" class="text-lg" />
Discord
</a>
<span class="text-zinc-400 hidden sm:inline" aria-hidden="true"> • </span>
<a href="https://trafficlunar.net" target="_blank" class="text-zinc-500 hover:text-zinc-700 transition-colors duration-200 hover:underline group">
Made by <span class="text-orange-400 group-hover:text-orange-500 font-medium transition-colors duration-200">trafficlunar</span>
</a>
</div>
{/* Copyright */}
<div class="text-center mt-4 mb-4">
<p class="text-xs text-zinc-400">© {new Date().getFullYear()} TomodachiShare. All rights reserved.</p>
</div>
</div>
</footer>

View file

@ -0,0 +1,55 @@
import { Icon } from "@iconify/react";
export default function Footer() {
return (
<footer className="mt-auto">
<div className="max-w-4xl mx-auto px-4 py-4">
{/* Main disclaimer */}
<div className="text-center mb-2">
<p className="text-sm text-zinc-600 font-medium">TomodachiShare is not affiliated with Nintendo</p>
</div>
{/* Links section */}
<div className="flex flex-wrap justify-center items-center gap-x-4 text-sm max-sm:gap-x-12">
<a href="/terms-of-service" className="text-zinc-500 hover:text-zinc-700 transition-colors duration-200 hover:underline">
Terms of Service
</a>
<span className="text-zinc-400 hidden sm:inline" aria-hidden="true">
</span>
<a href="/privacy" className="text-zinc-500 hover:text-zinc-700 transition-colors duration-200 hover:underline">
Privacy Policy
</a>
<span className="text-zinc-400 hidden sm:inline" aria-hidden="true">
</span>
<a
href="https://discord.gg/48cXBFKvWQ"
target="_blank"
className="text-[#5865F2] hover:text-[#454FBF] transition-colors duration-200 hover:underline inline-flex items-end gap-1"
>
<Icon icon="ic:baseline-discord" className="text-lg" />
Discord
</a>
<span className="text-zinc-400 hidden sm:inline" aria-hidden="true">
</span>
<a href="https://trafficlunar.net" target="_blank" className="text-zinc-500 hover:text-zinc-700 transition-colors duration-200 hover:underline group">
Made by <span className="text-orange-400 group-hover:text-orange-500 font-medium transition-colors duration-200">trafficlunar</span>
</a>
</div>
{/* Copyright */}
<div className="text-center mt-4 mb-4">
<p className="text-xs text-zinc-400">© {new Date().getFullYear()} TomodachiShare. All rights reserved.</p>
</div>
</div>
</footer>
);
}

View file

@ -4,7 +4,7 @@ import { useStore } from "@nanostores/react";
import { session } from "../session";
export default function HeaderProfile() {
const API_BASE_URL = import.meta.env.PUBLIC_API_URL;
const API_BASE_URL = import.meta.env.VITE_API_URL;
const $session = useStore(session);
useEffect(() => {

View file

@ -1,35 +0,0 @@
---
import { Icon } from "astro-icon/components";
import SearchBar from "./search-bar";
import HeaderProfile from "./header-profile";
---
<header
class="sticky top-0 z-50 w-full p-4 grid grid-cols-3 gap-2 gap-x-4 items-center bg-amber-50 border-b-4 border-amber-500 shadow-md max-lg:grid-cols-2 max-md:grid-cols-1"
>
<a href={"/"} aria-label="Go to Home Page" class="font-black text-3xl text-orange-400 flex items-center gap-2 max-md:justify-center max-md:col-span-2">
<img src="/logo.svg" width={56} height={45} alt="logo" />
TomodachiShare
</a>
<div class="flex justify-center max-lg:justify-end max-md:justify-center">
<SearchBar client:only />
</div>
<ul class="flex justify-end gap-3 items-center h-11 *:h-full max-lg:col-span-2 max-md:justify-center">
<li title="Random Mii">
<a
href={`${import.meta.env.PUBLIC_API_URL}/random`}
aria-label="Go to Random Link"
class="pill button p-0! h-full aspect-square"
data-tooltip="Go to a Random Mii"
>
<Icon name="mdi:dice-3" size={28} />
</a>
</li>
<li>
<a href={"/submit"} class="pill button h-full"> Submit </a>
</li>
<HeaderProfile client:only />
</ul>
</header>

View file

@ -0,0 +1,42 @@
import { Icon } from "@iconify/react";
import SearchBar from "./search-bar";
import HeaderProfile from "./header-profile";
export default function Header() {
return (
<header className="sticky top-0 z-50 w-full p-4 grid grid-cols-3 gap-2 gap-x-4 items-center bg-amber-50 border-b-4 border-amber-500 shadow-md max-lg:grid-cols-2 max-md:grid-cols-1">
<a
href={"/"}
aria-label="Go to Home Page"
className="font-black text-3xl text-orange-400 flex items-center gap-2 max-md:justify-center max-md:col-span-2"
>
<img src="/logo.svg" width={56} height={45} alt="logo" />
TomodachiShare
</a>
<div className="flex justify-center max-lg:justify-end max-md:justify-center">
<SearchBar />
</div>
<ul className="flex justify-end gap-3 items-center h-11 *:h-full max-lg:col-span-2 max-md:justify-center">
<li title="Random Mii">
<a
href={`${import.meta.env.VITE_API_URL}/random`}
aria-label="Go to Random Link"
className="pill button p-0! h-full aspect-square"
data-tooltip="Go to a Random Mii"
>
<Icon icon="mdi:dice-3" fontSize={28} />
</a>
</li>
<li>
<a href={"/submit"} className="pill button h-full">
{" "}
Submit{" "}
</a>
</li>
<HeaderProfile />
</ul>
</header>
);
}

View file

@ -1,165 +1,165 @@
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import useEmblaCarousel from "embla-carousel-react";
import { Icon } from "@iconify/react";
interface Props {
src: string;
alt: string;
width: number;
height: number;
className?: string;
images?: string[];
}
export default function ImageViewer({ src, alt, width, height, className, images = [] }: Props) {
const [isOpen, setIsOpen] = useState(false);
const [isVisible, setIsVisible] = useState(false);
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true, duration: 15 });
const [selectedIndex, setSelectedIndex] = useState(0);
const [scrollSnaps, setScrollSnaps] = useState<number[]>([]);
const close = () => {
setIsVisible(false);
setTimeout(() => {
setIsOpen(false);
}, 300);
};
useEffect(() => {
if (isOpen) {
// slight delay to trigger animation
setTimeout(() => setIsVisible(true), 10);
}
}, [isOpen]);
useEffect(() => {
if (!emblaApi) return;
// Keep order of images whilst opening at src prop
const index = images.indexOf(src);
if (index !== -1) {
emblaApi.scrollTo(index, true);
setSelectedIndex(index);
}
setScrollSnaps(emblaApi.scrollSnapList());
emblaApi.on("select", () => setSelectedIndex(emblaApi.selectedScrollSnap()));
}, [emblaApi, images, src]);
// Handle keyboard events
useEffect(() => {
if (!isOpen || !emblaApi) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "ArrowLeft") emblaApi.scrollPrev();
else if (event.key === "ArrowRight") emblaApi.scrollNext();
else if (event.key === "Escape") close();
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [isOpen, emblaApi]);
const imagesMap = images.length === 0 ? [src] : images;
return (
<>
{/* not inserting pixelated image-rendering here because i thought it looked a bit weird */}
<img src={src} alt={alt} width={width} height={height} loading="lazy" onClick={() => setIsOpen(true)} className={`cursor-pointer ${className}`} />
{isOpen &&
createPortal(
<div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-(--header-height) flex items-center justify-center z-40">
<div
onClick={close}
className={`absolute inset-0 backdrop-brightness-40 backdrop-contrast-125 backdrop-blur-sm transition-opacity duration-300 ${isVisible ? "opacity-100" : "opacity-0"}`}
/>
<button
type="button"
aria-label="Close"
onClick={close}
className={`pill button p-2! size-11 aspect-square text-2xl absolute top-4 right-4 shrink-0 ${isVisible ? "opacity-100" : "opacity-0"}`}
>
<Icon icon="material-symbols:close-rounded" />
</button>
<div
className={`overflow-hidden max-w-4xl h-[75vh] max-md:h-[55vh] transition-discrete duration-300 ${isVisible ? "scale-100 opacity-100" : "scale-90 opacity-0"}`}
ref={emblaRef}
>
<div className="flex h-full">
{imagesMap.map((image, index) => (
<div key={index} className="flex-[0_0_100%] h-full flex items-center px-4">
<img
src={image}
alt={alt}
width={896}
height={896}
loading={Math.abs(index - selectedIndex) <= 1 ? "eager" : "lazy"}
className="max-w-full max-h-full object-contain drop-shadow-lg"
style={{ imageRendering: image.includes("qr-code") ? "pixelated" : "auto" }}
/>
</div>
))}
</div>
</div>
{images.length > 1 && (
<>
{/* Carousel counter */}
<div
className={`flex justify-center gap-2 bg-orange-300 w-15 font-semibold text-sm py-1 rounded-full border-2 border-orange-400 absolute top-4 left-4 transition-opacity duration-300 ${
isVisible ? "opacity-100" : "opacity-0"
}`}
>
{selectedIndex + 1} / {images.length}
</div>
{/* Carousel buttons */}
{/* Prev button */}
<button
type="button"
aria-label="Scroll Carousel Left"
onClick={() => emblaApi?.scrollPrev()}
className={`absolute left-2 top-1/2 -translate-y-1/2 pill button p-0.5! aspect-square text-4xl transition-opacity duration-300 ${isVisible ? "opacity-100" : "opacity-0"}`}
>
<Icon icon="ic:round-chevron-left" />
</button>
{/* Next button */}
<button
type="button"
aria-label="Scroll Carousel Right"
onClick={() => emblaApi?.scrollNext()}
className={`absolute right-2 top-1/2 -translate-y-1/2 pill button p-0.5! aspect-square text-4xl transition-opacity duration-300 ${isVisible ? "opacity-100" : "opacity-0"}`}
>
<Icon icon="ic:round-chevron-right" />
</button>
{/* Carousel snaps */}
<div
className={`flex justify-center gap-2 bg-orange-300 p-2.5 rounded-full border-2 border-orange-400 absolute left-1/2 -translate-x-1/2 bottom-4 transition-opacity duration-300 ${
isVisible ? "opacity-100" : "opacity-0"
}`}
>
{scrollSnaps.map((_, index) => (
<button
key={index}
aria-label={`Go to ${index} in Carousel`}
onClick={() => emblaApi?.scrollTo(index)}
className={`size-2 cursor-pointer rounded-full transition-all duration-300 ${index === selectedIndex ? "bg-slate-800 w-8" : "bg-slate-800/30"}`}
/>
))}
</div>
</>
)}
</div>,
document.body,
)}
</>
);
}
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import useEmblaCarousel from "embla-carousel-react";
import { Icon } from "@iconify/react";
interface Props {
src: string;
alt: string;
width: number;
height: number;
className?: string;
images?: string[];
}
export default function ImageViewer({ src, alt, width, height, className, images = [] }: Props) {
const [isOpen, setIsOpen] = useState(false);
const [isVisible, setIsVisible] = useState(false);
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true, duration: 15 });
const [selectedIndex, setSelectedIndex] = useState(0);
const [scrollSnaps, setScrollSnaps] = useState<number[]>([]);
const close = () => {
setIsVisible(false);
setTimeout(() => {
setIsOpen(false);
}, 300);
};
useEffect(() => {
if (isOpen) {
// slight delay to trigger animation
setTimeout(() => setIsVisible(true), 10);
}
}, [isOpen]);
useEffect(() => {
if (!emblaApi) return;
// Keep order of images whilst opening at src prop
const index = images.indexOf(src);
if (index !== -1) {
emblaApi.scrollTo(index, true);
setSelectedIndex(index);
}
setScrollSnaps(emblaApi.scrollSnapList());
emblaApi.on("select", () => setSelectedIndex(emblaApi.selectedScrollSnap()));
}, [emblaApi, images, src]);
// Handle keyboard events
useEffect(() => {
if (!isOpen || !emblaApi) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "ArrowLeft") emblaApi.scrollPrev();
else if (event.key === "ArrowRight") emblaApi.scrollNext();
else if (event.key === "Escape") close();
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [isOpen, emblaApi]);
const imagesMap = images.length === 0 ? [src] : images;
return (
<>
{/* not inserting pixelated image-rendering here because i thought it looked a bit weird */}
<img src={src} alt={alt} width={width} height={height} loading="lazy" onClick={() => setIsOpen(true)} className={`cursor-pointer ${className}`} />
{isOpen &&
createPortal(
<div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-(--header-height) flex items-center justify-center z-40">
<div
onClick={close}
className={`absolute inset-0 backdrop-brightness-40 backdrop-contrast-125 backdrop-blur-sm transition-opacity duration-300 ${isVisible ? "opacity-100" : "opacity-0"}`}
/>
<button
type="button"
aria-label="Close"
onClick={close}
className={`pill button p-2! size-11 aspect-square text-2xl absolute top-4 right-4 shrink-0 ${isVisible ? "opacity-100" : "opacity-0"}`}
>
<Icon icon="material-symbols:close-rounded" />
</button>
<div
className={`overflow-hidden max-w-4xl h-[75vh] max-md:h-[55vh] transition-discrete duration-300 ${isVisible ? "scale-100 opacity-100" : "scale-90 opacity-0"}`}
ref={emblaRef}
>
<div className="flex h-full">
{imagesMap.map((image, index) => (
<div key={index} className="flex-[0_0_100%] h-full flex items-center px-4">
<img
src={image}
alt={alt}
width={896}
height={896}
loading={Math.abs(index - selectedIndex) <= 1 ? "eager" : "lazy"}
className="max-w-full max-h-full object-contain drop-shadow-lg"
style={{ imageRendering: image.includes("qr-code") ? "pixelated" : "auto" }}
/>
</div>
))}
</div>
</div>
{images.length > 1 && (
<>
{/* Carousel counter */}
<div
className={`flex justify-center gap-2 bg-orange-300 w-15 font-semibold text-sm py-1 rounded-full border-2 border-orange-400 absolute top-4 left-4 transition-opacity duration-300 ${
isVisible ? "opacity-100" : "opacity-0"
}`}
>
{selectedIndex + 1} / {images.length}
</div>
{/* Carousel buttons */}
{/* Prev button */}
<button
type="button"
aria-label="Scroll Carousel Left"
onClick={() => emblaApi?.scrollPrev()}
className={`absolute left-2 top-1/2 -translate-y-1/2 pill button p-0.5! aspect-square text-4xl transition-opacity duration-300 ${isVisible ? "opacity-100" : "opacity-0"}`}
>
<Icon icon="ic:round-chevron-left" />
</button>
{/* Next button */}
<button
type="button"
aria-label="Scroll Carousel Right"
onClick={() => emblaApi?.scrollNext()}
className={`absolute right-2 top-1/2 -translate-y-1/2 pill button p-0.5! aspect-square text-4xl transition-opacity duration-300 ${isVisible ? "opacity-100" : "opacity-0"}`}
>
<Icon icon="ic:round-chevron-right" />
</button>
{/* Carousel snaps */}
<div
className={`flex justify-center gap-2 bg-orange-300 p-2.5 rounded-full border-2 border-orange-400 absolute left-1/2 -translate-x-1/2 bottom-4 transition-opacity duration-300 ${
isVisible ? "opacity-100" : "opacity-0"
}`}
>
{scrollSnaps.map((_, index) => (
<button
key={index}
aria-label={`Go to ${index} in Carousel`}
onClick={() => emblaApi?.scrollTo(index)}
className={`size-2 cursor-pointer rounded-full transition-all duration-300 ${index === selectedIndex ? "bg-slate-800 w-8" : "bg-slate-800/30"}`}
/>
))}
</div>
</>
)}
</div>,
document.body,
)}
</>
);
}

View file

@ -11,10 +11,10 @@ interface Props {
big?: boolean;
}
export default function LikeButton({ likes, isLiked, miiId, disabled, abbreviate, big }: Props) {
export default function LikeButton({ likes, isLiked, disabled, abbreviate, big }: Props) {
const [isLikedState, setIsLikedState] = useState(isLiked);
const [likesState, setLikesState] = useState(likes);
const [isAnimating, setIsAnimating] = useState(false);
const [likesState] = useState(likes);
const [isAnimating] = useState(false);
const onClick = async () => {
// if (disabled) return;

View file

@ -1,23 +1,23 @@
import { Icon } from "@iconify/react";
import DeleteMiiButton from "./delete-mii-button";
interface Props {
mii: any;
}
export default function AuthorButtons({ mii }: Props) {
// const session = useSession();
// if (!session.data || (Number(session.data.user?.id) !== mii.userId && Number(session.data.user?.id) !== Number(import.meta.env.NEXT_PUBLIC_ADMIN_USER_ID)))
// return null;
return (
<>
<a aria-label="Edit Mii" href={`/edit/${mii.id}`}>
<Icon icon="mdi:pencil" />
<span>Edit</span>
</a>
<DeleteMiiButton miiId={mii.id} miiName={mii.name} likes={mii._count.likedBy ?? 0} inMiiPage />
</>
);
}
import { Icon } from "@iconify/react";
import DeleteMiiButton from "./delete-mii-button";
interface Props {
mii: any;
}
export default function AuthorButtons({ mii }: Props) {
// const session = useSession();
// if (!session.data || (Number(session.data.user?.id) !== mii.userId && Number(session.data.user?.id) !== Number(import.meta.env.NEXT_PUBLIC_ADMIN_USER_ID)))
// return null;
return (
<>
<a aria-label="Edit Mii" href={`/edit/${mii.id}`}>
<Icon icon="mdi:pencil" />
<span>Edit</span>
</a>
<DeleteMiiButton miiId={mii.id} miiName={mii.name} likes={mii._count.likedBy ?? 0} inMiiPage />
</>
);
}

View file

@ -1,34 +1,34 @@
import { type ChangeEvent } from "react";
import { type MiiGender, type SwitchMiiInstructions } from "@tomodachi-share/shared";
interface Props {
data: SwitchMiiInstructions["datingPreferences"];
onChecked?: (e: ChangeEvent<HTMLInputElement, HTMLInputElement>, gender: MiiGender) => void;
}
const DATING_PREFERENCES = ["Male", "Female", "Nonbinary"];
export default function DatingPreferencesViewer({ data, onChecked }: Props) {
return (
<div className="flex flex-col gap-1.5">
{DATING_PREFERENCES.map((gender) => {
const genderEnum = gender.toUpperCase() as MiiGender;
return (
<div key={gender} className="flex gap-1.5">
<input
type="checkbox"
id={gender}
className="checkbox"
checked={data.includes(genderEnum)}
{...(onChecked ? { onChange: (e: ChangeEvent<HTMLInputElement>) => onChecked(e, genderEnum) } : { readOnly: true })}
/>
<label htmlFor={gender} className="text-sm select-none">
{gender}
</label>
</div>
);
})}
</div>
);
}
import { type ChangeEvent } from "react";
import { type MiiGender, type SwitchMiiInstructions } from "@tomodachi-share/shared";
interface Props {
data: SwitchMiiInstructions["datingPreferences"];
onChecked?: (e: ChangeEvent<HTMLInputElement, HTMLInputElement>, gender: MiiGender) => void;
}
const DATING_PREFERENCES = ["Male", "Female", "Nonbinary"];
export default function DatingPreferencesViewer({ data, onChecked }: Props) {
return (
<div className="flex flex-col gap-1.5">
{DATING_PREFERENCES.map((gender) => {
const genderEnum = gender.toUpperCase() as MiiGender;
return (
<div key={gender} className="flex gap-1.5">
<input
type="checkbox"
id={gender}
className="checkbox"
checked={data.includes(genderEnum)}
{...(onChecked ? { onChange: (e: ChangeEvent<HTMLInputElement>) => onChecked(e, genderEnum) } : { readOnly: true })}
/>
<label htmlFor={gender} className="text-sm select-none">
{gender}
</label>
</div>
);
})}
</div>
);
}

View file

@ -20,7 +20,7 @@ export default function DeleteMiiButton({ miiId, miiName, likes, inMiiPage }: Pr
const [inputMiiName, setInputMiiName] = useState("");
const handleSubmit = async () => {
const response = await fetch(`${import.meta.env.PUBLIC_API_URL}/api/mii/${miiId}/delete`, { method: "DELETE", credentials: "include" });
const response = await fetch(`${import.meta.env.VITE_API_URL}/api/mii/${miiId}/delete`, { method: "DELETE", credentials: "include" });
if (!response.ok) {
const { error } = await response.json();
setError(error);
@ -83,7 +83,7 @@ export default function DeleteMiiButton({ miiId, miiName, likes, inMiiPage }: Pr
<p className="text-sm text-zinc-500">Are you sure? This will delete your Mii permanently. This action cannot be undone.</p>
<div className="bg-orange-100 rounded-xl border-2 border-orange-400 mt-4 flex overflow-hidden">
<img src={`${import.meta.env.PUBLIC_API_URL}/mii/${miiId}/image?type=mii`} alt="mii image" width={128} height={128} />
<img src={`${import.meta.env.VITE_API_URL}/mii/${miiId}/image?type=mii`} alt="mii image" width={128} height={128} />
<div className="p-4 min-w-0">
<p className="text-xl font-bold line-clamp-3 wrap-anywhere" title={miiName}>
{miiName}

View file

@ -1,254 +1,254 @@
import type { ReactNode } from "react";
import DatingPreferencesViewer from "./dating-preferences";
import PersonalityViewer from "./personality-viewer";
import { type SwitchMiiInstructions, COLORS } from "@tomodachi-share/shared";
interface Props {
instructions: Partial<SwitchMiiInstructions>;
}
interface SectionProps {
name: string;
instructions: Partial<SwitchMiiInstructions[keyof SwitchMiiInstructions]>;
children?: ReactNode;
isSubSection?: boolean;
}
const ORDINAL_SUFFIXES: Record<string, string> = {
one: "st",
two: "nd",
few: "rd",
other: "th",
};
const ordinalRules = new Intl.PluralRules("en-US", { type: "ordinal" });
function not(value: any) {
return value !== undefined && value !== null;
}
function numberValue(value: number, cutoff: number = 25) {
return value === cutoff ? "0" : value > cutoff ? `+${value - cutoff}` : `${value - cutoff}`;
}
function GridPosition({ index, cols = 5 }: { index: number; cols?: number }) {
const row = Math.floor(index / cols) + 1;
const col = (index % cols) + 1;
const rowSuffix = ORDINAL_SUFFIXES[ordinalRules.select(row)];
const colSuffix = ORDINAL_SUFFIXES[ordinalRules.select(col)];
return `${row}${rowSuffix} row, ${col}${colSuffix} column`;
}
function ColorPosition({ color }: { color: number | undefined | null }) {
if (color === undefined || color === null) return null;
if (color <= 7) {
return (
<span className="flex items-center">
<div className="size-5 rounded mr-1.5 shrink-0" style={{ backgroundColor: `#${COLORS[color]}` }}></div>
Color menu on left, <GridPosition index={color} cols={1} />
</span>
);
}
if (color >= 108) {
return (
<span className="flex items-center">
<div className="size-5 rounded mr-1.5 shrink-0" style={{ backgroundColor: `#${COLORS[color]}` }}></div>
Outside color menu, <GridPosition index={color - 108} cols={2} />
</span>
);
}
return (
<span className="flex items-center">
<div className="size-5 rounded mr-1.5 shrink-0" style={{ backgroundColor: `#${COLORS[color]}` }}></div>
Color menu on right, <GridPosition index={color - 8} cols={10} />
</span>
);
}
interface TableCellProps {
label: string;
children: React.ReactNode;
}
function TableCell({ label, children }: TableCellProps) {
return (
<tr className={"border-b border-orange-300/50 last:border-0"}>
<td className={"py-0.5 pr-6 text-amber-700 font-semibold w-30 text-sm"}>{label}</td>
<td className={"py-0.5 text-amber-950"}>{children}</td>
</tr>
);
}
function Section({ name, instructions, children, isSubSection }: SectionProps) {
if (typeof instructions !== "object" || !instructions) return null;
const color = "color" in instructions ? instructions.color : undefined;
const height = "height" in instructions ? instructions.height : undefined;
const distance = "distance" in instructions ? instructions.distance : undefined;
const rotation = "rotation" in instructions ? instructions.rotation : undefined;
const size = "size" in instructions ? instructions.size : undefined;
const stretch = "stretch" in instructions ? instructions.stretch : undefined;
return (
<div className={`p-3 w-max ${isSubSection ? "not-first:mt-2 pt-0!" : "border-l-4 border-amber-400 bg-amber-100/50 rounded-r-lg py-2.5"}`}>
<h3 className="font-semibold text-xl text-amber-800 mb-1">{name}</h3>
<table className="w-full">
<tbody>
{not(color) && (
<TableCell label="Color">
<ColorPosition color={color} />
</TableCell>
)}
{not(height) && <TableCell label="Height">{numberValue(height!, 0)}</TableCell>}
{not(distance) && <TableCell label="Distance">{numberValue(distance!, 0)}</TableCell>}
{not(rotation) && <TableCell label="Rotation">{numberValue(rotation!, 0)}</TableCell>}
{not(size) && <TableCell label="Size">{numberValue(size!, 0)}</TableCell>}
{not(stretch) && <TableCell label="Stretch">{numberValue(stretch!, 0)}</TableCell>}
{children}
</tbody>
</table>
</div>
);
}
export default function MiiInstructions({ instructions }: Props) {
if (Object.keys(instructions).length === 0) return null;
const { head, hair, eyebrows, eyes, nose, lips, ears, glasses, other, height, weight, birthday, datingPreferences, voice, personality } = instructions;
return (
<>
{head && (
<Section name="Head" instructions={head}>
{not(head.skinColor) && (
<TableCell label="Skin Color">
<ColorPosition color={head.skinColor} />
</TableCell>
)}
</Section>
)}
{hair && (
<Section name="Hair" instructions={hair}>
{not(hair.subColor) && (
<TableCell label="Sub Color">
<ColorPosition color={hair.subColor} />
</TableCell>
)}
{not(hair.subColor2) && (
<TableCell label="Sub Color (Back)">
<ColorPosition color={hair.subColor2} />
</TableCell>
)}
{not(hair.style) && <TableCell label="Tying Style">{hair.style}</TableCell>}
{not(hair.isFlipped) && <TableCell label="Flipped">{hair.isFlipped ? "Yes" : "No"}</TableCell>}
</Section>
)}
{eyebrows && <Section name="Eyebrows" instructions={eyebrows}></Section>}
{eyes && (
<Section name="Eyes" instructions={eyes}>
<Section isSubSection name="Tab 1" instructions={eyes.main} />
<Section isSubSection name="Tab 2" instructions={eyes.eyelashesTop} />
<Section isSubSection name="Tab 3" instructions={eyes.eyelashesBottom} />
<Section isSubSection name="Tab 4" instructions={eyes.eyelidTop} />
<Section isSubSection name="Tab 5" instructions={eyes.eyelidBottom} />
<Section isSubSection name="Tab 6" instructions={eyes.eyeliner} />
<Section isSubSection name="Tab 7" instructions={eyes.pupil} />
</Section>
)}
{nose && <Section name="Nose" instructions={nose}></Section>}
{lips && (
<Section name="Lips" instructions={lips}>
{not(lips.hasLipstick) && <TableCell label="Lipstick">{lips.hasLipstick ? "Yes" : "No"}</TableCell>}
</Section>
)}
{ears && <Section name="Ears" instructions={ears}></Section>}
{glasses && (
<Section name="Glasses" instructions={glasses}>
{not(glasses.ringColor) && (
<TableCell label="Ring Color">
<ColorPosition color={glasses.ringColor} />
</TableCell>
)}
{not(glasses.shadesColor) && (
<TableCell label="Shades Color">
<ColorPosition color={glasses.shadesColor} />
</TableCell>
)}
</Section>
)}
{other && (
<Section name="Other" instructions={other}>
<Section isSubSection name="Tab 1" instructions={other.wrinkles1} />
<Section isSubSection name="Tab 2" instructions={other.wrinkles2} />
<Section isSubSection name="Tab 3" instructions={other.beard} />
<Section isSubSection name="Tab 4" instructions={other.moustache}>
{other.moustache && other.moustache.isFlipped && <TableCell label="Flipped">{other.moustache.isFlipped ? "Yes" : "No"}</TableCell>}
</Section>
<Section isSubSection name="Tab 5" instructions={other.goatee} />
<Section isSubSection name="Tab 6" instructions={other.mole} />
<Section isSubSection name="Tab 7" instructions={other.eyeShadow} />
<Section isSubSection name="Tab 8" instructions={other.blush} />
</Section>
)}
{(height || weight || datingPreferences || voice || personality) && (
<div className="p-3 border-l-4 border-amber-400 bg-amber-100/50 rounded-r-lg py-2.5 text-amber-950 w-max">
<h3 className="font-semibold text-xl text-amber-800 mb-1">Misc</h3>
<table className="w-full">
<tbody>
{not(height) && <TableCell label="Height">{numberValue(height!, 64)}</TableCell>}
{not(weight) && <TableCell label="Weight">{numberValue(weight!, 64)}</TableCell>}
</tbody>
</table>
{birthday && (
<div className="pl-2 not-nth-2:mt-4">
<h4 className="font-semibold text-xl text-amber-800 mb-1">Birthday</h4>
<table className="w-full">
<tbody>
{not(birthday.day) && <TableCell label="Day">{birthday.day}</TableCell>}
{not(birthday.month) && <TableCell label="Month">{birthday.month}</TableCell>}
{not(birthday.age) && <TableCell label="Age">{birthday.age}</TableCell>}
{not(birthday.dontAge) && <TableCell label="Don't Age">{birthday.dontAge ? "Yes" : "No"}</TableCell>}
</tbody>
</table>
</div>
)}
{voice && (
<div className="pl-2 not-nth-2:mt-4">
<h4 className="font-semibold text-xl text-amber-800 mb-1">Voice</h4>
<table className="w-full">
<tbody>
{not(voice.speed) && <TableCell label="Speed">{numberValue(voice.speed!, 25)}</TableCell>}
{not(voice.pitch) && <TableCell label="Pitch">{numberValue(voice.pitch!, 25)}</TableCell>}
{not(voice.depth) && <TableCell label="Depth">{numberValue(voice.depth!, 25)}</TableCell>}
{not(voice.delivery) && <TableCell label="Delivery">{numberValue(voice.delivery!, 25)}</TableCell>}
{not(voice.tone) && <TableCell label="Tone">{voice.tone}</TableCell>}
</tbody>
</table>
</div>
)}
{datingPreferences && (
<div className="pl-2 not-nth-2:mt-4">
<h4 className="font-semibold text-xl text-amber-800 mb-1">Dating Preferences</h4>
<div className="w-min">
<DatingPreferencesViewer data={datingPreferences} />
</div>
</div>
)}
{personality && (
<div className="pl-2 not-nth-2:mt-4">
<h4 className="font-semibold text-xl text-amber-800 mb-1">Personality</h4>
<div className="w-min">
<PersonalityViewer data={personality} />
</div>
</div>
)}
</div>
)}
</>
);
}
import type { ReactNode } from "react";
import DatingPreferencesViewer from "./dating-preferences";
import PersonalityViewer from "./personality-viewer";
import { type SwitchMiiInstructions, COLORS } from "@tomodachi-share/shared";
interface Props {
instructions: Partial<SwitchMiiInstructions>;
}
interface SectionProps {
name: string;
instructions: Partial<SwitchMiiInstructions[keyof SwitchMiiInstructions]>;
children?: ReactNode;
isSubSection?: boolean;
}
const ORDINAL_SUFFIXES: Record<string, string> = {
one: "st",
two: "nd",
few: "rd",
other: "th",
};
const ordinalRules = new Intl.PluralRules("en-US", { type: "ordinal" });
function not(value: any) {
return value !== undefined && value !== null;
}
function numberValue(value: number, cutoff: number = 25) {
return value === cutoff ? "0" : value > cutoff ? `+${value - cutoff}` : `${value - cutoff}`;
}
function GridPosition({ index, cols = 5 }: { index: number; cols?: number }) {
const row = Math.floor(index / cols) + 1;
const col = (index % cols) + 1;
const rowSuffix = ORDINAL_SUFFIXES[ordinalRules.select(row)];
const colSuffix = ORDINAL_SUFFIXES[ordinalRules.select(col)];
return `${row}${rowSuffix} row, ${col}${colSuffix} column`;
}
function ColorPosition({ color }: { color: number | undefined | null }) {
if (color === undefined || color === null) return null;
if (color <= 7) {
return (
<span className="flex items-center">
<div className="size-5 rounded mr-1.5 shrink-0" style={{ backgroundColor: `#${COLORS[color]}` }}></div>
Color menu on left, <GridPosition index={color} cols={1} />
</span>
);
}
if (color >= 108) {
return (
<span className="flex items-center">
<div className="size-5 rounded mr-1.5 shrink-0" style={{ backgroundColor: `#${COLORS[color]}` }}></div>
Outside color menu, <GridPosition index={color - 108} cols={2} />
</span>
);
}
return (
<span className="flex items-center">
<div className="size-5 rounded mr-1.5 shrink-0" style={{ backgroundColor: `#${COLORS[color]}` }}></div>
Color menu on right, <GridPosition index={color - 8} cols={10} />
</span>
);
}
interface TableCellProps {
label: string;
children: React.ReactNode;
}
function TableCell({ label, children }: TableCellProps) {
return (
<tr className={"border-b border-orange-300/50 last:border-0"}>
<td className={"py-0.5 pr-6 text-amber-700 font-semibold w-30 text-sm"}>{label}</td>
<td className={"py-0.5 text-amber-950"}>{children}</td>
</tr>
);
}
function Section({ name, instructions, children, isSubSection }: SectionProps) {
if (typeof instructions !== "object" || !instructions) return null;
const color = "color" in instructions ? instructions.color : undefined;
const height = "height" in instructions ? instructions.height : undefined;
const distance = "distance" in instructions ? instructions.distance : undefined;
const rotation = "rotation" in instructions ? instructions.rotation : undefined;
const size = "size" in instructions ? instructions.size : undefined;
const stretch = "stretch" in instructions ? instructions.stretch : undefined;
return (
<div className={`p-3 w-max ${isSubSection ? "not-first:mt-2 pt-0!" : "border-l-4 border-amber-400 bg-amber-100/50 rounded-r-lg py-2.5"}`}>
<h3 className="font-semibold text-xl text-amber-800 mb-1">{name}</h3>
<table className="w-full">
<tbody>
{not(color) && (
<TableCell label="Color">
<ColorPosition color={color} />
</TableCell>
)}
{not(height) && <TableCell label="Height">{numberValue(height!, 0)}</TableCell>}
{not(distance) && <TableCell label="Distance">{numberValue(distance!, 0)}</TableCell>}
{not(rotation) && <TableCell label="Rotation">{numberValue(rotation!, 0)}</TableCell>}
{not(size) && <TableCell label="Size">{numberValue(size!, 0)}</TableCell>}
{not(stretch) && <TableCell label="Stretch">{numberValue(stretch!, 0)}</TableCell>}
{children}
</tbody>
</table>
</div>
);
}
export default function MiiInstructions({ instructions }: Props) {
if (Object.keys(instructions).length === 0) return null;
const { head, hair, eyebrows, eyes, nose, lips, ears, glasses, other, height, weight, birthday, datingPreferences, voice, personality } = instructions;
return (
<>
{head && (
<Section name="Head" instructions={head}>
{not(head.skinColor) && (
<TableCell label="Skin Color">
<ColorPosition color={head.skinColor} />
</TableCell>
)}
</Section>
)}
{hair && (
<Section name="Hair" instructions={hair}>
{not(hair.subColor) && (
<TableCell label="Sub Color">
<ColorPosition color={hair.subColor} />
</TableCell>
)}
{not(hair.subColor2) && (
<TableCell label="Sub Color (Back)">
<ColorPosition color={hair.subColor2} />
</TableCell>
)}
{not(hair.style) && <TableCell label="Tying Style">{hair.style}</TableCell>}
{not(hair.isFlipped) && <TableCell label="Flipped">{hair.isFlipped ? "Yes" : "No"}</TableCell>}
</Section>
)}
{eyebrows && <Section name="Eyebrows" instructions={eyebrows}></Section>}
{eyes && (
<Section name="Eyes" instructions={eyes}>
<Section isSubSection name="Tab 1" instructions={eyes.main} />
<Section isSubSection name="Tab 2" instructions={eyes.eyelashesTop} />
<Section isSubSection name="Tab 3" instructions={eyes.eyelashesBottom} />
<Section isSubSection name="Tab 4" instructions={eyes.eyelidTop} />
<Section isSubSection name="Tab 5" instructions={eyes.eyelidBottom} />
<Section isSubSection name="Tab 6" instructions={eyes.eyeliner} />
<Section isSubSection name="Tab 7" instructions={eyes.pupil} />
</Section>
)}
{nose && <Section name="Nose" instructions={nose}></Section>}
{lips && (
<Section name="Lips" instructions={lips}>
{not(lips.hasLipstick) && <TableCell label="Lipstick">{lips.hasLipstick ? "Yes" : "No"}</TableCell>}
</Section>
)}
{ears && <Section name="Ears" instructions={ears}></Section>}
{glasses && (
<Section name="Glasses" instructions={glasses}>
{not(glasses.ringColor) && (
<TableCell label="Ring Color">
<ColorPosition color={glasses.ringColor} />
</TableCell>
)}
{not(glasses.shadesColor) && (
<TableCell label="Shades Color">
<ColorPosition color={glasses.shadesColor} />
</TableCell>
)}
</Section>
)}
{other && (
<Section name="Other" instructions={other}>
<Section isSubSection name="Tab 1" instructions={other.wrinkles1} />
<Section isSubSection name="Tab 2" instructions={other.wrinkles2} />
<Section isSubSection name="Tab 3" instructions={other.beard} />
<Section isSubSection name="Tab 4" instructions={other.moustache}>
{other.moustache && other.moustache.isFlipped && <TableCell label="Flipped">{other.moustache.isFlipped ? "Yes" : "No"}</TableCell>}
</Section>
<Section isSubSection name="Tab 5" instructions={other.goatee} />
<Section isSubSection name="Tab 6" instructions={other.mole} />
<Section isSubSection name="Tab 7" instructions={other.eyeShadow} />
<Section isSubSection name="Tab 8" instructions={other.blush} />
</Section>
)}
{(height || weight || datingPreferences || voice || personality) && (
<div className="p-3 border-l-4 border-amber-400 bg-amber-100/50 rounded-r-lg py-2.5 text-amber-950 w-max">
<h3 className="font-semibold text-xl text-amber-800 mb-1">Misc</h3>
<table className="w-full">
<tbody>
{not(height) && <TableCell label="Height">{numberValue(height!, 64)}</TableCell>}
{not(weight) && <TableCell label="Weight">{numberValue(weight!, 64)}</TableCell>}
</tbody>
</table>
{birthday && (
<div className="pl-2 not-nth-2:mt-4">
<h4 className="font-semibold text-xl text-amber-800 mb-1">Birthday</h4>
<table className="w-full">
<tbody>
{not(birthday.day) && <TableCell label="Day">{birthday.day}</TableCell>}
{not(birthday.month) && <TableCell label="Month">{birthday.month}</TableCell>}
{not(birthday.age) && <TableCell label="Age">{birthday.age}</TableCell>}
{not(birthday.dontAge) && <TableCell label="Don't Age">{birthday.dontAge ? "Yes" : "No"}</TableCell>}
</tbody>
</table>
</div>
)}
{voice && (
<div className="pl-2 not-nth-2:mt-4">
<h4 className="font-semibold text-xl text-amber-800 mb-1">Voice</h4>
<table className="w-full">
<tbody>
{not(voice.speed) && <TableCell label="Speed">{numberValue(voice.speed!, 25)}</TableCell>}
{not(voice.pitch) && <TableCell label="Pitch">{numberValue(voice.pitch!, 25)}</TableCell>}
{not(voice.depth) && <TableCell label="Depth">{numberValue(voice.depth!, 25)}</TableCell>}
{not(voice.delivery) && <TableCell label="Delivery">{numberValue(voice.delivery!, 25)}</TableCell>}
{not(voice.tone) && <TableCell label="Tone">{voice.tone}</TableCell>}
</tbody>
</table>
</div>
)}
{datingPreferences && (
<div className="pl-2 not-nth-2:mt-4">
<h4 className="font-semibold text-xl text-amber-800 mb-1">Dating Preferences</h4>
<div className="w-min">
<DatingPreferencesViewer data={datingPreferences} />
</div>
</div>
)}
{personality && (
<div className="pl-2 not-nth-2:mt-4">
<h4 className="font-semibold text-xl text-amber-800 mb-1">Personality</h4>
<div className="w-min">
<PersonalityViewer data={personality} />
</div>
</div>
)}
</div>
)}
</>
);
}

View file

@ -1,132 +1,132 @@
import { useEffect, useMemo, useState } from "react";
import { Icon } from "@iconify/react";
import PlatformSelect from "./platform-select";
import TagFilter from "./tag-filter";
import GenderSelect from "./gender-select";
import OtherFilters from "./other-filters";
import MakeupSelect from "./makeup-select";
import type { MiiGender, MiiMakeup, MiiPlatform } from "@tomodachi-share/shared";
export default function FilterMenu() {
const searchParams = new URLSearchParams(window.location.search);
const [isOpen, setIsOpen] = useState(false);
const [isVisible, setIsVisible] = useState(false);
const platform = (searchParams.get("platform") as MiiPlatform) || undefined;
const gender = (searchParams.get("gender") as MiiGender) || undefined;
const makeup = (searchParams.get("makeup") as MiiMakeup) || undefined;
const rawTags = searchParams.get("tags") || "";
const rawExclude = searchParams.get("exclude") || "";
const allowCopying = (searchParams.get("allowCopying") as unknown as boolean) || false;
const tags = useMemo(
() =>
rawTags
? rawTags
.split(",")
.map((tag) => tag.trim())
.filter((tag) => tag.length > 0)
: [],
[rawTags],
);
const exclude = useMemo(
() =>
rawExclude
? rawExclude
.split(",")
.map((tag) => tag.trim())
.filter((tag) => tag.length > 0)
: [],
[rawExclude],
);
const [filterCount, setFilterCount] = useState(tags.length);
// Filter menu button handler
const handleClick = () => {
if (!isOpen) {
setIsOpen(true);
// slight delay to trigger animation
setTimeout(() => setIsVisible(true), 10);
} else {
setIsVisible(false);
setTimeout(() => {
setIsOpen(false);
}, 200);
}
};
// Count all active filters
useEffect(() => {
let count = tags.length + exclude.length;
if (platform) count++;
if (gender) count++;
if (allowCopying) count++;
if (makeup) count++;
setFilterCount(count);
}, [tags, exclude, platform, gender, allowCopying, makeup]);
return (
<div className="relative">
<button className="pill button gap-2" onClick={handleClick}>
<Icon icon="mdi:filter" className="text-xl" />
Filter
<span className="w-5">({filterCount})</span>
</button>
{isOpen && (
<div
className={`absolute w-80 left-0 top-full mt-8 z-40 flex flex-col items-center bg-orange-50
border-2 border-amber-500 rounded-2xl shadow-lg p-4 transition-discrete duration-200 ${isVisible ? "translate-y-0 opacity-100" : "-translate-y-2 opacity-0"}`}
>
{/* Arrow */}
<div className="absolute bottom-full left-1/6 -translate-x-1/2 size-0 border-8 border-transparent border-b-amber-500"></div>
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium w-full mb-2">
<hr className="grow border-zinc-300" />
<span>Platform</span>
<hr className="grow border-zinc-300" />
</div>
<PlatformSelect />
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium w-full mt-2 mb-1">
<hr className="grow border-zinc-300" />
<span>Gender</span>
<hr className="grow border-zinc-300" />
</div>
<GenderSelect />
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium w-full mt-2 mb-2">
<hr className="grow border-zinc-300" />
<span>Tags Include</span>
<hr className="grow border-zinc-300" />
</div>
<TagFilter />
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium w-full mt-2 mb-2">
<hr className="grow border-zinc-300" />
<span>Tags Exclude</span>
<hr className="grow border-zinc-300" />
</div>
<TagFilter isExclude />
{platform !== "THREE_DS" && (
<>
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium w-full mt-2 mb-1">
<hr className="grow border-zinc-300" />
<span>Face Paint</span>
<hr className="grow border-zinc-300" />
</div>
<MakeupSelect />
</>
)}
<OtherFilters />
</div>
)}
</div>
);
}
import { useEffect, useMemo, useState } from "react";
import { Icon } from "@iconify/react";
import PlatformSelect from "./platform-select";
import TagFilter from "./tag-filter";
import GenderSelect from "./gender-select";
import OtherFilters from "./other-filters";
import MakeupSelect from "./makeup-select";
import type { MiiGender, MiiMakeup, MiiPlatform } from "@tomodachi-share/shared";
export default function FilterMenu() {
const searchParams = new URLSearchParams(window.location.search);
const [isOpen, setIsOpen] = useState(false);
const [isVisible, setIsVisible] = useState(false);
const platform = (searchParams.get("platform") as MiiPlatform) || undefined;
const gender = (searchParams.get("gender") as MiiGender) || undefined;
const makeup = (searchParams.get("makeup") as MiiMakeup) || undefined;
const rawTags = searchParams.get("tags") || "";
const rawExclude = searchParams.get("exclude") || "";
const allowCopying = (searchParams.get("allowCopying") as unknown as boolean) || false;
const tags = useMemo(
() =>
rawTags
? rawTags
.split(",")
.map((tag) => tag.trim())
.filter((tag) => tag.length > 0)
: [],
[rawTags],
);
const exclude = useMemo(
() =>
rawExclude
? rawExclude
.split(",")
.map((tag) => tag.trim())
.filter((tag) => tag.length > 0)
: [],
[rawExclude],
);
const [filterCount, setFilterCount] = useState(tags.length);
// Filter menu button handler
const handleClick = () => {
if (!isOpen) {
setIsOpen(true);
// slight delay to trigger animation
setTimeout(() => setIsVisible(true), 10);
} else {
setIsVisible(false);
setTimeout(() => {
setIsOpen(false);
}, 200);
}
};
// Count all active filters
useEffect(() => {
let count = tags.length + exclude.length;
if (platform) count++;
if (gender) count++;
if (allowCopying) count++;
if (makeup) count++;
setFilterCount(count);
}, [tags, exclude, platform, gender, allowCopying, makeup]);
return (
<div className="relative">
<button className="pill button gap-2" onClick={handleClick}>
<Icon icon="mdi:filter" className="text-xl" />
Filter
<span className="w-5">({filterCount})</span>
</button>
{isOpen && (
<div
className={`absolute w-80 left-0 top-full mt-8 z-40 flex flex-col items-center bg-orange-50
border-2 border-amber-500 rounded-2xl shadow-lg p-4 transition-discrete duration-200 ${isVisible ? "translate-y-0 opacity-100" : "-translate-y-2 opacity-0"}`}
>
{/* Arrow */}
<div className="absolute bottom-full left-1/6 -translate-x-1/2 size-0 border-8 border-transparent border-b-amber-500"></div>
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium w-full mb-2">
<hr className="grow border-zinc-300" />
<span>Platform</span>
<hr className="grow border-zinc-300" />
</div>
<PlatformSelect />
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium w-full mt-2 mb-1">
<hr className="grow border-zinc-300" />
<span>Gender</span>
<hr className="grow border-zinc-300" />
</div>
<GenderSelect />
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium w-full mt-2 mb-2">
<hr className="grow border-zinc-300" />
<span>Tags Include</span>
<hr className="grow border-zinc-300" />
</div>
<TagFilter />
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium w-full mt-2 mb-2">
<hr className="grow border-zinc-300" />
<span>Tags Exclude</span>
<hr className="grow border-zinc-300" />
</div>
<TagFilter isExclude />
{platform !== "THREE_DS" && (
<>
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium w-full mt-2 mb-1">
<hr className="grow border-zinc-300" />
<span>Face Paint</span>
<hr className="grow border-zinc-300" />
</div>
<MakeupSelect />
</>
)}
<OtherFilters />
</div>
)}
</div>
);
}

View file

@ -1,72 +1,72 @@
import { useState, useTransition } from "react";
import { Icon } from "@iconify/react";
import type { MiiGender, MiiPlatform } from "@tomodachi-share/shared";
export default function GenderSelect() {
const searchParams = new URLSearchParams(window.location.search);
const [, startTransition] = useTransition();
const [selected, setSelected] = useState<MiiGender | null>((searchParams.get("gender") as MiiGender) ?? null);
const platform = (searchParams.get("platform") as MiiPlatform) || undefined;
const handleClick = (gender: MiiGender) => {
const filter = selected === gender ? null : gender;
setSelected(filter);
const params = new URLSearchParams(searchParams);
params.set("page", "1");
if (filter) {
params.set("gender", filter);
} else {
params.delete("gender");
}
startTransition(() => {
// router.push(`?${params.toString()}`, { scroll: false });
window.location.href = `?${params.toString()}`;
});
};
return (
<div className="flex gap-0.5 w-fit">
<button
onClick={() => handleClick("MALE")}
aria-label="Filter for Male Miis"
data-tooltip-span
className={`cursor-pointer rounded-xl flex justify-center items-center size-13 text-5xl border-2 transition-all ${
selected === "MALE" ? "bg-blue-100 border-blue-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
}`}
>
<div className="tooltip bg-blue-400! border-blue-400! before:border-b-blue-400!">Male</div>
<Icon icon="foundation:male" className="text-blue-400" />
</button>
<button
onClick={() => handleClick("FEMALE")}
aria-label="Filter for Female Miis"
data-tooltip-span
className={`cursor-pointer rounded-xl flex justify-center items-center size-13 text-5xl border-2 transition-all ${
selected === "FEMALE" ? "bg-pink-100 border-pink-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
}`}
>
<div className="tooltip bg-pink-400! border-pink-400! before:border-b-pink-400!">Female</div>
<Icon icon="foundation:female" className="text-pink-400" />
</button>
{platform !== "THREE_DS" && (
<button
onClick={() => handleClick("NONBINARY")}
aria-label="Filter for Nonbinary Miis"
data-tooltip-span
className={`cursor-pointer rounded-xl flex justify-center items-center size-13 text-5xl border-2 transition-all ${
selected === "NONBINARY" ? "bg-purple-100 border-purple-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
}`}
>
<div className="tooltip bg-purple-400! border-purple-400! before:border-b-purple-400!">Nonbinary</div>
<Icon icon="mdi:gender-non-binary" className="text-purple-400" />
</button>
)}
</div>
);
}
import { useState, useTransition } from "react";
import { Icon } from "@iconify/react";
import type { MiiGender, MiiPlatform } from "@tomodachi-share/shared";
export default function GenderSelect() {
const searchParams = new URLSearchParams(window.location.search);
const [, startTransition] = useTransition();
const [selected, setSelected] = useState<MiiGender | null>((searchParams.get("gender") as MiiGender) ?? null);
const platform = (searchParams.get("platform") as MiiPlatform) || undefined;
const handleClick = (gender: MiiGender) => {
const filter = selected === gender ? null : gender;
setSelected(filter);
const params = new URLSearchParams(searchParams);
params.set("page", "1");
if (filter) {
params.set("gender", filter);
} else {
params.delete("gender");
}
startTransition(() => {
// router.push(`?${params.toString()}`, { scroll: false });
window.location.href = `?${params.toString()}`;
});
};
return (
<div className="flex gap-0.5 w-fit">
<button
onClick={() => handleClick("MALE")}
aria-label="Filter for Male Miis"
data-tooltip-span
className={`cursor-pointer rounded-xl flex justify-center items-center size-13 text-5xl border-2 transition-all ${
selected === "MALE" ? "bg-blue-100 border-blue-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
}`}
>
<div className="tooltip bg-blue-400! border-blue-400! before:border-b-blue-400!">Male</div>
<Icon icon="foundation:male" className="text-blue-400" />
</button>
<button
onClick={() => handleClick("FEMALE")}
aria-label="Filter for Female Miis"
data-tooltip-span
className={`cursor-pointer rounded-xl flex justify-center items-center size-13 text-5xl border-2 transition-all ${
selected === "FEMALE" ? "bg-pink-100 border-pink-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
}`}
>
<div className="tooltip bg-pink-400! border-pink-400! before:border-b-pink-400!">Female</div>
<Icon icon="foundation:female" className="text-pink-400" />
</button>
{platform !== "THREE_DS" && (
<button
onClick={() => handleClick("NONBINARY")}
aria-label="Filter for Nonbinary Miis"
data-tooltip-span
className={`cursor-pointer rounded-xl flex justify-center items-center size-13 text-5xl border-2 transition-all ${
selected === "NONBINARY" ? "bg-purple-100 border-purple-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
}`}
>
<div className="tooltip bg-purple-400! border-purple-400! before:border-b-purple-400!">Nonbinary</div>
<Icon icon="mdi:gender-non-binary" className="text-purple-400" />
</button>
)}
</div>
);
}

View file

@ -1,198 +1,184 @@
import crypto from "crypto";
import seedrandom from "seedrandom";
// import crypto from "crypto";
// import seedrandom from "seedrandom";
import { searchSchema } from "@tomodachi-share/shared/schemas";
// import { searchSchema } from "@tomodachi-share/shared/schemas";
import SortSelect from "./sort-select";
import Pagination from "./pagination";
import FilterMenu from "./filter-menu";
import MiiGrid from "./mii-grid";
// import SortSelect from "./sort-select";
// import Pagination from "./pagination";
// import FilterMenu from "./filter-menu";
// import MiiGrid from "./mii-grid";
interface Props {
searchParams: URLSearchParams;
userId?: number; // Profiles
parentPage?: "likes" | "admin";
}
// interface Props {
// searchParams: URLSearchParams;
// userId?: number; // Profiles
// parentPage?: "likes" | "admin";
// }
export default async function MiiList({ searchParams, userId, parentPage }: Props) {
const session = await auth();
const parsed = searchSchema.safeParse(searchParams);
if (!parsed.success) return <h1>{parsed.error.issues[0].message}</h1>;
// export default async function MiiList({ searchParams, userId, parentPage }: Props) {
// const session = await auth();
// const parsed = searchSchema.safeParse(searchParams);
// if (!parsed.success) return <h1>{parsed.error.issues[0].message}</h1>;
const { q: query, sort, tags, exclude, platform, gender, makeup, allowCopying, quarantined, page = 1, limit = 24, seed } = parsed.data;
// const { q: query, sort, tags, exclude, platform, gender, makeup, allowCopying, quarantined, page = 1, limit = 24, seed } = parsed.data;
// My Likes page
let miiIdsLiked: number[] | undefined = undefined;
// // My Likes page
// let miiIdsLiked: number[] | undefined = undefined;
if (parentPage === "likes" && session?.user?.id) {
const likedMiis = await prisma.like.findMany({
where: { userId: Number(session.user.id) },
select: { miiId: true },
});
miiIdsLiked = likedMiis.map((like) => like.miiId);
}
// if (parentPage === "likes" && session?.user?.id) {
// const likedMiis = await prisma.like.findMany({
// where: { userId: Number(session.user.id) },
// select: { miiId: true },
// });
// miiIdsLiked = likedMiis.map((like) => like.miiId);
// }
const where: Prisma.MiiWhereInput = {
// In queue logic
...(parentPage === "admin"
? { in_queue: true } // Only show queued Miis
: userId
? {
// Include queued Miis if user is on their profile
...(Number(session?.user?.id) === userId ? {} : { in_queue: false }),
userId,
}
: {
// Don't show queued Miis on main page
in_queue: false,
}),
// Only show liked miis on likes page
...(parentPage === "likes" && miiIdsLiked && { id: { in: miiIdsLiked } }),
// Searching
...(query && {
OR: [{ name: { contains: query, mode: "insensitive" } }, { tags: { has: query } }, { description: { contains: query, mode: "insensitive" } }],
}),
// Tag filtering
...(tags && tags.length > 0 && { tags: { hasEvery: tags } }),
...(exclude && exclude.length > 0 && { NOT: { tags: { hasSome: exclude } } }),
// Platform
...(platform && { platform: { equals: platform } }),
// Gender
...(gender && { gender: { equals: gender } }),
// Allow Copying
...(allowCopying && { allowedCopying: true }),
// Makeup
...(makeup && { makeup: { equals: makeup } }),
// Quarantined
...(!quarantined && !userId && { quarantined: false }),
};
// const where: Prisma.MiiWhereInput = {
// // In queue logic
// ...(parentPage === "admin"
// ? { in_queue: true } // Only show queued Miis
// : userId
// ? {
// // Include queued Miis if user is on their profile
// ...(Number(session?.user?.id) === userId ? {} : { in_queue: false }),
// userId,
// }
// : {
// // Don't show queued Miis on main page
// in_queue: false,
// }),
// // Only show liked miis on likes page
// ...(parentPage === "likes" && miiIdsLiked && { id: { in: miiIdsLiked } }),
// // Searching
// ...(query && {
// OR: [{ name: { contains: query, mode: "insensitive" } }, { tags: { has: query } }, { description: { contains: query, mode: "insensitive" } }],
// }),
// // Tag filtering
// ...(tags && tags.length > 0 && { tags: { hasEvery: tags } }),
// ...(exclude && exclude.length > 0 && { NOT: { tags: { hasSome: exclude } } }),
// // Platform
// ...(platform && { platform: { equals: platform } }),
// // Gender
// ...(gender && { gender: { equals: gender } }),
// // Allow Copying
// ...(allowCopying && { allowedCopying: true }),
// // Makeup
// ...(makeup && { makeup: { equals: makeup } }),
// // Quarantined
// ...(!quarantined && !userId && { quarantined: false }),
// };
const select: Prisma.MiiSelect = {
id: true,
// Don't show when userId is specified
...(!userId && {
user: {
select: {
id: true,
name: true,
},
},
}),
platform: true,
name: true,
imageCount: true,
tags: true,
createdAt: true,
gender: true,
makeup: true,
allowedCopying: true,
quarantined: true,
in_queue: true,
// Mii liked check
...(session?.user?.id && {
likedBy: {
where: { userId: Number(session.user.id) },
select: { userId: true },
},
}),
// Like count
_count: {
select: { likedBy: true },
},
};
// const select: Prisma.MiiSelect = {
// id: true,
// // Don't show when userId is specified
// ...(!userId && {
// user: {
// select: {
// id: true,
// name: true,
// },
// },
// }),
// platform: true,
// name: true,
// imageCount: true,
// tags: true,
// createdAt: true,
// gender: true,
// makeup: true,
// allowedCopying: true,
// quarantined: true,
// in_queue: true,
// // Mii liked check
// ...(session?.user?.id && {
// likedBy: {
// where: { userId: Number(session.user.id) },
// select: { userId: true },
// },
// }),
// // Like count
// _count: {
// select: { likedBy: true },
// },
// };
const skip = (page - 1) * limit;
// const skip = (page - 1) * limit;
let totalCount: number;
let filteredCount: number;
let miis: Prisma.MiiGetPayload<{ select: typeof select }>[];
// let totalCount: number;
// let miis: Prisma.MiiGetPayload<{ select: typeof select }>[];
if (sort === "random") {
// Get all IDs that match the where conditions
const matchingIds = await prisma.mii.findMany({
where,
select: { id: true },
});
// if (sort === "random") {
// // Get all IDs that match the where conditions
// const matchingIds = await prisma.mii.findMany({
// where,
// select: { id: true },
// });
totalCount = matchingIds.length;
filteredCount = Math.max(0, Math.min(limit, totalCount - skip));
// totalCount = matchingIds.length;
if (matchingIds.length === 0) return;
// if (matchingIds.length === 0) return;
// Use seed for consistent random results
const randomSeed = seed || crypto.randomInt(0, 1_000_000_000);
const rng = seedrandom(randomSeed.toString());
// // Use seed for consistent random results
// const randomSeed = seed || crypto.randomInt(0, 1_000_000_000);
// const rng = seedrandom(randomSeed.toString());
// Randomize all IDs using the Durstenfeld algorithm
for (let i = matchingIds.length - 1; i > 0; i--) {
const j = Math.floor(rng() * (i + 1));
[matchingIds[i], matchingIds[j]] = [matchingIds[j], matchingIds[i]];
}
// // Randomize all IDs using the Durstenfeld algorithm
// for (let i = matchingIds.length - 1; i > 0; i--) {
// const j = Math.floor(rng() * (i + 1));
// [matchingIds[i], matchingIds[j]] = [matchingIds[j], matchingIds[i]];
// }
// Convert to number[] array
const selectedIds = matchingIds.slice(skip, skip + limit).map((i) => i.id);
// // Convert to number[] array
// const selectedIds = matchingIds.slice(skip, skip + limit).map((i) => i.id);
miis = await prisma.mii.findMany({
where: {
id: { in: selectedIds },
},
select,
});
} else {
// Sorting by likes, newest, or oldest
let orderBy: Prisma.MiiOrderByWithRelationInput[];
// miis = await prisma.mii.findMany({
// where: {
// id: { in: selectedIds },
// },
// select,
// });
// } else {
// // Sorting by likes, newest, or oldest
// let orderBy: Prisma.MiiOrderByWithRelationInput[];
if (sort === "likes") {
orderBy = [{ likedBy: { _count: "desc" } }, { name: "asc" }];
} else if (sort === "oldest") {
orderBy = [{ createdAt: "asc" }, { name: "asc" }];
} else {
// default to newest
orderBy = [{ createdAt: "desc" }, { name: "asc" }];
}
// if (sort === "likes") {
// orderBy = [{ likedBy: { _count: "desc" } }, { name: "asc" }];
// } else if (sort === "oldest") {
// orderBy = [{ createdAt: "asc" }, { name: "asc" }];
// } else {
// // default to newest
// orderBy = [{ createdAt: "desc" }, { name: "asc" }];
// }
[totalCount, filteredCount, miis] = await Promise.all([
prisma.mii.count({ where: { ...where, userId } }),
prisma.mii.count({ where, skip, take: limit }),
prisma.mii.findMany({
where,
orderBy,
select,
skip: (page - 1) * limit,
take: limit,
}),
]);
}
// [totalCount, miis] = await Promise.all([
// prisma.mii.count({ where: { ...where, userId } }),
// prisma.mii.findMany({
// where,
// orderBy,
// select,
// skip,
// take: limit,
// }),
// ]);
// }
const lastPage = Math.ceil(totalCount / limit);
// const lastPage = Math.ceil(totalCount / limit);
return (
<div className="w-full">
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex justify-between items-center gap-2 mb-2 max-md:flex-col">
<div className="flex items-center gap-2">
{totalCount == filteredCount ? (
<>
<span className="text-2xl font-bold text-amber-900">{totalCount}</span>
<span className="text-lg text-amber-700">{totalCount === 1 ? "Mii" : "Miis"}</span>
</>
) : (
<>
<span className="text-2xl font-bold text-amber-900">{filteredCount}</span>
<span className="text-sm text-amber-700">of</span>
<span className="text-lg font-semibold text-amber-800">{totalCount}</span>
<span className="text-lg text-amber-700">Miis</span>
</>
)}
</div>
// return (
// <div className="w-full">
// <div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex justify-between items-center gap-2 mb-2 max-md:flex-col">
// <div className="flex items-center gap-2">
// <span className="text-2xl font-bold text-amber-900">{totalCount}</span>
// <span className="text-lg text-amber-700">{totalCount === 1 ? "Mii" : "Miis"}</span>
// </div>
<div className="relative flex items-center justify-end gap-2 w-full md:max-w-2/3 max-md:justify-center">
<FilterMenu />
<SortSelect />
</div>
</div>
// <div className="relative flex items-center justify-end gap-2 w-full md:max-w-2/3 max-md:justify-center">
// <FilterMenu />
// <SortSelect />
// </div>
// </div>
<MiiGrid miis={miis} userId={userId} parentPage={parentPage} />
<Pagination lastPage={lastPage} />
</div>
);
}
// <MiiGrid miis={miis} userId={userId} parentPage={parentPage} />
// <Pagination lastPage={lastPage} />
// </div>
// );
// }

View file

@ -1,72 +1,72 @@
import { useState, useTransition } from "react";
import { Icon } from "@iconify/react";
import type { MiiMakeup } from "@tomodachi-share/shared";
export default function MakeupSelect() {
const searchParams = new URLSearchParams(window.location.search);
const [, startTransition] = useTransition();
const [selected, setSelected] = useState<MiiMakeup | null>((searchParams.get("makeup") as MiiMakeup) ?? null);
const handleClick = (makeup: MiiMakeup) => {
const filter = selected === makeup ? null : makeup;
setSelected(filter);
const params = new URLSearchParams(searchParams);
params.set("page", "1");
if (filter) {
params.set("makeup", filter);
} else {
params.delete("makeup");
}
startTransition(() => {
// router.push(`?${params.toString()}`, { scroll: false });
window.location.href = `?${params.toString()}`;
});
};
return (
<div className="flex gap-0.5 w-fit">
{/* Full Makeup */}
<button
onClick={() => handleClick("FULL")}
aria-label="Filter for Full Face Paint"
data-tooltip-span
className={`cursor-pointer rounded-xl flex justify-center items-center size-13 text-5xl border-2 transition-all ${
selected === "FULL" ? "bg-pink-100 border-pink-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
}`}
>
<div className="tooltip bg-pink-400! border-pink-400! before:border-b-pink-400!">Full Face Paint</div>
<Icon icon="mdi:palette" className="text-pink-400" />
</button>
{/* Partial Makeup */}
<button
onClick={() => handleClick("PARTIAL")}
aria-label="Filter for Partial Face Paint"
data-tooltip-span
className={`cursor-pointer rounded-xl flex justify-center items-center size-13 text-5xl border-2 transition-all ${
selected === "PARTIAL" ? "bg-purple-100 border-purple-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
}`}
>
<div className="tooltip bg-purple-400! border-purple-400! before:border-b-purple-400!">Partial Face Paint</div>
<Icon icon="mdi:lipstick" className="text-purple-400" />
</button>
{/* No Makeup */}
<button
onClick={() => handleClick("NONE")}
aria-label="Filter for No Face Paint"
data-tooltip-span
className={`cursor-pointer rounded-xl flex justify-center items-center size-13 text-5xl border-2 transition-all ${
selected === "NONE" ? "bg-gray-200 border-gray-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
}`}
>
<div className="tooltip bg-gray-400! border-gray-400! before:border-b-gray-400!">No Face Paint</div>
<Icon icon="codex:cross" className="text-gray-400" />
</button>
</div>
);
}
import { useState, useTransition } from "react";
import { Icon } from "@iconify/react";
import type { MiiMakeup } from "@tomodachi-share/shared";
export default function MakeupSelect() {
const searchParams = new URLSearchParams(window.location.search);
const [, startTransition] = useTransition();
const [selected, setSelected] = useState<MiiMakeup | null>((searchParams.get("makeup") as MiiMakeup) ?? null);
const handleClick = (makeup: MiiMakeup) => {
const filter = selected === makeup ? null : makeup;
setSelected(filter);
const params = new URLSearchParams(searchParams);
params.set("page", "1");
if (filter) {
params.set("makeup", filter);
} else {
params.delete("makeup");
}
startTransition(() => {
// router.push(`?${params.toString()}`, { scroll: false });
window.location.href = `?${params.toString()}`;
});
};
return (
<div className="flex gap-0.5 w-fit">
{/* Full Makeup */}
<button
onClick={() => handleClick("FULL")}
aria-label="Filter for Full Face Paint"
data-tooltip-span
className={`cursor-pointer rounded-xl flex justify-center items-center size-13 text-5xl border-2 transition-all ${
selected === "FULL" ? "bg-pink-100 border-pink-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
}`}
>
<div className="tooltip bg-pink-400! border-pink-400! before:border-b-pink-400!">Full Face Paint</div>
<Icon icon="mdi:palette" className="text-pink-400" />
</button>
{/* Partial Makeup */}
<button
onClick={() => handleClick("PARTIAL")}
aria-label="Filter for Partial Face Paint"
data-tooltip-span
className={`cursor-pointer rounded-xl flex justify-center items-center size-13 text-5xl border-2 transition-all ${
selected === "PARTIAL" ? "bg-purple-100 border-purple-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
}`}
>
<div className="tooltip bg-purple-400! border-purple-400! before:border-b-purple-400!">Partial Face Paint</div>
<Icon icon="mdi:lipstick" className="text-purple-400" />
</button>
{/* No Makeup */}
<button
onClick={() => handleClick("NONE")}
aria-label="Filter for No Face Paint"
data-tooltip-span
className={`cursor-pointer rounded-xl flex justify-center items-center size-13 text-5xl border-2 transition-all ${
selected === "NONE" ? "bg-gray-200 border-gray-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
}`}
>
<div className="tooltip bg-gray-400! border-gray-400! before:border-b-gray-400!">No Face Paint</div>
<Icon icon="codex:cross" className="text-gray-400" />
</button>
</div>
);
}

View file

@ -2,8 +2,6 @@ import { Icon } from "@iconify/react";
import LikeButton from "../../like-button";
import DeleteMiiButton from "../delete-mii-button";
import Carousel from "../../carousel";
import ImageViewer from "../../image-viewer";
interface Props {
// miis: Prisma.MiiGetPayload<{ include: { user: { select: { id: true; name: true } }; _count: { select: { likedBy: true } } } }>[];
@ -12,11 +10,7 @@ interface Props {
parentPage?: string;
}
const fetcher = (url: string) => fetch(url).then((res) => res.json());
export default function MiiGrid({ miis, userId, parentPage }: Props) {
const likedIds = new Set([]);
return (
<div className="grid grid-cols-4 gap-4 max-lg:grid-cols-3 max-md:grid-cols-2 max-[30rem]:grid-cols-1">
{miis.map((mii) => (
@ -33,7 +27,7 @@ export default function MiiGrid({ miis, userId, parentPage }: Props) {
<a href={`/mii/${mii.id}`} className="overflow-hidden rounded-xl bg-zinc-300 shrink-0">
<img
src={`${import.meta.env.PUBLIC_API_URL}/mii/${mii.id}/image?type=mii`}
src={`${import.meta.env.VITE_API_URL}/mii/${mii.id}/image?type=mii`}
width={240}
height={160}
alt="mii image"
@ -63,7 +57,7 @@ export default function MiiGrid({ miis, userId, parentPage }: Props) {
</div>
<div className="mt-auto grid grid-cols-2 items-center">
<LikeButton likes={mii._count.likedBy} miiId={mii.id} isLiked={likedIds.has(mii.id)} abbreviate />
<LikeButton likes={mii._count.likedBy} miiId={mii.id} isLiked={false} abbreviate />
{!userId && (
<a href={`/profile/${mii.user?.id}`} className="text-sm text-right overflow-hidden text-ellipsis whitespace-nowrap">

View file

@ -1,79 +1,79 @@
import type { MiiPlatform } from "@tomodachi-share/shared";
import { type ChangeEvent, useState, useTransition } from "react";
export default function OtherFilters() {
const searchParams = new URLSearchParams(window.location.search);
const [, startTransition] = useTransition();
const platform = (searchParams.get("platform") as MiiPlatform) || undefined;
const [allowCopying, setAllowCopying] = useState<boolean>((searchParams.get("allowCopying") as unknown as boolean) ?? false);
const [quarantined, setQuarantined] = useState<boolean>((searchParams.get("quarantined") as unknown as boolean) ?? false);
const handleChangeAllowCopying = (e: ChangeEvent<HTMLInputElement>) => {
setAllowCopying(e.target.checked);
const params = new URLSearchParams(searchParams);
params.set("page", "1");
if (!allowCopying) {
params.set("allowCopying", "true");
} else {
params.delete("allowCopying");
}
startTransition(() => {
// router.push(`?${params.toString()}`, { scroll: false });
window.location.href = `?${params.toString()}`;
});
};
const handleChangeQuarantined = (e: ChangeEvent<HTMLInputElement>) => {
setQuarantined(e.target.checked);
const params = new URLSearchParams(searchParams);
params.set("page", "1");
if (!quarantined) {
params.set("quarantined", "true");
} else {
params.delete("quarantined");
}
startTransition(() => {
// router.push(`?${params.toString()}`, { scroll: false });
window.location.href = `?${params.toString()}`;
});
};
const showAllowCopying = platform !== "SWITCH";
const showQuarantined = !location.pathname.startsWith("/profile");
if (!showAllowCopying && !showQuarantined) return null;
return (
<>
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium w-full mt-2 mb-1">
<hr className="grow border-zinc-300" />
<span>Other</span>
<hr className="grow border-zinc-300" />
</div>
{showAllowCopying && (
<div className="flex justify-between items-center w-full mb-1">
<label htmlFor="allowCopying" className="text-sm">
Allow Copying
</label>
<input type="checkbox" id="allowCopying" className="checkbox-alt" checked={allowCopying} onChange={handleChangeAllowCopying} />
</div>
)}
{showQuarantined && (
<div className="flex justify-between items-center w-full">
<label htmlFor="quarantined" className="text-sm">
Show Controversial Miis
</label>
<input type="checkbox" id="quarantined" className="checkbox-alt" checked={quarantined} onChange={handleChangeQuarantined} />
</div>
)}
</>
);
}
import type { MiiPlatform } from "@tomodachi-share/shared";
import { type ChangeEvent, useState, useTransition } from "react";
export default function OtherFilters() {
const searchParams = new URLSearchParams(window.location.search);
const [, startTransition] = useTransition();
const platform = (searchParams.get("platform") as MiiPlatform) || undefined;
const [allowCopying, setAllowCopying] = useState<boolean>((searchParams.get("allowCopying") as unknown as boolean) ?? false);
const [quarantined, setQuarantined] = useState<boolean>((searchParams.get("quarantined") as unknown as boolean) ?? false);
const handleChangeAllowCopying = (e: ChangeEvent<HTMLInputElement>) => {
setAllowCopying(e.target.checked);
const params = new URLSearchParams(searchParams);
params.set("page", "1");
if (!allowCopying) {
params.set("allowCopying", "true");
} else {
params.delete("allowCopying");
}
startTransition(() => {
// router.push(`?${params.toString()}`, { scroll: false });
window.location.href = `?${params.toString()}`;
});
};
const handleChangeQuarantined = (e: ChangeEvent<HTMLInputElement>) => {
setQuarantined(e.target.checked);
const params = new URLSearchParams(searchParams);
params.set("page", "1");
if (!quarantined) {
params.set("quarantined", "true");
} else {
params.delete("quarantined");
}
startTransition(() => {
// router.push(`?${params.toString()}`, { scroll: false });
window.location.href = `?${params.toString()}`;
});
};
const showAllowCopying = platform !== "SWITCH";
const showQuarantined = !location.pathname.startsWith("/profile");
if (!showAllowCopying && !showQuarantined) return null;
return (
<>
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium w-full mt-2 mb-1">
<hr className="grow border-zinc-300" />
<span>Other</span>
<hr className="grow border-zinc-300" />
</div>
{showAllowCopying && (
<div className="flex justify-between items-center w-full mb-1">
<label htmlFor="allowCopying" className="text-sm">
Allow Copying
</label>
<input type="checkbox" id="allowCopying" className="checkbox-alt" checked={allowCopying} onChange={handleChangeAllowCopying} />
</div>
)}
{showQuarantined && (
<div className="flex justify-between items-center w-full">
<label htmlFor="quarantined" className="text-sm">
Show Controversial Miis
</label>
<input type="checkbox" id="quarantined" className="checkbox-alt" checked={quarantined} onChange={handleChangeQuarantined} />
</div>
)}
</>
);
}

View file

@ -1,55 +1,55 @@
import { useState, useTransition } from "react";
import { Icon } from "@iconify/react";
import type { MiiPlatform } from "@tomodachi-share/shared";
export default function PlatformSelect() {
const searchParams = new URLSearchParams(window.location.search);
const [, startTransition] = useTransition();
const [selected, setSelected] = useState<MiiPlatform | null>((searchParams.get("platform") as MiiPlatform) ?? null);
const handleClick = (platform: MiiPlatform) => {
const filter = selected === platform ? null : platform;
setSelected(filter);
const params = new URLSearchParams(searchParams);
if (filter) {
params.set("platform", filter);
} else {
params.delete("platform");
}
startTransition(() => {
// router.push(`?${params.toString()}`);
window.location.href = `?${params.toString()}`;
});
};
return (
<div className="grid grid-cols-2 gap-0.5 w-fit">
<button
onClick={() => handleClick("THREE_DS")}
aria-label="Filter for 3DS Miis"
data-tooltip-span
className={`cursor-pointer rounded-xl flex justify-center items-center size-13 text-3xl border-2 transition-all ${
selected === "THREE_DS" ? "bg-sky-100 border-sky-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
}`}
>
<div className="tooltip bg-sky-400! border-sky-400! before:border-b-sky-400!">3DS</div>
<Icon icon="cib:nintendo-3ds" className="text-sky-400" />
</button>
<button
onClick={() => handleClick("SWITCH")}
aria-label="Filter for Switch Miis"
data-tooltip-span
className={`cursor-pointer rounded-xl flex justify-center items-center size-13 text-3xl border-2 transition-all ${
selected === "SWITCH" ? "bg-red-100 border-red-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
}`}
>
<div className="tooltip bg-red-400! border-red-400! before:border-b-red-400!">Switch</div>
<Icon icon="cib:nintendo-switch" className="text-red-400" />
</button>
</div>
);
}
import { useState, useTransition } from "react";
import { Icon } from "@iconify/react";
import type { MiiPlatform } from "@tomodachi-share/shared";
export default function PlatformSelect() {
const searchParams = new URLSearchParams(window.location.search);
const [, startTransition] = useTransition();
const [selected, setSelected] = useState<MiiPlatform | null>((searchParams.get("platform") as MiiPlatform) ?? null);
const handleClick = (platform: MiiPlatform) => {
const filter = selected === platform ? null : platform;
setSelected(filter);
const params = new URLSearchParams(searchParams);
if (filter) {
params.set("platform", filter);
} else {
params.delete("platform");
}
startTransition(() => {
// router.push(`?${params.toString()}`);
window.location.href = `?${params.toString()}`;
});
};
return (
<div className="grid grid-cols-2 gap-0.5 w-fit">
<button
onClick={() => handleClick("THREE_DS")}
aria-label="Filter for 3DS Miis"
data-tooltip-span
className={`cursor-pointer rounded-xl flex justify-center items-center size-13 text-3xl border-2 transition-all ${
selected === "THREE_DS" ? "bg-sky-100 border-sky-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
}`}
>
<div className="tooltip bg-sky-400! border-sky-400! before:border-b-sky-400!">3DS</div>
<Icon icon="cib:nintendo-3ds" className="text-sky-400" />
</button>
<button
onClick={() => handleClick("SWITCH")}
aria-label="Filter for Switch Miis"
data-tooltip-span
className={`cursor-pointer rounded-xl flex justify-center items-center size-13 text-3xl border-2 transition-all ${
selected === "SWITCH" ? "bg-red-100 border-red-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
}`}
>
<div className="tooltip bg-red-400! border-red-400! before:border-b-red-400!">Switch</div>
<Icon icon="cib:nintendo-switch" className="text-red-400" />
</button>
</div>
);
}

View file

@ -1,53 +1,53 @@
import FilterSelect from "./tag-filter";
import SortSelect from "./sort-select";
import Pagination from "../../pagination";
export default function Skeleton() {
return (
<div className="w-full animate-pulse">
<div className="flex justify-between items-end mb-2 max-[32rem]:flex-col max-[32rem]:items-center">
<p className="text-lg">
<span className="font-extrabold">???</span> Miis
</p>
<div className="flex gap-2 pointer-events-none">
<FilterSelect />
<SortSelect />
</div>
</div>
<div className="grid grid-cols-4 gap-4 max-lg:grid-cols-3 max-md:grid-cols-2 max-[30rem]:grid-cols-1">
{[...Array(24)].map((_, index) => (
<div key={index} className="flex flex-col bg-zinc-50 rounded-3xl border-2 border-zinc-300 shadow-lg p-3">
{/* Carousel Skeleton */}
<div className="relative rounded-xl bg-zinc-300 border-2 border-zinc-300 mb-1">
<div className="aspect-3/2"></div>
</div>
{/* Content */}
<div className="p-4 flex flex-col gap-1 h-full">
{/* Name */}
<div className="h-7 bg-zinc-300 rounded w-2/3 mb-0.5" />
{/* Tags */}
<div className="flex flex-wrap gap-1">
<div className="px-4 py-2 bg-orange-200 rounded-full w-14 h-6" />
<div className="px-4 py-2 bg-orange-200 rounded-full w-10 h-6" />
</div>
{/* Bottom row */}
<div className="mt-0.5 grid grid-cols-2 items-center">
<div className="h-6 w-12 bg-red-200 rounded" />
<div className="h-4 w-24 bg-zinc-200 rounded justify-self-end" />
</div>
</div>
</div>
))}
</div>
<div className="pointer-events-none">
<Pagination lastPage={10} />
</div>
</div>
);
}
import FilterSelect from "./tag-filter";
import SortSelect from "./sort-select";
import Pagination from "../../pagination";
export default function Skeleton() {
return (
<div className="w-full animate-pulse">
<div className="flex justify-between items-end mb-2 max-[32rem]:flex-col max-[32rem]:items-center">
<p className="text-lg">
<span className="font-extrabold">???</span> Miis
</p>
<div className="flex gap-2 pointer-events-none">
<FilterSelect />
<SortSelect />
</div>
</div>
<div className="grid grid-cols-4 gap-4 max-lg:grid-cols-3 max-md:grid-cols-2 max-[30rem]:grid-cols-1">
{[...Array(24)].map((_, index) => (
<div key={index} className="flex flex-col bg-zinc-50 rounded-3xl border-2 border-zinc-300 shadow-lg p-3">
{/* Carousel Skeleton */}
<div className="relative rounded-xl bg-zinc-300 border-2 border-zinc-300 mb-1">
<div className="aspect-3/2"></div>
</div>
{/* Content */}
<div className="p-4 flex flex-col gap-1 h-full">
{/* Name */}
<div className="h-7 bg-zinc-300 rounded w-2/3 mb-0.5" />
{/* Tags */}
<div className="flex flex-wrap gap-1">
<div className="px-4 py-2 bg-orange-200 rounded-full w-14 h-6" />
<div className="px-4 py-2 bg-orange-200 rounded-full w-10 h-6" />
</div>
{/* Bottom row */}
<div className="mt-0.5 grid grid-cols-2 items-center">
<div className="h-6 w-12 bg-red-200 rounded" />
<div className="h-4 w-24 bg-zinc-200 rounded justify-self-end" />
</div>
</div>
</div>
))}
</div>
<div className="pointer-events-none">
<Pagination lastPage={10} />
</div>
</div>
);
}

View file

@ -1,61 +1,61 @@
import { useTransition } from "react";
import { useSelect } from "downshift";
import { Icon } from "@iconify/react";
type Sort = "likes" | "newest" | "oldest" | "random";
const items = ["likes", "newest", "oldest", "random"];
export default function SortSelect() {
const searchParams = new URLSearchParams(window.location.search);
const [, startTransition] = useTransition();
const currentSort = (searchParams.get("sort") as Sort) || "newest";
const { isOpen, getToggleButtonProps, getMenuProps, getItemProps, highlightedIndex, selectedItem } = useSelect({
items,
selectedItem: currentSort,
onSelectedItemChange: ({ selectedItem }) => {
if (!selectedItem) return;
const params = new URLSearchParams(searchParams);
params.set("page", "1");
params.set("sort", selectedItem);
if (selectedItem == "random") {
params.set("seed", Math.floor(Math.random() * 1_000_000_000).toString());
}
startTransition(() => {
// router.push(`?${params.toString()}`, { scroll: false });
window.location.href = `?${params.toString()}`;
});
},
});
return (
<div className="relative w-fit">
{/* Toggle button to open the dropdown */}
<button type="button" {...getToggleButtonProps()} aria-label="Sort dropdown" className="pill input w-full gap-1 justify-between! text-nowrap">
<span>Sort by </span>
{selectedItem || "Select a way to sort"}
<Icon icon="tabler:chevron-down" className="ml-2 size-5" />
</button>
{/* Dropdown menu */}
<ul
{...getMenuProps()}
className={`absolute z-50 w-full bg-orange-200 border-2 border-orange-400 rounded-lg mt-1 shadow-lg max-h-60 overflow-y-auto ${
isOpen ? "block" : "hidden"
}`}
>
{isOpen &&
items.map((item, index) => (
<li key={item} {...getItemProps({ item, index })} className={`px-4 py-1 cursor-pointer text-sm ${highlightedIndex === index ? "bg-black/15" : ""}`}>
{item}
</li>
))}
</ul>
</div>
);
}
import { useTransition } from "react";
import { useSelect } from "downshift";
import { Icon } from "@iconify/react";
type Sort = "likes" | "newest" | "oldest" | "random";
const items = ["likes", "newest", "oldest", "random"];
export default function SortSelect() {
const searchParams = new URLSearchParams(window.location.search);
const [, startTransition] = useTransition();
const currentSort = (searchParams.get("sort") as Sort) || "newest";
const { isOpen, getToggleButtonProps, getMenuProps, getItemProps, highlightedIndex, selectedItem } = useSelect({
items,
selectedItem: currentSort,
onSelectedItemChange: ({ selectedItem }) => {
if (!selectedItem) return;
const params = new URLSearchParams(searchParams);
params.set("page", "1");
params.set("sort", selectedItem);
if (selectedItem == "random") {
params.set("seed", Math.floor(Math.random() * 1_000_000_000).toString());
}
startTransition(() => {
// router.push(`?${params.toString()}`, { scroll: false });
window.location.href = `?${params.toString()}`;
});
},
});
return (
<div className="relative w-fit">
{/* Toggle button to open the dropdown */}
<button type="button" {...getToggleButtonProps()} aria-label="Sort dropdown" className="pill input w-full gap-1 justify-between! text-nowrap">
<span>Sort by </span>
{selectedItem || "Select a way to sort"}
<Icon icon="tabler:chevron-down" className="ml-2 size-5" />
</button>
{/* Dropdown menu */}
<ul
{...getMenuProps()}
className={`absolute z-50 w-full bg-orange-200 border-2 border-orange-400 rounded-lg mt-1 shadow-lg max-h-60 overflow-y-auto ${
isOpen ? "block" : "hidden"
}`}
>
{isOpen &&
items.map((item, index) => (
<li key={item} {...getItemProps({ item, index })} className={`px-4 py-1 cursor-pointer text-sm ${highlightedIndex === index ? "bg-black/15" : ""}`}>
{item}
</li>
))}
</ul>
</div>
);
}

View file

@ -1,59 +1,59 @@
import { useEffect, useMemo, useState, useTransition } from "react";
import TagSelector from "../../tag-selector";
interface Props {
isExclude?: boolean;
}
export default function TagFilter({ isExclude }: Props) {
const searchParams = new URLSearchParams(window.location.search);
const [, startTransition] = useTransition();
const rawTags = searchParams.get(isExclude ? "exclude" : "tags") || "";
const preexistingTags = useMemo(
() =>
rawTags
? rawTags
.split(",")
.map((tag) => tag.trim())
.filter((tag) => tag.length > 0)
: [],
[rawTags],
);
const [tags, setTags] = useState<string[]>(preexistingTags);
// Sync state if the URL tags change (e.g. via navigation)
useEffect(() => {
setTags(preexistingTags);
}, [preexistingTags]);
// Redirect automatically on tags change
useEffect(() => {
const urlTags = preexistingTags.join(",");
const stateTags = tags.join(",");
if (urlTags === stateTags) return;
const params = new URLSearchParams(searchParams);
params.set("page", "1");
if (tags.length > 0) {
params.set(isExclude ? "exclude" : "tags", stateTags);
} else {
params.delete(isExclude ? "exclude" : "tags");
}
startTransition(() => {
// router.push(`?${params.toString()}`, { scroll: false });
window.location.href = `?${params.toString()}`;
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tags]);
return (
<div className="w-72">
<TagSelector tags={tags} setTags={setTags} isExclude={isExclude} />
</div>
);
}
import { useEffect, useMemo, useState, useTransition } from "react";
import TagSelector from "../../tag-selector";
interface Props {
isExclude?: boolean;
}
export default function TagFilter({ isExclude }: Props) {
const searchParams = new URLSearchParams(window.location.search);
const [, startTransition] = useTransition();
const rawTags = searchParams.get(isExclude ? "exclude" : "tags") || "";
const preexistingTags = useMemo(
() =>
rawTags
? rawTags
.split(",")
.map((tag) => tag.trim())
.filter((tag) => tag.length > 0)
: [],
[rawTags],
);
const [tags, setTags] = useState<string[]>(preexistingTags);
// Sync state if the URL tags change (e.g. via navigation)
useEffect(() => {
setTags(preexistingTags);
}, [preexistingTags]);
// Redirect automatically on tags change
useEffect(() => {
const urlTags = preexistingTags.join(",");
const stateTags = tags.join(",");
if (urlTags === stateTags) return;
const params = new URLSearchParams(searchParams);
params.set("page", "1");
if (tags.length > 0) {
params.set(isExclude ? "exclude" : "tags", stateTags);
} else {
params.delete(isExclude ? "exclude" : "tags");
}
startTransition(() => {
// router.push(`?${params.toString()}`, { scroll: false });
window.location.href = `?${params.toString()}`;
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tags]);
return (
<div className="w-72">
<TagSelector tags={tags} setTags={setTags} isExclude={isExclude} />
</div>
);
}

View file

@ -1,56 +1,56 @@
import { type SwitchMiiInstructions } from "@tomodachi-share/shared";
interface Props {
data: SwitchMiiInstructions["personality"];
onClick?: (key: string, i: number) => void;
}
const PERSONALITY_SETTINGS: { label: string; left: string; right: string }[] = [
{ label: "Movement", left: "Slow", right: "Quick" },
{ label: "Speech", left: "Polite", right: "Honest" },
{ label: "Energy", left: "Flat", right: "Varied" },
{ label: "Thinking", left: "Serious", right: "Chill" },
{ label: "Overall", left: "Normal", right: "Quirky" },
];
export default function PersonalityViewer({ data, onClick }: Props) {
return (
<div className="flex flex-col gap-1.5 mb-3">
{PERSONALITY_SETTINGS.map(({ label, left, right }) => {
const key = label.toLowerCase() as keyof typeof data;
return (
<div key={label} className="flex justify-center items-center gap-2">
<span className="text-sm w-24 shrink-0">{label}</span>
<span className="text-sm text-zinc-500 w-14 text-right">{left}</span>
<div className="flex gap-0.5">
{Array.from({ length: 8 }).map((_, i) => {
const colors = [
"bg-green-400",
"bg-green-300",
"bg-emerald-200",
"bg-teal-200",
"bg-orange-200",
"bg-orange-300",
"bg-orange-400",
"bg-orange-500",
];
return (
<button
key={i}
type="button"
onClick={() => {
if (onClick) onClick(key, i);
}}
className={`size-7 rounded-lg transition-opacity duration-100 border-black/40
${colors[i]} ${data[key] === i ? "border-2 opacity-100" : "opacity-70"} ${onClick ? "cursor-pointer" : ""}`}
></button>
);
})}
</div>
<span className="text-sm text-zinc-500 w-12 shrink-0">{right}</span>
</div>
);
})}
</div>
);
}
import { type SwitchMiiInstructions } from "@tomodachi-share/shared";
interface Props {
data: SwitchMiiInstructions["personality"];
onClick?: (key: string, i: number) => void;
}
const PERSONALITY_SETTINGS: { label: string; left: string; right: string }[] = [
{ label: "Movement", left: "Slow", right: "Quick" },
{ label: "Speech", left: "Polite", right: "Honest" },
{ label: "Energy", left: "Flat", right: "Varied" },
{ label: "Thinking", left: "Serious", right: "Chill" },
{ label: "Overall", left: "Normal", right: "Quirky" },
];
export default function PersonalityViewer({ data, onClick }: Props) {
return (
<div className="flex flex-col gap-1.5 mb-3">
{PERSONALITY_SETTINGS.map(({ label, left, right }) => {
const key = label.toLowerCase() as keyof typeof data;
return (
<div key={label} className="flex justify-center items-center gap-2">
<span className="text-sm w-24 shrink-0">{label}</span>
<span className="text-sm text-zinc-500 w-14 text-right">{left}</span>
<div className="flex gap-0.5">
{Array.from({ length: 8 }).map((_, i) => {
const colors = [
"bg-green-400",
"bg-green-300",
"bg-emerald-200",
"bg-teal-200",
"bg-orange-200",
"bg-orange-300",
"bg-orange-400",
"bg-orange-500",
];
return (
<button
key={i}
type="button"
onClick={() => {
if (onClick) onClick(key, i);
}}
className={`size-7 rounded-lg transition-opacity duration-100 border-black/40
${colors[i]} ${data[key] === i ? "border-2 opacity-100" : "opacity-70"} ${onClick ? "cursor-pointer" : ""}`}
></button>
);
})}
</div>
<span className="text-sm text-zinc-500 w-12 shrink-0">{right}</span>
</div>
);
})}
</div>
);
}

View file

@ -27,7 +27,7 @@ export default function ShareMiiButton({ miiId }: Props) {
};
const handleCopyImage = async () => {
const response = await fetch(`${import.meta.env.PUBLIC_API_URL}/mii/${miiId}/image?type=metadata`);
const response = await fetch(`${import.meta.env.VITE_API_URL}/mii/${miiId}/image?type=metadata`);
const blob = await response.blob();
await navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })]);
@ -117,7 +117,7 @@ export default function ShareMiiButton({ miiId }: Props) {
<div className="flex justify-center items-center p-4 w-full bg-orange-100 border border-orange-400 rounded-lg">
<img
src={`${import.meta.env.PUBLIC_API_URL}/mii/${miiId}/image?type=metadata`}
src={`${import.meta.env.VITE_API_URL}/mii/${miiId}/image?type=metadata`}
alt="mii 'metadata' image"
width={248}
height={248}
@ -129,7 +129,7 @@ export default function ShareMiiButton({ miiId }: Props) {
<div className="flex gap-2 w-full">
{/* Save button */}
<a
href={`${import.meta.env.PUBLIC_API_URL}/mii/${miiId}/image?type=metadata`}
href={`${import.meta.env.VITE_API_URL}/mii/${miiId}/image?type=metadata`}
className="pill button p-0! aspect-square size-11 cursor-pointer text-xl"
aria-label="Save Image"
data-tooltip="Save Image"

View file

@ -1,41 +1,41 @@
import type { SwitchMiiInstructions } from "@tomodachi-share/shared";
import EnhancedSlider from "../submit-form/mii-editor/enhanced-slider";
interface Props {
data: SwitchMiiInstructions["voice"];
onChange: (value: number, label: string) => void;
onClickTone: (i: number) => void;
}
const VOICE_SETTINGS = ["Speed", "Pitch", "Depth", "Delivery"];
export default function VoiceViewer({ data, onChange, onClickTone }: Props) {
return (
<div className="flex flex-col">
{VOICE_SETTINGS.map((label) => {
const value = data[label.toLowerCase() as keyof typeof data] ?? 25;
return <EnhancedSlider key={label} label={label} value={value} onChange={(v) => onChange?.(v, label.toLowerCase())} min={0} max={50} mid={25} />;
})}
<div className="flex gap-3 mt-2">
<label htmlFor="delivery" className="text-sm w-14">
Tone
</label>
<div className="grid grid-cols-6 gap-1 min-w-50">
{Array.from({ length: 6 }).map((_, i) => (
<button
type="button"
key={i}
onClick={() => {
if (onClickTone) onClickTone(i + 1);
}}
className={`transition-colors duration-100 rounded-xl hover:bg-orange-300 cursor-pointer ${data.tone === i + 1 ? "bg-orange-400!" : ""}`}
>
{i + 1}
</button>
))}
</div>
</div>
</div>
);
}
import type { SwitchMiiInstructions } from "@tomodachi-share/shared";
import EnhancedSlider from "../submit-form/mii-editor/enhanced-slider";
interface Props {
data: SwitchMiiInstructions["voice"];
onChange: (value: number, label: string) => void;
onClickTone: (i: number) => void;
}
const VOICE_SETTINGS = ["Speed", "Pitch", "Depth", "Delivery"];
export default function VoiceViewer({ data, onChange, onClickTone }: Props) {
return (
<div className="flex flex-col">
{VOICE_SETTINGS.map((label) => {
const value = data[label.toLowerCase() as keyof typeof data] ?? 25;
return <EnhancedSlider key={label} label={label} value={value} onChange={(v) => onChange?.(v, label.toLowerCase())} min={0} max={50} mid={25} />;
})}
<div className="flex gap-3 mt-2">
<label htmlFor="delivery" className="text-sm w-14">
Tone
</label>
<div className="grid grid-cols-6 gap-1 min-w-50">
{Array.from({ length: 6 }).map((_, i) => (
<button
type="button"
key={i}
onClick={() => {
if (onClickTone) onClickTone(i + 1);
}}
className={`transition-colors duration-100 rounded-xl hover:bg-orange-300 cursor-pointer ${data.tone === i + 1 ? "bg-orange-400!" : ""}`}
>
{i + 1}
</button>
))}
</div>
</div>
</div>
);
}

View file

@ -1,95 +1,95 @@
import { useCallback, useMemo } from "react";
import { Icon } from "@iconify/react";
interface Props {
lastPage: number;
}
export default function Pagination({ lastPage }: Props) {
const searchParams = new URLSearchParams(location.search);
const page = Number(searchParams.get("page") ?? 1);
const createPageUrl = useCallback(
(pageNumber: number) => {
const params = new URLSearchParams(searchParams);
params.set("page", pageNumber.toString());
return `${location.pathname}?${params.toString()}`;
},
[searchParams, location.pathname],
);
const numbers = useMemo(() => {
const result = [];
// Always show 5 pages, centering around the current page when possible
const start = Math.max(1, Math.min(page - 2, lastPage - 4));
const end = Math.min(lastPage, start + 4);
for (let i = start; i <= end; i++) result.push(i);
return result;
}, [page, lastPage]);
return (
<div className="flex justify-center items-center w-full mt-8">
{/* Go to first page */}
<a
href={page === 1 ? "#" : createPageUrl(1)}
aria-label="Go to First Page"
aria-disabled={page === 1}
tabIndex={page === 1 ? -1 : undefined}
className={`pill button bg-orange-100! p-0.5! aspect-square text-2xl ${page === 1 ? "pointer-events-none opacity-50" : "hover:bg-orange-400!"}`}
>
<Icon icon="stash:chevron-double-left" />
</a>
{/* Previous page */}
<a
href={page === 1 ? "#" : createPageUrl(page - 1)}
aria-label="Go to Previous Page"
aria-disabled={page === 1}
tabIndex={page === 1 ? -1 : undefined}
className={`pill bg-orange-100! p-0.5! aspect-square text-2xl ${page === 1 ? "pointer-events-none opacity-50" : "hover:bg-orange-400!"}`}
>
<Icon icon="stash:chevron-left" />
</a>
{/* Page numbers */}
<div className="flex mx-2">
{numbers.map((number) => (
<a
key={number}
href={createPageUrl(number)}
aria-label={`Go to Page ${number}`}
aria-current={number === page ? "page" : undefined}
className={`pill p-0! w-8 h-8 text-center rounded-md! ${number == page ? "bg-orange-400!" : "bg-orange-100! hover:bg-orange-400!"}`}
>
{number}
</a>
))}
</div>
{/* Next page */}
<a
href={page >= lastPage ? "#" : createPageUrl(page + 1)}
aria-label="Go to Next Page"
aria-disabled={page >= lastPage}
tabIndex={page >= lastPage ? -1 : undefined}
className={`pill button bg-orange-100! p-0.5! aspect-square text-2xl ${page >= lastPage ? "pointer-events-none opacity-50" : "hover:bg-orange-400!"}`}
>
<Icon icon="stash:chevron-right" />
</a>
{/* Go to last page */}
<a
href={page >= lastPage ? "#" : createPageUrl(lastPage)}
aria-label="Go to Last Page"
aria-disabled={page >= lastPage}
tabIndex={page >= lastPage ? -1 : undefined}
className={`pill button bg-orange-100! p-0.5! aspect-square text-2xl ${page >= lastPage ? "pointer-events-none opacity-50" : "hover:bg-orange-400!"}`}
>
<Icon icon="stash:chevron-double-right" />
</a>
</div>
);
}
import { useCallback, useMemo } from "react";
import { Icon } from "@iconify/react";
interface Props {
lastPage: number;
}
export default function Pagination({ lastPage }: Props) {
const searchParams = new URLSearchParams(location.search);
const page = Number(searchParams.get("page") ?? 1);
const createPageUrl = useCallback(
(pageNumber: number) => {
const params = new URLSearchParams(searchParams);
params.set("page", pageNumber.toString());
return `${location.pathname}?${params.toString()}`;
},
[searchParams, location.pathname],
);
const numbers = useMemo(() => {
const result = [];
// Always show 5 pages, centering around the current page when possible
const start = Math.max(1, Math.min(page - 2, lastPage - 4));
const end = Math.min(lastPage, start + 4);
for (let i = start; i <= end; i++) result.push(i);
return result;
}, [page, lastPage]);
return (
<div className="flex justify-center items-center w-full mt-8">
{/* Go to first page */}
<a
href={page === 1 ? "#" : createPageUrl(1)}
aria-label="Go to First Page"
aria-disabled={page === 1}
tabIndex={page === 1 ? -1 : undefined}
className={`pill button bg-orange-100! p-0.5! aspect-square text-2xl ${page === 1 ? "pointer-events-none opacity-50" : "hover:bg-orange-400!"}`}
>
<Icon icon="stash:chevron-double-left" />
</a>
{/* Previous page */}
<a
href={page === 1 ? "#" : createPageUrl(page - 1)}
aria-label="Go to Previous Page"
aria-disabled={page === 1}
tabIndex={page === 1 ? -1 : undefined}
className={`pill bg-orange-100! p-0.5! aspect-square text-2xl ${page === 1 ? "pointer-events-none opacity-50" : "hover:bg-orange-400!"}`}
>
<Icon icon="stash:chevron-left" />
</a>
{/* Page numbers */}
<div className="flex mx-2">
{numbers.map((number) => (
<a
key={number}
href={createPageUrl(number)}
aria-label={`Go to Page ${number}`}
aria-current={number === page ? "page" : undefined}
className={`pill p-0! w-8 h-8 text-center rounded-md! ${number == page ? "bg-orange-400!" : "bg-orange-100! hover:bg-orange-400!"}`}
>
{number}
</a>
))}
</div>
{/* Next page */}
<a
href={page >= lastPage ? "#" : createPageUrl(page + 1)}
aria-label="Go to Next Page"
aria-disabled={page >= lastPage}
tabIndex={page >= lastPage ? -1 : undefined}
className={`pill button bg-orange-100! p-0.5! aspect-square text-2xl ${page >= lastPage ? "pointer-events-none opacity-50" : "hover:bg-orange-400!"}`}
>
<Icon icon="stash:chevron-right" />
</a>
{/* Go to last page */}
<a
href={page >= lastPage ? "#" : createPageUrl(lastPage)}
aria-label="Go to Last Page"
aria-disabled={page >= lastPage}
tabIndex={page >= lastPage ? -1 : undefined}
className={`pill button bg-orange-100! p-0.5! aspect-square text-2xl ${page >= lastPage ? "pointer-events-none opacity-50" : "hover:bg-orange-400!"}`}
>
<Icon icon="stash:chevron-double-right" />
</a>
</div>
);
}

View file

@ -12,16 +12,17 @@ interface Props {
export default function ProfileInformation({ user, page }: Props) {
const $session = useStore(session);
const isAdmin = (!user ? $session?.user.id : user.id) === Number(import.meta.env.PUBLIC_ADMIN_USER_ID);
const currentUser = user ?? $session?.user;
const isAdmin = currentUser?.id === Number(import.meta.env.PUBLIC_ADMIN_USER_ID);
const isContributor = import.meta.env.PUBLIC_CONTRIBUTORS_USER_IDS?.split(",").includes(user.id);
const isOwnProfile = !user || $session?.user?.id === user.id;
const isOwnProfile = currentUser?.id === user.id;
return (
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex gap-4 mb-2 max-md:flex-col">
<div className="flex w-full gap-4 overflow-x-scroll">
{/* Profile picture */}
<a href={`/profile/${user.id}`} className="size-28 aspect-square">
<image src={user.image ?? "/guest.png"} className="rounded-full bg-white border-2 border-orange-400 shadow max-md:self-center" />
<img src={user.image ?? "/guest.png"} className="rounded-full bg-white border-2 border-orange-400 shadow max-md:self-center" />
</a>
{/* User information */}
<div className="flex flex-col w-full relative py-3">
@ -57,7 +58,7 @@ export default function ProfileInformation({ user, page }: Props) {
{/* Buttons */}
<div className="flex gap-1 w-fit text-3xl text-orange-400 max-md:place-self-center *:size-17 *:flex *:flex-col *:items-center *:gap-1 **:transition-discrete **:duration-150 *:hover:brightness-75 *:hover:scale-[1.08] *:[&_span]:text-sm">
{!isOwnProfile && (
<a aria-label="Report User" href={`${import.meta.env.PUBLIC_API_URL}/report/user/${user.id}`}>
<a aria-label="Report User" href={`${import.meta.env.VITE_API_URL}/report/user/${user.id}`}>
<Icon icon="material-symbols:flag-rounded" />
<span>Report</span>
</a>

View file

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

View file

@ -25,7 +25,7 @@ export default function ProfileSettings({ currentDescription }: Props) {
return;
}
const response = await fetch(`${import.meta.env.PUBLIC_API_URL}/api/auth/about-me`, {
const response = await fetch(`${import.meta.env.VITE_API_URL}/api/auth/about-me`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ description }),
@ -49,7 +49,7 @@ export default function ProfileSettings({ currentDescription }: Props) {
return;
}
const response = await fetch(`${import.meta.env.PUBLIC_API_URL}/api/auth/name`, {
const response = await fetch(`${import.meta.env.VITE_API_URL}/api/auth/name`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),

View file

@ -17,7 +17,7 @@ export default function ProfilePictureSettings() {
const formData = new FormData();
if (newPicture) formData.append("image", newPicture);
const response = await fetch(`${import.meta.env.PUBLIC_API_URL}/api/auth/picture`, {
const response = await fetch(`${import.meta.env.VITE_API_URL}/api/auth/picture`, {
method: "PATCH",
body: formData,
credentials: "include",

View file

@ -1,81 +1,81 @@
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { Icon } from "@iconify/react";
import SubmitButton from "../submit-button";
interface Props {
title: string;
description: string;
onSubmit: (close: () => void) => void;
error?: string;
children?: React.ReactNode;
}
export default function SubmitDialogButton({ title, description, onSubmit, error, children }: Props) {
const [isOpen, setIsOpen] = useState(false);
const [isVisible, setIsVisible] = useState(false);
const submit = () => {
onSubmit(close);
};
const close = () => {
setIsVisible(false);
setTimeout(() => {
setIsOpen(false);
}, 300);
};
useEffect(() => {
if (isOpen) {
// slight delay to trigger animation
setTimeout(() => setIsVisible(true), 10);
}
}, [isOpen]);
return (
<>
<button onClick={() => setIsOpen(true)} aria-label="Open Submit Dialog" className="pill button size-11 p-1! text-2xl">
<Icon icon="material-symbols:check-rounded" />
</button>
{isOpen &&
createPortal(
<div className="fixed inset-0 w-full h-[calc(100%-var(--header-height))] top-(--header-height) flex items-center justify-center z-40">
<div
onClick={close}
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${
isVisible ? "opacity-100" : "opacity-0"
}`}
/>
<div
className={`z-50 bg-orange-50 border-2 border-amber-500 rounded-2xl shadow-lg p-6 w-full max-w-md transition-discrete duration-300 flex flex-col ${
isVisible ? "scale-100 opacity-100" : "scale-75 opacity-0"
}`}
>
<div className="flex justify-between items-center mb-2">
<h2 className="text-xl font-bold">{title}</h2>
<button onClick={close} aria-label="Close" className="text-red-400 hover:text-red-500 text-2xl cursor-pointer">
<Icon icon="material-symbols:close-rounded" />
</button>
</div>
<p className="text-sm text-zinc-500">{description}</p>
{children}
{error && <span className="text-red-400 font-bold mt-2">Error: {error}</span>}
<div className="flex justify-end gap-2 mt-4">
<button onClick={close} className="pill button">
Cancel
</button>
<SubmitButton onClick={submit} />
</div>
</div>
</div>,
document.body,
)}
</>
);
}
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { Icon } from "@iconify/react";
import SubmitButton from "../submit-button";
interface Props {
title: string;
description: string;
onSubmit: (close: () => void) => void;
error?: string;
children?: React.ReactNode;
}
export default function SubmitDialogButton({ title, description, onSubmit, error, children }: Props) {
const [isOpen, setIsOpen] = useState(false);
const [isVisible, setIsVisible] = useState(false);
const submit = () => {
onSubmit(close);
};
const close = () => {
setIsVisible(false);
setTimeout(() => {
setIsOpen(false);
}, 300);
};
useEffect(() => {
if (isOpen) {
// slight delay to trigger animation
setTimeout(() => setIsVisible(true), 10);
}
}, [isOpen]);
return (
<>
<button onClick={() => setIsOpen(true)} aria-label="Open Submit Dialog" className="pill button size-11 p-1! text-2xl">
<Icon icon="material-symbols:check-rounded" />
</button>
{isOpen &&
createPortal(
<div className="fixed inset-0 w-full h-[calc(100%-var(--header-height))] top-(--header-height) flex items-center justify-center z-40">
<div
onClick={close}
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${
isVisible ? "opacity-100" : "opacity-0"
}`}
/>
<div
className={`z-50 bg-orange-50 border-2 border-amber-500 rounded-2xl shadow-lg p-6 w-full max-w-md transition-discrete duration-300 flex flex-col ${
isVisible ? "scale-100 opacity-100" : "scale-75 opacity-0"
}`}
>
<div className="flex justify-between items-center mb-2">
<h2 className="text-xl font-bold">{title}</h2>
<button onClick={close} aria-label="Close" className="text-red-400 hover:text-red-500 text-2xl cursor-pointer">
<Icon icon="material-symbols:close-rounded" />
</button>
</div>
<p className="text-sm text-zinc-500">{description}</p>
{children}
{error && <span className="text-red-400 font-bold mt-2">Error: {error}</span>}
<div className="flex justify-end gap-2 mt-4">
<button onClick={close} className="pill button">
Cancel
</button>
<SubmitButton onClick={submit} />
</div>
</div>
</div>,
document.body,
)}
</>
);
}

View file

@ -1,31 +1,31 @@
import { useEffect } from "react";
import { ProgressProvider } from "@bprogress/react";
export default function Providers({ children }: { children: React.ReactNode }) {
// Calculate header height
useEffect(() => {
const header = document.querySelector("header");
if (!header) return;
const updateHeaderHeight = () => {
document.documentElement.style.setProperty("--header-height", `${header.offsetHeight}px`);
};
const resizeObserver = new ResizeObserver(updateHeaderHeight);
resizeObserver.observe(header);
window.addEventListener("resize", updateHeaderHeight);
updateHeaderHeight();
return () => {
resizeObserver.disconnect();
window.removeEventListener("resize", updateHeaderHeight);
};
}, []);
return (
<ProgressProvider height="4px" color="var(--color-amber-500)" options={{ showSpinner: false }} shallowRouting>
{children}
</ProgressProvider>
);
}
import { useEffect } from "react";
import { ProgressProvider } from "@bprogress/react";
export default function Providers({ children }: { children: React.ReactNode }) {
// Calculate header height
useEffect(() => {
const header = document.querySelector("header");
if (!header) return;
const updateHeaderHeight = () => {
document.documentElement.style.setProperty("--header-height", `${header.offsetHeight}px`);
};
const resizeObserver = new ResizeObserver(updateHeaderHeight);
resizeObserver.observe(header);
window.addEventListener("resize", updateHeaderHeight);
updateHeaderHeight();
return () => {
resizeObserver.disconnect();
window.removeEventListener("resize", updateHeaderHeight);
};
}, []);
return (
<ProgressProvider height="4px" color="var(--color-amber-500)" options={{ showSpinner: false }} shallowRouting>
{children}
</ProgressProvider>
);
}

View file

@ -1,50 +1,50 @@
import { useState } from "react";
import { Icon } from "@iconify/react";
import { querySchema } from "@tomodachi-share/shared/schemas";
export default function SearchBar() {
const searchParams = new URLSearchParams(window.location.search);
const [query, setQuery] = useState(searchParams.get("q") || "");
const handleSearch = () => {
const result = querySchema.safeParse(query);
if (!result.success) {
// router.push("/", { scroll: false });
window.location.href = "/";
return;
}
// Clone current search params and add query param
const params = new URLSearchParams(searchParams.toString());
params.set("q", query);
params.set("page", "1");
// router.push(`/?${params.toString()}`, { scroll: false });
window.location.href = `/?${params.toString()}`;
};
const handleKeyDown = (event: React.KeyboardEvent) => {
if (event.key === "Enter") handleSearch();
};
return (
<div className="max-w-md w-full flex rounded-xl focus-within:ring-[3px] ring-orange-400/50 transition shadow-md">
<input
type="text"
placeholder="Search..."
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
className="bg-orange-200 border-2 border-orange-400 py-2 px-3 rounded-l-xl outline-0 w-full placeholder:text-black/40"
/>
<button
onClick={handleSearch}
aria-label="Search"
data-tooltip="Search"
className="bg-orange-400 p-2 w-12 rounded-r-xl flex justify-center items-center cursor-pointer text-2xl"
>
<Icon icon="ic:baseline-search" />
</button>
</div>
);
}
import { useState } from "react";
import { Icon } from "@iconify/react";
import { querySchema } from "@tomodachi-share/shared/schemas";
export default function SearchBar() {
const searchParams = new URLSearchParams(window.location.search);
const [query, setQuery] = useState(searchParams.get("q") || "");
const handleSearch = () => {
const result = querySchema.safeParse(query);
if (!result.success) {
// router.push("/", { scroll: false });
window.location.href = "/";
return;
}
// Clone current search params and add query param
const params = new URLSearchParams(searchParams.toString());
params.set("q", query);
params.set("page", "1");
// router.push(`/?${params.toString()}`, { scroll: false });
window.location.href = `/?${params.toString()}`;
};
const handleKeyDown = (event: React.KeyboardEvent) => {
if (event.key === "Enter") handleSearch();
};
return (
<div className="max-w-md w-full flex rounded-xl focus-within:ring-[3px] ring-orange-400/50 transition shadow-md">
<input
type="text"
placeholder="Search..."
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
className="bg-orange-200 border-2 border-orange-400 py-2 px-3 rounded-l-xl outline-0 w-full placeholder:text-black/40"
/>
<button
onClick={handleSearch}
aria-label="Search"
data-tooltip="Search"
className="bg-orange-400 p-2 w-12 rounded-r-xl flex justify-center items-center cursor-pointer text-2xl"
>
<Icon icon="ic:baseline-search" />
</button>
</div>
);
}

View file

@ -1,31 +1,31 @@
import { useState } from "react";
import { Icon } from "@iconify/react";
interface Props {
onClick: () => void | Promise<void>;
disabled?: boolean;
text?: string;
className?: string;
}
export default function SubmitButton({ onClick, disabled = false, text = "Submit", className }: Props) {
const [isLoading, setIsLoading] = useState(false);
const handleClick = async (event: React.FormEvent) => {
event.preventDefault();
setIsLoading(true);
try {
await onClick();
} finally {
setIsLoading(false);
}
};
return (
<button type="submit" aria-label={text} onClick={handleClick} disabled={disabled} className={`pill button w-min ${className}`}>
{text}
{isLoading && <Icon icon="svg-spinners:180-ring-with-bg" className="ml-2" />}
</button>
);
}
import { useState } from "react";
import { Icon } from "@iconify/react";
interface Props {
onClick: () => void | Promise<void>;
disabled?: boolean;
text?: string;
className?: string;
}
export default function SubmitButton({ onClick, disabled = false, text = "Submit", className }: Props) {
const [isLoading, setIsLoading] = useState(false);
const handleClick = async (event: React.FormEvent) => {
event.preventDefault();
setIsLoading(true);
try {
await onClick();
} finally {
setIsLoading(false);
}
};
return (
<button type="submit" aria-label={text} onClick={handleClick} disabled={disabled} className={`pill button w-min ${className}`}>
{text}
{isLoading && <Icon icon="svg-spinners:180-ring-with-bg" className="ml-2" />}
</button>
);
}

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>
);
}

View file

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

View file

@ -1,54 +1,54 @@
import { useState } from "react";
import { createPortal } from "react-dom";
import { Icon } from "@iconify/react";
import Tutorial from ".";
export default function ThreeDsScanTutorialButton() {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<button aria-label="Tutorial" type="button" onClick={() => setIsOpen(true)} className="text-3xl cursor-pointer">
<Icon icon="fa:question-circle" />
<span>Tutorial</span>
</button>
{isOpen &&
createPortal(
<Tutorial
tutorials={[
{
title: "Adding Mii",
steps: [
{
text: "1. Enter the town hall",
imageSrc: "/tutorial/3ds/step1.png",
},
{
text: "2. Go into 'QR Code'",
imageSrc: "/tutorial/3ds/adding-mii/step2.png",
},
{
text: "3. Press 'Scan QR Code'",
imageSrc: "/tutorial/3ds/adding-mii/step3.png",
},
{
text: "4. Click on the QR code below the Mii's image",
imageSrc: "/tutorial/3ds/adding-mii/step4.png",
},
{
text: "5. Scan with your 3DS",
imageSrc: "/tutorial/3ds/adding-mii/step5.png",
},
{ type: "finish" },
],
},
]}
isOpen={isOpen}
setIsOpen={setIsOpen}
/>,
document.body,
)}
</>
);
}
import { useState } from "react";
import { createPortal } from "react-dom";
import { Icon } from "@iconify/react";
import Tutorial from ".";
export default function ThreeDsScanTutorialButton() {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<button aria-label="Tutorial" type="button" onClick={() => setIsOpen(true)} className="text-3xl cursor-pointer">
<Icon icon="fa:question-circle" />
<span>Tutorial</span>
</button>
{isOpen &&
createPortal(
<Tutorial
tutorials={[
{
title: "Adding Mii",
steps: [
{
text: "1. Enter the town hall",
imageSrc: "/tutorial/3ds/step1.png",
},
{
text: "2. Go into 'QR Code'",
imageSrc: "/tutorial/3ds/adding-mii/step2.png",
},
{
text: "3. Press 'Scan QR Code'",
imageSrc: "/tutorial/3ds/adding-mii/step3.png",
},
{
text: "4. Click on the QR code below the Mii's image",
imageSrc: "/tutorial/3ds/adding-mii/step4.png",
},
{
text: "5. Scan with your 3DS",
imageSrc: "/tutorial/3ds/adding-mii/step5.png",
},
{ type: "finish" },
],
},
]}
isOpen={isOpen}
setIsOpen={setIsOpen}
/>,
document.body,
)}
</>
);
}

View file

@ -1,95 +1,95 @@
import { useState } from "react";
import { createPortal } from "react-dom";
import Tutorial from ".";
export default function SubmitTutorialButton() {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<button type="button" onClick={() => setIsOpen(true)} className="text-sm text-orange-400 cursor-pointer underline-offset-2 hover:underline">
How to?
</button>
{isOpen &&
createPortal(
<Tutorial
tutorials={[
{
title: "Allow Copying",
thumbnail: "/tutorial/3ds/allow-copying/thumbnail.png",
hint: "Suggested!",
steps: [
{ type: "start" },
{
text: "1. Enter the town hall",
imageSrc: "/tutorial/3ds/step1.png",
},
{
text: "2. Go into 'Mii List'",
imageSrc: "/tutorial/3ds/allow-copying/step2.png",
},
{
text: "3. Select and edit the Mii you wish to submit",
imageSrc: "/tutorial/3ds/allow-copying/step3.png",
},
{
text: "4. Click 'Other Settings' in the information screen",
imageSrc: "/tutorial/3ds/allow-copying/step4.png",
},
{
text: "5. Click on 'Don't Allow' under the 'Copying' text",
imageSrc: "/tutorial/3ds/allow-copying/step5.png",
},
{
text: "6. Press 'Allow'",
imageSrc: "/tutorial/3ds/allow-copying/step6.png",
},
{
text: "7. Confirm the edits to the Mii",
imageSrc: "/tutorial/3ds/allow-copying/step7.png",
},
{ type: "finish" },
],
},
{
title: "Create QR Code",
thumbnail: "/tutorial/3ds/create-qr-code/thumbnail.png",
steps: [
{ type: "start" },
{
text: "1. Enter the town hall",
imageSrc: "/tutorial/3ds/step1.png",
},
{
text: "2. Go into 'QR Code'",
imageSrc: "/tutorial/3ds/create-qr-code/step2.png",
},
{
text: "3. Press 'Create QR Code'",
imageSrc: "/tutorial/3ds/create-qr-code/step3.png",
},
{
text: "4. Select and press 'OK' on the Mii you wish to submit",
imageSrc: "/tutorial/3ds/create-qr-code/step4.png",
},
{
text: "5. Pick any option; it doesn't matter since the QR code regenerates upon submission.",
imageSrc: "/tutorial/3ds/create-qr-code/step5.png",
},
{
text: "6. Exit the tutorial; Upload the QR code (scan with camera or upload file through SD card).",
imageSrc: "/tutorial/3ds/create-qr-code/step6.png",
},
{ type: "finish" },
],
},
]}
isOpen={isOpen}
setIsOpen={setIsOpen}
/>,
document.body,
)}
</>
);
}
import { useState } from "react";
import { createPortal } from "react-dom";
import Tutorial from ".";
export default function SubmitTutorialButton() {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<button type="button" onClick={() => setIsOpen(true)} className="text-sm text-orange-400 cursor-pointer underline-offset-2 hover:underline">
How to?
</button>
{isOpen &&
createPortal(
<Tutorial
tutorials={[
{
title: "Allow Copying",
thumbnail: "/tutorial/3ds/allow-copying/thumbnail.png",
hint: "Suggested!",
steps: [
{ type: "start" },
{
text: "1. Enter the town hall",
imageSrc: "/tutorial/3ds/step1.png",
},
{
text: "2. Go into 'Mii List'",
imageSrc: "/tutorial/3ds/allow-copying/step2.png",
},
{
text: "3. Select and edit the Mii you wish to submit",
imageSrc: "/tutorial/3ds/allow-copying/step3.png",
},
{
text: "4. Click 'Other Settings' in the information screen",
imageSrc: "/tutorial/3ds/allow-copying/step4.png",
},
{
text: "5. Click on 'Don't Allow' under the 'Copying' text",
imageSrc: "/tutorial/3ds/allow-copying/step5.png",
},
{
text: "6. Press 'Allow'",
imageSrc: "/tutorial/3ds/allow-copying/step6.png",
},
{
text: "7. Confirm the edits to the Mii",
imageSrc: "/tutorial/3ds/allow-copying/step7.png",
},
{ type: "finish" },
],
},
{
title: "Create QR Code",
thumbnail: "/tutorial/3ds/create-qr-code/thumbnail.png",
steps: [
{ type: "start" },
{
text: "1. Enter the town hall",
imageSrc: "/tutorial/3ds/step1.png",
},
{
text: "2. Go into 'QR Code'",
imageSrc: "/tutorial/3ds/create-qr-code/step2.png",
},
{
text: "3. Press 'Create QR Code'",
imageSrc: "/tutorial/3ds/create-qr-code/step3.png",
},
{
text: "4. Select and press 'OK' on the Mii you wish to submit",
imageSrc: "/tutorial/3ds/create-qr-code/step4.png",
},
{
text: "5. Pick any option; it doesn't matter since the QR code regenerates upon submission.",
imageSrc: "/tutorial/3ds/create-qr-code/step5.png",
},
{
text: "6. Exit the tutorial; Upload the QR code (scan with camera or upload file through SD card).",
imageSrc: "/tutorial/3ds/create-qr-code/step6.png",
},
{ type: "finish" },
],
},
]}
isOpen={isOpen}
setIsOpen={setIsOpen}
/>,
document.body,
)}
</>
);
}

View file

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

View file

@ -1,54 +1,54 @@
import { useState } from "react";
import { createPortal } from "react-dom";
import { Icon } from "@iconify/react";
import Tutorial from ".";
export default function SwitchAddMiiTutorialButton() {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<button aria-label="Tutorial" type="button" onClick={() => setIsOpen(true)} className="text-3xl cursor-pointer">
<Icon icon="fa:question-circle" />
<span>Tutorial</span>
</button>
{isOpen &&
createPortal(
<Tutorial
tutorials={[
{
title: "Adding Mii",
steps: [
{
text: "1. Press X to open the menu, then select 'Add a Mii'",
imageSrc: "/tutorial/switch/adding-mii/step1.jpg",
},
{
text: "2. Press 'From scratch' and choose the Male template",
imageSrc: "/tutorial/switch/adding-mii/step2.jpg",
},
{
text: "3. Click on the features image on this page to zoom it in and add all features on the mii editor",
imageSrc: "/tutorial/switch/adding-mii/step3.png",
},
{
text: "4. If the author added instructions, follow them (not all instructions will be there, check next slide for more)",
imageSrc: "/tutorial/switch/adding-mii/step4.jpg",
},
{
text: "5. For instructions like height or distance, use the number of button clicks (positive for buttons on right, negative for buttons on left)",
imageSrc: "/tutorial/switch/step4.jpg",
},
{ type: "finish" },
],
},
]}
isOpen={isOpen}
setIsOpen={setIsOpen}
/>,
document.body,
)}
</>
);
}
import { useState } from "react";
import { createPortal } from "react-dom";
import { Icon } from "@iconify/react";
import Tutorial from ".";
export default function SwitchAddMiiTutorialButton() {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<button aria-label="Tutorial" type="button" onClick={() => setIsOpen(true)} className="text-3xl cursor-pointer">
<Icon icon="fa:question-circle" />
<span>Tutorial</span>
</button>
{isOpen &&
createPortal(
<Tutorial
tutorials={[
{
title: "Adding Mii",
steps: [
{
text: "1. Press X to open the menu, then select 'Add a Mii'",
imageSrc: "/tutorial/switch/adding-mii/step1.jpg",
},
{
text: "2. Press 'From scratch' and choose the Male template",
imageSrc: "/tutorial/switch/adding-mii/step2.jpg",
},
{
text: "3. Click on the features image on this page to zoom it in and add all features on the mii editor",
imageSrc: "/tutorial/switch/adding-mii/step3.png",
},
{
text: "4. If the author added instructions, follow them (not all instructions will be there, check next slide for more)",
imageSrc: "/tutorial/switch/adding-mii/step4.jpg",
},
{
text: "5. For instructions like height or distance, use the number of button clicks (positive for buttons on right, negative for buttons on left)",
imageSrc: "/tutorial/switch/step4.jpg",
},
{ type: "finish" },
],
},
]}
isOpen={isOpen}
setIsOpen={setIsOpen}
/>,
document.body,
)}
</>
);
}

View file

@ -1,48 +1,48 @@
import { useState } from "react";
import { createPortal } from "react-dom";
import Tutorial from ".";
export default function SwitchSubmitTutorialButton() {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<button type="button" onClick={() => setIsOpen(true)} className="text-sm text-orange-400 cursor-pointer underline-offset-2 hover:underline">
How to?
</button>
{isOpen &&
createPortal(
<Tutorial
tutorials={[
{
title: "Submitting",
steps: [
{
text: "1. Press X to open the menu, then select 'Residents'",
imageSrc: "/tutorial/switch/submitting/step1.jpg",
},
{
text: "2. Find the Mii you want to submit and edit it",
imageSrc: "/tutorial/switch/submitting/step2.jpg",
},
{
text: "3. Press Y to open the features list, then take a screenshot and upload to this submit form",
imageSrc: "/tutorial/switch/submitting/step3.jpg",
},
{
text: "4. Adding Mii colors and settings is recommended. All instructions are optional; for values like height or distance, use the number of button clicks (positive for buttons on right, negative for buttons on left)",
imageSrc: "/tutorial/switch/step4.jpg",
},
{ type: "finish" },
],
},
]}
isOpen={isOpen}
setIsOpen={setIsOpen}
/>,
document.body,
)}
</>
);
}
import { useState } from "react";
import { createPortal } from "react-dom";
import Tutorial from ".";
export default function SwitchSubmitTutorialButton() {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<button type="button" onClick={() => setIsOpen(true)} className="text-sm text-orange-400 cursor-pointer underline-offset-2 hover:underline">
How to?
</button>
{isOpen &&
createPortal(
<Tutorial
tutorials={[
{
title: "Submitting",
steps: [
{
text: "1. Press X to open the menu, then select 'Residents'",
imageSrc: "/tutorial/switch/submitting/step1.jpg",
},
{
text: "2. Find the Mii you want to submit and edit it",
imageSrc: "/tutorial/switch/submitting/step2.jpg",
},
{
text: "3. Press Y to open the features list, then take a screenshot and upload to this submit form",
imageSrc: "/tutorial/switch/submitting/step3.jpg",
},
{
text: "4. Adding Mii colors and settings is recommended. All instructions are optional; for values like height or distance, use the number of button clicks (positive for buttons on right, negative for buttons on left)",
imageSrc: "/tutorial/switch/step4.jpg",
},
{ type: "finish" },
],
},
]}
isOpen={isOpen}
setIsOpen={setIsOpen}
/>,
document.body,
)}
</>
);
}

View file

@ -19,6 +19,10 @@
}
}
#root {
@apply antialiased flex flex-col items-center w-screen h-screen;
}
.pill {
@apply flex justify-center items-center px-5 py-2 bg-orange-300 border-2 border-orange-400 rounded-3xl shadow-md;
}
@ -134,8 +138,8 @@ input[type="range"]:hover::-moz-range-thumb {
}
body {
@apply bg-amber-50 text-slate-800;
font-family: var(--font-lexend);
@apply bg-amber-50 text-slate-800 min-h-screen;
font-family: "Lexend Variable", sans-serif;
/* syntax highlighting is a bit broken when it's at the top so it's at the bottom */
background-image: url('data:image/svg+xml;utf8,\

View file

@ -1,91 +0,0 @@
---
import "./styles/global.css";
import "react-image-crop/dist/ReactCrop.css";
import Header from "./components/header.astro";
import Footer from "./components/footer.astro";
// import AdminBanner from "./components/admin/banner";
import Providers from "./components/provider";
import { Font } from "astro:assets";
// import SessionWrapper from "./components/SessionWrapper";
const baseUrl = import.meta.env.PUBLIC_BASE_URL;
const jsonLd = {
"@context": "https://schema.org",
"@type": "WebSite",
name: "TomodachiShare",
url: "https://tomodachishare.com",
description: "Discover and share Mii residents for your Tomodachi Life island!",
inLanguage: "en",
publisher: {
"@type": "Organization",
name: "TomodachiShare",
url: "https://tomodachishare.com",
logo: {
"@type": "ImageObject",
url: "https://tomodachishare.com/logo.png",
},
sameAs: ["https://trafficlunar.net", "https://twitter.com/trafficlunr", "https://bsky.app/profile/trafficlunar.net"],
},
potentialAction: {
"@type": "SearchAction",
target: "https://tomodachishare.com/?q={search_term_string}",
"query-input": "required name=search_term_string",
},
};
---
<html lang="en">
<head>
<Font cssVariable="--font-lexend" />
<meta charset="UTF-8" />
<!-- SEO -->
<title>TomodachiShare - home for Tomodachi Life Miis!</title>
<meta name="description" content="Discover and share Mii residents for your Tomodachi Life island!" />
<meta name="keywords" content="mii, tomodachi life, nintendo, tomodachishare, tomodachi-share, mii creator, mii collection" />
<meta name="robots" content="index, follow" />
<!-- OpenGraph -->
<meta property="og:site_name" content="TomodachiShare" />
<meta property="og:title" content="TomodachiShare" />
<meta property="og:description" content="Discover and share Mii residents for your Tomodachi Life island!" />
<meta property="og:image" content="/preview.png" />
<meta property="og:type" content="website" />
<meta property="og:url" content={baseUrl} />
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="TomodachiShare - Discover and Share Your Mii Residents" />
<meta name="twitter:description" content="Discover and share Mii residents for your Tomodachi Life island!" />
<meta name="twitter:image" content="/preview.png" />
<meta name="twitter:creator" content="@trafficlunr" />
<!-- JSON-LD -->
<script is:inline type="application/ld+json" set:html={JSON.stringify(jsonLd).replace(/</g, "\\u003c")} />
<!-- Analytics -->
{
import.meta.env.PROD && (
<script is:inline defer src="https://analytics.trafficlunar.net/script.js" data-website-id="bc530384-9b7d-471a-b2e3-f9859da50c24" />
)
}
</head>
<body class="font-[Lexend] antialiased flex flex-col items-center min-h-screen">
<Providers client:load>
<!-- <SessionWrapper client:load> -->
<Header />
<!-- <AdminBanner client:load /> -->
<main class="px-4 py-8 max-w-7xl w-full grow flex flex-col">
<slot />
</main>
<Footer />
<!-- </SessionWrapper> -->
</Providers>
</body>
</html>

47
frontend/src/main.tsx Normal file
View file

@ -0,0 +1,47 @@
import { StrictMode, Suspense } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter, Route, Routes } from "react-router";
import "./index.css";
import "@fontsource-variable/lexend/wght.css";
import PrivacyPage from "./pages/privacy.tsx";
import TermsOfServicePage from "./pages/terms-of-service.tsx";
import NotFoundPage from "./pages/not-found.tsx";
import LoginPage from "./pages/login.tsx";
import ProfilePage from "./pages/profile.tsx";
import MiiPage from "./pages/mii.tsx";
import SubmitPage from "./pages/submit.tsx";
import IndexPage from "./pages/index.tsx";
import ProfileSettingsPage from "./pages/settings.tsx";
import Providers from "./components/provider.tsx";
import Header from "./components/header.tsx";
import Footer from "./components/footer.tsx";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<Providers>
<Suspense fallback={<div>Loading header...</div>}>
<Header />
</Suspense>
{/* <AdminBanner /> */}
<main className="px-4 py-8 max-w-7xl w-full grow flex flex-col">
<BrowserRouter>
<Routes>
<Route path="/" element={<IndexPage />} />
<Route path="/mii/:id" element={<MiiPage />} />
<Route path="/profile">
<Route path=":id" element={<ProfilePage />} />
<Route path="settings" element={<ProfileSettingsPage />} />
</Route>
<Route path="/submit" element={<SubmitPage />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/privacy" element={<PrivacyPage />} />
<Route path="/terms-of-service" element={<TermsOfServicePage />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</BrowserRouter>
</main>
<Footer />
</Providers>
</StrictMode>,
);

View file

@ -1,17 +0,0 @@
---
import { Icon } from "astro-icon/components";
import Layout from "../layout.astro";
---
<Layout>
<div class="grow flex items-center justify-center">
<div class="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-8 max-w-xs w-full text-center flex flex-col">
<h2 class="text-7xl font-black">404</h2>
<p>Page not found - you swam off the island!</p>
<a href="/" class="pill button gap-2 mt-8 w-fit self-center">
<Icon name="ic:round-home" size={24} />
Travel Back
</a>
</div>
</div>
</Layout>

View file

@ -1,11 +0,0 @@
---
import Layout from "../layout.astro";
import IndexPage from "../components/pages/index";
---
<Layout>
<!-- <Suspense fallback={<Skeleton />}> -->
<!-- <MiiList searchParams={Astro.url.searchParams} /> -->
<!-- </Suspense> -->
<IndexPage client:only />
</Layout>

View file

@ -1,9 +1,9 @@
import { Suspense, useEffect, useState } from "react";
import FilterMenu from "../mii/list/filter-menu";
import SortSelect from "../mii/list/sort-select";
import MiiGrid from "../mii/list/mii-grid";
import Pagination from "../pagination";
import Skeleton from "../mii/list/skeleton";
import FilterMenu from "../components/mii/list/filter-menu";
import SortSelect from "../components/mii/list/sort-select";
import MiiGrid from "../components/mii/list/mii-grid";
import Pagination from "../components/pagination";
import Skeleton from "../components/mii/list/skeleton";
interface ApiResponse {
totalCount: number;
@ -13,12 +13,12 @@ interface ApiResponse {
}
export default function IndexPage() {
const searchParams = new URLSearchParams(window.location.search);
const [data, setData] = useState<ApiResponse>();
const searchParams = new URLSearchParams(location.search);
const [data, setData] = useState<ApiResponse | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`${import.meta.env.PUBLIC_API_URL}/api/mii/list?${searchParams.toString()}`)
fetch(`${import.meta.env.VITE_API_URL}/api/mii/list?${searchParams.toString()}`)
.then((res) => {
if (!res.ok) throw new Error("Failed to fetch Miis");
return res.json();

View file

@ -1,54 +0,0 @@
---
import Layout from "../layout.astro";
import { Icon } from "astro-icon/components";
const API_BASE_URL = import.meta.env.PUBLIC_API_URL;
---
<Layout>
<div class="grow flex items-center justify-center">
<div class="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg px-10 py-12 max-w-md text-center">
<h1 class="text-3xl font-bold mb-4">Welcome to TomodachiShare!</h1>
<div class="flex items-center gap-4 text-zinc-500 text-sm font-medium mb-8">
<hr class="grow border-zinc-300" />
<span>Choose your login method</span>
<hr class="grow border-zinc-300" />
</div>
<div class="flex flex-col items-center gap-2">
<a
href={`${API_BASE_URL}/api/auth/signin/discord`}
aria-label="Login with Discord"
class="pill button gap-2 px-3! bg-indigo-400! border-indigo-500! hover:bg-indigo-500!"
>
<Icon name="ic:baseline-discord" size={32} />
Login with Discord
</a>
<a
href={`${API_BASE_URL}/api/auth/signin/github`}
aria-label="Login with GitHub"
class="pill button gap-2 px-3! bg-zinc-700! border-zinc-800! hover:bg-zinc-800! text-white"
>
<Icon name="mdi:github" size={32} />
Login with GitHub
</a>
<a
href={`${API_BASE_URL}/api/auth/signin/google`}
aria-label="Login with Google"
class="pill button gap-2 px-3! bg-white! border-gray-300! hover:bg-gray-100! text-black! flex items-center"
>
<Icon name="material-icon-theme:google" size={32} />
Login with Google
</a>
</div>
<p class="mt-8 text-xs text-zinc-400">
By signing up, you agree to the{" "}
<a href="/terms-of-service" class="underline hover:text-zinc-600">Terms of Service</a>{" "}
and{" "}
<a href="/privacy" class="underline hover:text-zinc-600">Privacy Policy</a>.
</p>
</div>
</div>
</Layout>

View file

@ -0,0 +1,58 @@
import { Icon } from "@iconify/react";
export default function LoginPage() {
const API_URL = import.meta.env.VITE_API_URL;
return (
<div className="grow flex items-center justify-center">
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg px-10 py-12 max-w-md text-center">
<h1 className="text-3xl font-bold mb-4">Welcome to TomodachiShare!</h1>
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mb-8">
<hr className="grow border-zinc-300" />
<span>Choose your login method</span>
<hr className="grow border-zinc-300" />
</div>
<div className="flex flex-col items-center gap-2">
<a
href={`${API_URL}/api/auth/signin/discord`}
aria-label="Login with Discord"
className="pill button gap-2 px-3! bg-indigo-400! border-indigo-500! hover:bg-indigo-500!"
>
<Icon icon="ic:baseline-discord" fontSize={32} />
Login with Discord
</a>
<a
href={`${API_URL}/api/auth/signin/github`}
aria-label="Login with GitHub"
className="pill button gap-2 px-3! bg-zinc-700! border-zinc-800! hover:bg-zinc-800! text-white"
>
<Icon icon="mdi:github" fontSize={32} />
Login with GitHub
</a>
<a
href={`${API_URL}/api/auth/signin/google`}
aria-label="Login with Google"
className="pill button gap-2 px-3! bg-white! border-gray-300! hover:bg-gray-100! text-black! flex items-center"
>
<Icon icon="material-icon-theme:google" fontSize={32} />
Login with Google
</a>
</div>
<p className="mt-8 text-xs text-zinc-400">
By signing up, you agree to the{" "}
<a href="/terms-of-service" className="underline hover:text-zinc-600">
Terms of Service
</a>{" "}
and{" "}
<a href="/privacy" className="underline hover:text-zinc-600">
Privacy Policy
</a>
.
</p>
</div>
</div>
);
}

View file

@ -1,26 +1,25 @@
import type { SwitchMiiInstructions } from "@tomodachi-share/shared";
import ImageViewer from "../image-viewer";
import LikeButton from "../like-button";
import Description from "../description";
import AuthorButtons from "../mii/author-buttons";
import ShareMiiButton from "../mii/share-mii-button";
import ThreeDsScanTutorialButton from "../tutorial/3ds-scan";
import SwitchAddMiiTutorialButton from "../tutorial/switch-add-mii";
import MiiInstructions from "../mii/instructions";
import ImageViewer from "../components/image-viewer";
import LikeButton from "../components/like-button";
import Description from "../components/description";
import ShareMiiButton from "../components/mii/share-mii-button";
import ThreeDsScanTutorialButton from "../components/tutorial/3ds-scan";
import SwitchAddMiiTutorialButton from "../components/tutorial/switch-add-mii";
import MiiInstructions from "../components/mii/instructions";
import { Icon } from "@iconify/react";
import { useEffect, useState } from "react";
import { Link, useParams } from "react-router";
interface Props {
id: string;
}
export default function MiiPage({ id }: Props) {
export default function MiiPage() {
const { id } = useParams();
const [mii, setMii] = useState<any>(null);
const [loading, setLoading] = useState(true);
const API_URL = import.meta.env.VITE_API_URL;
useEffect(() => {
fetch(`${import.meta.env.PUBLIC_API_URL}/api/mii/${id}/info`)
fetch(`${API_URL}/api/mii/${id}/info`)
.then((res) => {
if (!res.ok) throw new Error("Failed to fetch Miis");
return res.json();
@ -40,8 +39,7 @@ export default function MiiPage({ id }: Props) {
return <div className="p-6 text-center">Loading...</div>;
}
const API_BASE_URL = import.meta.env.PUBLIC_API_URL;
const images = [...Array.from({ length: mii.imageCount }, (_, index) => `${API_BASE_URL}/mii/${mii.id}/image?type=image${index}`)];
const images = [...Array.from({ length: mii.imageCount }, (_, index) => `${API_URL}/mii/${mii.id}/image?type=image${index}`)];
return (
<div className="flex flex-col items-center">
@ -67,7 +65,7 @@ export default function MiiPage({ id }: Props) {
{/* Mii Image */}
<div className="bg-linear-to-b from-amber-100 to-amber-200 overflow-hidden rounded-xl w-full mb-4 flex justify-center">
<ImageViewer
src={`${API_BASE_URL}/mii/${mii.id}/image?type=mii`}
src={`${API_URL}/mii/${mii.id}/image?type=mii`}
alt="mii headshot"
width={250}
height={250}
@ -78,7 +76,7 @@ export default function MiiPage({ id }: Props) {
{mii.platform === "THREE_DS" ? (
<div className="bg-amber-200 overflow-hidden rounded-xl w-full mb-4 flex justify-center p-2">
<ImageViewer
src={`${API_BASE_URL}/mii/${mii.id}/image?type=qr-code`}
src={`${API_URL}/mii/${mii.id}/image?type=qr-code`}
alt="mii qr code"
width={128}
height={128}
@ -87,7 +85,7 @@ export default function MiiPage({ id }: Props) {
</div>
) : (
<ImageViewer
src={`${API_BASE_URL}/mii/${mii.id}/image?type=features`}
src={`${API_URL}/mii/${mii.id}/image?type=features`}
alt="mii features"
width={300}
height={300}
@ -260,15 +258,15 @@ export default function MiiPage({ id }: Props) {
{/* Tags */}
<div id="tags" className="flex flex-wrap gap-1 mt-1 *:px-2 *:py-1 *:bg-orange-300 *:rounded-full *:text-xs">
{mii.tags.map((tag: string) => (
<a href={`/tags=${tag}`}>{tag}</a>
<Link to={`/tags=${tag}`}>{tag}</Link>
))}
</div>
{/* Author and Created date */}
<div className="mt-2">
<a href={`/profile/${mii.userId}`} className="text-lg wrap-break-word">
<Link to={`/profile/${mii.userId}`} className="text-lg wrap-break-word">
By <span className="font-bold">{mii.user.name}</span>
</a>
</Link>
<h4 className="text-sm">
Created:{" "}
{new Date(mii.createdAt).toLocaleString("en-GB", {
@ -293,10 +291,10 @@ export default function MiiPage({ id }: Props) {
{/* <AuthorButtons mii={mii} /> */}
<ShareMiiButton miiId={mii.id} />
<a aria-label="Report Mii" href={`${import.meta.env.PUBLIC_API_URL}/report/mii/${mii.id}`}>
<Link aria-label="Report Mii" to={`${API_URL}/report/mii/${mii.id}`}>
<Icon icon="material-symbols:flag-rounded" />
<span>Report</span>
</a>
</Link>
{mii.platform === "THREE_DS" ? <ThreeDsScanTutorialButton /> : <SwitchAddMiiTutorialButton />}
</div>

View file

@ -1,16 +0,0 @@
---
import MiiPage from "../../components/pages/mii";
import Layout from "../../layout.astro";
const { id } = Astro.params;
export async function getStaticPaths() {
return Array.from({ length: 30000 }, (_, i) => ({
params: { id: String(i + 1) },
}));
}
---
<Layout>
<MiiPage client:load id={id} />
</Layout>

View file

@ -0,0 +1,14 @@
import { Icon } from "@iconify/react";
export default function NotFoundPage() {
return <div className="grow flex items-center justify-center">
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-8 max-w-xs w-full text-center flex flex-col">
<h2 className="text-7xl font-black">404</h2>
<p>Page not found - you swam off the island!</p>
<a href="/" className="pill button gap-2 mt-8 w-fit self-center">
<Icon icon="ic:round-home" fontSize={24} />
Travel Back
</a>
</div>
</div>
}

View file

@ -1,104 +1,100 @@
---
import Layout from "../layout.astro";
---
<Layout>
<div class="bg-amber-50 border-2 border-amber-500 rounded-2xl p-6">
<h1 class="text-2xl font-bold">Privacy Policy</h1>
<h2 class="font-light">
<strong class="font-medium">Effective Date:</strong> 13 April 2026
</h2>
<hr class="border-black/20 mt-1 mb-4" />
<p>By using this website, you confirm that you understand and agree to this Privacy Policy.</p>
<p class="mt-1">
If you have any questions or concerns, please contact me at:{" "}
<a href="mailto:hello@trafficlunar.net" class="text-blue-700"> hello@trafficlunar.net </a>
.
</p>
<ul class="list-decimal ml-5 marker:text-xl marker:font-semibold">
<li>
<h3 class="text-xl font-semibold mt-6 mb-2">Information We Collect</h3>
<section>
<p class="mb-2">The following types of information are stored when you use this website:</p>
<ul class="list-disc list-inside">
<li>
<strong>Account Information:</strong> When you sign up or log in using Discord or Github, your name, e-mail, and profile picture are collected. Your
authentication tokens may also be temporarily stored to maintain your login session.
</li>
<li>
<strong>Miis:</strong> We store any Miis you submit, including associated images (such as a picture of your Mii, QR codes, and custom images).
</li>
<li>
<strong>Interaction Data:</strong> The Miis you like.
</li>
</ul>
</section>
</li>
<li>
<h3 class="text-xl font-semibold mt-6 mb-2">Use of Cookies</h3>
<section>
<p class="mb-2">Cookies are necessary for user sessions and authentication. We do not use cookies for tracking or advertising purposes.</p>
</section>
</li>
<li>
<h3 class="text-xl font-semibold mt-6 mb-2">Analytics</h3>
<section>
<p class="mb-2">
We use{" "}
<a href="https://umami.is/" class="text-blue-700"> Umami </a>{" "}
to collect anonymous data about how users interact with the site. Umami is fully GDPR-compliant, and no personally identifiable information is collected
through this service.
</p>
</section>
</li>
<li>
<h3 class="text-xl font-semibold mt-6 mb-2">Data Sharing</h3>
<section>
<p class="mb-2">
We do not sell your personal data to third parties. Your data may be sent anonymously to self-hosted third-party services or trusted third-party
tools (such as analytics) but these services are used solely to keep the site functional.
</p>
</section>
</li>
<li>
<h3 class="text-xl font-semibold mt-6 mb-2">Your Rights</h3>
<section>
<p class="mb-2">As a user, you have the right to:</p>
<ul class="list-disc list-inside indent-4">
<li>Access the personal data we hold about you.</li>
<li>Request corrections to any inaccurate or incomplete information.</li>
<li>Request the deletion of your personal data.</li>
</ul>
</section>
</li>
<li>
<h3 class="text-xl font-semibold mt-6 mb-2">Data Deletion</h3>
<section>
<p class="mb-2">
Your data, including your Miis, will be retained for as long as you have an account on the site. You may request that your data be deleted at any
time by going to your profile page, clicking the settings icon, and clicking the &apos;Delete Account&apos; button. Upon clicking, your data will be
promptly removed from our servers.
</p>
</section>
</li>
<li>
<h3 class="text-xl font-semibold mt-6 mb-2">Changes to this Privacy Policy</h3>
<section>
<p class="mb-2">
This Privacy Policy may be updated from time to time. We encourage you to review this policy periodically to stay informed about your privacy.
</p>
</section>
</li>
</ul>
</div>
</Layout>
export default function PrivacyPage() {
return <div className="bg-amber-50 border-2 border-amber-500 rounded-2xl p-6">
<h1 className="text-2xl font-bold">Privacy Policy</h1>
<h2 className="font-light">
<strong className="font-medium">Effective Date:</strong> 13 April 2026
</h2>
<hr className="border-black/20 mt-1 mb-4" />
<p>By using this website, you confirm that you understand and agree to this Privacy Policy.</p>
<p className="mt-1">
If you have any questions or concerns, please contact me at:{" "}
<a href="mailto:hello@trafficlunar.net" className="text-blue-700"> hello@trafficlunar.net </a>
.
</p>
<ul className="list-decimal ml-5 marker:text-xl marker:font-semibold">
<li>
<h3 className="text-xl font-semibold mt-6 mb-2">Information We Collect</h3>
<section>
<p className="mb-2">The following types of information are stored when you use this website:</p>
<ul className="list-disc list-inside">
<li>
<strong>Account Information:</strong> When you sign up or log in using Discord or Github, your name, e-mail, and profile picture are collected. Your
authentication tokens may also be temporarily stored to maintain your login session.
</li>
<li>
<strong>Miis:</strong> We store any Miis you submit, including associated images (such as a picture of your Mii, QR codes, and custom images).
</li>
<li>
<strong>Interaction Data:</strong> The Miis you like.
</li>
</ul>
</section>
</li>
<li>
<h3 className="text-xl font-semibold mt-6 mb-2">Use of Cookies</h3>
<section>
<p className="mb-2">Cookies are necessary for user sessions and authentication. We do not use cookies for tracking or advertising purposes.</p>
</section>
</li>
<li>
<h3 className="text-xl font-semibold mt-6 mb-2">Analytics</h3>
<section>
<p className="mb-2">
We use{" "}
<a href="https://umami.is/" className="text-blue-700"> Umami </a>{" "}
to collect anonymous data about how users interact with the site. Umami is fully GDPR-compliant, and no personally identifiable information is collected
through this service.
</p>
</section>
</li>
<li>
<h3 className="text-xl font-semibold mt-6 mb-2">Data Sharing</h3>
<section>
<p className="mb-2">
We do not sell your personal data to third parties. Your data may be sent anonymously to self-hosted third-party services or trusted third-party
tools (such as analytics) but these services are used solely to keep the site functional.
</p>
</section>
</li>
<li>
<h3 className="text-xl font-semibold mt-6 mb-2">Your Rights</h3>
<section>
<p className="mb-2">As a user, you have the right to:</p>
<ul className="list-disc list-inside indent-4">
<li>Access the personal data we hold about you.</li>
<li>Request corrections to any inaccurate or incomplete information.</li>
<li>Request the deletion of your personal data.</li>
</ul>
</section>
</li>
<li>
<h3 className="text-xl font-semibold mt-6 mb-2">Data Deletion</h3>
<section>
<p className="mb-2">
Your data, including your Miis, will be retained for as long as you have an account on the site. You may request that your data be deleted at any
time by going to your profile page, clicking the settings icon, and clicking the &apos;Delete Account&apos; button. Upon clicking, your data will be
promptly removed from our servers.
</p>
</section>
</li>
<li>
<h3 className="text-xl font-semibold mt-6 mb-2">Changes to this Privacy Policy</h3>
<section>
<p className="mb-2">
This Privacy Policy may be updated from time to time. We encourage you to review this policy periodically to stay informed about your privacy.
</p>
</section>
</li>
</ul>
</div>
}

View file

@ -1,16 +1,14 @@
import { useEffect, useState } from "react";
import ProfileInformation from "../profile-information";
import ProfileInformation from "../components/profile-information";
import { useParams } from "react-router";
interface Props {
id: string;
}
export default function ProfilePage({ id }: Props) {
export default function ProfilePage() {
const { id } = useParams();
const [user, setUser] = useState<any>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`${import.meta.env.PUBLIC_API_URL}/api/profile/${id}/info`)
fetch(`${import.meta.env.VITE_API_URL}/api/profile/${id}/info`)
.then((res) => {
if (!res.ok) throw new Error("Failed to fetch profile");
return res.json();

View file

@ -1,16 +0,0 @@
---
import ProfilePage from "../../components/pages/profile";
import Layout from "../../layout.astro";
const { id } = Astro.params;
export async function getStaticPaths() {
return Array.from({ length: 50000 }, (_, i) => ({
params: { id: String(i + 1) },
}));
}
---
<Layout>
<ProfilePage client:only id={id} />
</Layout>

View file

@ -1,9 +0,0 @@
---
import ProfileSettings from "../../components/profile-settings";
import Layout from "../../layout.astro";
---
<Layout>
<!-- <ProfileInformation client:only page="settings" /> -->
<ProfileSettings client:only currentDescription={null} />
</Layout>

View file

@ -0,0 +1,5 @@
import ProfileSettings from "../components/profile-settings";
export default function ProfileSettingsPage() {
return <ProfileSettings currentDescription={null} />;
}

View file

@ -1,8 +0,0 @@
---
import SubmitForm from "../components/submit-form";
import Layout from "../layout.astro";
---
<Layout>
<SubmitForm client:load />
</Layout>

View file

@ -0,0 +1,5 @@
import SubmitForm from "../components/submit-form";
export default function SubmitPage() {
return <SubmitForm />;
}

View file

@ -1,133 +1,129 @@
---
import Layout from "../layout.astro";
---
<Layout>
<div class="bg-amber-50 border-2 border-amber-500 rounded-2xl p-6">
<h1 class="text-2xl font-bold">Terms of Service</h1>
<h2 class="font-light">
<strong class="font-medium">Effective Date:</strong> March 26, 2026
</h2>
<hr class="border-black/20 mt-1 mb-4" />
<p>
By registering for, or using this service, you confirm that you understand and agree to the terms below. If you do not agree to these terms, you should
not use the service.
</p>
<p class="mt-1">
If you have any questions or concerns, please contact me at:{" "}
<a href="mailto:hello@trafficlunar.net" class="text-blue-700"> hello@trafficlunar.net </a>
.
</p>
<ul class="list-decimal ml-5 marker:text-xl marker:font-semibold">
<li>
<h3 class="text-xl font-semibold mt-6 mb-2">Usage Policy</h3>
<section>
<p class="mb-2">As a user of this site, you must abide by these guidelines:</p>
<ul class="list-disc list-inside indent-4">
<li>Nothing that would interfere with or gain unauthorized access to the website or its systems.</li>
<li>Nothing that is against the law in the United Kingdom.</li>
<li>No NSFW, violent, gory, or inappropriate Miis or images.</li>
<li>No spam.</li>
<li>No impersonation of others.</li>
<li>No malware, malicious links, or phishing content.</li>
<li>No harassment, hate speech, threats, or bullying towards others.</li>
<li>Miis must be high quality: for example, not following all instructions on the submit form correctly.</li>
<li>Avoid using inappropriate language. Profanity may be automatically censored.</li>
<li>No use of automated scripts, bots, or scrapers to access or interact with the site.</li>
</ul>
<p class="mt-2">
If you find anybody or a Mii breaking these rules, please report it by going to their page and clicking the &quot;Report&quot; button.
</p>
</section>
</li>
<li>
<h3 class="text-xl font-semibold mt-6 mb-2">Termination</h3>
<section>
<p class="mb-2">
We reserve the right to suspend or terminate your access to the site at any time if you violate these Terms of Service or engage in any activities
that disrupt the functionality of the site.
</p>
<p>
To request deletion of your account and personal data, please refer to the{" "}
<a href="/privacy" class="text-blue-700"> Privacy Policy </a>{" "}
(see &quot;Data Deletion&quot;) or email me at{" "}
<a href="mailto:hello@trafficlunar.net" class="text-blue-700"> hello@trafficlunar.net </a>
</p>
</section>
</li>
<li>
<h3 class="text-xl font-semibold mt-6 mb-2">Eligibility</h3>
<section>
<p class="mb-2">By using this service, you confirm that you are at least 13 years old or have the consent of a parent or guardian.</p>
</section>
</li>
<li>
<h3 class="text-xl font-semibold mt-6 mb-2">Liability</h3>
<section>
<p class="mb-2">
This service is provided &quot;as is&quot; and without any warranties. We are not responsible for any user-generated content or the actions of users
on the site. You use the site at your own risk.
</p>
<p>
We do not guarantee continuous or secure access to the service and are not liable for any damages resulting from interruptions, loss of data, or
unauthorized access.
</p>
</section>
</li>
<li>
<h3 class="text-xl font-semibold mt-6 mb-2">DMCA & Copyright</h3>
<section>
<p class="mb-2">
If you believe that content uploaded to this site infringes on your copyright, you may submit a DMCA takedown request by emailing{" "}
<a href="mailto:hello@trafficlunar.net" class="text-blue-700"> hello@trafficlunar.net </a>{" "}
or by reporting the Mii on its page.
</p>
<p class="mb-2">Please include:</p>
<ul class="list-disc list-inside indent-4">
<li>Your name and contact information</li>
<li>A description of the copyrighted work</li>
<li>A link to the allegedly infringing material</li>
<li>A statement that you have a good faith belief that the use is not authorized</li>
<li>
A statement that the information in the notice is accurate and, under penalty of perjury, that you are authorized to act on behalf of the
copyright owner
</li>
<li>Your electronic or physical signature</li>
</ul>
</section>
</li>
<li>
<h3 class="text-xl font-semibold mt-6 mb-2">Nintendo Disclaimer</h3>
<section>
<p class="mb-2">
This site is not affiliated with, endorsed by, or associated with Nintendo in any way. &quot;Mii&quot; and all related character designs are
trademarks of Nintendo Co., Ltd.
</p>
<p>
All Mii-related content is shared by users under the assumption that it does not violate any third-party rights. If you believe your rights have
been infringed, please see the DMCA section above.
</p>
</section>
</li>
<li>
<h3 class="text-xl font-semibold mt-6 mb-2">Changes to this Terms of Service</h3>
<section>
<p class="mb-2">
This Terms of Service may be updated from time to time. We encourage you to review the terms periodically to stay informed about the use of the
site. We may notify users via a site banner or other means if changes are made to the Terms of Service.
</p>
</section>
</li>
</ul>
</div>
</Layout>
export default function TermsOfServicePage() {
return <div className="bg-amber-50 border-2 border-amber-500 rounded-2xl p-6">
<h1 className="text-2xl font-bold">Terms of Service</h1>
<h2 className="font-light">
<strong className="font-medium">Effective Date:</strong> March 26, 2026
</h2>
<hr className="border-black/20 mt-1 mb-4" />
<p>
By registering for, or using this service, you confirm that you understand and agree to the terms below. If you do not agree to these terms, you should
not use the service.
</p>
<p className="mt-1">
If you have any questions or concerns, please contact me at:{" "}
<a href="mailto:hello@trafficlunar.net" className="text-blue-700"> hello@trafficlunar.net </a>
.
</p>
<ul className="list-decimal ml-5 marker:text-xl marker:font-semibold">
<li>
<h3 className="text-xl font-semibold mt-6 mb-2">Usage Policy</h3>
<section>
<p className="mb-2">As a user of this site, you must abide by these guidelines:</p>
<ul className="list-disc list-inside indent-4">
<li>Nothing that would interfere with or gain unauthorized access to the website or its systems.</li>
<li>Nothing that is against the law in the United Kingdom.</li>
<li>No NSFW, violent, gory, or inappropriate Miis or images.</li>
<li>No spam.</li>
<li>No impersonation of others.</li>
<li>No malware, malicious links, or phishing content.</li>
<li>No harassment, hate speech, threats, or bullying towards others.</li>
<li>Miis must be high quality: for example, not following all instructions on the submit form correctly.</li>
<li>Avoid using inappropriate language. Profanity may be automatically censored.</li>
<li>No use of automated scripts, bots, or scrapers to access or interact with the site.</li>
</ul>
<p className="mt-2">
If you find anybody or a Mii breaking these rules, please report it by going to their page and clicking the &quot;Report&quot; button.
</p>
</section>
</li>
<li>
<h3 className="text-xl font-semibold mt-6 mb-2">Termination</h3>
<section>
<p className="mb-2">
We reserve the right to suspend or terminate your access to the site at any time if you violate these Terms of Service or engage in any activities
that disrupt the functionality of the site.
</p>
<p>
To request deletion of your account and personal data, please refer to the{" "}
<a href="/privacy" className="text-blue-700"> Privacy Policy </a>{" "}
(see &quot;Data Deletion&quot;) or email me at{" "}
<a href="mailto:hello@trafficlunar.net" className="text-blue-700"> hello@trafficlunar.net </a>
</p>
</section>
</li>
<li>
<h3 className="text-xl font-semibold mt-6 mb-2">Eligibility</h3>
<section>
<p className="mb-2">By using this service, you confirm that you are at least 13 years old or have the consent of a parent or guardian.</p>
</section>
</li>
<li>
<h3 className="text-xl font-semibold mt-6 mb-2">Liability</h3>
<section>
<p className="mb-2">
This service is provided &quot;as is&quot; and without any warranties. We are not responsible for any user-generated content or the actions of users
on the site. You use the site at your own risk.
</p>
<p>
We do not guarantee continuous or secure access to the service and are not liable for any damages resulting from interruptions, loss of data, or
unauthorized access.
</p>
</section>
</li>
<li>
<h3 className="text-xl font-semibold mt-6 mb-2">DMCA & Copyright</h3>
<section>
<p className="mb-2">
If you believe that content uploaded to this site infringes on your copyright, you may submit a DMCA takedown request by emailing{" "}
<a href="mailto:hello@trafficlunar.net" className="text-blue-700"> hello@trafficlunar.net </a>{" "}
or by reporting the Mii on its page.
</p>
<p className="mb-2">Please include:</p>
<ul className="list-disc list-inside indent-4">
<li>Your name and contact information</li>
<li>A description of the copyrighted work</li>
<li>A link to the allegedly infringing material</li>
<li>A statement that you have a good faith belief that the use is not authorized</li>
<li>
A statement that the information in the notice is accurate and, under penalty of perjury, that you are authorized to act on behalf of the
copyright owner
</li>
<li>Your electronic or physical signature</li>
</ul>
</section>
</li>
<li>
<h3 className="text-xl font-semibold mt-6 mb-2">Nintendo Disclaimer</h3>
<section>
<p className="mb-2">
This site is not affiliated with, endorsed by, or associated with Nintendo in any way. &quot;Mii&quot; and all related character designs are
trademarks of Nintendo Co., Ltd.
</p>
<p>
All Mii-related content is shared by users under the assumption that it does not violate any third-party rights. If you believe your rights have
been infringed, please see the DMCA section above.
</p>
</section>
</li>
<li>
<h3 className="text-xl font-semibold mt-6 mb-2">Changes to this Terms of Service</h3>
<section>
<p className="mb-2">
This Terms of Service may be updated from time to time. We encourage you to review the terms periodically to stay informed about the use of the
site. We may notify users via a site banner or other means if changes are made to the Terms of Service.
</p>
</section>
</li>
</ul>
</div>;
}