mirror of
https://github.com/trafficlunar/tomodachi-share.git
synced 2026-05-13 05:07:46 +00:00
feat: show miis on profiles
and other changes
This commit is contained in:
parent
9795849830
commit
896dc40553
17 changed files with 271 additions and 408 deletions
|
|
@ -9,7 +9,7 @@ export async function GET(request: NextRequest) {
|
|||
const parsed = searchSchema.safeParse(Object.fromEntries(request.nextUrl.searchParams));
|
||||
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
|
||||
let miiIdsLiked: number[] | undefined = undefined;
|
||||
|
|
@ -107,7 +107,7 @@ export async function GET(request: NextRequest) {
|
|||
}
|
||||
|
||||
[totalCount, miis] = await Promise.all([
|
||||
prisma.mii.count({ where: { ...where } }), // TODO: User id
|
||||
prisma.mii.count({ where: { ...where, userId } }),
|
||||
prisma.mii.findMany({
|
||||
where,
|
||||
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 |
|
|
@ -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 |
|
|
@ -11,7 +11,7 @@ export default function Header() {
|
|||
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" />
|
||||
<img src="/favicon.svg" width={56} height={45} alt="logo" />
|
||||
TomodachiShare
|
||||
</Link>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,184 +1,164 @@
|
|||
// import crypto from "crypto";
|
||||
// import seedrandom from "seedrandom";
|
||||
import { useEffect, useState } from "react";
|
||||
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";
|
||||
// import Pagination from "./pagination";
|
||||
// import FilterMenu from "./filter-menu";
|
||||
// import MiiGrid from "./mii-grid";
|
||||
interface Props {
|
||||
userId?: number;
|
||||
parentPage?: "likes" | "admin";
|
||||
}
|
||||
|
||||
// interface Props {
|
||||
// searchParams: URLSearchParams;
|
||||
// userId?: number; // Profiles
|
||||
// parentPage?: "likes" | "admin";
|
||||
// }
|
||||
export default function MiiList({ parentPage, userId }: Props) {
|
||||
const [searchParams] = useSearchParams();
|
||||
const [data, setData] = useState<ApiResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// export default async function MiiList({ searchParams, userId, parentPage }: Props) {
|
||||
// const session = await auth();
|
||||
// const parsed = searchSchema.safeParse(searchParams);
|
||||
// if (!parsed.success) return <h1>{parsed.error.issues[0].message}</h1>;
|
||||
const $session = useStore(session);
|
||||
|
||||
// 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
|
||||
// let miiIdsLiked: number[] | undefined = undefined;
|
||||
fetch(`${import.meta.env.VITE_API_URL}/api/mii/list?${params.toString()}`, { credentials: "include" })
|
||||
.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) {
|
||||
// const likedMiis = await prisma.like.findMany({
|
||||
// where: { userId: Number(session.user.id) },
|
||||
// select: { miiId: true },
|
||||
// });
|
||||
// miiIdsLiked = likedMiis.map((like) => like.miiId);
|
||||
// }
|
||||
return (
|
||||
<>
|
||||
{loading ? (
|
||||
<Skeleton />
|
||||
) : 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>
|
||||
|
||||
// const where: Prisma.MiiWhereInput = {
|
||||
// // In queue logic
|
||||
// ...(parentPage === "admin"
|
||||
// ? { in_queue: true } // Only show queued Miis
|
||||
// : userId
|
||||
// ? {
|
||||
// // 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 }),
|
||||
// };
|
||||
<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>
|
||||
|
||||
// const select: Prisma.MiiSelect = {
|
||||
// id: true,
|
||||
// // Don't show when userId is specified
|
||||
// ...(!userId && {
|
||||
// user: {
|
||||
// select: {
|
||||
// id: true,
|
||||
// name: true,
|
||||
// },
|
||||
// },
|
||||
// }),
|
||||
// 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 },
|
||||
// },
|
||||
// };
|
||||
<div className="grid grid-cols-4 gap-4 max-lg:grid-cols-3 max-md:grid-cols-2 max-[30rem]:grid-cols-1">
|
||||
{data.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>
|
||||
)}
|
||||
|
||||
// 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;
|
||||
// let miis: Prisma.MiiGetPayload<{ select: typeof select }>[];
|
||||
<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>
|
||||
|
||||
// if (sort === "random") {
|
||||
// // Get all IDs that match the where conditions
|
||||
// const matchingIds = await prisma.mii.findMany({
|
||||
// where,
|
||||
// select: { id: true },
|
||||
// });
|
||||
<div className="mt-auto grid grid-cols-2 items-center">
|
||||
<LikeButton likes={mii._count.likedBy} miiId={mii.id} isLiked={false} abbreviate />
|
||||
|
||||
// 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
|
||||
// const randomSeed = seed || crypto.randomInt(0, 1_000_000_000);
|
||||
// const rng = seedrandom(randomSeed.toString());
|
||||
{/* 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>
|
||||
|
||||
// // Randomize all IDs using the Durstenfeld algorithm
|
||||
// for (let i = matchingIds.length - 1; i > 0; i--) {
|
||||
// const j = Math.floor(rng() * (i + 1));
|
||||
// [matchingIds[i], matchingIds[j]] = [matchingIds[j], matchingIds[i]];
|
||||
// }
|
||||
|
||||
// // Convert to number[] array
|
||||
// const selectedIds = matchingIds.slice(skip, skip + limit).map((i) => i.id);
|
||||
|
||||
// miis = await prisma.mii.findMany({
|
||||
// where: {
|
||||
// id: { in: selectedIds },
|
||||
// },
|
||||
// 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>
|
||||
// );
|
||||
// }
|
||||
<span className="text-sm w-1/2 text-right">{new Date(mii.createdAt).toLocaleString("en-GB", { timeZone: "UTC" })}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Pagination lastPage={data.lastPage} />
|
||||
</div>
|
||||
) : (
|
||||
<p>No Miis found, has the server died?</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,53 +1,54 @@
|
|||
import FilterSelect from "./tag-filter";
|
||||
import SortSelect from "./sort-select";
|
||||
import Pagination from "../../pagination";
|
||||
|
||||
export default function Skeleton() {
|
||||
return (
|
||||
<div className="w-full animate-pulse">
|
||||
<div className="flex justify-between items-end mb-2 max-[32rem]:flex-col max-[32rem]:items-center">
|
||||
<p className="text-lg">
|
||||
<span className="font-extrabold">???</span> Miis
|
||||
</p>
|
||||
|
||||
<div className="flex gap-2 pointer-events-none">
|
||||
<FilterSelect />
|
||||
<SortSelect />
|
||||
</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 key={index} className="flex flex-col bg-zinc-50 rounded-3xl border-2 border-zinc-300 shadow-lg p-3">
|
||||
{/* Carousel Skeleton */}
|
||||
<div className="relative rounded-xl bg-zinc-300 border-2 border-zinc-300 mb-1">
|
||||
<div className="aspect-3/2"></div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4 flex flex-col gap-1 h-full">
|
||||
{/* Name */}
|
||||
<div className="h-7 bg-zinc-300 rounded w-2/3 mb-0.5" />
|
||||
|
||||
{/* Tags */}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<div className="px-4 py-2 bg-orange-200 rounded-full w-14 h-6" />
|
||||
<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">
|
||||
<div className="h-6 w-12 bg-red-200 rounded" />
|
||||
<div className="h-4 w-24 bg-zinc-200 rounded justify-self-end" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="pointer-events-none">
|
||||
<Pagination lastPage={10} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import SortSelect from "./sort-select";
|
||||
import Pagination from "../../pagination";
|
||||
import FilterMenu from "./filter-menu";
|
||||
|
||||
export default function Skeleton() {
|
||||
return (
|
||||
<div className="w-full animate-pulse">
|
||||
<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">???</span>
|
||||
<span className="text-lg text-amber-700">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>
|
||||
|
||||
<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 key={index} className="flex flex-col bg-zinc-50 rounded-3xl border-2 border-zinc-300 shadow-lg p-3">
|
||||
{/* Carousel Skeleton */}
|
||||
<div className="relative rounded-xl bg-zinc-300 border-2 border-zinc-300 mb-1">
|
||||
<div className="aspect-3/2"></div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4 flex flex-col gap-1 h-full">
|
||||
{/* Name */}
|
||||
<div className="h-7 bg-zinc-300 rounded w-2/3 mb-0.5" />
|
||||
|
||||
{/* Tags */}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<div className="px-4 py-2 bg-orange-200 rounded-full w-14 h-6" />
|
||||
<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">
|
||||
<div className="h-6 w-12 bg-red-200 rounded" />
|
||||
<div className="h-4 w-24 bg-zinc-200 rounded justify-self-end" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="pointer-events-none">
|
||||
<Pagination lastPage={10} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,19 +3,20 @@ import { Icon } from "@iconify/react";
|
|||
import Description from "./description";
|
||||
import { useStore } from "@nanostores/react";
|
||||
import { session } from "../session";
|
||||
import { Link } from "react-router";
|
||||
import { Link, useLocation } from "react-router";
|
||||
|
||||
interface Props {
|
||||
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);
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
const currentUser = user ?? $session?.user;
|
||||
const page = location.pathname;
|
||||
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 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="flex w-full gap-4 overflow-x-scroll">
|
||||
{/* Profile picture */}
|
||||
<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" />
|
||||
<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 w-full max-md:self-center" />
|
||||
</Link>
|
||||
{/* User information */}
|
||||
<div className="flex flex-col w-full relative py-3">
|
||||
|
|
@ -72,19 +73,19 @@ export default function ProfileInformation({ user, page }: Props) {
|
|||
<span>Admin</span>
|
||||
</Link>
|
||||
)}
|
||||
{/* {isOwnProfile && page !== "likes" && (
|
||||
{isOwnProfile && page !== "/profile/likes" && (
|
||||
<Link aria-label="Go to My Likes" to="/profile/likes">
|
||||
<Icon icon="icon-park-solid:like" />
|
||||
<span>My Likes</span>
|
||||
</Link>
|
||||
)} */}
|
||||
{isOwnProfile && page !== "settings" && (
|
||||
)}
|
||||
{isOwnProfile && page !== "/profile/settings" && (
|
||||
<Link aria-label="Go to Settings" to="/profile/settings">
|
||||
<Icon icon="material-symbols:settings-rounded" />
|
||||
<span>Settings</span>
|
||||
</Link>
|
||||
)}
|
||||
{page && (
|
||||
{(page === "/profile/likes" || page === "/profile/settings") && (
|
||||
<Link aria-label="Go Back to Profile" to={`/profile/${user.id}`}>
|
||||
<Icon icon="tabler:chevron-left" />
|
||||
<span>Back</span>
|
||||
|
|
|
|||
|
|
@ -8,14 +8,16 @@ import PrivacyPage from "./pages/privacy.tsx";
|
|||
import TermsOfServicePage from "./pages/terms-of-service.tsx";
|
||||
import NotFoundPage from "./pages/not-found.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 SubmitPage from "./pages/submit.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 LinkOutPage from "./pages/out.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(
|
||||
<StrictMode>
|
||||
|
|
@ -25,8 +27,9 @@ createRoot(document.getElementById("root")!).render(
|
|||
<Routes>
|
||||
<Route path="/" element={<IndexPage />} />
|
||||
<Route path="/mii/:id" element={<MiiPage />} />
|
||||
<Route path="/profile">
|
||||
<Route path="/profile" element={<ProfileLayout />}>
|
||||
<Route path=":id" element={<ProfilePage />} />
|
||||
<Route path="likes" element={<ProfileLikesPage />} />
|
||||
<Route path="settings" element={<ProfileSettingsPage />} />
|
||||
</Route>
|
||||
<Route path="/submit" element={<SubmitPage />} />
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
interface ApiResponse {
|
||||
totalCount: number;
|
||||
miis: any[];
|
||||
lastPage: number;
|
||||
}
|
||||
import MiiList from "../components/mii/list";
|
||||
|
||||
export default function IndexPage() {
|
||||
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 (
|
||||
<>
|
||||
<h1 className="sr-only">
|
||||
{searchParams.get("tags") ? `Miis tagged with '${searchParams.get("tags")}' - TomodachiShare` : "TomodachiShare - index mii list"}
|
||||
</h1>
|
||||
|
||||
<p className="text-center mb-4">We're currently going through some major code changes therefore some features won't work.</p>
|
||||
|
||||
<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>
|
||||
<MiiList />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { session } from "../session";
|
|||
|
||||
export default function LoginPage() {
|
||||
const $session = useStore(session);
|
||||
if ($session === undefined) return <div className="p-6 text-center">Loading...</div>;
|
||||
if ($session) return <Navigate to="/" replace />;
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL;
|
||||
|
|
|
|||
8
frontend/src/pages/profile/index.tsx
Normal file
8
frontend/src/pages/profile/index.tsx
Normal 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)} />;
|
||||
}
|
||||
|
|
@ -1,15 +1,27 @@
|
|||
import { Outlet, useNavigate, useParams } from "react-router";
|
||||
import ProfileInformation from "../../components/profile-information";
|
||||
import { useEffect, useState } from "react";
|
||||
import ProfileInformation from "../components/profile-information";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import { useStore } from "@nanostores/react";
|
||||
import { session } from "../../session";
|
||||
|
||||
export default function ProfilePage() {
|
||||
export default function ProfileLayout() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [user, setUser] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const $session = useStore(session);
|
||||
|
||||
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) => {
|
||||
if (!res.ok) throw new Error("Failed to fetch profile");
|
||||
return res.json();
|
||||
|
|
@ -23,7 +35,7 @@ export default function ProfilePage() {
|
|||
setLoading(false);
|
||||
navigate("/404");
|
||||
});
|
||||
}, [id]);
|
||||
}, [id, $session]);
|
||||
|
||||
if (loading || !user) {
|
||||
return <div className="p-6 text-center">Loading...</div>;
|
||||
|
|
@ -32,9 +44,7 @@ export default function ProfilePage() {
|
|||
return (
|
||||
<div>
|
||||
<ProfileInformation user={user} />
|
||||
{/* <Suspense fallback={<Skeleton />}>
|
||||
<MiiList searchParams={await searchParams} userId={user.id} />
|
||||
</Suspense> */}
|
||||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
15
frontend/src/pages/profile/likes.tsx
Normal file
15
frontend/src/pages/profile/likes.tsx
Normal 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" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import ProfileSettings from "../components/profile-settings";
|
||||
import ProfileSettings from "../../components/profile-settings";
|
||||
|
||||
export default function ProfileSettingsPage() {
|
||||
return <ProfileSettings currentDescription={null} />;
|
||||
|
|
@ -5,6 +5,7 @@ import { Navigate } from "react-router";
|
|||
|
||||
export default function SubmitPage() {
|
||||
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 />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue