feat: use react-router for links and redirects

This commit is contained in:
trafficlunar 2026-04-17 18:25:33 +01:00
parent 87b885a2f8
commit 12203901e9
35 changed files with 1222 additions and 1111 deletions

View file

@ -1,12 +0,0 @@
import type { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: "*",
allow: "/",
disallow: ["/*?*page=", "/profile*?*tags=", "/edit/*", "/profile/settings", "/random", "/submit", "/report/mii/*", "/report/user/*", "/admin"],
},
sitemap: `${process.env.NEXT_PUBLIC_BASE_URL}/sitemap.xml`,
};
}

View file

@ -1,53 +1,52 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@bprogress/react": "^1.2.7",
"@fontsource-variable/lexend": "^5.2.11",
"@hello-pangea/dnd": "^18.0.1",
"@nanostores/react": "^1.1.0",
"@tailwindcss/vite": "^4.2.2",
"@tomodachi-share/shared": "workspace:*",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"canvas-confetti": "^1.9.4",
"dayjs": "^1.11.20",
"downshift": "^9.3.2",
"embla-carousel-react": "^8.6.0",
"jsqr": "^1.4.0",
"nanostores": "^1.2.0",
"qrcode-generator": "^2.0.4",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-dropzone": "^15.0.0",
"react-image-crop": "^11.0.10",
"react-router": "^7.14.1",
"tailwindcss": "^4.2.2",
"zod": "^4.3.6"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@iconify/react": "^6.0.2",
"@types/canvas-confetti": "^1.9.0",
"@types/node": "^24.12.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/seedrandom": "^3.0.8",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"typescript": "~6.0.2",
"typescript-eslint": "^8.58.0",
"vite": "^8.0.4"
}
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@bprogress/react": "^1.2.7",
"@fontsource-variable/lexend": "^5.2.11",
"@hello-pangea/dnd": "^18.0.1",
"@nanostores/react": "^1.1.0",
"@tailwindcss/vite": "^4.2.2",
"@tomodachi-share/shared": "workspace:*",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"canvas-confetti": "^1.9.4",
"dayjs": "^1.11.20",
"downshift": "^9.3.2",
"embla-carousel-react": "^8.6.0",
"jsqr": "^1.4.0",
"nanostores": "^1.2.0",
"qrcode-generator": "^2.0.4",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-dropzone": "^15.0.0",
"react-image-crop": "^11.0.10",
"react-router": "^7.14.1",
"tailwindcss": "^4.2.2",
"zod": "^4.3.6"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@iconify/react": "^6.0.2",
"@types/canvas-confetti": "^1.9.0",
"@types/node": "^24.12.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"typescript": "~6.0.2",
"typescript-eslint": "^8.58.0",
"vite": "^8.0.4"
}
}

View file

@ -0,0 +1,13 @@
User-Agent: *
Allow: /
Disallow: /*?*page=
Disallow: /profile*?*tags=
Disallow: /edit/*
Disallow: /profile/settings
Disallow: /random
Disallow: /submit
Disallow: /report/mii/*
Disallow: /report/user/*
Disallow: /admin
Sitemap: https://api.tomodachishare.com/sitemap.xml

View file

@ -1,4 +1,5 @@
import { Icon } from "@iconify/react";
import { Link } from "react-router";
interface Props {
text: string;
@ -19,9 +20,9 @@ export default function Description({ text, className }: Props) {
const url = new URL(part);
return (
<a
<Link
key={index}
href={`/out?url=${encodeURIComponent(part)}`}
to={`/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}`}
@ -30,7 +31,7 @@ export default function Description({ text, className }: Props) {
{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>
</Link>
);
} catch {
// Normal text/Invalid URL fallback

View file

@ -1,4 +1,5 @@
import { Icon } from "@iconify/react";
import { Link } from "react-router";
export default function Footer() {
return (
@ -11,38 +12,42 @@ export default function Footer() {
{/* 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">
<Link to="/terms-of-service" className="text-zinc-500 hover:text-zinc-700 transition-colors duration-200 hover:underline">
Terms of Service
</a>
</Link>
<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">
<Link to="/privacy" className="text-zinc-500 hover:text-zinc-700 transition-colors duration-200 hover:underline">
Privacy Policy
</a>
</Link>
<span className="text-zinc-400 hidden sm:inline" aria-hidden="true">
</span>
<a
href="https://discord.gg/48cXBFKvWQ"
<Link
to="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>
</Link>
<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">
<Link
to="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>
</Link>
</div>
{/* Copyright */}

