diff --git a/backend/src/app/robots.ts b/backend/src/app/robots.ts
deleted file mode 100644
index f0bc168..0000000
--- a/backend/src/app/robots.ts
+++ /dev/null
@@ -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`,
- };
-}
diff --git a/frontend/package.json b/frontend/package.json
index 6905b0e..be36be5 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -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"
+ }
}
diff --git a/frontend/public/robots.txt b/frontend/public/robots.txt
new file mode 100644
index 0000000..3e0910d
--- /dev/null
+++ b/frontend/public/robots.txt
@@ -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
diff --git a/frontend/src/components/description.tsx b/frontend/src/components/description.tsx
index 6b5f22d..17efeb6 100644
--- a/frontend/src/components/description.tsx
+++ b/frontend/src/components/description.tsx
@@ -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 (
-
-
+
);
} catch {
// Normal text/Invalid URL fallback
diff --git a/frontend/src/components/footer.tsx b/frontend/src/components/footer.tsx
index 69b3358..393002b 100644
--- a/frontend/src/components/footer.tsx
+++ b/frontend/src/components/footer.tsx
@@ -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 */}
{/* Copyright */}
diff --git a/frontend/src/components/header-profile.tsx b/frontend/src/components/header-profile.tsx
index f3a50f9..4853946 100644
--- a/frontend/src/components/header-profile.tsx
+++ b/frontend/src/components/header-profile.tsx
@@ -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 ? (
-
+
Login
-
+
) : (
<>
-
{$session?.user?.name ?? "unknown"}
-
+
-
+
-
+
>
)}
diff --git a/frontend/src/components/header.tsx b/frontend/src/components/header.tsx
index 72a2ba0..96467b7 100644
--- a/frontend/src/components/header.tsx
+++ b/frontend/src/components/header.tsx
@@ -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 (
-
TomodachiShare
-
+
@@ -20,19 +21,19 @@ export default function Header() {
diff --git a/frontend/src/components/mii/author-buttons.tsx b/frontend/src/components/mii/author-buttons.tsx
index f75a8a0..c7f4840 100644
--- a/frontend/src/components/mii/author-buttons.tsx
+++ b/frontend/src/components/mii/author-buttons.tsx
@@ -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 (
- <>
-
-
- Edit
-
-
- >
- );
-}
+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 (
+ <>
+
+
+
Edit
+
+
+ >
+ );
+}
diff --git a/frontend/src/components/mii/delete-mii-button.tsx b/frontend/src/components/mii/delete-mii-button.tsx
index 50b4ee0..3820466 100644
--- a/frontend/src/components/mii/delete-mii-button.tsx
+++ b/frontend/src/components/mii/delete-mii-button.tsx
@@ -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 = () => {
diff --git a/frontend/src/components/mii/list/filter-menu.tsx b/frontend/src/components/mii/list/filter-menu.tsx
index 3be5e3d..b7504e8 100644
--- a/frontend/src/components/mii/list/filter-menu.tsx
+++ b/frontend/src/components/mii/list/filter-menu.tsx
@@ -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 (
-
-
-
- {isOpen && (
-
- {/* Arrow */}
-
-
-
-
- Platform
-
-
-
-
-
-
- Gender
-
-
-
-
-
-
- Tags Include
-
-
-
-
-
-
- Tags Exclude
-
-
-
-
- {platform !== "THREE_DS" && (
- <>
-
-
- Face Paint
-
-
-
- >
- )}
-
-
-
- )}
-
- );
-}
+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 (
+
+
+
+ {isOpen && (
+
+ {/* Arrow */}
+
+
+
+
+ Platform
+
+
+
+
+
+
+ Gender
+
+
+
+
+
+
+ Tags Include
+
+
+
+
+
+
+ Tags Exclude
+
+
+
+
+ {platform !== "THREE_DS" && (
+ <>
+
+
+ Face Paint
+
+
+
+ >
+ )}
+
+
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/mii/list/gender-select.tsx b/frontend/src/components/mii/list/gender-select.tsx
index cdf70db..2fd9cdb 100644
--- a/frontend/src/components/mii/list/gender-select.tsx
+++ b/frontend/src/components/mii/list/gender-select.tsx
@@ -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
((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 (
-
-
-
-
-
- {platform !== "THREE_DS" && (
-
- )}
-
- );
-}
+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((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 (
+
+
+
+
+
+ {platform !== "THREE_DS" && (
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/mii/list/makeup-select.tsx b/frontend/src/components/mii/list/makeup-select.tsx
index 40f8926..6747dbb 100644
--- a/frontend/src/components/mii/list/makeup-select.tsx
+++ b/frontend/src/components/mii/list/makeup-select.tsx
@@ -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((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 (
-
- {/* Full Makeup */}
-
-
- {/* Partial Makeup */}
-
-
- {/* No Makeup */}
-
-
- );
-}
+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((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 (
+
+ {/* Full Makeup */}
+
+
+ {/* Partial Makeup */}
+
+
+ {/* No Makeup */}
+
+
+ );
+}
diff --git a/frontend/src/components/mii/list/mii-grid.tsx b/frontend/src/components/mii/list/mii-grid.tsx
index f9382fd..f9a2e0a 100644
--- a/frontend/src/components/mii/list/mii-grid.tsx
+++ b/frontend/src/components/mii/list/mii-grid.tsx
@@ -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) {
)}
-
+
-
+
-
+
{mii.name}
-
+
{mii.platform === "SWITCH" ? (
@@ -50,9 +51,9 @@ export default function MiiGrid({ miis, userId, parentPage }: Props) {
@@ -60,16 +61,16 @@ export default function MiiGrid({ miis, userId, parentPage }: Props) {
{!userId && (
-
+
@{mii.user?.name}
-
+
)}
{/* {userId && Number(session.data?.user?.id) == userId && (
)} */}
diff --git a/frontend/src/components/mii/list/other-filters.tsx b/frontend/src/components/mii/list/other-filters.tsx
index 24b4a0e..3da9ebc 100644
--- a/frontend/src/components/mii/list/other-filters.tsx
+++ b/frontend/src/components/mii/list/other-filters.tsx
@@ -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
((searchParams.get("allowCopying") as unknown as boolean) ?? false);
- const [quarantined, setQuarantined] = useState((searchParams.get("quarantined") as unknown as boolean) ?? false);
-
- const handleChangeAllowCopying = (e: ChangeEvent) => {
- 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) => {
- 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 (
- <>
-
-
- Other
-
-
-
- {showAllowCopying && (
-
-
-
-
- )}
- {showQuarantined && (
-
-
-
-
- )}
- >
- );
-}
+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((searchParams.get("allowCopying") as unknown as boolean) ?? false);
+ const [quarantined, setQuarantined] = useState((searchParams.get("quarantined") as unknown as boolean) ?? false);
+
+ const handleChangeAllowCopying = (e: ChangeEvent) => {
+ 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) => {
+ 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 (
+ <>
+
+
+ Other
+
+
+
+ {showAllowCopying && (
+
+
+
+
+ )}
+ {showQuarantined && (
+
+
+
+
+ )}
+ >
+ );
+}
diff --git a/frontend/src/components/mii/list/platform-select.tsx b/frontend/src/components/mii/list/platform-select.tsx
index 38e9086..01bdf79 100644
--- a/frontend/src/components/mii/list/platform-select.tsx
+++ b/frontend/src/components/mii/list/platform-select.tsx
@@ -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((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 (
-
-
-
-
-
- );
-}
+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((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 (
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/mii/list/sort-select.tsx b/frontend/src/components/mii/list/sort-select.tsx
index 7aff6ac..a28d1f8 100644
--- a/frontend/src/components/mii/list/sort-select.tsx
+++ b/frontend/src/components/mii/list/sort-select.tsx
@@ -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 (
-
- {/* Toggle button to open the dropdown */}
-
-
- {/* Dropdown menu */}
-
- {isOpen &&
- items.map((item, index) => (
- -
- {item}
-
- ))}
-
-
- );
-}
+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 (
+
+ {/* Toggle button to open the dropdown */}
+
+
+ {/* Dropdown menu */}
+
+ {isOpen &&
+ items.map((item, index) => (
+ -
+ {item}
+
+ ))}
+
+
+ );
+}
diff --git a/frontend/src/components/mii/list/tag-filter.tsx b/frontend/src/components/mii/list/tag-filter.tsx
index 2c89171..a7df351 100644
--- a/frontend/src/components/mii/list/tag-filter.tsx
+++ b/frontend/src/components/mii/list/tag-filter.tsx
@@ -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(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 (
-
-
-
- );
-}
+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(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 (
+
+
+
+ );
+}
diff --git a/frontend/src/components/mii/share-mii-button.tsx b/frontend/src/components/mii/share-mii-button.tsx
index 38f3d37..8ce513b 100644
--- a/frontend/src/components/mii/share-mii-button.tsx
+++ b/frontend/src/components/mii/share-mii-button.tsx
@@ -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) {
{/* Save button */}
-
-
+
{/* Copy button */}