From 12203901e914f66b319be41bd46e74e47b444c92 Mon Sep 17 00:00:00 2001 From: trafficlunar Date: Fri, 17 Apr 2026 18:25:33 +0100 Subject: [PATCH] feat: use react-router for links and redirects --- backend/src/app/robots.ts | 12 - frontend/package.json | 101 ++++--- frontend/public/robots.txt | 13 + frontend/src/components/description.tsx | 7 +- frontend/src/components/footer.tsx | 23 +- frontend/src/components/header-profile.tsx | 15 +- frontend/src/components/header.tsx | 17 +- .../src/components/mii/author-buttons.tsx | 47 +-- .../src/components/mii/delete-mii-button.tsx | 4 +- .../src/components/mii/list/filter-menu.tsx | 265 ++++++++--------- .../src/components/mii/list/gender-select.tsx | 145 ++++----- .../src/components/mii/list/makeup-select.tsx | 145 ++++----- frontend/src/components/mii/list/mii-grid.tsx | 21 +- .../src/components/mii/list/other-filters.tsx | 159 +++++----- .../components/mii/list/platform-select.tsx | 111 +++---- .../src/components/mii/list/sort-select.tsx | 119 ++++---- .../src/components/mii/list/tag-filter.tsx | 119 ++++---- .../src/components/mii/share-mii-button.tsx | 7 +- frontend/src/components/pagination.tsx | 192 ++++++------ .../src/components/profile-information.tsx | 25 +- .../profile-settings/delete-account.tsx | 164 +++++------ .../src/components/profile-settings/index.tsx | 6 +- .../profile-settings/profile-picture.tsx | 4 +- frontend/src/components/search-bar.tsx | 100 +++---- frontend/src/components/submit-form/index.tsx | 4 +- .../{components/provider.tsx => layout.tsx} | 66 +++-- frontend/src/main.tsx | 26 +- frontend/src/pages/index.tsx | 5 +- frontend/src/pages/login.tsx | 28 +- frontend/src/pages/mii.tsx | 5 +- frontend/src/pages/not-found.tsx | 31 +- frontend/src/pages/out.tsx | 64 ++++ frontend/src/pages/profile.tsx | 5 +- frontend/src/pages/terms-of-service.tsx | 274 +++++++++--------- shared/src/schemas.ts | 4 +- 35 files changed, 1222 insertions(+), 1111 deletions(-) delete mode 100644 backend/src/app/robots.ts create mode 100644 frontend/public/robots.txt rename frontend/src/{components/provider.tsx => layout.tsx} (66%) create mode 100644 frontend/src/pages/out.tsx 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 */}
- + Terms of Service - + - + Privacy Policy - + - Discord - + - + Made by trafficlunar - +
{/* 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 (
    - logo 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 image - +
    - + {mii.name} - +
    {mii.platform === "SWITCH" ? ( @@ -50,9 +51,9 @@ export default function MiiGrid({ miis, userId, parentPage }: Props) {
    {mii.tags.map((tag: string) => ( - + {tag} - + ))}
    @@ -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 */} - - {isOpen && - createPortal( -
    -
    - -
    -
    -

    Delete Account

    - -
    - -

    Are you sure? This is permanent and will remove all uploaded Miis. This action cannot be undone.

    - - {error && Error: {error}} - -
    - - -
    -
    -
    , - 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(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 ( + <> + + + {isOpen && + createPortal( +
    +
    + +
    +
    +

    Delete Account

    + +
    + +

    Are you sure? This is permanent and will remove all uploaded Miis. This action cannot be undone.

    + + {error && Error: {error}} + +
    + + +
    +
    +
    , + document.body, + )} + + ); +} diff --git a/frontend/src/components/profile-settings/index.tsx b/frontend/src/components/profile-settings/index.tsx index 0a7192b..f9d2b72 100644 --- a/frontend/src/components/profile-settings/index.tsx +++ b/frontend/src/components/profile-settings/index.tsx @@ -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 ( diff --git a/frontend/src/components/profile-settings/profile-picture.tsx b/frontend/src/components/profile-settings/profile-picture.tsx index 41a19af..3035dfa 100644 --- a/frontend/src/components/profile-settings/profile-picture.tsx +++ b/frontend/src/components/profile-settings/profile-picture.tsx @@ -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(undefined); const [newPicture, setNewPicture] = useState(); @@ -30,7 +32,7 @@ export default function ProfilePictureSettings() { } close(); - location.reload(); + navigate(0); }; const handleDrop = useCallback((acceptedFiles: FileWithPath[]) => { diff --git a/frontend/src/components/search-bar.tsx b/frontend/src/components/search-bar.tsx index 597c471..73d5b1a 100644 --- a/frontend/src/components/search-bar.tsx +++ b/frontend/src/components/search-bar.tsx @@ -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 ( -
    - 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" - /> - -
    - ); -} +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 ( +
    + 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" + /> + +
    + ); +} diff --git a/frontend/src/components/submit-form/index.tsx b/frontend/src/components/submit-form/index.tsx index e9f01d5..ed385e7 100644 --- a/frontend/src/components/submit-form/index.tsx +++ b/frontend/src/components/submit-form/index.tsx @@ -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([]); const handleDrop = useCallback( @@ -113,7 +115,7 @@ export default function SubmitForm() { return; } - window.location.href = `/mii/${id}`; + navigate(`/mii/${id}`); }; useEffect(() => { diff --git a/frontend/src/components/provider.tsx b/frontend/src/layout.tsx similarity index 66% rename from frontend/src/components/provider.tsx rename to frontend/src/layout.tsx index 5f20677..509927f 100644 --- a/frontend/src/components/provider.tsx +++ b/frontend/src/layout.tsx @@ -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 ( - - {children} - - ); -} +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 ( + <> +
    + {/* */} +
    {children}
    +
    + + ); +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index f67bf7b..101ab9a 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -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( - - Loading header...
    }> -
    - - {/* */} -
    - + + + } /> } /> @@ -35,13 +31,13 @@ createRoot(document.getElementById("root")!).render( } /> } /> + } /> } /> } /> } /> - -
    -
    - + + + , ); diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx index fcaac8f..5d72df9 100644 --- a/frontend/src/pages/index.tsx +++ b/frontend/src/pages/index.tsx @@ -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(null); const [loading, setLoading] = useState(true); diff --git a/frontend/src/pages/login.tsx b/frontend/src/pages/login.tsx index 29d838e..12c3c7a 100644 --- a/frontend/src/pages/login.tsx +++ b/frontend/src/pages/login.tsx @@ -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() {

    By signing up, you agree to the{" "} - + Terms of Service - {" "} + {" "} and{" "} - + Privacy Policy - + .

    diff --git a/frontend/src/pages/mii.tsx b/frontend/src/pages/mii.tsx index 00b4778..96de775 100644 --- a/frontend/src/pages/mii.tsx +++ b/frontend/src/pages/mii.tsx @@ -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(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]); diff --git a/frontend/src/pages/not-found.tsx b/frontend/src/pages/not-found.tsx index e9c3f71..f3a5237 100644 --- a/frontend/src/pages/not-found.tsx +++ b/frontend/src/pages/not-found.tsx @@ -1,14 +1,17 @@ -import { Icon } from "@iconify/react"; - -export default function NotFoundPage() { - return
    -
    -

    404

    -

    Page not found - you swam off the island!

    - - - Travel Back - -
    -
    -} \ No newline at end of file +import { Icon } from "@iconify/react"; +import { Link } from "react-router"; + +export default function NotFoundPage() { + return ( +
    +
    +

    404

    +

    Page not found - you swam off the island!

    + + + Travel Back + +
    +
    + ); +} diff --git a/frontend/src/pages/out.tsx b/frontend/src/pages/out.tsx new file mode 100644 index 0000000..16f85dc --- /dev/null +++ b/frontend/src/pages/out.tsx @@ -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 ( +
    +
    +

    + + Warning +

    +

    You're attempting to leave TomodachiShare island! The destination website is potentially dangerous.

    + +
    + {url} +
    + +
    + + + Travel Back + + + + Continue + +
    +
    +
    + ); +} + +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", +]); diff --git a/frontend/src/pages/profile.tsx b/frontend/src/pages/profile.tsx index 6c1aaa1..c184a4f 100644 --- a/frontend/src/pages/profile.tsx +++ b/frontend/src/pages/profile.tsx @@ -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(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]); diff --git a/frontend/src/pages/terms-of-service.tsx b/frontend/src/pages/terms-of-service.tsx index 2e7e9d7..48f8985 100644 --- a/frontend/src/pages/terms-of-service.tsx +++ b/frontend/src/pages/terms-of-service.tsx @@ -1,129 +1,145 @@ -export default function TermsOfServicePage() { - return
    -

    Terms of Service

    -

    - Effective Date: March 26, 2026 -

    - -
    - -

    - 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. -

    -

    - If you have any questions or concerns, please contact me at:{" "} - hello@trafficlunar.net - . -

    - -
      -
    • -

      Usage Policy

      - -
      -

      As a user of this site, you must abide by these guidelines:

      -
        -
      • Nothing that would interfere with or gain unauthorized access to the website or its systems.
      • -
      • Nothing that is against the law in the United Kingdom.
      • -
      • No NSFW, violent, gory, or inappropriate Miis or images.
      • -
      • No spam.
      • -
      • No impersonation of others.
      • -
      • No malware, malicious links, or phishing content.
      • -
      • No harassment, hate speech, threats, or bullying towards others.
      • -
      • Miis must be high quality: for example, not following all instructions on the submit form correctly.
      • -
      • Avoid using inappropriate language. Profanity may be automatically censored.
      • -
      • No use of automated scripts, bots, or scrapers to access or interact with the site.
      • -
      -

      - If you find anybody or a Mii breaking these rules, please report it by going to their page and clicking the "Report" button. -

      -
      -
    • -
    • -

      Termination

      - -
      -

      - 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. -

      -

      - To request deletion of your account and personal data, please refer to the{" "} - Privacy Policy {" "} - (see "Data Deletion") or email me at{" "} - hello@trafficlunar.net -

      -
      -
    • -
    • -

      Eligibility

      -
      -

      By using this service, you confirm that you are at least 13 years old or have the consent of a parent or guardian.

      -
      -
    • - -
    • -

      Liability

      - -
      -

      - This service is provided "as is" 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. -

      -

      - 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. -

      -
      -
    • -
    • -

      DMCA & Copyright

      - -
      -

      - If you believe that content uploaded to this site infringes on your copyright, you may submit a DMCA takedown request by emailing{" "} - hello@trafficlunar.net {" "} - or by reporting the Mii on its page. -

      -

      Please include:

      -
        -
      • Your name and contact information
      • -
      • A description of the copyrighted work
      • -
      • A link to the allegedly infringing material
      • -
      • A statement that you have a good faith belief that the use is not authorized
      • -
      • - 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 -
      • -
      • Your electronic or physical signature
      • -
      -
      -
    • -
    • -

      Nintendo Disclaimer

      - -
      -

      - This site is not affiliated with, endorsed by, or associated with Nintendo in any way. "Mii" and all related character designs are - trademarks of Nintendo Co., Ltd. -

      -

      - 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. -

      -
      -
    • -
    • -

      Changes to this Terms of Service

      - -
      -

      - 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. -

      -
      -
    • -
    -
    ; -} \ No newline at end of file +import { Link } from "react-router"; + +export default function TermsOfServicePage() { + return ( +
    +

    Terms of Service

    +

    + Effective Date: March 26, 2026 +

    + +
    + +

    + 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. +

    +

    + If you have any questions or concerns, please contact me at:{" "} + + {" "} + hello@trafficlunar.net{" "} + + . +

    + +
      +
    • +

      Usage Policy

      + +
      +

      As a user of this site, you must abide by these guidelines:

      +
        +
      • Nothing that would interfere with or gain unauthorized access to the website or its systems.
      • +
      • Nothing that is against the law in the United Kingdom.
      • +
      • No NSFW, violent, gory, or inappropriate Miis or images.
      • +
      • No spam.
      • +
      • No impersonation of others.
      • +
      • No malware, malicious links, or phishing content.
      • +
      • No harassment, hate speech, threats, or bullying towards others.
      • +
      • Miis must be high quality: for example, not following all instructions on the submit form correctly.
      • +
      • Avoid using inappropriate language. Profanity may be automatically censored.
      • +
      • No use of automated scripts, bots, or scrapers to access or interact with the site.
      • +
      +

      + If you find anybody or a Mii breaking these rules, please report it by going to their page and clicking the "Report" button. +

      +
      +
    • +
    • +

      Termination

      + +
      +

      + 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. +

      +

      + To request deletion of your account and personal data, please refer to the{" "} + + {" "} + Privacy Policy{" "} + {" "} + (see "Data Deletion") or email me at{" "} + + {" "} + hello@trafficlunar.net{" "} + +

      +
      +
    • +
    • +

      Eligibility

      +
      +

      By using this service, you confirm that you are at least 13 years old or have the consent of a parent or guardian.

      +
      +
    • + +
    • +

      Liability

      + +
      +

      + This service is provided "as is" 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. +

      +

      + 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. +

      +
      +
    • +
    • +

      DMCA & Copyright

      + +
      +

      + If you believe that content uploaded to this site infringes on your copyright, you may submit a DMCA takedown request by emailing{" "} + + {" "} + hello@trafficlunar.net{" "} + {" "} + or by reporting the Mii on its page. +

      +

      Please include:

      +
        +
      • Your name and contact information
      • +
      • A description of the copyrighted work
      • +
      • A link to the allegedly infringing material
      • +
      • A statement that you have a good faith belief that the use is not authorized
      • +
      • + 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 +
      • +
      • Your electronic or physical signature
      • +
      +
      +
    • +
    • +

      Nintendo Disclaimer

      + +
      +

      + This site is not affiliated with, endorsed by, or associated with Nintendo in any way. "Mii" and all related character designs are + trademarks of Nintendo Co., Ltd. +

      +

      + 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. +

      +
      +
    • +
    • +

      Changes to this Terms of Service

      + +
      +

      + 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. +

      +
      +
    • +
    +
    + ); +} diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index 2f639fa..483ef0b 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -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(),