feat: show miis on profiles

and other changes
This commit is contained in:
trafficlunar 2026-04-17 19:50:47 +01:00
parent 9795849830
commit 896dc40553
17 changed files with 271 additions and 408 deletions

View file

@ -9,7 +9,7 @@ export async function GET(request: NextRequest) {
const parsed = searchSchema.safeParse(Object.fromEntries(request.nextUrl.searchParams)); const parsed = searchSchema.safeParse(Object.fromEntries(request.nextUrl.searchParams));
if (!parsed.success) return NextResponse.json({ error: parsed.error.issues[0].message }, { status: 400 }); if (!parsed.success) return NextResponse.json({ error: parsed.error.issues[0].message }, { status: 400 });
const { q: query, sort, tags, exclude, platform, gender, makeup, allowCopying, quarantined, page = 1, limit = 24, seed, parentPage, userId } = parsed.data; const { q: query, sort, tags, exclude, platform, gender, makeup, allowCopying, quarantined, page = 1, limit = 24, parentPage, userId } = parsed.data;
// My Likes page // My Likes page
let miiIdsLiked: number[] | undefined = undefined; let miiIdsLiked: number[] | undefined = undefined;
@ -107,7 +107,7 @@ export async function GET(request: NextRequest) {
} }
[totalCount, miis] = await Promise.all([ [totalCount, miis] = await Promise.all([
prisma.mii.count({ where: { ...where } }), // TODO: User id prisma.mii.count({ where: { ...where, userId } }),
prisma.mii.findMany({ prisma.mii.findMany({
where, where,
orderBy, orderBy,

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="130.734" height="105.615" viewBox="0 0 34.59 27.944"><rect width="32.208" height="25.562" x="1.191" y="1.191" rx="1.874" fill="#f8f8f8" stroke="#ff8904" stroke-width="2.381" paint-order="stroke fill markers"/><rect width="29.369" height="22.49" x="2.611" y="2.727" rx=".966" fill="#c8c8c8" paint-order="stroke fill markers"/><g fill="#fef3c6"><rect width="13.371" height="20.989" x="17.918" y="3.478" rx=".423" paint-order="stroke fill markers"/><rect width="13.371" height="20.989" x="3.301" y="3.478" rx=".423" paint-order="stroke fill markers"/></g><g fill="#ff8904"><use href="#B" paint-order="stroke fill markers"/><circle cx="9.986" cy="13.076" r="5.512" paint-order="stroke fill markers"/><use href="#B" x="14.204" y="-0.093" paint-order="stroke fill markers"/><circle cx="24.191" cy="12.983" r="5.512" paint-order="stroke fill markers"/></g><g fill="none" stroke="#c8c8c8" stroke-linejoin="round"><rect width="13.791" height="20.704" x="17.295" y="3.62" ry="1.146" rx="1.095" stroke-width="1.786" paint-order="stroke fill markers"/><rect width="13.366" height="21.167" x="3.301" y="3.389" ry="1.146" rx="1.095" stroke-width="1.323" paint-order="stroke fill markers"/></g><defs ><path id="B" d="M15.03 24.516c0-2.307-.961-4.439-2.522-5.592s-3.483-1.153-5.044 0-2.522 3.285-2.522 5.592h5.044z"/></defs></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -11,7 +11,7 @@ export default function Header() {
aria-label="Go to Home Page" aria-label="Go to Home Page"
className="font-black text-3xl text-orange-400 flex items-center gap-2 max-md:justify-center max-md:col-span-2" className="font-black text-3xl text-orange-400 flex items-center gap-2 max-md:justify-center max-md:col-span-2"
> >
<img src="/logo.svg" width={56} height={45} alt="logo" /> <img src="/favicon.svg" width={56} height={45} alt="logo" />
TomodachiShare TomodachiShare
</Link> </Link>

View file

@ -1,184 +1,164 @@
// import crypto from "crypto"; import { useEffect, useState } from "react";
// import seedrandom from "seedrandom"; import { Link, useSearchParams } from "react-router";
import Skeleton from "./skeleton";
import FilterMenu from "./filter-menu";
import SortSelect from "./sort-select";
import Pagination from "../../pagination";
import DeleteMiiButton from "../delete-mii-button";
import { Icon } from "@iconify/react";
import LikeButton from "../../like-button";
import { useStore } from "@nanostores/react";
import { session } from "../../../session";
// import { searchSchema } from "@tomodachi-share/shared/schemas"; interface ApiResponse {
totalCount: number;
miis: any[];
lastPage: number;
}
// import SortSelect from "./sort-select"; interface Props {
// import Pagination from "./pagination"; userId?: number;
// import FilterMenu from "./filter-menu"; parentPage?: "likes" | "admin";
// import MiiGrid from "./mii-grid"; }
// interface Props { export default function MiiList({ parentPage, userId }: Props) {
// searchParams: URLSearchParams; const [searchParams] = useSearchParams();
// userId?: number; // Profiles const [data, setData] = useState<ApiResponse | null>(null);
// parentPage?: "likes" | "admin"; const [loading, setLoading] = useState(true);
// }
// export default async function MiiList({ searchParams, userId, parentPage }: Props) { const $session = useStore(session);
// const session = await auth();
// const parsed = searchSchema.safeParse(searchParams);
// if (!parsed.success) return <h1>{parsed.error.issues[0].message}</h1>;
// const { q: query, sort, tags, exclude, platform, gender, makeup, allowCopying, quarantined, page = 1, limit = 24, seed } = parsed.data; useEffect(() => {
const params = new URLSearchParams(searchParams.toString());
if (userId) params.append("userId", userId.toString());
if (parentPage) params.append("parentPage", parentPage);
// // My Likes page fetch(`${import.meta.env.VITE_API_URL}/api/mii/list?${params.toString()}`, { credentials: "include" })
// let miiIdsLiked: number[] | undefined = undefined; .then((res) => {
if (!res.ok) throw new Error("Failed to fetch Miis");
return res.json();
})
.then((data) => {
setData(data);
setLoading(false);
})
.catch((err) => {
console.error(err);
setLoading(false);
});
}, [searchParams, userId, parentPage]);
// if (parentPage === "likes" && session?.user?.id) { return (
// const likedMiis = await prisma.like.findMany({ <>
// where: { userId: Number(session.user.id) }, {loading ? (
// select: { miiId: true }, <Skeleton />
// }); ) : data ? (
// miiIdsLiked = likedMiis.map((like) => like.miiId); <div className="w-full">
// } <div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex justify-between items-center gap-2 mb-2 max-md:flex-col">
<div className="flex items-center gap-2">
<span className="text-2xl font-bold text-amber-900">{data.totalCount}</span>
<span className="text-lg text-amber-700">{data.totalCount === 1 ? "Mii" : "Miis"}</span>
</div>
// const where: Prisma.MiiWhereInput = { <div className="relative flex items-center justify-end gap-2 w-full md:max-w-2/3 max-md:justify-center">
// // In queue logic <FilterMenu />
// ...(parentPage === "admin" <SortSelect />
// ? { in_queue: true } // Only show queued Miis </div>
// : userId </div>
// ? {
// // Include queued Miis if user is on their profile
// ...(Number(session?.user?.id) === userId ? {} : { in_queue: false }),
// userId,
// }
// : {
// // Don't show queued Miis on main page
// in_queue: false,
// }),
// // Only show liked miis on likes page
// ...(parentPage === "likes" && miiIdsLiked && { id: { in: miiIdsLiked } }),
// // Searching
// ...(query && {
// OR: [{ name: { contains: query, mode: "insensitive" } }, { tags: { has: query } }, { description: { contains: query, mode: "insensitive" } }],
// }),
// // Tag filtering
// ...(tags && tags.length > 0 && { tags: { hasEvery: tags } }),
// ...(exclude && exclude.length > 0 && { NOT: { tags: { hasSome: exclude } } }),
// // Platform
// ...(platform && { platform: { equals: platform } }),
// // Gender
// ...(gender && { gender: { equals: gender } }),
// // Allow Copying
// ...(allowCopying && { allowedCopying: true }),
// // Makeup
// ...(makeup && { makeup: { equals: makeup } }),
// // Quarantined
// ...(!quarantined && !userId && { quarantined: false }),
// };
// const select: Prisma.MiiSelect = { <div className="grid grid-cols-4 gap-4 max-lg:grid-cols-3 max-md:grid-cols-2 max-[30rem]:grid-cols-1">
// id: true, {data.miis.map((mii) => (
// // Don't show when userId is specified <div
// ...(!userId && { key={mii.id}
// user: { className={`flex flex-col relative bg-zinc-50 rounded-3xl border-2 shadow-lg p-[0.8rem] transition hover:scale-105 hover:bg-cyan-100 hover:border-cyan-600 ${mii.quarantined ? "border-red-300 bg-red-50!" : mii.in_queue && parentPage !== "admin" ? "border-zinc-400 opacity-70" : "border-zinc-300"}`}
// select: { >
// id: true, {mii.in_queue && (
// name: true, <div className="absolute top-2 left-2 z-10 bg-zinc-500 text-white text-xs font-semibold px-2 py-1 rounded-full shadow-sm flex items-center gap-1">
// }, <Icon icon="mdi:clock-outline" className="text-base" />
// }, In Queue
// }), </div>
// platform: true, )}
// name: true,
// imageCount: true,
// tags: true,
// createdAt: true,
// gender: true,
// makeup: true,
// allowedCopying: true,
// quarantined: true,
// in_queue: true,
// // Mii liked check
// ...(session?.user?.id && {
// likedBy: {
// where: { userId: Number(session.user.id) },
// select: { userId: true },
// },
// }),
// // Like count
// _count: {
// select: { likedBy: true },
// },
// };
// const skip = (page - 1) * limit; <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}
height={160}
alt="mii image"
className="w-full h-auto aspect-3/2 object-contain"
/>
</Link>
// let totalCount: number; <div className="p-4 flex flex-col gap-1 h-full">
// let miis: Prisma.MiiGetPayload<{ select: typeof select }>[]; <div className="flex justify-between">
<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}
</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" />
) : (
<Icon icon="cib:nintendo-3ds" className="text-sky-400" />
)}
</div>
</div>
<div id="tags" className="flex flex-wrap gap-1">
{mii.tags.map((tag: string) => (
<Link to={`?tags=${tag}`} key={tag} className="px-2 py-1 bg-orange-300 rounded-full text-xs">
{tag}
</Link>
))}
</div>
// if (sort === "random") { <div className="mt-auto grid grid-cols-2 items-center">
// // Get all IDs that match the where conditions <LikeButton likes={mii._count.likedBy} miiId={mii.id} isLiked={false} abbreviate />
// const matchingIds = await prisma.mii.findMany({
// where,
// select: { id: true },
// });
// totalCount = matchingIds.length; {!userId && (
<Link to={`/profile/${mii.user?.id}`} className="text-sm text-right overflow-hidden text-ellipsis whitespace-nowrap">
@{mii.user?.name}
</Link>
)}
// if (matchingIds.length === 0) return; {userId && Number($session?.user?.id) == userId && (
<div className="flex gap-1 text-2xl justify-end text-zinc-400">
<Link to={`/edit/${mii.id}`} title="Edit Mii" aria-label="Edit Mii" data-tooltip="Edit">
<Icon icon="mdi:pencil" />
</Link>
<DeleteMiiButton miiId={mii.id} miiName={mii.name} likes={mii._count.likedBy} />
</div>
)}
// // Use seed for consistent random results {/* Admin Controls */}
// const randomSeed = seed || crypto.randomInt(0, 1_000_000_000); {parentPage === "admin" && (
// const rng = seedrandom(randomSeed.toString()); <div className="flex justify-between w-full col-span-2 mt-2">
<div className="flex gap-1 text-3xl justify-center">
<button
onClick={async () => {
await fetch(`/api/admin/accept-mii?id=${mii.id}`, { method: "PATCH" });
}}
className="cursor-pointer text-zinc-400 hover:text-green-500 transition-colors p-1 bg-white rounded-md shadow-sm border border-zinc-200 hover:border-green-500"
title="Accept Mii"
>
<Icon icon="material-symbols:check-rounded" />
</button>
<div className="text-zinc-400 hover:text-red-500 transition-colors p-1 bg-white rounded-md shadow-sm border border-zinc-200 hover:border-red-500 flex items-center justify-center">
<DeleteMiiButton miiId={mii.id} miiName={mii.name} likes={mii._count.likedBy} />
</div>
</div>
// // Randomize all IDs using the Durstenfeld algorithm <span className="text-sm w-1/2 text-right">{new Date(mii.createdAt).toLocaleString("en-GB", { timeZone: "UTC" })}</span>
// for (let i = matchingIds.length - 1; i > 0; i--) { </div>
// const j = Math.floor(rng() * (i + 1)); )}
// [matchingIds[i], matchingIds[j]] = [matchingIds[j], matchingIds[i]]; </div>
// } </div>
</div>
// // Convert to number[] array ))}
// const selectedIds = matchingIds.slice(skip, skip + limit).map((i) => i.id); </div>
<Pagination lastPage={data.lastPage} />
// miis = await prisma.mii.findMany({ </div>
// where: { ) : (
// id: { in: selectedIds }, <p>No Miis found, has the server died?</p>
// }, )}
// select, </>
// }); );
// } else { }
// // Sorting by likes, newest, or oldest
// let orderBy: Prisma.MiiOrderByWithRelationInput[];
// if (sort === "likes") {
// orderBy = [{ likedBy: { _count: "desc" } }, { name: "asc" }];
// } else if (sort === "oldest") {
// orderBy = [{ createdAt: "asc" }, { name: "asc" }];
// } else {
// // default to newest
// orderBy = [{ createdAt: "desc" }, { name: "asc" }];
// }
// [totalCount, miis] = await Promise.all([
// prisma.mii.count({ where: { ...where, userId } }),
// prisma.mii.findMany({
// where,
// orderBy,
// select,
// skip,
// take: limit,
// }),
// ]);
// }
// const lastPage = Math.ceil(totalCount / limit);
// return (
// <div className="w-full">
// <div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex justify-between items-center gap-2 mb-2 max-md:flex-col">
// <div className="flex items-center gap-2">
// <span className="text-2xl font-bold text-amber-900">{totalCount}</span>
// <span className="text-lg text-amber-700">{totalCount === 1 ? "Mii" : "Miis"}</span>
// </div>
// <div className="relative flex items-center justify-end gap-2 w-full md:max-w-2/3 max-md:justify-center">
// <FilterMenu />
// <SortSelect />
// </div>
// </div>
// <MiiGrid miis={miis} userId={userId} parentPage={parentPage} />
// <Pagination lastPage={lastPage} />
// </div>
// );
// }

View file

@ -1,105 +0,0 @@
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 } } } }>[];
miis: any[];
userId?: number;
parentPage?: string;
}
export default function MiiGrid({ miis, userId, parentPage }: Props) {
return (
<div className="grid grid-cols-4 gap-4 max-lg:grid-cols-3 max-md:grid-cols-2 max-[30rem]:grid-cols-1">
{miis.map((mii) => (
<div
key={mii.id}
className={`flex flex-col relative bg-zinc-50 rounded-3xl border-2 shadow-lg p-[0.8rem] transition hover:scale-105 hover:bg-cyan-100 hover:border-cyan-600 ${mii.quarantined ? "border-red-300 bg-red-50!" : mii.in_queue && parentPage !== "admin" ? "border-zinc-400 opacity-70" : "border-zinc-300"}`}
>
{mii.in_queue && (
<div className="absolute top-2 left-2 z-10 bg-zinc-500 text-white text-xs font-semibold px-2 py-1 rounded-full shadow-sm flex items-center gap-1">
<Icon icon="mdi:clock-outline" className="text-base" />
In Queue
</div>
)}
<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}
height={160}
alt="mii image"
className="w-full h-auto aspect-3/2 object-contain"
/>
</Link>
<div className="p-4 flex flex-col gap-1 h-full">
<div className="flex justify-between">
<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}
</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" />
) : (
<Icon icon="cib:nintendo-3ds" className="text-sky-400" />
)}
</div>
</div>
<div id="tags" className="flex flex-wrap gap-1">
{mii.tags.map((tag: string) => (
<Link to={`?tags=${tag}`} key={tag} className="px-2 py-1 bg-orange-300 rounded-full text-xs">
{tag}
</Link>
))}
</div>
<div className="mt-auto grid grid-cols-2 items-center">
<LikeButton likes={mii._count.likedBy} miiId={mii.id} isLiked={false} abbreviate />
{!userId && (
<Link to={`/profile/${mii.user?.id}`} className="text-sm text-right overflow-hidden text-ellipsis whitespace-nowrap">
@{mii.user?.name}
</Link>
)}
{/* {userId && Number(session.data?.user?.id) == userId && (
<div className="flex gap-1 text-2xl justify-end text-zinc-400">
<Link to={`/edit/${mii.id}`} title="Edit Mii" aria-label="Edit Mii" data-tooltip="Edit">
<Icon icon="mdi:pencil" />
</Link>
<DeleteMiiButton miiId={mii.id} miiName={mii.name} likes={mii._count.likedBy} />
</div>
)} */}
{/* Admin Controls */}
{parentPage === "admin" && (
<div className="flex justify-between w-full col-span-2 mt-2">
<div className="flex gap-1 text-3xl justify-center">
<button
onClick={async () => {
await fetch(`/api/admin/accept-mii?id=${mii.id}`, { method: "PATCH" });
}}
className="cursor-pointer text-zinc-400 hover:text-green-500 transition-colors p-1 bg-white rounded-md shadow-sm border border-zinc-200 hover:border-green-500"
title="Accept Mii"
>
<Icon icon="material-symbols:check-rounded" />
</button>
<div className="text-zinc-400 hover:text-red-500 transition-colors p-1 bg-white rounded-md shadow-sm border border-zinc-200 hover:border-red-500 flex items-center justify-center">
<DeleteMiiButton miiId={mii.id} miiName={mii.name} likes={mii._count.likedBy} />
</div>
</div>
<span className="text-sm w-1/2 text-right">{new Date(mii.createdAt).toLocaleString("en-GB", { timeZone: "UTC" })}</span>
</div>
)}
</div>
</div>
</div>
))}
</div>
);
}

