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

@ -40,7 +40,6 @@
"@types/node": "^24.12.2", "@types/node": "^24.12.2",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/seedrandom": "^3.0.8",
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "^6.0.1",
"eslint": "^9.39.4", "eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",

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 { Icon } from "@iconify/react";
import { Link } from "react-router";
interface Props { interface Props {
text: string; text: string;
@ -19,9 +20,9 @@ export default function Description({ text, className }: Props) {
const url = new URL(part); const url = new URL(part);
return ( return (
<a <Link
key={index} key={index}
href={`/out?url=${encodeURIComponent(part)}`} to={`/out?url=${encodeURIComponent(part)}`}
target="_blank" target="_blank"
className="text-blue-700 underline break-all ml-1 inline-flex items-center group" className="text-blue-700 underline break-all ml-1 inline-flex items-center group"
title={`Go to ${url.hostname}`} title={`Go to ${url.hostname}`}
@ -30,7 +31,7 @@ export default function Description({ text, className }: Props) {
{url.pathname !== "/" ? url.pathname : ""} {url.pathname !== "/" ? url.pathname : ""}
{url.search} {url.search}
<Icon icon="mi:arrow-right-up" fontSize={16} className="transition group-hover:translate-x-0.5 group-hover:-translate-y-0.5" /> <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 { } catch {
// Normal text/Invalid URL fallback // Normal text/Invalid URL fallback

View file

@ -1,4 +1,5 @@
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import { Link } from "react-router";
export default function Footer() { export default function Footer() {
return ( return (
@ -11,38 +12,42 @@ export default function Footer() {
{/* Links section */} {/* Links section */}
<div className="flex flex-wrap justify-center items-center gap-x-4 text-sm max-sm:gap-x-12"> <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 Terms of Service
</a> </Link>
<span className="text-zinc-400 hidden sm:inline" aria-hidden="true"> <span className="text-zinc-400 hidden sm:inline" aria-hidden="true">
</span> </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 Privacy Policy
</a> </Link>
<span className="text-zinc-400 hidden sm:inline" aria-hidden="true"> <span className="text-zinc-400 hidden sm:inline" aria-hidden="true">
</span> </span>
<a <Link
href="https://discord.gg/48cXBFKvWQ" to="https://discord.gg/48cXBFKvWQ"
target="_blank" target="_blank"
className="text-[#5865F2] hover:text-[#454FBF] transition-colors duration-200 hover:underline inline-flex items-end gap-1" 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" /> <Icon icon="ic:baseline-discord" className="text-lg" />
Discord Discord
</a> </Link>
<span className="text-zinc-400 hidden sm:inline" aria-hidden="true"> <span className="text-zinc-400 hidden sm:inline" aria-hidden="true">
</span> </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> Made by <span className="text-orange-400 group-hover:text-orange-500 font-medium transition-colors duration-200">trafficlunar</span>
</a> </Link>
</div> </div>
{/* Copyright */} {/* Copyright */}

View file

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

View file

@ -1,18 +1,19 @@
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import SearchBar from "./search-bar"; import SearchBar from "./search-bar";
import HeaderProfile from "./header-profile"; import HeaderProfile from "./header-profile";
import { Link } from "react-router";
export default function Header() { export default function Header() {
return ( 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"> <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 <Link
href={"/"} to={"/"}
aria-label="Go to Home Page" 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" 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" /> <img src="/logo.svg" width={56} height={45} alt="logo" />
TomodachiShare TomodachiShare
</a> </Link>
<div className="flex justify-center max-lg:justify-end max-md:justify-center"> <div className="flex justify-center max-lg:justify-end max-md:justify-center">
<SearchBar /> <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"> <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"> <li title="Random Mii">
<a <Link
href={`${import.meta.env.VITE_API_URL}/random`} to={`${import.meta.env.VITE_API_URL}/random`}
aria-label="Go to Random Link" aria-label="Go to Random Link"
className="pill button p-0! h-full aspect-square" className="pill button p-0! h-full aspect-square"
data-tooltip="Go to a Random Mii" data-tooltip="Go to a Random Mii"
> >
<Icon icon="mdi:dice-3" fontSize={28} /> <Icon icon="mdi:dice-3" fontSize={28} />
</a> </Link>
</li> </li>
<li> <li>
<a href={"/submit"} className="pill button h-full"> <Link to={"/submit"} className="pill button h-full">
Submit Submit
</a> </Link>
</li> </li>
<HeaderProfile /> <HeaderProfile />
</ul> </ul>

View file

@ -1,5 +1,6 @@
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import DeleteMiiButton from "./delete-mii-button"; import DeleteMiiButton from "./delete-mii-button";
import { Link } from "react-router";
interface Props { interface Props {
mii: any; mii: any;
@ -13,10 +14,10 @@ export default function AuthorButtons({ mii }: Props) {
return ( return (
<> <>
<a aria-label="Edit Mii" href={`/edit/${mii.id}`}> <Link aria-label="Edit Mii" to={`/edit/${mii.id}`}>
<Icon icon="mdi:pencil" /> <Icon icon="mdi:pencil" />
<span>Edit</span> <span>Edit</span>
</a> </Link>
<DeleteMiiButton miiId={mii.id} miiName={mii.name} likes={mii._count.likedBy ?? 0} inMiiPage /> <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 LikeButton from "../like-button";
import SubmitButton from "../submit-button"; import SubmitButton from "../submit-button";
import { useNavigate } from "react-router";
interface Props { interface Props {
miiId: number; miiId: number;
@ -13,6 +14,7 @@ interface Props {
} }
export default function DeleteMiiButton({ miiId, miiName, likes, inMiiPage }: Props) { export default function DeleteMiiButton({ miiId, miiName, likes, inMiiPage }: Props) {
const navigate = useNavigate();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [isVisible, setIsVisible] = useState(false); const [isVisible, setIsVisible] = useState(false);
@ -28,7 +30,7 @@ export default function DeleteMiiButton({ miiId, miiName, likes, inMiiPage }: Pr
} }
close(); close();
window.location.reload(); // I would use router.refresh() here but the Mii list doesn't update navigate(0);
}; };
const close = () => { const close = () => {

View file

@ -7,9 +7,10 @@ import GenderSelect from "./gender-select";
import OtherFilters from "./other-filters"; import OtherFilters from "./other-filters";
import MakeupSelect from "./makeup-select"; import MakeupSelect from "./makeup-select";
import type { MiiGender, MiiMakeup, MiiPlatform } from "@tomodachi-share/shared"; import type { MiiGender, MiiMakeup, MiiPlatform } from "@tomodachi-share/shared";
import { useSearchParams } from "react-router";
export default function FilterMenu() { export default function FilterMenu() {
const searchParams = new URLSearchParams(window.location.search); const [searchParams] = useSearchParams();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [isVisible, setIsVisible] = useState(false); const [isVisible, setIsVisible] = useState(false);

View file

@ -1,9 +1,11 @@
import { useState, useTransition } from "react"; import { useState, useTransition } from "react";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import type { MiiGender, MiiPlatform } from "@tomodachi-share/shared"; import type { MiiGender, MiiPlatform } from "@tomodachi-share/shared";
import { useNavigate, useSearchParams } from "react-router";
export default function GenderSelect() { export default function GenderSelect() {
const searchParams = new URLSearchParams(window.location.search); const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [, startTransition] = useTransition(); const [, startTransition] = useTransition();
const [selected, setSelected] = useState<MiiGender | null>((searchParams.get("gender") as MiiGender) ?? null); const [selected, setSelected] = useState<MiiGender | null>((searchParams.get("gender") as MiiGender) ?? null);
@ -23,8 +25,7 @@ export default function GenderSelect() {
} }
startTransition(() => { startTransition(() => {
// router.push(`?${params.toString()}`, { scroll: false }); navigate(`?${params.toString()}`);
window.location.href = `?${params.toString()}`;
}); });
}; };

View file

@ -1,9 +1,11 @@
import { useState, useTransition } from "react"; import { useState, useTransition } from "react";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import type { MiiMakeup } from "@tomodachi-share/shared"; import type { MiiMakeup } from "@tomodachi-share/shared";
import { useNavigate, useSearchParams } from "react-router";
export default function MakeupSelect() { export default function MakeupSelect() {
const searchParams = new URLSearchParams(window.location.search); const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [, startTransition] = useTransition(); const [, startTransition] = useTransition();
const [selected, setSelected] = useState<MiiMakeup | null>((searchParams.get("makeup") as MiiMakeup) ?? null); const [selected, setSelected] = useState<MiiMakeup | null>((searchParams.get("makeup") as MiiMakeup) ?? null);
@ -22,8 +24,7 @@ export default function MakeupSelect() {
} }
startTransition(() => { startTransition(() => {
// router.push(`?${params.toString()}`, { scroll: false }); navigate(`?${params.toString()}`);
window.location.href = `?${params.toString()}`;
}); });
}; };

View file

@ -2,6 +2,7 @@ import { Icon } from "@iconify/react";
import LikeButton from "../../like-button"; import LikeButton from "../../like-button";
import DeleteMiiButton from "../delete-mii-button"; import DeleteMiiButton from "../delete-mii-button";
import { Link } from "react-router";
interface Props { interface Props {
// miis: Prisma.MiiGetPayload<{ include: { user: { select: { id: true; name: true } }; _count: { select: { likedBy: true } } } }>[]; // 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> </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 <img
src={`${import.meta.env.VITE_API_URL}/mii/${mii.id}/image?type=mii`} src={`${import.meta.env.VITE_API_URL}/mii/${mii.id}/image?type=mii`}
width={240} width={240}
@ -33,13 +34,13 @@ export default function MiiGrid({ miis, userId, parentPage }: Props) {
alt="mii image" alt="mii image"
className="w-full h-auto aspect-3/2 object-contain" 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="p-4 flex flex-col gap-1 h-full">
<div className="flex justify-between"> <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} {mii.name}
</a> </Link>
<div title={mii.platform === "SWITCH" ? "Switch" : "3DS"} className="text-[1.25rem] opacity-25"> <div title={mii.platform === "SWITCH" ? "Switch" : "3DS"} className="text-[1.25rem] opacity-25">
{mii.platform === "SWITCH" ? ( {mii.platform === "SWITCH" ? (
<Icon icon="cib:nintendo-switch" className="text-red-400" /> <Icon icon="cib:nintendo-switch" className="text-red-400" />
@ -50,9 +51,9 @@ export default function MiiGrid({ miis, userId, parentPage }: Props) {
</div> </div>
<div id="tags" className="flex flex-wrap gap-1"> <div id="tags" className="flex flex-wrap gap-1">
{mii.tags.map((tag: string) => ( {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} {tag}
</a> </Link>
))} ))}
</div> </div>
@ -60,16 +61,16 @@ export default function MiiGrid({ miis, userId, parentPage }: Props) {
<LikeButton likes={mii._count.likedBy} miiId={mii.id} isLiked={false} abbreviate /> <LikeButton likes={mii._count.likedBy} miiId={mii.id} isLiked={false} abbreviate />
{!userId && ( {!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} @{mii.user?.name}
</a> </Link>
)} )}
{/* {userId && Number(session.data?.user?.id) == userId && ( {/* {userId && Number(session.data?.user?.id) == userId && (
<div className="flex gap-1 text-2xl justify-end text-zinc-400"> <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" /> <Icon icon="mdi:pencil" />
</a> </Link>
<DeleteMiiButton miiId={mii.id} miiName={mii.name} likes={mii._count.likedBy} /> <DeleteMiiButton miiId={mii.id} miiName={mii.name} likes={mii._count.likedBy} />
</div> </div>
)} */} )} */}

View file

@ -1,8 +1,11 @@
import type { MiiPlatform } from "@tomodachi-share/shared"; import type { MiiPlatform } from "@tomodachi-share/shared";
import { type ChangeEvent, useState, useTransition } from "react"; import { type ChangeEvent, useState, useTransition } from "react";
import { useLocation, useNavigate, useSearchParams } from "react-router";
export default function OtherFilters() { export default function OtherFilters() {
const searchParams = new URLSearchParams(window.location.search); const location = useLocation();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [, startTransition] = useTransition(); const [, startTransition] = useTransition();
const platform = (searchParams.get("platform") as MiiPlatform) || undefined; const platform = (searchParams.get("platform") as MiiPlatform) || undefined;
@ -22,8 +25,7 @@ export default function OtherFilters() {
} }
startTransition(() => { startTransition(() => {
// router.push(`?${params.toString()}`, { scroll: false }); navigate(`?${params.toString()}`);
window.location.href = `?${params.toString()}`;
}); });
}; };
@ -40,8 +42,7 @@ export default function OtherFilters() {
} }
startTransition(() => { startTransition(() => {
// router.push(`?${params.toString()}`, { scroll: false }); navigate(`?${params.toString()}`);
window.location.href = `?${params.toString()}`;
}); });
}; };

View file

@ -1,9 +1,11 @@
import { useState, useTransition } from "react"; import { useState, useTransition } from "react";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import type { MiiPlatform } from "@tomodachi-share/shared"; import type { MiiPlatform } from "@tomodachi-share/shared";
import { useNavigate, useSearchParams } from "react-router";
export default function PlatformSelect() { export default function PlatformSelect() {
const searchParams = new URLSearchParams(window.location.search); const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [, startTransition] = useTransition(); const [, startTransition] = useTransition();
const [selected, setSelected] = useState<MiiPlatform | null>((searchParams.get("platform") as MiiPlatform) ?? null); const [selected, setSelected] = useState<MiiPlatform | null>((searchParams.get("platform") as MiiPlatform) ?? null);
@ -20,8 +22,7 @@ export default function PlatformSelect() {
} }
startTransition(() => { startTransition(() => {
// router.push(`?${params.toString()}`); navigate(`?${params.toString()}`);
window.location.href = `?${params.toString()}`;
}); });
}; };

View file

@ -1,13 +1,15 @@
import { useTransition } from "react"; import { useTransition } from "react";
import { useSelect } from "downshift"; import { useSelect } from "downshift";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import { useNavigate, useSearchParams } from "react-router";
type Sort = "likes" | "newest" | "oldest" | "random"; type Sort = "likes" | "newest" | "oldest";
const items = ["likes", "newest", "oldest", "random"]; const items = ["likes", "newest", "oldest"];
export default function SortSelect() { export default function SortSelect() {
const searchParams = new URLSearchParams(window.location.search); const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [, startTransition] = useTransition(); const [, startTransition] = useTransition();
const currentSort = (searchParams.get("sort") as Sort) || "newest"; const currentSort = (searchParams.get("sort") as Sort) || "newest";
@ -22,13 +24,8 @@ export default function SortSelect() {
params.set("page", "1"); params.set("page", "1");
params.set("sort", selectedItem); params.set("sort", selectedItem);
if (selectedItem == "random") {
params.set("seed", Math.floor(Math.random() * 1_000_000_000).toString());
}
startTransition(() => { startTransition(() => {
// router.push(`?${params.toString()}`, { scroll: false }); navigate(`?${params.toString()}`);
window.location.href = `?${params.toString()}`;
}); });
}, },
}); });

View file

@ -1,12 +1,14 @@
import { useEffect, useMemo, useState, useTransition } from "react"; import { useEffect, useMemo, useState, useTransition } from "react";
import TagSelector from "../../tag-selector"; import TagSelector from "../../tag-selector";
import { useNavigate, useSearchParams } from "react-router";
interface Props { interface Props {
isExclude?: boolean; isExclude?: boolean;
} }
export default function TagFilter({ isExclude }: Props) { export default function TagFilter({ isExclude }: Props) {
const searchParams = new URLSearchParams(window.location.search); const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [, startTransition] = useTransition(); const [, startTransition] = useTransition();
const rawTags = searchParams.get(isExclude ? "exclude" : "tags") || ""; const rawTags = searchParams.get(isExclude ? "exclude" : "tags") || "";
@ -45,8 +47,7 @@ export default function TagFilter({ isExclude }: Props) {
} }
startTransition(() => { startTransition(() => {
// router.push(`?${params.toString()}`, { scroll: false }); navigate(`?${params.toString()}`);
window.location.href = `?${params.toString()}`;
}); });
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [tags]); }, [tags]);

View file

@ -1,6 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import { Link } from "react-router";
interface Props { interface Props {
miiId: number; miiId: number;
@ -128,15 +129,15 @@ export default function ShareMiiButton({ miiId }: Props) {
<div className="flex justify-end gap-2 mt-4"> <div className="flex justify-end gap-2 mt-4">
<div className="flex gap-2 w-full"> <div className="flex gap-2 w-full">
{/* Save button */} {/* Save button */}
<a <Link
href={`${import.meta.env.VITE_API_URL}/mii/${miiId}/image?type=metadata`} 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" className="pill button p-0! aspect-square size-11 cursor-pointer text-xl"
aria-label="Save Image" aria-label="Save Image"
data-tooltip="Save Image" data-tooltip="Save Image"
download={"hello.png"} download={"hello.png"}
> >
<Icon icon="material-symbols:save-rounded" /> <Icon icon="material-symbols:save-rounded" />
</a> </Link>
{/* Copy button */} {/* Copy button */}
<button <button

View file

@ -1,12 +1,14 @@
import { useCallback, useMemo } from "react"; import { useCallback, useMemo } from "react";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import { Link, useLocation, useSearchParams } from "react-router";
interface Props { interface Props {
lastPage: number; lastPage: number;
} }
export default function Pagination({ lastPage }: Props) { export default function Pagination({ lastPage }: Props) {
const searchParams = new URLSearchParams(location.search); const location = useLocation();
const [searchParams] = useSearchParams();
const page = Number(searchParams.get("page") ?? 1); const page = Number(searchParams.get("page") ?? 1);
const createPageUrl = useCallback( const createPageUrl = useCallback(
@ -33,63 +35,63 @@ export default function Pagination({ lastPage }: Props) {
return ( return (
<div className="flex justify-center items-center w-full mt-8"> <div className="flex justify-center items-center w-full mt-8">
{/* Go to first page */} {/* Go to first page */}
<a <Link
href={page === 1 ? "#" : createPageUrl(1)} to={page === 1 ? "#" : createPageUrl(1)}
aria-label="Go to First Page" aria-label="Go to First Page"
aria-disabled={page === 1} aria-disabled={page === 1}
tabIndex={page === 1 ? -1 : undefined} 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!"}`} 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" /> <Icon icon="stash:chevron-double-left" />
</a> </Link>
{/* Previous page */} {/* Previous page */}
<a <Link
href={page === 1 ? "#" : createPageUrl(page - 1)} to={page === 1 ? "#" : createPageUrl(page - 1)}
aria-label="Go to Previous Page" aria-label="Go to Previous Page"
aria-disabled={page === 1} aria-disabled={page === 1}
tabIndex={page === 1 ? -1 : undefined} 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!"}`} 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" /> <Icon icon="stash:chevron-left" />
</a> </Link>
{/* Page numbers */} {/* Page numbers */}
<div className="flex mx-2"> <div className="flex mx-2">
{numbers.map((number) => ( {numbers.map((number) => (
<a <Link
key={number} key={number}
href={createPageUrl(number)} to={createPageUrl(number)}
aria-label={`Go to Page ${number}`} aria-label={`Go to Page ${number}`}
aria-current={number === page ? "page" : undefined} 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!"}`} 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} {number}
</a> </Link>
))} ))}
</div> </div>
{/* Next page */} {/* Next page */}
<a <Link
href={page >= lastPage ? "#" : createPageUrl(page + 1)} to={page >= lastPage ? "#" : createPageUrl(page + 1)}
aria-label="Go to Next Page" aria-label="Go to Next Page"
aria-disabled={page >= lastPage} aria-disabled={page >= lastPage}
tabIndex={page >= lastPage ? -1 : undefined} 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!"}`} 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" /> <Icon icon="stash:chevron-right" />
</a> </Link>
{/* Go to last page */} {/* Go to last page */}
<a <Link
href={page >= lastPage ? "#" : createPageUrl(lastPage)} to={page >= lastPage ? "#" : createPageUrl(lastPage)}
aria-label="Go to Last Page" aria-label="Go to Last Page"
aria-disabled={page >= lastPage} aria-disabled={page >= lastPage}
tabIndex={page >= lastPage ? -1 : undefined} 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!"}`} 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" /> <Icon icon="stash:chevron-double-right" />
</a> </Link>
</div> </div>
); );
} }

View file

@ -3,6 +3,7 @@ import { Icon } from "@iconify/react";
import Description from "./description"; import Description from "./description";
import { useStore } from "@nanostores/react"; import { useStore } from "@nanostores/react";
import { session } from "../session"; import { session } from "../session";
import { Link } from "react-router";
interface Props { interface Props {
user?: any; 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="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"> <div className="flex w-full gap-4 overflow-x-scroll">
{/* Profile picture */} {/* 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" /> <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 */} {/* User information */}
<div className="flex flex-col w-full relative py-3"> <div className="flex flex-col w-full relative py-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -60,34 +61,34 @@ export default function ProfileInformation({ user, page }: Props) {
{/* Buttons */} {/* 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"> <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 && ( {!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" /> <Icon icon="material-symbols:flag-rounded" />
<span>Report</span> <span>Report</span>
</a> </Link>
)} )}
{isOwnProfile && isAdmin && ( {isOwnProfile && isAdmin && (
<a aria-label="Go to Admin" href="/admin"> <Link aria-label="Go to Admin" to="/admin">
<Icon icon="mdi:shield-moon" /> <Icon icon="mdi:shield-moon" />
<span>Admin</span> <span>Admin</span>
</a> </Link>
)} )}
{/* {isOwnProfile && page !== "likes" && ( {/* {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" /> <Icon icon="icon-park-solid:like" />
<span>My Likes</span> <span>My Likes</span>
</a> </Link>
)} */} )} */}
{isOwnProfile && page !== "settings" && ( {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" /> <Icon icon="material-symbols:settings-rounded" />
<span>Settings</span> <span>Settings</span>
</a> </Link>
)} )}
{page && ( {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" /> <Icon icon="tabler:chevron-left" />
<span>Back</span> <span>Back</span>
</a> </Link>
)} )}
</div> </div>
</div> </div>

View file

@ -2,8 +2,10 @@ import { useEffect, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import SubmitButton from "../submit-button"; import SubmitButton from "../submit-button";
import { useNavigate } from "react-router";
export default function DeleteAccount() { export default function DeleteAccount() {
const navigate = useNavigate();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [isVisible, setIsVisible] = useState(false); const [isVisible, setIsVisible] = useState(false);
@ -17,7 +19,7 @@ export default function DeleteAccount() {
return; return;
} }
window.location.href = "/404"; navigate("/404");
}; };
const close = () => { const close = () => {

View file

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

View file

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

View file

@ -1,16 +1,17 @@
import { useState } from "react"; import { useState } from "react";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import { querySchema } from "@tomodachi-share/shared/schemas"; import { querySchema } from "@tomodachi-share/shared/schemas";
import { useNavigate, useSearchParams } from "react-router";
export default function SearchBar() { export default function SearchBar() {
const searchParams = new URLSearchParams(window.location.search); const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [query, setQuery] = useState(searchParams.get("q") || ""); const [query, setQuery] = useState(searchParams.get("q") || "");
const handleSearch = () => { const handleSearch = () => {
const result = querySchema.safeParse(query); const result = querySchema.safeParse(query);
if (!result.success) { if (!result.success) {
// router.push("/", { scroll: false }); navigate("/", { preventScrollReset: true });
window.location.href = "/";
return; return;
} }
@ -19,8 +20,7 @@ export default function SearchBar() {
params.set("q", query); params.set("q", query);
params.set("page", "1"); params.set("page", "1");
// router.push(`/?${params.toString()}`, { scroll: false }); navigate(`/?${params.toString()}`, { preventScrollReset: true });
window.location.href = `/?${params.toString()}`;
}; };
const handleKeyDown = (event: React.KeyboardEvent) => { const handleKeyDown = (event: React.KeyboardEvent) => {

View file

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

View file

@ -1,7 +1,8 @@
import Footer from "./components/footer";
import Header from "./components/header";
import { useEffect } from "react"; import { useEffect } from "react";
import { ProgressProvider } from "@bprogress/react";
export default function Providers({ children }: { children: React.ReactNode }) { export default function Layout({ children }: { children: React.ReactNode }) {
// Calculate header height // Calculate header height
useEffect(() => { useEffect(() => {
const header = document.querySelector("header"); const header = document.querySelector("header");
@ -24,8 +25,11 @@ export default function Providers({ children }: { children: React.ReactNode }) {
}, []); }, []);
return ( return (
<ProgressProvider height="4px" color="var(--color-amber-500)" options={{ showSpinner: false }} shallowRouting> <>
{children} <Header />
</ProgressProvider> {/* <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 { createRoot } from "react-dom/client";
import { BrowserRouter, Route, Routes } from "react-router"; import { BrowserRouter, Route, Routes } from "react-router";
import "./index.css"; import "./index.css";
@ -13,19 +13,15 @@ import MiiPage from "./pages/mii.tsx";
import SubmitPage from "./pages/submit.tsx"; import SubmitPage from "./pages/submit.tsx";
import IndexPage from "./pages/index.tsx"; import IndexPage from "./pages/index.tsx";
import ProfileSettingsPage from "./pages/settings.tsx"; import ProfileSettingsPage from "./pages/settings.tsx";
import Providers from "./components/provider.tsx"; import { ProgressProvider } from "@bprogress/react";
import Header from "./components/header.tsx"; import LinkOutPage from "./pages/out.tsx";
import Footer from "./components/footer.tsx"; import Layout from "./layout.tsx";
createRoot(document.getElementById("root")!).render( createRoot(document.getElementById("root")!).render(
<StrictMode> <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> <Routes>
<Route path="/" element={<IndexPage />} /> <Route path="/" element={<IndexPage />} />
<Route path="/mii/:id" element={<MiiPage />} /> <Route path="/mii/:id" element={<MiiPage />} />
@ -35,13 +31,13 @@ createRoot(document.getElementById("root")!).render(
</Route> </Route>
<Route path="/submit" element={<SubmitPage />} /> <Route path="/submit" element={<SubmitPage />} />
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route path="/out" element={<LinkOutPage />} />
<Route path="/privacy" element={<PrivacyPage />} /> <Route path="/privacy" element={<PrivacyPage />} />
<Route path="/terms-of-service" element={<TermsOfServicePage />} /> <Route path="/terms-of-service" element={<TermsOfServicePage />} />
<Route path="*" element={<NotFoundPage />} /> <Route path="*" element={<NotFoundPage />} />
</Routes> </Routes>
</Layout>
</ProgressProvider>
</BrowserRouter> </BrowserRouter>
</main>
<Footer />
</Providers>
</StrictMode>, </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 FilterMenu from "../components/mii/list/filter-menu";
import SortSelect from "../components/mii/list/sort-select"; import SortSelect from "../components/mii/list/sort-select";
import MiiGrid from "../components/mii/list/mii-grid"; import MiiGrid from "../components/mii/list/mii-grid";
import Pagination from "../components/pagination"; import Pagination from "../components/pagination";
import Skeleton from "../components/mii/list/skeleton"; import Skeleton from "../components/mii/list/skeleton";
import { useSearchParams } from "react-router";
interface ApiResponse { interface ApiResponse {
totalCount: number; totalCount: number;
@ -12,7 +13,7 @@ interface ApiResponse {
} }
export default function IndexPage() { export default function IndexPage() {
const searchParams = useMemo(() => new URLSearchParams(location.search), []); const [searchParams] = useSearchParams();
const [data, setData] = useState<ApiResponse | null>(null); const [data, setData] = useState<ApiResponse | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);

View file

@ -1,6 +1,6 @@
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import { useStore } from "@nanostores/react"; import { useStore } from "@nanostores/react";
import { useNavigate } from "react-router"; import { Link, useNavigate } from "react-router";
import { session } from "../session"; import { session } from "../session";
export default function LoginPage() { export default function LoginPage() {
@ -23,41 +23,41 @@ export default function LoginPage() {
</div> </div>
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
<a <Link
href={`${API_URL}/api/auth/signin/discord`} to={`${API_URL}/api/auth/signin/discord`}
aria-label="Login with Discord" aria-label="Login with Discord"
className="pill button gap-2 px-3! bg-indigo-400! border-indigo-500! hover:bg-indigo-500!" className="pill button gap-2 px-3! bg-indigo-400! border-indigo-500! hover:bg-indigo-500!"
> >
<Icon icon="ic:baseline-discord" fontSize={32} /> <Icon icon="ic:baseline-discord" fontSize={32} />
Login with Discord Login with Discord
</a> </Link>
<a <Link
href={`${API_URL}/api/auth/signin/github`} to={`${API_URL}/api/auth/signin/github`}
aria-label="Login with 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" 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} /> <Icon icon="mdi:github" fontSize={32} />
Login with GitHub Login with GitHub
</a> </Link>
<a <Link
href={`${API_URL}/api/auth/signin/google`} to={`${API_URL}/api/auth/signin/google`}
aria-label="Login with 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" 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} /> <Icon icon="material-icon-theme:google" fontSize={32} />
Login with Google Login with Google
</a> </Link>
</div> </div>
<p className="mt-8 text-xs text-zinc-400"> <p className="mt-8 text-xs text-zinc-400">
By signing up, you agree to the{" "} 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 Terms of Service
</a>{" "} </Link>{" "}
and{" "} and{" "}
<a href="/privacy" className="underline hover:text-zinc-600"> <Link to="/privacy" className="underline hover:text-zinc-600">
Privacy Policy Privacy Policy
</a> </Link>
. .
</p> </p>
</div> </div>

View file

@ -9,10 +9,11 @@ import SwitchAddMiiTutorialButton from "../components/tutorial/switch-add-mii";
import MiiInstructions from "../components/mii/instructions"; import MiiInstructions from "../components/mii/instructions";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Link, useParams } from "react-router"; import { Link, useNavigate, useParams } from "react-router";
export default function MiiPage() { export default function MiiPage() {
const { id } = useParams(); const { id } = useParams();
const navigate = useNavigate();
const [mii, setMii] = useState<any>(null); const [mii, setMii] = useState<any>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -31,7 +32,7 @@ export default function MiiPage() {
.catch((err) => { .catch((err) => {
console.error(err); console.error(err);
setLoading(false); setLoading(false);
window.location.href = "/404"; navigate("/404");
}); });
}, [id]); }, [id]);

View file

@ -1,14 +1,17 @@
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import { Link } from "react-router";
export default function NotFoundPage() { export default function NotFoundPage() {
return <div className="grow flex items-center justify-center"> 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"> <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> <h2 className="text-7xl font-black">404</h2>
<p>Page not found - you swam off the island!</p> <p>Page not found - you swam off the island!</p>
<a href="/" className="pill button gap-2 mt-8 w-fit self-center"> <Link to="/" className="pill button gap-2 mt-8 w-fit self-center">
<Icon icon="ic:round-home" fontSize={24} /> <Icon icon="ic:round-home" fontSize={24} />
Travel Back Travel Back
</a> </Link>
</div> </div>
</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 { useEffect, useState } from "react";
import ProfileInformation from "../components/profile-information"; import ProfileInformation from "../components/profile-information";
import { useParams } from "react-router"; import { useNavigate, useParams } from "react-router";
export default function ProfilePage() { export default function ProfilePage() {
const { id } = useParams(); const { id } = useParams();
const navigate = useNavigate();
const [user, setUser] = useState<any>(null); const [user, setUser] = useState<any>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -20,7 +21,7 @@ export default function ProfilePage() {
.catch((err) => { .catch((err) => {
console.error(err); console.error(err);
setLoading(false); setLoading(false);
window.location.href = "/404"; navigate("/404");
}); });
}, [id]); }, [id]);

View file

@ -1,5 +1,8 @@
import { Link } from "react-router";
export default function TermsOfServicePage() { export default function TermsOfServicePage() {
return <div className="bg-amber-50 border-2 border-amber-500 rounded-2xl p-6"> 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> <h1 className="text-2xl font-bold">Terms of Service</h1>
<h2 className="font-light"> <h2 className="font-light">
<strong className="font-medium">Effective Date:</strong> March 26, 2026 <strong className="font-medium">Effective Date:</strong> March 26, 2026
@ -13,7 +16,10 @@ export default function TermsOfServicePage() {
</p> </p>
<p className="mt-1"> <p className="mt-1">
If you have any questions or concerns, please contact me at:{" "} 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> <a href="mailto:hello@trafficlunar.net" className="text-blue-700">
{" "}
hello@trafficlunar.net{" "}
</a>
. .
</p> </p>
@ -50,9 +56,15 @@ export default function TermsOfServicePage() {
</p> </p>
<p> <p>
To request deletion of your account and personal data, please refer to the{" "} To request deletion of your account and personal data, please refer to the{" "}
<a href="/privacy" className="text-blue-700"> Privacy Policy </a>{" "} <Link to="/privacy" className="text-blue-700">
{" "}
Privacy Policy{" "}
</Link>{" "}
(see &quot;Data Deletion&quot;) or email me at{" "} (see &quot;Data Deletion&quot;) or email me at{" "}
<a href="mailto:hello@trafficlunar.net" className="text-blue-700"> hello@trafficlunar.net </a> <a href="mailto:hello@trafficlunar.net" className="text-blue-700">
{" "}
hello@trafficlunar.net{" "}
</a>
</p> </p>
</section> </section>
</li> </li>
@ -68,8 +80,8 @@ export default function TermsOfServicePage() {
<section> <section>
<p className="mb-2"> <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 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
on the site. You use the site at your own risk. users on the site. You use the site at your own risk.
</p> </p>
<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 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
@ -83,7 +95,10 @@ export default function TermsOfServicePage() {
<section> <section>
<p className="mb-2"> <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{" "} 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>{" "} <a href="mailto:hello@trafficlunar.net" className="text-blue-700">
{" "}
hello@trafficlunar.net{" "}
</a>{" "}
or by reporting the Mii on its page. or by reporting the Mii on its page.
</p> </p>
<p className="mb-2">Please include:</p> <p className="mb-2">Please include:</p>
@ -125,5 +140,6 @@ export default function TermsOfServicePage() {
</section> </section>
</li> </li>
</ul> </ul>
</div>; </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({ export const searchSchema = z.object({
q: querySchema.optional(), 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 tags: z
.string() .string()
.optional() .optional()
@ -71,8 +71,6 @@ export const searchSchema = z.object({
.max(100, { error: "Limit cannot be more than 100" }) .max(100, { error: "Limit cannot be more than 100" })
.optional(), .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(), 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 // Other
parentPage: z.string().optional(), parentPage: z.string().optional(),
userId: idSchema.optional(), userId: idSchema.optional(),