View file

@ -2,6 +2,7 @@ import { Icon } from "@iconify/react";
import { useEffect } from "react";
import { useStore } from "@nanostores/react";
import { session } from "../session";
import { Link } from "react-router";
export default function HeaderProfile() {
const API_BASE_URL = import.meta.env.VITE_API_URL;
@ -25,15 +26,15 @@ export default function HeaderProfile() {
<>
{!$session?.user ? (
<li>
<a href={"/login"} className="pill button h-full">
<Link to={"/login"} className="pill button h-full">
Login
</a>
</Link>
</li>
) : (
<>
<li title="Your profile">
<a
href={`/profile/${$session?.user?.id}`}
<Link
to={`/profile/${$session?.user?.id}`}
aria-label="Go to profile"
className="pill button gap-2! p-0! h-full max-w-64"
data-tooltip="Your Profile"
@ -46,12 +47,12 @@ export default function HeaderProfile() {
className="rounded-full aspect-square object-cover h-full bg-white outline-2 outline-orange-400"
/>
<span className="pr-4 overflow-hidden whitespace-nowrap text-ellipsis w-full">{$session?.user?.name ?? "unknown"}</span>
</a>
</Link>
</li>
<li title="Logout">
<a href={`${API_BASE_URL}/api/auth/signout`} aria-label="Log Out" className="pill button p-2! aspect-square h-full" data-tooltip="Log Out">
<Link to={`${API_BASE_URL}/api/auth/signout`} aria-label="Log Out" className="pill button p-2! aspect-square h-full" data-tooltip="Log Out">
<Icon icon="ic:round-logout" fontSize={24} />
</a>
</Link>
</li>
</>
)}

View file

@ -1,18 +1,19 @@
import { Icon } from "@iconify/react";
import SearchBar from "./search-bar";
import HeaderProfile from "./header-profile";
import { Link } from "react-router";
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={"/"}
<Link
to={"/"}
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>
</Link>
<div className="flex justify-center max-lg:justify-end max-md:justify-center">
<SearchBar />
@ -20,19 +21,19 @@ export default function Header() {
<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`}
<Link
to={`${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>
</Link>
</li>
<li>
<a href={"/submit"} className="pill button h-full">
<Link to={"/submit"} className="pill button h-full">
Submit
</a>
</Link>
</li>
<HeaderProfile />
</ul>

View file

@ -1,23 +1,24 @@
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";
import { Link } from "react-router";
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 (
<>
<Link aria-label="Edit Mii" to={`/edit/${mii.id}`}>
<Icon icon="mdi:pencil" />
<span>Edit</span>
</Link>
<DeleteMiiButton miiId={mii.id} miiName={mii.name} likes={mii._count.likedBy ?? 0} inMiiPage />
</>
);
}

View file

@ -4,6 +4,7 @@ import { Icon } from "@iconify/react";
import LikeButton from "../like-button";
import SubmitButton from "../submit-button";
import { useNavigate } from "react-router";
interface Props {
miiId: number;
@ -13,6 +14,7 @@ interface Props {
}
export default function DeleteMiiButton({ miiId, miiName, likes, inMiiPage }: Props) {
const navigate = useNavigate();
const [isOpen, setIsOpen] = useState(false);
const [isVisible, setIsVisible] = useState(false);
@ -28,7 +30,7 @@ export default function DeleteMiiButton({ miiId, miiName, likes, inMiiPage }: Pr
}
close();
window.location.reload(); // I would use router.refresh() here but the Mii list doesn't update
navigate(0);
};
const close = () => {

View file

@ -1,132 +1,133 @@
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";
import { useSearchParams } from "react-router";
export default function FilterMenu() {
const [searchParams] = useSearchParams();
const [isOpen, setIsOpen] = useState(false);
const [isVisible, setIsVisible] = useState(false);
const platform = (searchParams.get("platform") as MiiPlatform) || undefined;
const gender = (searchParams.get("gender") as MiiGender) || undefined;
const 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,73 @@
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";
import { useNavigate, useSearchParams } from "react-router";
export default function GenderSelect() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [, startTransition] = useTransition();
const [selected, setSelected] = useState<MiiGender | null>((searchParams.get("gender") as MiiGender) ?? null);
const platform = (searchParams.get("platform") as MiiPlatform) || undefined;
const handleClick = (gender: MiiGender) => {
const filter = selected === gender ? null : gender;
setSelected(filter);
const params = new URLSearchParams(searchParams);
params.set("page", "1");
if (filter) {
params.set("gender", filter);
} else {
params.delete("gender");
}
startTransition(() => {
navigate(`?${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,72 +1,73 @@
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";
import { useNavigate, useSearchParams } from "react-router";
export default function MakeupSelect() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
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(() => {
navigate(`?${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,6 +2,7 @@ import { Icon } from "@iconify/react";
import LikeButton from "../../like-button";
import DeleteMiiButton from "../delete-mii-button";
import { Link } from "react-router";
interface Props {
// miis: Prisma.MiiGetPayload<{ include: { user: { select: { id: true; name: true } }; _count: { select: { likedBy: true } } } }>[];
@ -25,7 +26,7 @@ export default function MiiGrid({ miis, userId, parentPage }: Props) {
</div>
)}
<a href={`/mii/${mii.id}`} className="overflow-hidden rounded-xl bg-zinc-300 shrink-0">
<Link to={`/mii/${mii.id}`} className="overflow-hidden rounded-xl bg-zinc-300 shrink-0">
<img
src={`${import.meta.env.VITE_API_URL}/mii/${mii.id}/image?type=mii`}
width={240}
@ -33,13 +34,13 @@ export default function MiiGrid({ miis, userId, parentPage }: Props) {
alt="mii image"
className="w-full h-auto aspect-3/2 object-contain"
/>
</a>
</Link>
<div className="p-4 flex flex-col gap-1 h-full">
<div className="flex justify-between">
<a href={`/mii/${mii.id}`} className="relative font-bold text-2xl line-clamp-1 w-full text-ellipsis wrap-break-word" title={mii.name}>
<Link to={`/mii/${mii.id}`} className="relative font-bold text-2xl line-clamp-1 w-full text-ellipsis wrap-break-word" title={mii.name}>
{mii.name}
</a>
</Link>
<div title={mii.platform === "SWITCH" ? "Switch" : "3DS"} className="text-[1.25rem] opacity-25">
{mii.platform === "SWITCH" ? (
<Icon icon="cib:nintendo-switch" className="text-red-400" />
@ -50,9 +51,9 @@ export default function MiiGrid({ miis, userId, parentPage }: Props) {
</div>
<div id="tags" className="flex flex-wrap gap-1">
{mii.tags.map((tag: string) => (
<a href={`?tags=${tag}`} key={tag} className="px-2 py-1 bg-orange-300 rounded-full text-xs">
<Link to={`?tags=${tag}`} key={tag} className="px-2 py-1 bg-orange-300 rounded-full text-xs">
{tag}
</a>
</Link>
))}
</div>
@ -60,16 +61,16 @@ export default function MiiGrid({ miis, userId, parentPage }: Props) {
<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">
<Link to={`/profile/${mii.user?.id}`} className="text-sm text-right overflow-hidden text-ellipsis whitespace-nowrap">
@{mii.user?.name}
</a>
</Link>
)}
{/* {userId && Number(session.data?.user?.id) == userId && (
<div className="flex gap-1 text-2xl justify-end text-zinc-400">
<a href={`/edit/${mii.id}`} title="Edit Mii" aria-label="Edit Mii" data-tooltip="Edit">
<Link to={`/edit/${mii.id}`} title="Edit Mii" aria-label="Edit Mii" data-tooltip="Edit">
<Icon icon="mdi:pencil" />
</a>
</Link>
<DeleteMiiButton miiId={mii.id} miiName={mii.name} likes={mii._count.likedBy} />
</div>
)} */}

View file

@ -1,79 +1,80 @@
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";
import { useLocation, useNavigate, useSearchParams } from "react-router";
export default function OtherFilters() {
const location = useLocation();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
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(() => {
navigate(`?${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(() => {
navigate(`?${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,56 @@
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";
import { useNavigate, useSearchParams } from "react-router";
export default function PlatformSelect() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [, startTransition] = useTransition();
const [selected, setSelected] = useState<MiiPlatform | null>((searchParams.get("platform") as MiiPlatform) ?? null);
const handleClick = (platform: MiiPlatform) => {
const filter = selected === platform ? null : platform;
setSelected(filter);
const params = new URLSearchParams(searchParams);
if (filter) {
params.set("platform", filter);
} else {
params.delete("platform");
}
startTransition(() => {
navigate(`?${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,61 +1,58 @@
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";
import { useNavigate, useSearchParams } from "react-router";
type Sort = "likes" | "newest" | "oldest";
const items = ["likes", "newest", "oldest"];
export default function SortSelect() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [, startTransition] = useTransition();
const currentSort = (searchParams.get("sort") as Sort) || "newest";
const { isOpen, getToggleButtonProps, getMenuProps, getItemProps, highlightedIndex, selectedItem } = useSelect({
items,
selectedItem: currentSort,
onSelectedItemChange: ({ selectedItem }) => {
if (!selectedItem) return;
const params = new URLSearchParams(searchParams);
params.set("page", "1");
params.set("sort", selectedItem);
startTransition(() => {
navigate(`?${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,60 @@
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";
import { useNavigate, useSearchParams } from "react-router";
interface Props {
isExclude?: boolean;
}
export default function TagFilter({ isExclude }: Props) {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [, startTransition] = useTransition();
const rawTags = searchParams.get(isExclude ? "exclude" : "tags") || "";
const preexistingTags = useMemo(
() =>
rawTags
? rawTags
.split(",")
.map((tag) => tag.trim())
.filter((tag) => tag.length > 0)
: [],
[rawTags],
);
const [tags, setTags] = useState<string[]>(preexistingTags);
// Sync state if the URL tags change (e.g. via navigation)
useEffect(() => {
setTags(preexistingTags);
}, [preexistingTags]);
// Redirect automatically on tags change
useEffect(() => {
const urlTags = preexistingTags.join(",");
const stateTags = tags.join(",");
if (urlTags === stateTags) return;
const params = new URLSearchParams(searchParams);
params.set("page", "1");
if (tags.length > 0) {
params.set(isExclude ? "exclude" : "tags", stateTags);
} else {
params.delete(isExclude ? "exclude" : "tags");
}
startTransition(() => {
navigate(`?${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,6 +1,7 @@
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { Icon } from "@iconify/react";
import { Link } from "react-router";
interface Props {
miiId: number;
@ -128,15 +129,15 @@ export default function ShareMiiButton({ miiId }: Props) {
<div className="flex justify-end gap-2 mt-4">
<div className="flex gap-2 w-full">
{/* Save button */}
<a
href={`${import.meta.env.VITE_API_URL}/mii/${miiId}/image?type=metadata`}
<Link
to={`${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"
download={"hello.png"}
>
<Icon icon="material-symbols:save-rounded" />
</a>
</Link>
{/* Copy button */}
<button

View file

@ -1,95 +1,97 @@
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";
import { Link, useLocation, useSearchParams } from "react-router";
interface Props {
lastPage: number;
}
export default function Pagination({ lastPage }: Props) {
const location = useLocation();
const [searchParams] = useSearchParams();
const page = Number(searchParams.get("page") ?? 1);
const createPageUrl = useCallback(
(pageNumber: number) => {
const params = new URLSearchParams(searchParams);
params.set("page", pageNumber.toString());
return `${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 */}
<Link
to={page === 1 ? "#" : createPageUrl(1)}
aria-label="Go to First Page"
aria-disabled={page === 1}
tabIndex={page === 1 ? -1 : undefined}
className={`pill button bg-orange-100! p-0.5! aspect-square text-2xl ${page === 1 ? "pointer-events-none opacity-50" : "hover:bg-orange-400!"}`}
>
<Icon icon="stash:chevron-double-left" />
</Link>
{/* Previous page */}
<Link
to={page === 1 ? "#" : createPageUrl(page - 1)}
aria-label="Go to Previous Page"
aria-disabled={page === 1}
tabIndex={page === 1 ? -1 : undefined}
className={`pill bg-orange-100! p-0.5! aspect-square text-2xl ${page === 1 ? "pointer-events-none opacity-50" : "hover:bg-orange-400!"}`}
>
<Icon icon="stash:chevron-left" />
</Link>
{/* Page numbers */}
<div className="flex mx-2">
{numbers.map((number) => (
<Link
key={number}
to={createPageUrl(number)}
aria-label={`Go to Page ${number}`}
aria-current={number === page ? "page" : undefined}
className={`pill p-0! w-8 h-8 text-center rounded-md! ${number == page ? "bg-orange-400!" : "bg-orange-100! hover:bg-orange-400!"}`}
>
{number}
</Link>
))}
</div>
{/* Next page */}
<Link
to={page >= lastPage ? "#" : createPageUrl(page + 1)}
aria-label="Go to Next Page"
aria-disabled={page >= lastPage}
tabIndex={page >= lastPage ? -1 : undefined}
className={`pill button bg-orange-100! p-0.5! aspect-square text-2xl ${page >= lastPage ? "pointer-events-none opacity-50" : "hover:bg-orange-400!"}`}
>
<Icon icon="stash:chevron-right" />
</Link>
{/* Go to last page */}
<Link
to={page >= lastPage ? "#" : createPageUrl(lastPage)}
aria-label="Go to Last Page"
aria-disabled={page >= lastPage}
tabIndex={page >= lastPage ? -1 : undefined}
className={`pill button bg-orange-100! p-0.5! aspect-square text-2xl ${page >= lastPage ? "pointer-events-none opacity-50" : "hover:bg-orange-400!"}`}
>
<Icon icon="stash:chevron-double-right" />
</Link>
</div>
);
}

View file

@ -3,6 +3,7 @@ import { Icon } from "@iconify/react";
import Description from "./description";
import { useStore } from "@nanostores/react";
import { session } from "../session";
import { Link } from "react-router";
interface Props {
user?: any;
@ -23,9 +24,9 @@ export default function ProfileInformation({ user, page }: Props) {
<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">
<Link to={`/profile/${user.id}`} className="size-28 aspect-square">
<img src={user.image ?? "/guest.png"} className="rounded-full bg-white border-2 border-orange-400 shadow max-md:self-center" />
</a>
</Link>
{/* User information */}
<div className="flex flex-col w-full relative py-3">
<div className="flex items-center gap-2">
@ -60,34 +61,34 @@ 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.VITE_API_URL}/report/user/${user.id}`}>
<Link aria-label="Report User" to={`${import.meta.env.VITE_API_URL}/report/user/${user.id}`}>
<Icon icon="material-symbols:flag-rounded" />
<span>Report</span>
</a>
</Link>
)}
{isOwnProfile && isAdmin && (
<a aria-label="Go to Admin" href="/admin">
<Link aria-label="Go to Admin" to="/admin">
<Icon icon="mdi:shield-moon" />
<span>Admin</span>
</a>
</Link>
)}
{/* {isOwnProfile && page !== "likes" && (
<a aria-label="Go to My Likes" href="/profile/likes">
<Link aria-label="Go to My Likes" to="/profile/likes">
<Icon icon="icon-park-solid:like" />
<span>My Likes</span>
</a>
</Link>
)} */}
{isOwnProfile && page !== "settings" && (
<a aria-label="Go to Settings" href="/profile/settings">
<Link aria-label="Go to Settings" to="/profile/settings">
<Icon icon="material-symbols:settings-rounded" />
<span>Settings</span>
</a>
</Link>
)}
{page && (
<a aria-label="Go Back to Profile" href={`/profile/${user.id}`}>
<Link aria-label="Go Back to Profile" to={`/profile/${user.id}`}>
<Icon icon="tabler:chevron-left" />
<span>Back</span>
</a>
</Link>
)}
</div>
</div>

View file

@ -1,81 +1,83 @@
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";
import { useNavigate } from "react-router";
export default function DeleteAccount() {
const navigate = useNavigate();
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;
}
navigate("/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

@ -6,12 +6,14 @@ import ProfilePictureSettings from "./profile-picture";
import SubmitDialogButton from "./submit-dialog-button";
import DeleteAccount from "./delete-account";
import z from "zod";
import { useNavigate } from "react-router";
interface Props {
currentDescription: string | null | undefined;
}
export default function ProfileSettings({ currentDescription }: Props) {
const navigate = useNavigate();
const [description, setDescription] = useState(currentDescription);
const [name, setName] = useState("");
@ -39,7 +41,7 @@ export default function ProfileSettings({ currentDescription }: Props) {
}
close();
window.location.reload();
navigate(0);
};
const handleSubmitNameChange = async (close: () => void) => {
@ -63,7 +65,7 @@ export default function ProfileSettings({ currentDescription }: Props) {
}
close();
window.location.reload();
navigate(0);
};
return (

View file

@ -6,8 +6,10 @@ import dayjs from "dayjs";
import SubmitDialogButton from "./submit-dialog-button";
import Dropzone from "../dropzone";
import { useNavigate } from "react-router";
export default function ProfilePictureSettings() {
const navigate = useNavigate();
const [error, setError] = useState<string | undefined>(undefined);
const [newPicture, setNewPicture] = useState<FileWithPath | undefined>();
@ -30,7 +32,7 @@ export default function ProfilePictureSettings() {
}
close();
location.reload();
navigate(0);
};
const handleDrop = useCallback((acceptedFiles: FileWithPath[]) => {

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";
import { useNavigate, useSearchParams } from "react-router";
export default function SearchBar() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [query, setQuery] = useState(searchParams.get("q") || "");
const handleSearch = () => {
const result = querySchema.safeParse(query);
if (!result.success) {
navigate("/", { preventScrollReset: true });
return;
}
// Clone current search params and add query param
const params = new URLSearchParams(searchParams.toString());
params.set("q", query);
params.set("page", "1");
navigate(`/?${params.toString()}`, { preventScrollReset: true });
};
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

@ -21,8 +21,10 @@ import Carousel from "../carousel";
import SubmitButton from "../submit-button";
import Dropzone from "../dropzone";
import type { MiiPlatform, MiiGender, MiiMakeup } from "@tomodachi-share/shared";
import { useNavigate } from "react-router";
export default function SubmitForm() {
const navigate = useNavigate();
const [files, setFiles] = useState<FileWithPath[]>([]);
const handleDrop = useCallback(
@ -113,7 +115,7 @@ export default function SubmitForm() {
return;
}
window.location.href = `/mii/${id}`;
navigate(`/mii/${id}`);
};
useEffect(() => {

View file

@ -1,31 +1,35 @@
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 Footer from "./components/footer";
import Header from "./components/header";
import { useEffect } from "react";
export default function Layout({ 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 (
<>
<Header />
{/* <AdminBanner /> */}
<main className="px-4 py-8 max-w-7xl w-full grow flex flex-col">{children}</main>
<Footer />
</>
);
}

View file

@ -1,4 +1,4 @@
import { StrictMode, Suspense } from "react";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter, Route, Routes } from "react-router";
import "./index.css";
@ -13,19 +13,15 @@ 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";
import { ProgressProvider } from "@bprogress/react";
import LinkOutPage from "./pages/out.tsx";
import Layout from "./layout.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>
<BrowserRouter>
<ProgressProvider height="4px" color="var(--color-amber-500)" options={{ showSpinner: false }} shallowRouting>
<Layout>
<Routes>
<Route path="/" element={<IndexPage />} />
<Route path="/mii/:id" element={<MiiPage />} />
@ -35,13 +31,13 @@ createRoot(document.getElementById("root")!).render(
</Route>
<Route path="/submit" element={<SubmitPage />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/out" element={<LinkOutPage />} />
<Route path="/privacy" element={<PrivacyPage />} />
<Route path="/terms-of-service" element={<TermsOfServicePage />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</BrowserRouter>
</main>
<Footer />
</Providers>
</Layout>
</ProgressProvider>
</BrowserRouter>
</StrictMode>,
);

View file

@ -1,9 +1,10 @@
import { Suspense, useEffect, useMemo, useState } from "react";
import { Suspense, useEffect, useState } from "react";
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";
import { useSearchParams } from "react-router";
interface ApiResponse {
totalCount: number;
@ -12,7 +13,7 @@ interface ApiResponse {
}
export default function IndexPage() {
const searchParams = useMemo(() => new URLSearchParams(location.search), []);
const [searchParams] = useSearchParams();
const [data, setData] = useState<ApiResponse | null>(null);
const [loading, setLoading] = useState(true);

View file

@ -1,6 +1,6 @@
import { Icon } from "@iconify/react";
import { useStore } from "@nanostores/react";
import { useNavigate } from "react-router";
import { Link, useNavigate } from "react-router";
import { session } from "../session";
export default function LoginPage() {
@ -23,41 +23,41 @@ export default function LoginPage() {
</div>
<div className="flex flex-col items-center gap-2">
<a
href={`${API_URL}/api/auth/signin/discord`}
<Link
to={`${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`}
</Link>
<Link
to={`${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`}
</Link>
<Link
to={`${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>
</Link>
</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">
<Link to="/terms-of-service" className="underline hover:text-zinc-600">
Terms of Service
</a>{" "}
</Link>{" "}
and{" "}
<a href="/privacy" className="underline hover:text-zinc-600">
<Link to="/privacy" className="underline hover:text-zinc-600">
Privacy Policy
</a>
</Link>
.
</p>
</div>

View file

@ -9,10 +9,11 @@ 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";
import { Link, useNavigate, useParams } from "react-router";
export default function MiiPage() {
const { id } = useParams();
const navigate = useNavigate();
const [mii, setMii] = useState<any>(null);
const [loading, setLoading] = useState(true);
@ -31,7 +32,7 @@ export default function MiiPage() {
.catch((err) => {
console.error(err);
setLoading(false);
window.location.href = "/404";
navigate("/404");
});
}, [id]);

View file

@ -1,14 +1,17 @@
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>
}
import { Icon } from "@iconify/react";
import { Link } from "react-router";
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>
<Link to="/" className="pill button gap-2 mt-8 w-fit self-center">
<Icon icon="ic:round-home" fontSize={24} />
Travel Back
</Link>
</div>
</div>
);
}

View file

@ -0,0 +1,64 @@
import { Icon } from "@iconify/react";
import { Link, useNavigate, useSearchParams } from "react-router";
export default function LinkOutPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const url = searchParams.get("url");
if (!url || Array.isArray(url)) navigate("/");
let parsed: URL;
try {
parsed = new URL(url);
} catch {
navigate("/"); // redirect if URL is invalid
}
// Next.js doesn't allow attacks like these but you can never be too safe
if (!["http:", "https:"].includes(parsed.protocol)) navigate("/");
const isSafe = Array.from(SAFE_LINKS).some((domain) => parsed.hostname === domain || parsed.hostname.endsWith(`.${domain}`));
if (isSafe) navigate(url);
return (
<div className="grow flex items-center justify-center">
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg py-8 px-6 max-w-md w-full text-center flex flex-col items-center">
<h2 className="text-3xl font-black flex items-center gap-2 mb-1">
<Icon icon="mingcute:alert-fill" className="text-5xl" />
Warning
</h2>
<p>You're attempting to leave TomodachiShare island! The destination website is potentially dangerous.</p>
<div className="bg-zinc-100 border border-zinc-300 rounded-md p-2 break-all w-full mt-4">
<code className="font-mono text-sm">{url}</code>
</div>
<div className="flex justify-center gap-2">
<Link to="/" className="pill button gap-2 mt-8 w-fit self-center bg-zinc-100! border-zinc-300! hover:bg-zinc-300!">
<Icon icon="ic:round-home" fontSize={24} />
Travel Back
</Link>
<Link to={url} target="_blank" rel="noopener noreferrer" className="pill button gap-2 mt-8 w-fit self-center">
<Icon icon="ic:round-open-in-new" fontSize={21} />
Continue
</Link>
</div>
</div>
</div>
);
}
const SAFE_LINKS = new Set([
"tomodachishare.com",
"trafficlunar.net",
"youtube.com",
"youtu.be",
"twitter.com",
"x.com",
"reddit.com",
"tiktok.com",
"tumblr.com",
"instagram.com",
"wikipedia.org",
]);

View file

@ -1,9 +1,10 @@
import { useEffect, useState } from "react";
import ProfileInformation from "../components/profile-information";
import { useParams } from "react-router";
import { useNavigate, useParams } from "react-router";
export default function ProfilePage() {
const { id } = useParams();
const navigate = useNavigate();
const [user, setUser] = useState<any>(null);
const [loading, setLoading] = useState(true);
@ -20,7 +21,7 @@ export default function ProfilePage() {
.catch((err) => {
console.error(err);
setLoading(false);
window.location.href = "/404";
navigate("/404");
});
}, [id]);

View file

@ -1,129 +1,145 @@
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>;
}
import { Link } from "react-router";
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{" "}
<Link to="/privacy" className="text-blue-700">
{" "}
Privacy Policy{" "}
</Link>{" "}
(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>
);
}

View file

@ -38,7 +38,7 @@ export const idSchema = z.coerce.number({ error: "ID must be a number" }).int({
export const searchSchema = z.object({
q: querySchema.optional(),
sort: z.enum(["likes", "newest", "oldest", "random"], { error: "Sort must be either 'likes', 'newest', 'oldest', or 'random'" }).default("newest"),
sort: z.enum(["likes", "newest", "oldest"], { error: "Sort must be either 'likes', 'newest', 'oldest'" }).default("newest"),
tags: z
.string()
.optional()
@ -71,8 +71,6 @@ export const searchSchema = z.object({
.max(100, { error: "Limit cannot be more than 100" })
.optional(),
page: z.coerce.number({ error: "Page must be a number" }).int({ error: "Page must be an integer" }).min(1, { error: "Page must be at least 1" }).optional(),
// Random sort
seed: z.coerce.number({ error: "Seed must be a number" }).int({ error: "Seed must be an integer" }).optional(),
// Other
parentPage: z.string().optional(),
userId: idSchema.optional(),