View file

@ -1,53 +1,54 @@
import FilterSelect from "./tag-filter"; import SortSelect from "./sort-select";
import SortSelect from "./sort-select"; import Pagination from "../../pagination";
import Pagination from "../../pagination"; import FilterMenu from "./filter-menu";
export default function Skeleton() { export default function Skeleton() {
return ( return (
<div className="w-full animate-pulse"> <div className="w-full animate-pulse">
<div className="flex justify-between items-end mb-2 max-[32rem]:flex-col max-[32rem]:items-center"> <div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex justify-between items-center gap-2 mb-2 max-md:flex-col">
<p className="text-lg"> <div className="flex items-center gap-2">
<span className="font-extrabold">???</span> Miis <span className="text-2xl font-bold text-amber-900">???</span>
</p> <span className="text-lg text-amber-700">Miis</span>
</div>
<div className="flex gap-2 pointer-events-none">
<FilterSelect /> <div className="relative flex items-center justify-end gap-2 w-full md:max-w-2/3 max-md:justify-center">
<SortSelect /> <FilterMenu />
</div> <SortSelect />
</div> </div>
</div>
<div className="grid grid-cols-4 gap-4 max-lg:grid-cols-3 max-md:grid-cols-2 max-[30rem]:grid-cols-1">
{[...Array(24)].map((_, index) => ( <div className="grid grid-cols-4 gap-4 max-lg:grid-cols-3 max-md:grid-cols-2 max-[30rem]:grid-cols-1">
<div key={index} className="flex flex-col bg-zinc-50 rounded-3xl border-2 border-zinc-300 shadow-lg p-3"> {[...Array(24)].map((_, index) => (
{/* Carousel Skeleton */} <div key={index} className="flex flex-col bg-zinc-50 rounded-3xl border-2 border-zinc-300 shadow-lg p-3">
<div className="relative rounded-xl bg-zinc-300 border-2 border-zinc-300 mb-1"> {/* Carousel Skeleton */}
<div className="aspect-3/2"></div> <div className="relative rounded-xl bg-zinc-300 border-2 border-zinc-300 mb-1">
</div> <div className="aspect-3/2"></div>
</div>
{/* Content */}
<div className="p-4 flex flex-col gap-1 h-full"> {/* Content */}
{/* Name */} <div className="p-4 flex flex-col gap-1 h-full">
<div className="h-7 bg-zinc-300 rounded w-2/3 mb-0.5" /> {/* Name */}
<div className="h-7 bg-zinc-300 rounded w-2/3 mb-0.5" />
{/* Tags */}
<div className="flex flex-wrap gap-1"> {/* Tags */}
<div className="px-4 py-2 bg-orange-200 rounded-full w-14 h-6" /> <div className="flex flex-wrap gap-1">
<div className="px-4 py-2 bg-orange-200 rounded-full w-10 h-6" /> <div className="px-4 py-2 bg-orange-200 rounded-full w-14 h-6" />
</div> <div className="px-4 py-2 bg-orange-200 rounded-full w-10 h-6" />
</div>
{/* Bottom row */}
<div className="mt-0.5 grid grid-cols-2 items-center"> {/* Bottom row */}
<div className="h-6 w-12 bg-red-200 rounded" /> <div className="mt-0.5 grid grid-cols-2 items-center">
<div className="h-4 w-24 bg-zinc-200 rounded justify-self-end" /> <div className="h-6 w-12 bg-red-200 rounded" />
</div> <div className="h-4 w-24 bg-zinc-200 rounded justify-self-end" />
</div> </div>
</div> </div>
))} </div>
</div> ))}
</div>
<div className="pointer-events-none">
<Pagination lastPage={10} /> <div className="pointer-events-none">
</div> <Pagination lastPage={10} />
</div> </div>
); </div>
} );
}

View file

@ -3,19 +3,20 @@ import { Icon } from "@iconify/react";
import Description from "./description"; import Description from "./description";
import { useStore } from "@nanostores/react"; import { useStore } from "@nanostores/react";
import { session } from "../session"; import { session } from "../session";
import { Link } from "react-router"; import { Link, useLocation } from "react-router";
interface Props { interface Props {
user?: any; user?: any;
page?: "settings" | "likes";
} }
export default function ProfileInformation({ user, page }: Props) { export default function ProfileInformation({ user }: Props) {
const location = useLocation();
const $session = useStore(session); const $session = useStore(session);
if (!user) return null; if (!user) return null;
const currentUser = user ?? $session?.user; const currentUser = user ?? $session?.user;
const page = location.pathname;
const isAdmin = currentUser?.id === Number(import.meta.env.VITE_ADMIN_USER_ID); const isAdmin = currentUser?.id === Number(import.meta.env.VITE_ADMIN_USER_ID);
const isContributor = import.meta.env.VITE_CONTRIBUTORS_USER_IDS?.split(",").includes(user?.id); const isContributor = import.meta.env.VITE_CONTRIBUTORS_USER_IDS?.split(",").includes(user?.id);
const isOwnProfile = currentUser?.id === user?.id; const isOwnProfile = currentUser?.id === user?.id;
@ -24,8 +25,8 @@ export default function ProfileInformation({ user, page }: Props) {
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex gap-4 mb-2 max-md:flex-col"> <div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex gap-4 mb-2 max-md:flex-col">
<div className="flex w-full gap-4 overflow-x-scroll"> <div className="flex w-full gap-4 overflow-x-scroll">
{/* Profile picture */} {/* Profile picture */}
<Link to={`/profile/${user.id}`} className="size-28 aspect-square"> <Link to={`${import.meta.env.VITE_API_URL}/profile/${user.id}`} className="size-28 aspect-square">
<img src={user.image ?? "/guest.png"} className="rounded-full bg-white border-2 border-orange-400 shadow max-md:self-center" /> <img src={user.image ?? "/guest.png"} className="rounded-full bg-white border-2 border-orange-400 shadow w-full max-md:self-center" />
</Link> </Link>
{/* User information */} {/* User information */}
<div className="flex flex-col w-full relative py-3"> <div className="flex flex-col w-full relative py-3">
@ -72,19 +73,19 @@ export default function ProfileInformation({ user, page }: Props) {
<span>Admin</span> <span>Admin</span>
</Link> </Link>
)} )}
{/* {isOwnProfile && page !== "likes" && ( {isOwnProfile && page !== "/profile/likes" && (
<Link aria-label="Go to My Likes" to="/profile/likes"> <Link aria-label="Go to My Likes" to="/profile/likes">
<Icon icon="icon-park-solid:like" /> <Icon icon="icon-park-solid:like" />
<span>My Likes</span> <span>My Likes</span>
</Link> </Link>
)} */} )}
{isOwnProfile && page !== "settings" && ( {isOwnProfile && page !== "/profile/settings" && (
<Link aria-label="Go to Settings" to="/profile/settings"> <Link aria-label="Go to Settings" to="/profile/settings">
<Icon icon="material-symbols:settings-rounded" /> <Icon icon="material-symbols:settings-rounded" />
<span>Settings</span> <span>Settings</span>
</Link> </Link>
)} )}
{page && ( {(page === "/profile/likes" || page === "/profile/settings") && (
<Link aria-label="Go Back to Profile" to={`/profile/${user.id}`}> <Link aria-label="Go Back to Profile" to={`/profile/${user.id}`}>
<Icon icon="tabler:chevron-left" /> <Icon icon="tabler:chevron-left" />
<span>Back</span> <span>Back</span>

View file

@ -8,14 +8,16 @@ import PrivacyPage from "./pages/privacy.tsx";
import TermsOfServicePage from "./pages/terms-of-service.tsx"; import TermsOfServicePage from "./pages/terms-of-service.tsx";
import NotFoundPage from "./pages/not-found.tsx"; import NotFoundPage from "./pages/not-found.tsx";
import LoginPage from "./pages/login.tsx"; import LoginPage from "./pages/login.tsx";
import ProfilePage from "./pages/profile.tsx"; import ProfilePage from "./pages/profile";
import MiiPage from "./pages/mii.tsx"; import MiiPage from "./pages/mii.tsx";
import SubmitPage from "./pages/submit.tsx"; import SubmitPage from "./pages/submit.tsx";
import IndexPage from "./pages/index.tsx"; import IndexPage from "./pages/index.tsx";
import ProfileSettingsPage from "./pages/settings.tsx"; import ProfileSettingsPage from "./pages/profile/settings.tsx";
import { ProgressProvider } from "@bprogress/react"; import { ProgressProvider } from "@bprogress/react";
import LinkOutPage from "./pages/out.tsx"; import LinkOutPage from "./pages/out.tsx";
import Layout from "./layout.tsx"; import Layout from "./layout.tsx";
import ProfileLayout from "./pages/profile/layout.tsx";
import ProfileLikesPage from "./pages/profile/likes.tsx";
createRoot(document.getElementById("root")!).render( createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>
@ -25,8 +27,9 @@ createRoot(document.getElementById("root")!).render(
<Routes> <Routes>
<Route path="/" element={<IndexPage />} /> <Route path="/" element={<IndexPage />} />
<Route path="/mii/:id" element={<MiiPage />} /> <Route path="/mii/:id" element={<MiiPage />} />
<Route path="/profile"> <Route path="/profile" element={<ProfileLayout />}>
<Route path=":id" element={<ProfilePage />} /> <Route path=":id" element={<ProfilePage />} />
<Route path="likes" element={<ProfileLikesPage />} />
<Route path="settings" element={<ProfileSettingsPage />} /> <Route path="settings" element={<ProfileSettingsPage />} />
</Route> </Route>
<Route path="/submit" element={<SubmitPage />} /> <Route path="/submit" element={<SubmitPage />} />

View file

@ -1,68 +1,16 @@
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"; import { useSearchParams } from "react-router";
import MiiList from "../components/mii/list";
interface ApiResponse {
totalCount: number;
miis: any[];
lastPage: number;
}
export default function IndexPage() { export default function IndexPage() {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const [data, setData] = useState<ApiResponse | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`${import.meta.env.VITE_API_URL}/api/mii/list?${searchParams.toString()}`)
.then((res) => {
if (!res.ok) throw new Error("Failed to fetch Miis");
return res.json();
})
.then((data) => {
setData(data);
setLoading(false);
})
.catch((err) => {
console.error(err);
setLoading(false);
});
}, [searchParams]);
return ( return (
<> <>
<h1 className="sr-only"> <h1 className="sr-only">
{searchParams.get("tags") ? `Miis tagged with '${searchParams.get("tags")}' - TomodachiShare` : "TomodachiShare - index mii list"} {searchParams.get("tags") ? `Miis tagged with '${searchParams.get("tags")}' - TomodachiShare` : "TomodachiShare - index mii list"}
</h1> </h1>
<p className="text-center mb-4">We're currently going through some major code changes therefore some features won't work.</p> <p className="text-center mb-4">We're currently going through some major code changes therefore some features won't work.</p>
<MiiList />
<Suspense fallback={<Skeleton />}>
{!loading && data ? (
<div className="w-full">
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex justify-between items-center gap-2 mb-2 max-md:flex-col">
<div className="flex items-center gap-2">
<span className="text-2xl font-bold text-amber-900">{data.totalCount}</span>
<span className="text-lg text-amber-700">{data.totalCount === 1 ? "Mii" : "Miis"}</span>
</div>
<div className="relative flex items-center justify-end gap-2 w-full md:max-w-2/3 max-md:justify-center">
<FilterMenu />
<SortSelect />
</div>
</div>
<MiiGrid miis={data.miis} />
<Pagination lastPage={data.lastPage} />
</div>
) : (
<p>No Miis found :( Has the server died?</p>
)}
</Suspense>
</> </>
); );
} }

View file

@ -5,6 +5,7 @@ import { session } from "../session";
export default function LoginPage() { export default function LoginPage() {
const $session = useStore(session); const $session = useStore(session);
if ($session === undefined) return <div className="p-6 text-center">Loading...</div>;
if ($session) return <Navigate to="/" replace />; if ($session) return <Navigate to="/" replace />;
const API_URL = import.meta.env.VITE_API_URL; const API_URL = import.meta.env.VITE_API_URL;

View file

@ -0,0 +1,8 @@
import { useParams } from "react-router";
import MiiList from "../../components/mii/list";
export default function ProfilePage() {
const { id } = useParams();
return <MiiList userId={Number(id)} />;
}

View file

@ -1,15 +1,27 @@
import { Outlet, useNavigate, useParams } from "react-router";
import ProfileInformation from "../../components/profile-information";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import ProfileInformation from "../components/profile-information"; import { useStore } from "@nanostores/react";
import { useNavigate, useParams } from "react-router"; import { session } from "../../session";
export default function ProfilePage() { export default function ProfileLayout() {
const { id } = useParams(); const { id } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const [user, setUser] = useState<any>(null); const [user, setUser] = useState<any>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const $session = useStore(session);
useEffect(() => { useEffect(() => {
fetch(`${import.meta.env.VITE_API_URL}/api/profile/${id}/info`) if ($session === undefined) return; // session still loading
if ($session === null) {
// not logged in
navigate("/404");
return;
}
const userId = id ? id : $session.user!.id;
fetch(`${import.meta.env.VITE_API_URL}/api/profile/${userId}/info`)
.then((res) => { .then((res) => {
if (!res.ok) throw new Error("Failed to fetch profile"); if (!res.ok) throw new Error("Failed to fetch profile");
return res.json(); return res.json();
@ -23,7 +35,7 @@ export default function ProfilePage() {
setLoading(false); setLoading(false);
navigate("/404"); navigate("/404");
}); });
}, [id]); }, [id, $session]);
if (loading || !user) { if (loading || !user) {
return <div className="p-6 text-center">Loading...</div>; return <div className="p-6 text-center">Loading...</div>;
@ -32,9 +44,7 @@ export default function ProfilePage() {
return ( return (
<div> <div>
<ProfileInformation user={user} /> <ProfileInformation user={user} />
{/* <Suspense fallback={<Skeleton />}> <Outlet />
<MiiList searchParams={await searchParams} userId={user.id} />
</Suspense> */}
</div> </div>
); );
} }

View file

@ -0,0 +1,15 @@
import MiiList from "../../components/mii/list";
export default function ProfileLikesPage() {
return (
<>
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex flex-col gap-4 mb-2">
<div>
<h2 className="text-2xl font-bold">My Likes</h2>
<p className="text-sm text-zinc-500">View every Mii you have liked on TomodachiShare.</p>
</div>
</div>
<MiiList parentPage="likes" />
</>
);
}

View file

@ -1,4 +1,4 @@
import ProfileSettings from "../components/profile-settings"; import ProfileSettings from "../../components/profile-settings";
export default function ProfileSettingsPage() { export default function ProfileSettingsPage() {
return <ProfileSettings currentDescription={null} />; return <ProfileSettings currentDescription={null} />;

View file

@ -5,6 +5,7 @@ import { Navigate } from "react-router";
export default function SubmitPage() { export default function SubmitPage() {
const $session = useStore(session); const $session = useStore(session);
if (!$session) return <Navigate to="/login" replace />; if ($session === undefined) return <div className="p-6 text-center">Loading...</div>;
if ($session === null) return <Navigate to="/login" replace />;
return <SubmitForm />; return <SubmitForm />;
} }

View file

@ -8,4 +8,5 @@ interface SessionData {
}; };
} }
export const session = atom<SessionData | null>(null); // Undefined means still loading, null means no session
export const session = atom<SessionData | null | undefined>(undefined);