mirror of
https://github.com/trafficlunar/tomodachi-share.git
synced 2026-05-13 13:17:45 +00:00
feat: use react-router for links and redirects
This commit is contained in:
parent
87b885a2f8
commit
12203901e9
35 changed files with 1222 additions and 1111 deletions
|
|
@ -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`,
|
||||
};
|
||||
}
|
||||
|
|
@ -40,7 +40,6 @@
|
|||
"@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",
|
||||
|
|
|
|||
13
frontend/public/robots.txt
Normal file
13
frontend/public/robots.txt
Normal 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
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { Icon } from "@iconify/react";
|
||||
import { Link } from "react-router";
|
||||
|
||||
interface Props {
|
||||
text: string;
|
||||
|
|
@ -19,9 +20,9 @@ export default function Description({ text, className }: Props) {
|
|||
const url = new URL(part);
|
||||
|
||||
return (
|
||||
<a
|
||||
<Link
|
||||
key={index}
|
||||
href={`/out?url=${encodeURIComponent(part)}`}
|
||||
to={`/out?url=${encodeURIComponent(part)}`}
|
||||
target="_blank"
|
||||
className="text-blue-700 underline break-all ml-1 inline-flex items-center group"
|
||||
title={`Go to ${url.hostname}`}
|
||||
|
|
@ -30,7 +31,7 @@ export default function Description({ text, className }: Props) {
|
|||
{url.pathname !== "/" ? url.pathname : ""}
|
||||
{url.search}
|
||||
<Icon icon="mi:arrow-right-up" fontSize={16} className="transition group-hover:translate-x-0.5 group-hover:-translate-y-0.5" />
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
} catch {
|
||||
// Normal text/Invalid URL fallback
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { Icon } from "@iconify/react";
|
||||
import { Link } from "react-router";
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
|
|
@ -11,38 +12,42 @@ export default function Footer() {
|
|||
|
||||
{/* Links section */}
|
||||
<div className="flex flex-wrap justify-center items-center gap-x-4 text-sm max-sm:gap-x-12">
|
||||
<a href="/terms-of-service" className="text-zinc-500 hover:text-zinc-700 transition-colors duration-200 hover:underline">
|
||||
<Link to="/terms-of-service" className="text-zinc-500 hover:text-zinc-700 transition-colors duration-200 hover:underline">
|
||||
Terms of Service
|
||||
</a>
|
||||
</Link>
|
||||
|
||||
<span className="text-zinc-400 hidden sm:inline" aria-hidden="true">
|
||||
•
|
||||
</span>
|
||||
|
||||
<a href="/privacy" className="text-zinc-500 hover:text-zinc-700 transition-colors duration-200 hover:underline">
|
||||
<Link to="/privacy" className="text-zinc-500 hover:text-zinc-700 transition-colors duration-200 hover:underline">
|
||||
Privacy Policy
|
||||
</a>
|
||||
</Link>
|
||||
|
||||
<span className="text-zinc-400 hidden sm:inline" aria-hidden="true">
|
||||
•
|
||||
</span>
|
||||
|
||||
<a
|
||||
href="https://discord.gg/48cXBFKvWQ"
|
||||
<Link
|
||||
to="https://discord.gg/48cXBFKvWQ"
|
||||
target="_blank"
|
||||
className="text-[#5865F2] hover:text-[#454FBF] transition-colors duration-200 hover:underline inline-flex items-end gap-1"
|
||||
>
|
||||
<Icon icon="ic:baseline-discord" className="text-lg" />
|
||||
Discord
|
||||
</a>
|
||||
</Link>
|
||||
|
||||
<span className="text-zinc-400 hidden sm:inline" aria-hidden="true">
|
||||
•
|
||||
</span>
|
||||
|
||||
<a href="https://trafficlunar.net" target="_blank" className="text-zinc-500 hover:text-zinc-700 transition-colors duration-200 hover:underline group">
|
||||
<Link
|
||||
to="https://trafficlunar.net"
|
||||
target="_blank"
|
||||
className="text-zinc-500 hover:text-zinc-700 transition-colors duration-200 hover:underline group"
|
||||
>
|
||||
Made by <span className="text-orange-400 group-hover:text-orange-500 font-medium transition-colors duration-200">trafficlunar</span>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Copyright */}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { Icon } from "@iconify/react";
|
|||
import { useEffect } from "react";
|
||||
import { useStore } from "@nanostores/react";
|
||||
import { session } from "../session";
|
||||
import { Link } from "react-router";
|
||||
|
||||
export default function HeaderProfile() {
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL;
|
||||
|
|
@ -25,15 +26,15 @@ export default function HeaderProfile() {
|
|||
<>
|
||||
{!$session?.user ? (
|
||||
<li>
|
||||
<a href={"/login"} className="pill button h-full">
|
||||
<Link to={"/login"} className="pill button h-full">
|
||||
Login
|
||||
</a>
|
||||
</Link>
|
||||
</li>
|
||||
) : (
|
||||
<>
|
||||
<li title="Your profile">
|
||||
<a
|
||||
href={`/profile/${$session?.user?.id}`}
|
||||
<Link
|
||||
to={`/profile/${$session?.user?.id}`}
|
||||
aria-label="Go to profile"
|
||||
className="pill button gap-2! p-0! h-full max-w-64"
|
||||
data-tooltip="Your Profile"
|
||||
|
|
@ -46,12 +47,12 @@ export default function HeaderProfile() {
|
|||
className="rounded-full aspect-square object-cover h-full bg-white outline-2 outline-orange-400"
|
||||
/>
|
||||
<span className="pr-4 overflow-hidden whitespace-nowrap text-ellipsis w-full">{$session?.user?.name ?? "unknown"}</span>
|
||||
</a>
|
||||
</Link>
|
||||
</li>
|
||||
<li title="Logout">
|
||||
<a href={`${API_BASE_URL}/api/auth/signout`} aria-label="Log Out" className="pill button p-2! aspect-square h-full" data-tooltip="Log Out">
|
||||
<Link to={`${API_BASE_URL}/api/auth/signout`} aria-label="Log Out" className="pill button p-2! aspect-square h-full" data-tooltip="Log Out">
|
||||
<Icon icon="ic:round-logout" fontSize={24} />
|
||||
</a>
|
||||
</Link>
|
||||
</li>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,19 @@
|
|||
import { Icon } from "@iconify/react";
|
||||
import SearchBar from "./search-bar";
|
||||
import HeaderProfile from "./header-profile";
|
||||
import { Link } from "react-router";
|
||||
|
||||
export default function Header() {
|
||||
return (
|
||||
<header className="sticky top-0 z-50 w-full p-4 grid grid-cols-3 gap-2 gap-x-4 items-center bg-amber-50 border-b-4 border-amber-500 shadow-md max-lg:grid-cols-2 max-md:grid-cols-1">
|
||||
<a
|
||||
href={"/"}
|
||||
<Link
|
||||
to={"/"}
|
||||
aria-label="Go to Home Page"
|
||||
className="font-black text-3xl text-orange-400 flex items-center gap-2 max-md:justify-center max-md:col-span-2"
|
||||
>
|
||||
<img src="/logo.svg" width={56} height={45} alt="logo" />
|
||||
TomodachiShare
|
||||
</a>
|
||||
</Link>
|
||||
|
||||
<div className="flex justify-center max-lg:justify-end max-md:justify-center">
|
||||
<SearchBar />
|
||||
|
|
@ -20,19 +21,19 @@ export default function Header() {
|
|||
|
||||
<ul className="flex justify-end gap-3 items-center h-11 *:h-full max-lg:col-span-2 max-md:justify-center">
|
||||
<li title="Random Mii">
|
||||
<a
|
||||
href={`${import.meta.env.VITE_API_URL}/random`}
|
||||
<Link
|
||||
to={`${import.meta.env.VITE_API_URL}/random`}
|
||||
aria-label="Go to Random Link"
|
||||
className="pill button p-0! h-full aspect-square"
|
||||
data-tooltip="Go to a Random Mii"
|
||||
>
|
||||
<Icon icon="mdi:dice-3" fontSize={28} />
|
||||
</a>
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<a href={"/submit"} className="pill button h-full">
|
||||
<Link to={"/submit"} className="pill button h-full">
|
||||
Submit
|
||||
</a>
|
||||
</Link>
|
||||
</li>
|
||||
<HeaderProfile />
|
||||
</ul>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { Icon } from "@iconify/react";
|
||||
import DeleteMiiButton from "./delete-mii-button";
|
||||
import { Link } from "react-router";
|
||||
|
||||
interface Props {
|
||||
mii: any;
|
||||
|
|
@ -13,10 +14,10 @@ export default function AuthorButtons({ mii }: Props) {
|
|||
|
||||
return (
|
||||
<>
|
||||
<a aria-label="Edit Mii" href={`/edit/${mii.id}`}>
|
||||
<Link aria-label="Edit Mii" to={`/edit/${mii.id}`}>
|
||||
<Icon icon="mdi:pencil" />
|
||||
<span>Edit</span>
|
||||
</a>
|
||||
</Link>
|
||||
<DeleteMiiButton miiId={mii.id} miiName={mii.name} likes={mii._count.likedBy ?? 0} inMiiPage />
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
||||
|
|
|
|||
|
|
@ -7,9 +7,10 @@ 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 = new URLSearchParams(window.location.search);
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
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 searchParams = new URLSearchParams(window.location.search);
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const [selected, setSelected] = useState<MiiGender | null>((searchParams.get("gender") as MiiGender) ?? null);
|
||||
|
|
@ -23,8 +25,7 @@ export default function GenderSelect() {
|
|||
}
|
||||
|
||||
startTransition(() => {
|
||||
// router.push(`?${params.toString()}`, { scroll: false });
|
||||
window.location.href = `?${params.toString()}`;
|
||||
navigate(`?${params.toString()}`);
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
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 searchParams = new URLSearchParams(window.location.search);
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const [selected, setSelected] = useState<MiiMakeup | null>((searchParams.get("makeup") as MiiMakeup) ?? null);
|
||||
|
|
@ -22,8 +24,7 @@ export default function MakeupSelect() {
|
|||
}
|
||||
|
||||
startTransition(() => {
|
||||
// router.push(`?${params.toString()}`, { scroll: false });
|
||||
window.location.href = `?${params.toString()}`;
|
||||
navigate(`?${params.toString()}`);
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { Icon } from "@iconify/react";
|
|||
|
||||
import LikeButton from "../../like-button";
|
||||
import DeleteMiiButton from "../delete-mii-button";
|
||||
import { Link } from "react-router";
|
||||
|
||||
interface Props {
|
||||
// miis: Prisma.MiiGetPayload<{ include: { user: { select: { id: true; name: true } }; _count: { select: { likedBy: true } } } }>[];
|
||||
|
|
@ -25,7 +26,7 @@ export default function MiiGrid({ miis, userId, parentPage }: Props) {
|
|||
</div>
|
||||
)}
|
||||
|
||||
<a href={`/mii/${mii.id}`} className="overflow-hidden rounded-xl bg-zinc-300 shrink-0">
|
||||
<Link to={`/mii/${mii.id}`} className="overflow-hidden rounded-xl bg-zinc-300 shrink-0">
|
||||
<img
|
||||
src={`${import.meta.env.VITE_API_URL}/mii/${mii.id}/image?type=mii`}
|
||||
width={240}
|
||||
|
|
@ -33,13 +34,13 @@ export default function MiiGrid({ miis, userId, parentPage }: Props) {
|
|||
alt="mii image"
|
||||
className="w-full h-auto aspect-3/2 object-contain"
|
||||
/>
|
||||
</a>
|
||||
</Link>
|
||||
|
||||
<div className="p-4 flex flex-col gap-1 h-full">
|
||||
<div className="flex justify-between">
|
||||
<a href={`/mii/${mii.id}`} className="relative font-bold text-2xl line-clamp-1 w-full text-ellipsis wrap-break-word" title={mii.name}>
|
||||
<Link to={`/mii/${mii.id}`} className="relative font-bold text-2xl line-clamp-1 w-full text-ellipsis wrap-break-word" title={mii.name}>
|
||||
{mii.name}
|
||||
</a>
|
||||
</Link>
|
||||
<div title={mii.platform === "SWITCH" ? "Switch" : "3DS"} className="text-[1.25rem] opacity-25">
|
||||
{mii.platform === "SWITCH" ? (
|
||||
<Icon icon="cib:nintendo-switch" className="text-red-400" />
|
||||
|
|
@ -50,9 +51,9 @@ export default function MiiGrid({ miis, userId, parentPage }: Props) {
|
|||
</div>
|
||||
<div id="tags" className="flex flex-wrap gap-1">
|
||||
{mii.tags.map((tag: string) => (
|
||||
<a href={`?tags=${tag}`} key={tag} className="px-2 py-1 bg-orange-300 rounded-full text-xs">
|
||||
<Link to={`?tags=${tag}`} key={tag} className="px-2 py-1 bg-orange-300 rounded-full text-xs">
|
||||
{tag}
|
||||
</a>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
|
@ -60,16 +61,16 @@ export default function MiiGrid({ miis, userId, parentPage }: Props) {
|
|||
<LikeButton likes={mii._count.likedBy} miiId={mii.id} isLiked={false} abbreviate />
|
||||
|
||||
{!userId && (
|
||||
<a href={`/profile/${mii.user?.id}`} className="text-sm text-right overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
<Link to={`/profile/${mii.user?.id}`} className="text-sm text-right overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
@{mii.user?.name}
|
||||
</a>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* {userId && Number(session.data?.user?.id) == userId && (
|
||||
<div className="flex gap-1 text-2xl justify-end text-zinc-400">
|
||||
<a href={`/edit/${mii.id}`} title="Edit Mii" aria-label="Edit Mii" data-tooltip="Edit">
|
||||
<Link to={`/edit/${mii.id}`} title="Edit Mii" aria-label="Edit Mii" data-tooltip="Edit">
|
||||
<Icon icon="mdi:pencil" />
|
||||
</a>
|
||||
</Link>
|
||||
<DeleteMiiButton miiId={mii.id} miiName={mii.name} likes={mii._count.likedBy} />
|
||||
</div>
|
||||
)} */}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
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 searchParams = new URLSearchParams(window.location.search);
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const platform = (searchParams.get("platform") as MiiPlatform) || undefined;
|
||||
|
|
@ -22,8 +25,7 @@ export default function OtherFilters() {
|
|||
}
|
||||
|
||||
startTransition(() => {
|
||||
// router.push(`?${params.toString()}`, { scroll: false });
|
||||
window.location.href = `?${params.toString()}`;
|
||||
navigate(`?${params.toString()}`);
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -40,8 +42,7 @@ export default function OtherFilters() {
|
|||
}
|
||||
|
||||
startTransition(() => {
|
||||
// router.push(`?${params.toString()}`, { scroll: false });
|
||||
window.location.href = `?${params.toString()}`;
|
||||
navigate(`?${params.toString()}`);
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
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 searchParams = new URLSearchParams(window.location.search);
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const [selected, setSelected] = useState<MiiPlatform | null>((searchParams.get("platform") as MiiPlatform) ?? null);
|
||||
|
|
@ -20,8 +22,7 @@ export default function PlatformSelect() {
|
|||
}
|
||||
|
||||
startTransition(() => {
|
||||
// router.push(`?${params.toString()}`);
|
||||
window.location.href = `?${params.toString()}`;
|
||||
navigate(`?${params.toString()}`);
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
import { useTransition } from "react";
|
||||
import { useSelect } from "downshift";
|
||||
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() {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const currentSort = (searchParams.get("sort") as Sort) || "newest";
|
||||
|
|
@ -22,13 +24,8 @@ export default function SortSelect() {
|
|||
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()}`;
|
||||
navigate(`?${params.toString()}`);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
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 searchParams = new URLSearchParams(window.location.search);
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const rawTags = searchParams.get(isExclude ? "exclude" : "tags") || "";
|
||||
|
|
@ -45,8 +47,7 @@ export default function TagFilter({ isExclude }: Props) {
|
|||
}
|
||||
|
||||
startTransition(() => {
|
||||
// router.push(`?${params.toString()}`, { scroll: false });
|
||||
window.location.href = `?${params.toString()}`;
|
||||
navigate(`?${params.toString()}`);
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tags]);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Icon } from "@iconify/react";
|
||||
import { Link } from "react-router";
|
||||
|
||||
interface Props {
|
||||
miiId: number;
|
||||
|
|
@ -128,15 +129,15 @@ export default function ShareMiiButton({ miiId }: Props) {
|
|||
<div className="flex justify-end gap-2 mt-4">
|
||||
<div className="flex gap-2 w-full">
|
||||
{/* Save button */}
|
||||
<a
|
||||
href={`${import.meta.env.VITE_API_URL}/mii/${miiId}/image?type=metadata`}
|
||||
<Link
|
||||
to={`${import.meta.env.VITE_API_URL}/mii/${miiId}/image?type=metadata`}
|
||||
className="pill button p-0! aspect-square size-11 cursor-pointer text-xl"
|
||||
aria-label="Save Image"
|
||||
data-tooltip="Save Image"
|
||||
download={"hello.png"}
|
||||
>
|
||||
<Icon icon="material-symbols:save-rounded" />
|
||||
</a>
|
||||
</Link>
|
||||
|
||||
{/* Copy button */}
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
import { useCallback, useMemo } from "react";
|
||||
import { Icon } from "@iconify/react";
|
||||
import { Link, useLocation, useSearchParams } from "react-router";
|
||||
|
||||
interface Props {
|
||||
lastPage: number;
|
||||
}
|
||||
|
||||
export default function Pagination({ lastPage }: Props) {
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
const location = useLocation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const page = Number(searchParams.get("page") ?? 1);
|
||||
|
||||
const createPageUrl = useCallback(
|
||||
|
|
@ -33,63 +35,63 @@ export default function Pagination({ lastPage }: Props) {
|
|||
return (
|
||||
<div className="flex justify-center items-center w-full mt-8">
|
||||
{/* Go to first page */}
|
||||
<a
|
||||
href={page === 1 ? "#" : createPageUrl(1)}
|
||||
<Link
|
||||
to={page === 1 ? "#" : createPageUrl(1)}
|
||||
aria-label="Go to First Page"
|
||||
aria-disabled={page === 1}
|
||||
tabIndex={page === 1 ? -1 : undefined}
|
||||
className={`pill button bg-orange-100! p-0.5! aspect-square text-2xl ${page === 1 ? "pointer-events-none opacity-50" : "hover:bg-orange-400!"}`}
|
||||
>
|
||||
<Icon icon="stash:chevron-double-left" />
|
||||
</a>
|
||||
</Link>
|
||||
|
||||
{/* Previous page */}
|
||||
<a
|
||||
href={page === 1 ? "#" : createPageUrl(page - 1)}
|
||||
<Link
|
||||
to={page === 1 ? "#" : createPageUrl(page - 1)}
|
||||
aria-label="Go to Previous Page"
|
||||
aria-disabled={page === 1}
|
||||
tabIndex={page === 1 ? -1 : undefined}
|
||||
className={`pill bg-orange-100! p-0.5! aspect-square text-2xl ${page === 1 ? "pointer-events-none opacity-50" : "hover:bg-orange-400!"}`}
|
||||
>
|
||||
<Icon icon="stash:chevron-left" />
|
||||
</a>
|
||||
</Link>
|
||||
|
||||
{/* Page numbers */}
|
||||
<div className="flex mx-2">
|
||||
{numbers.map((number) => (
|
||||
<a
|
||||
<Link
|
||||
key={number}
|
||||
href={createPageUrl(number)}
|
||||
to={createPageUrl(number)}
|
||||
aria-label={`Go to Page ${number}`}
|
||||
aria-current={number === page ? "page" : undefined}
|
||||
className={`pill p-0! w-8 h-8 text-center rounded-md! ${number == page ? "bg-orange-400!" : "bg-orange-100! hover:bg-orange-400!"}`}
|
||||
>
|
||||
{number}
|
||||
</a>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Next page */}
|
||||
<a
|
||||
href={page >= lastPage ? "#" : createPageUrl(page + 1)}
|
||||
<Link
|
||||
to={page >= lastPage ? "#" : createPageUrl(page + 1)}
|
||||
aria-label="Go to Next Page"
|
||||
aria-disabled={page >= lastPage}
|
||||
tabIndex={page >= lastPage ? -1 : undefined}
|
||||
className={`pill button bg-orange-100! p-0.5! aspect-square text-2xl ${page >= lastPage ? "pointer-events-none opacity-50" : "hover:bg-orange-400!"}`}
|
||||
>
|
||||
<Icon icon="stash:chevron-right" />
|
||||
</a>
|
||||
</Link>
|
||||
|
||||
{/* Go to last page */}
|
||||
<a
|
||||
href={page >= lastPage ? "#" : createPageUrl(lastPage)}
|
||||
<Link
|
||||
to={page >= lastPage ? "#" : createPageUrl(lastPage)}
|
||||
aria-label="Go to Last Page"
|
||||
aria-disabled={page >= lastPage}
|
||||
tabIndex={page >= lastPage ? -1 : undefined}
|
||||
className={`pill button bg-orange-100! p-0.5! aspect-square text-2xl ${page >= lastPage ? "pointer-events-none opacity-50" : "hover:bg-orange-400!"}`}
|
||||
>
|
||||
<Icon icon="stash:chevron-double-right" />
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { Icon } from "@iconify/react";
|
|||
import Description from "./description";
|
||||
import { useStore } from "@nanostores/react";
|
||||
import { session } from "../session";
|
||||
import { Link } from "react-router";
|
||||
|
||||
interface Props {
|
||||
user?: any;
|
||||
|
|
@ -23,9 +24,9 @@ export default function ProfileInformation({ user, page }: Props) {
|
|||
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex gap-4 mb-2 max-md:flex-col">
|
||||
<div className="flex w-full gap-4 overflow-x-scroll">
|
||||
{/* Profile picture */}
|
||||
<a href={`/profile/${user.id}`} className="size-28 aspect-square">
|
||||
<Link to={`/profile/${user.id}`} className="size-28 aspect-square">
|
||||
<img src={user.image ?? "/guest.png"} className="rounded-full bg-white border-2 border-orange-400 shadow max-md:self-center" />
|
||||
</a>
|
||||
</Link>
|
||||
{/* User information */}
|
||||
<div className="flex flex-col w-full relative py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
@ -60,34 +61,34 @@ export default function ProfileInformation({ user, page }: Props) {
|
|||
{/* Buttons */}
|
||||
<div className="flex gap-1 w-fit text-3xl text-orange-400 max-md:place-self-center *:size-17 *:flex *:flex-col *:items-center *:gap-1 **:transition-discrete **:duration-150 *:hover:brightness-75 *:hover:scale-[1.08] *:[&_span]:text-sm">
|
||||
{!isOwnProfile && (
|
||||
<a aria-label="Report User" href={`${import.meta.env.VITE_API_URL}/report/user/${user.id}`}>
|
||||
<Link aria-label="Report User" to={`${import.meta.env.VITE_API_URL}/report/user/${user.id}`}>
|
||||
<Icon icon="material-symbols:flag-rounded" />
|
||||
<span>Report</span>
|
||||
</a>
|
||||
</Link>
|
||||
)}
|
||||
{isOwnProfile && isAdmin && (
|
||||
<a aria-label="Go to Admin" href="/admin">
|
||||
<Link aria-label="Go to Admin" to="/admin">
|
||||
<Icon icon="mdi:shield-moon" />
|
||||
<span>Admin</span>
|
||||
</a>
|
||||
</Link>
|
||||
)}
|
||||
{/* {isOwnProfile && page !== "likes" && (
|
||||
<a aria-label="Go to My Likes" href="/profile/likes">
|
||||
<Link aria-label="Go to My Likes" to="/profile/likes">
|
||||
<Icon icon="icon-park-solid:like" />
|
||||
<span>My Likes</span>
|
||||
</a>
|
||||
</Link>
|
||||
)} */}
|
||||
{isOwnProfile && page !== "settings" && (
|
||||
<a aria-label="Go to Settings" href="/profile/settings">
|
||||
<Link aria-label="Go to Settings" to="/profile/settings">
|
||||
<Icon icon="material-symbols:settings-rounded" />
|
||||
<span>Settings</span>
|
||||
</a>
|
||||
</Link>
|
||||
)}
|
||||
{page && (
|
||||
<a aria-label="Go Back to Profile" href={`/profile/${user.id}`}>
|
||||
<Link aria-label="Go Back to Profile" to={`/profile/${user.id}`}>
|
||||
<Icon icon="tabler:chevron-left" />
|
||||
<span>Back</span>
|
||||
</a>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,8 +2,10 @@ 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);
|
||||
|
||||
|
|
@ -17,7 +19,7 @@ export default function DeleteAccount() {
|
|||
return;
|
||||
}
|
||||
|
||||
window.location.href = "/404";
|
||||
navigate("/404");
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -6,8 +6,10 @@ import dayjs from "dayjs";
|
|||
|
||||
import SubmitDialogButton from "./submit-dialog-button";
|
||||
import Dropzone from "../dropzone";
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
export default function ProfilePictureSettings() {
|
||||
const navigate = useNavigate();
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
const [newPicture, setNewPicture] = useState<FileWithPath | undefined>();
|
||||
|
||||
|
|
@ -30,7 +32,7 @@ export default function ProfilePictureSettings() {
|
|||
}
|
||||
|
||||
close();
|
||||
location.reload();
|
||||
navigate(0);
|
||||
};
|
||||
|
||||
const handleDrop = useCallback((acceptedFiles: FileWithPath[]) => {
|
||||
|
|
|
|||
|
|
@ -1,16 +1,17 @@
|
|||
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 searchParams = new URLSearchParams(window.location.search);
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [query, setQuery] = useState(searchParams.get("q") || "");
|
||||
|
||||
const handleSearch = () => {
|
||||
const result = querySchema.safeParse(query);
|
||||
if (!result.success) {
|
||||
// router.push("/", { scroll: false });
|
||||
window.location.href = "/";
|
||||
navigate("/", { preventScrollReset: true });
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -19,8 +20,7 @@ export default function SearchBar() {
|
|||
params.set("q", query);
|
||||
params.set("page", "1");
|
||||
|
||||
// router.push(`/?${params.toString()}`, { scroll: false });
|
||||
window.location.href = `/?${params.toString()}`;
|
||||
navigate(`/?${params.toString()}`, { preventScrollReset: true });
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||
|
|
|
|||
|
|
@ -21,8 +21,10 @@ import Carousel from "../carousel";
|
|||
import SubmitButton from "../submit-button";
|
||||
import Dropzone from "../dropzone";
|
||||
import type { MiiPlatform, MiiGender, MiiMakeup } from "@tomodachi-share/shared";
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
export default function SubmitForm() {
|
||||
const navigate = useNavigate();
|
||||
const [files, setFiles] = useState<FileWithPath[]>([]);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
|
|
@ -113,7 +115,7 @@ export default function SubmitForm() {
|
|||
return;
|
||||
}
|
||||
|
||||
window.location.href = `/mii/${id}`;
|
||||
navigate(`/mii/${id}`);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import Footer from "./components/footer";
|
||||
import Header from "./components/header";
|
||||
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
|
||||
useEffect(() => {
|
||||
const header = document.querySelector("header");
|
||||
|
|
@ -24,8 +25,11 @@ export default function Providers({ children }: { children: React.ReactNode }) {
|
|||
}, []);
|
||||
|
||||
return (
|
||||
<ProgressProvider height="4px" color="var(--color-amber-500)" options={{ showSpinner: false }} shallowRouting>
|
||||
{children}
|
||||
</ProgressProvider>
|
||||
<>
|
||||
<Header />
|
||||
{/* <AdminBanner /> */}
|
||||
<main className="px-4 py-8 max-w-7xl w-full grow flex flex-col">{children}</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { StrictMode, Suspense } from "react";
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { BrowserRouter, Route, Routes } from "react-router";
|
||||
import "./index.css";
|
||||
|
|
@ -13,19 +13,15 @@ import MiiPage from "./pages/mii.tsx";
|
|||
import SubmitPage from "./pages/submit.tsx";
|
||||
import IndexPage from "./pages/index.tsx";
|
||||
import ProfileSettingsPage from "./pages/settings.tsx";
|
||||
import Providers from "./components/provider.tsx";
|
||||
import Header from "./components/header.tsx";
|
||||
import Footer from "./components/footer.tsx";
|
||||
import { ProgressProvider } from "@bprogress/react";
|
||||
import LinkOutPage from "./pages/out.tsx";
|
||||
import Layout from "./layout.tsx";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<Providers>
|
||||
<Suspense fallback={<div>Loading header...</div>}>
|
||||
<Header />
|
||||
</Suspense>
|
||||
{/* <AdminBanner /> */}
|
||||
<main className="px-4 py-8 max-w-7xl w-full grow flex flex-col">
|
||||
<BrowserRouter>
|
||||
<ProgressProvider height="4px" color="var(--color-amber-500)" options={{ showSpinner: false }} shallowRouting>
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<IndexPage />} />
|
||||
<Route path="/mii/:id" element={<MiiPage />} />
|
||||
|
|
@ -35,13 +31,13 @@ createRoot(document.getElementById("root")!).render(
|
|||
</Route>
|
||||
<Route path="/submit" element={<SubmitPage />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/out" element={<LinkOutPage />} />
|
||||
<Route path="/privacy" element={<PrivacyPage />} />
|
||||
<Route path="/terms-of-service" element={<TermsOfServicePage />} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
</ProgressProvider>
|
||||
</BrowserRouter>
|
||||
</main>
|
||||
<Footer />
|
||||
</Providers>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import { Suspense, useEffect, useMemo, useState } from "react";
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import FilterMenu from "../components/mii/list/filter-menu";
|
||||
import SortSelect from "../components/mii/list/sort-select";
|
||||
import MiiGrid from "../components/mii/list/mii-grid";
|
||||
import Pagination from "../components/pagination";
|
||||
import Skeleton from "../components/mii/list/skeleton";
|
||||
import { useSearchParams } from "react-router";
|
||||
|
||||
interface ApiResponse {
|
||||
totalCount: number;
|
||||
|
|
@ -12,7 +13,7 @@ interface ApiResponse {
|
|||
}
|
||||
|
||||
export default function IndexPage() {
|
||||
const searchParams = useMemo(() => new URLSearchParams(location.search), []);
|
||||
const [searchParams] = useSearchParams();
|
||||
const [data, setData] = useState<ApiResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Icon } from "@iconify/react";
|
||||
import { useStore } from "@nanostores/react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import { session } from "../session";
|
||||
|
||||
export default function LoginPage() {
|
||||
|
|
@ -23,41 +23,41 @@ export default function LoginPage() {
|
|||
</div>
|
||||
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<a
|
||||
href={`${API_URL}/api/auth/signin/discord`}
|
||||
<Link
|
||||
to={`${API_URL}/api/auth/signin/discord`}
|
||||
aria-label="Login with Discord"
|
||||
className="pill button gap-2 px-3! bg-indigo-400! border-indigo-500! hover:bg-indigo-500!"
|
||||
>
|
||||
<Icon icon="ic:baseline-discord" fontSize={32} />
|
||||
Login with Discord
|
||||
</a>
|
||||
<a
|
||||
href={`${API_URL}/api/auth/signin/github`}
|
||||
</Link>
|
||||
<Link
|
||||
to={`${API_URL}/api/auth/signin/github`}
|
||||
aria-label="Login with GitHub"
|
||||
className="pill button gap-2 px-3! bg-zinc-700! border-zinc-800! hover:bg-zinc-800! text-white"
|
||||
>
|
||||
<Icon icon="mdi:github" fontSize={32} />
|
||||
Login with GitHub
|
||||
</a>
|
||||
<a
|
||||
href={`${API_URL}/api/auth/signin/google`}
|
||||
</Link>
|
||||
<Link
|
||||
to={`${API_URL}/api/auth/signin/google`}
|
||||
aria-label="Login with Google"
|
||||
className="pill button gap-2 px-3! bg-white! border-gray-300! hover:bg-gray-100! text-black! flex items-center"
|
||||
>
|
||||
<Icon icon="material-icon-theme:google" fontSize={32} />
|
||||
Login with Google
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<p className="mt-8 text-xs text-zinc-400">
|
||||
By signing up, you agree to the{" "}
|
||||
<a href="/terms-of-service" className="underline hover:text-zinc-600">
|
||||
<Link to="/terms-of-service" className="underline hover:text-zinc-600">
|
||||
Terms of Service
|
||||
</a>{" "}
|
||||
</Link>{" "}
|
||||
and{" "}
|
||||
<a href="/privacy" className="underline hover:text-zinc-600">
|
||||
<Link to="/privacy" className="underline hover:text-zinc-600">
|
||||
Privacy Policy
|
||||
</a>
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -9,10 +9,11 @@ import SwitchAddMiiTutorialButton from "../components/tutorial/switch-add-mii";
|
|||
import MiiInstructions from "../components/mii/instructions";
|
||||
import { Icon } from "@iconify/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useParams } from "react-router";
|
||||
import { Link, useNavigate, useParams } from "react-router";
|
||||
|
||||
export default function MiiPage() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [mii, setMii] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
|
|
@ -31,7 +32,7 @@ export default function MiiPage() {
|
|||
.catch((err) => {
|
||||
console.error(err);
|
||||
setLoading(false);
|
||||
window.location.href = "/404";
|
||||
navigate("/404");
|
||||
});
|
||||
}, [id]);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,17 @@
|
|||
import { Icon } from "@iconify/react";
|
||||
import { Link } from "react-router";
|
||||
|
||||
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">
|
||||
<h2 className="text-7xl font-black">404</h2>
|
||||
<p>Page not found - you swam off the island!</p>
|
||||
<a href="/" className="pill button gap-2 mt-8 w-fit self-center">
|
||||
<Link to="/" className="pill button gap-2 mt-8 w-fit self-center">
|
||||
<Icon icon="ic:round-home" fontSize={24} />
|
||||
Travel Back
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
64
frontend/src/pages/out.tsx
Normal file
64
frontend/src/pages/out.tsx
Normal 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",
|
||||
]);
|
||||
|
|
@ -1,9 +1,10 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import ProfileInformation from "../components/profile-information";
|
||||
import { useParams } from "react-router";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
|
||||
export default function ProfilePage() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [user, setUser] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
|
|
@ -20,7 +21,7 @@ export default function ProfilePage() {
|
|||
.catch((err) => {
|
||||
console.error(err);
|
||||
setLoading(false);
|
||||
window.location.href = "/404";
|
||||
navigate("/404");
|
||||
});
|
||||
}, [id]);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import { Link } from "react-router";
|
||||
|
||||
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>
|
||||
<h2 className="font-light">
|
||||
<strong className="font-medium">Effective Date:</strong> March 26, 2026
|
||||
|
|
@ -13,7 +16,10 @@ export default function TermsOfServicePage() {
|
|||
</p>
|
||||
<p className="mt-1">
|
||||
If you have any questions or concerns, please contact me at:{" "}
|
||||
<a href="mailto:hello@trafficlunar.net" className="text-blue-700"> hello@trafficlunar.net </a>
|
||||
<a href="mailto:hello@trafficlunar.net" className="text-blue-700">
|
||||
{" "}
|
||||
hello@trafficlunar.net{" "}
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
|
||||
|
|
@ -50,9 +56,15 @@ export default function TermsOfServicePage() {
|
|||
</p>
|
||||
<p>
|
||||
To request deletion of your account and personal data, please refer to the{" "}
|
||||
<a href="/privacy" className="text-blue-700"> Privacy Policy </a>{" "}
|
||||
<Link to="/privacy" className="text-blue-700">
|
||||
{" "}
|
||||
Privacy Policy{" "}
|
||||
</Link>{" "}
|
||||
(see "Data Deletion") 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>
|
||||
</section>
|
||||
</li>
|
||||
|
|
@ -68,8 +80,8 @@ export default function TermsOfServicePage() {
|
|||
|
||||
<section>
|
||||
<p className="mb-2">
|
||||
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.
|
||||
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.
|
||||
</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
|
||||
|
|
@ -83,7 +95,10 @@ export default function TermsOfServicePage() {
|
|||
<section>
|
||||
<p className="mb-2">
|
||||
If you believe that content uploaded to this site infringes on your copyright, you may submit a DMCA takedown request by emailing{" "}
|
||||
<a href="mailto:hello@trafficlunar.net" className="text-blue-700"> hello@trafficlunar.net </a>{" "}
|
||||
<a href="mailto:hello@trafficlunar.net" className="text-blue-700">
|
||||
{" "}
|
||||
hello@trafficlunar.net{" "}
|
||||
</a>{" "}
|
||||
or by reporting the Mii on its page.
|
||||
</p>
|
||||
<p className="mb-2">Please include:</p>
|
||||
|
|
@ -125,5 +140,6 @@ export default function TermsOfServicePage() {
|
|||
</section>
|
||||
</li>
|
||||
</ul>
|
||||
</div>;
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Reference in a new issue