mirror of
https://github.com/trafficlunar/tomodachi-share.git
synced 2026-05-13 21:27:46 +00:00
Compare commits
No commits in common. "af37b05ab1bfd9a85a5f05a1d0f0e5fe03190503" and "d0fe60067a9b586710c8cb5549b1523cc9abb430" have entirely different histories.
af37b05ab1
...
d0fe60067a
10 changed files with 141 additions and 201 deletions
|
|
@ -9,22 +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 {
|
const { q: query, sort, tags, exclude, platform, gender, makeup, allowCopying, quarantined, page = 1, limit = 24, parentPage, userId } = parsed.data;
|
||||||
q: query,
|
|
||||||
sort,
|
|
||||||
tags,
|
|
||||||
exclude,
|
|
||||||
platform,
|
|
||||||
gender,
|
|
||||||
makeup,
|
|
||||||
allowCopying,
|
|
||||||
quarantined,
|
|
||||||
page = 1,
|
|
||||||
limit = 24,
|
|
||||||
parentPage,
|
|
||||||
userId,
|
|
||||||
timeRange,
|
|
||||||
} = parsed.data;
|
|
||||||
|
|
||||||
// My Likes page
|
// My Likes page
|
||||||
let miiIdsLiked: number[] | undefined = undefined;
|
let miiIdsLiked: number[] | undefined = undefined;
|
||||||
|
|
@ -70,12 +55,6 @@ export async function GET(request: NextRequest) {
|
||||||
...(makeup && { makeup: { equals: makeup } }),
|
...(makeup && { makeup: { equals: makeup } }),
|
||||||
// Quarantined
|
// Quarantined
|
||||||
...(!quarantined && !userId && { quarantined: false }),
|
...(!quarantined && !userId && { quarantined: false }),
|
||||||
// Time range
|
|
||||||
...(timeRange && {
|
|
||||||
createdAt: {
|
|
||||||
gte: new Date(Date.now() - { day: 86400000, week: 604800000, month: 2592000000, year: 31536000000 }[timeRange]),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const select: Prisma.MiiSelect = {
|
const select: Prisma.MiiSelect = {
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ export default async function MiiList({ searchParams }: Props) {
|
||||||
const parsed = searchSchema.safeParse(searchParams);
|
const parsed = searchSchema.safeParse(searchParams);
|
||||||
if (!parsed.success) return <h1>{parsed.error.issues[0].message}</h1>;
|
if (!parsed.success) return <h1>{parsed.error.issues[0].message}</h1>;
|
||||||
|
|
||||||
const { page = 1, limit = 100 } = parsed.data;
|
const { page = 1, limit = 24 } = parsed.data;
|
||||||
|
|
||||||
const skip = (page - 1) * limit;
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,6 @@ export default async function Reports({ searchParams }: { searchParams: { status
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const totalPages = Math.ceil(total / PAGE_SIZE);
|
const totalPages = Math.ceil(total / PAGE_SIZE);
|
||||||
const FRONTEND_URL = process.env.NEXT_PUBLIC_FRONTEND_URL;
|
|
||||||
|
|
||||||
const updateStatus = async (formData: FormData) => {
|
const updateStatus = async (formData: FormData) => {
|
||||||
"use server";
|
"use server";
|
||||||
|
|
@ -89,24 +88,21 @@ export default async function Reports({ searchParams }: { searchParams: { status
|
||||||
<div className="grid grid-cols-4 text-xs text-zinc-600 mt-4 max-sm:grid-cols-2">
|
<div className="grid grid-cols-4 text-xs text-zinc-600 mt-4 max-sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<p>Target ID</p>
|
<p>Target ID</p>
|
||||||
<a
|
<a href={report.reportType === "MII" ? `/mii/${report.targetId}` : `/profile/${report.targetId}`} className="text-blue-600 text-sm">
|
||||||
href={report.reportType === "MII" ? `${FRONTEND_URL}/mii/${report.targetId}` : `${FRONTEND_URL}/profile/${report.targetId}`}
|
|
||||||
className="text-blue-600 text-sm"
|
|
||||||
>
|
|
||||||
{report.targetId}
|
{report.targetId}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p>Creator ID</p>
|
<p>Creator ID</p>
|
||||||
<a href={`${FRONTEND_URL}/profile/${report.creatorId}`} className="text-blue-600 text-sm">
|
<a href={`/profile/${report.creatorId}`} className="text-blue-600 text-sm">
|
||||||
{report.creatorId}
|
{report.creatorId}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p>Reporter</p>
|
<p>Reporter</p>
|
||||||
<a href={`${FRONTEND_URL}/profile/${report.authorId}`} className="text-blue-600 text-sm">
|
<a href={`/profile/${report.authorId}`} className="text-blue-600 text-sm">
|
||||||
{report.authorId}
|
{report.authorId}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import { Icon } from "@iconify/react";
|
||||||
import LikeButton from "../../like-button";
|
import LikeButton from "../../like-button";
|
||||||
import { useStore } from "@nanostores/react";
|
import { useStore } from "@nanostores/react";
|
||||||
import { session } from "../../../session";
|
import { session } from "../../../session";
|
||||||
import TimeRangeSelect from "./time-range-select";
|
import Description from "../../description";
|
||||||
|
|
||||||
interface ApiResponse {
|
interface ApiResponse {
|
||||||
totalCount: number;
|
totalCount: number;
|
||||||
|
|
@ -19,13 +19,14 @@ interface ApiResponse {
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
userId?: number;
|
userId?: number;
|
||||||
parentPage?: "likes";
|
parentPage?: "likes" | "admin";
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MiiList({ parentPage, userId }: Props) {
|
export default function MiiList({ parentPage, userId }: Props) {
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const [data, setData] = useState<ApiResponse | null>(null);
|
const [data, setData] = useState<ApiResponse | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [acceptingAll, setAcceptingAll] = useState(false);
|
||||||
const [likedIds, setLikedIds] = useState<Set<number>>(new Set());
|
const [likedIds, setLikedIds] = useState<Set<number>>(new Set());
|
||||||
|
|
||||||
const $session = useStore(session);
|
const $session = useStore(session);
|
||||||
|
|
@ -59,6 +60,28 @@ export default function MiiList({ parentPage, userId }: Props) {
|
||||||
});
|
});
|
||||||
}, [searchParams, userId, parentPage, $session]);
|
}, [searchParams, userId, parentPage, $session]);
|
||||||
|
|
||||||
|
async function handleAcceptAll() {
|
||||||
|
if (!data) return;
|
||||||
|
setAcceptingAll(true);
|
||||||
|
try {
|
||||||
|
await Promise.all(
|
||||||
|
data.miis.map((mii) =>
|
||||||
|
fetch(`${import.meta.env.VITE_API_URL}/api/admin/accept-mii?id=${mii.id}`, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
|
if (userId) params.append("userId", userId.toString());
|
||||||
|
if (parentPage) params.append("parentPage", parentPage);
|
||||||
|
const res = await fetch(`${import.meta.env.VITE_API_URL}/api/mii/list?${params.toString()}`, { credentials: "include" });
|
||||||
|
if (res.ok) setData(await res.json());
|
||||||
|
} finally {
|
||||||
|
setAcceptingAll(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
|
|
@ -72,9 +95,18 @@ export default function MiiList({ parentPage, userId }: Props) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative flex items-center justify-end gap-2 w-full md:max-w-2/3 max-md:justify-center">
|
<div className="relative flex items-center justify-end gap-2 w-full md:max-w-2/3 max-md:justify-center">
|
||||||
|
{parentPage === "admin" && data.miis.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={handleAcceptAll}
|
||||||
|
disabled={acceptingAll}
|
||||||
|
className="pill button flex items-center gap-1.5 px-3 py-1.5 bg-green-500! border-green-600! hover:bg-green-600! disabled:opacity-60 disabled:cursor-not-allowed text-white text-sm font-semibold rounded-xl shadow transition-colors"
|
||||||
|
>
|
||||||
|
<Icon icon="material-symbols:check-circle-rounded" className="text-base" />
|
||||||
|
{acceptingAll ? "Accepting…" : `Accept All`}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<FilterMenu />
|
<FilterMenu />
|
||||||
<SortSelect />
|
<SortSelect />
|
||||||
<TimeRangeSelect />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -82,7 +114,7 @@ export default function MiiList({ parentPage, userId }: Props) {
|
||||||
{data.miis.map((mii) => (
|
{data.miis.map((mii) => (
|
||||||
<div
|
<div
|
||||||
key={mii.id}
|
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 ? "border-zinc-400 opacity-70" : "border-zinc-300"}`}
|
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 && (
|
{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">
|
<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">
|
||||||
|
|
@ -91,6 +123,7 @@ export default function MiiList({ parentPage, userId }: Props) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{parentPage !== "admin" ? (
|
||||||
<Link to={`/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
|
<img
|
||||||
src={`${import.meta.env.VITE_API_URL}/mii/${mii.id}/image?type=mii`}
|
src={`${import.meta.env.VITE_API_URL}/mii/${mii.id}/image?type=mii`}
|
||||||
|
|
@ -100,6 +133,19 @@ export default function MiiList({ parentPage, userId }: Props) {
|
||||||
className="w-full h-auto aspect-3/2 object-contain"
|
className="w-full h-auto aspect-3/2 object-contain"
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-2 gap-1 rounded-xl bg-zinc-200">
|
||||||
|
{[
|
||||||
|
`${import.meta.env.VITE_API_URL}/mii/${mii.id}/image?type=mii`,
|
||||||
|
mii.platform === "THREE_DS"
|
||||||
|
? `${import.meta.env.VITE_API_URL}/mii/${mii.id}/image?type=qr-code`
|
||||||
|
: `${import.meta.env.VITE_API_URL}/mii/${mii.id}/image?type=features`,
|
||||||
|
...Array.from({ length: mii.imageCount }, (_, i) => `${import.meta.env.VITE_API_URL}/mii/${mii.id}/image?type=image${i}`),
|
||||||
|
].map((src, i) => (
|
||||||
|
<img key={i} src={src} alt="mii image" className="w-full bg-zinc-200" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="p-4 flex flex-col gap-1 h-full">
|
<div className="p-4 flex flex-col gap-1 h-full">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
|
|
@ -123,6 +169,8 @@ export default function MiiList({ parentPage, userId }: Props) {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{parentPage === "admin" && mii.description && <Description text={mii.description} />}
|
||||||
|
|
||||||
<div className="mt-auto grid grid-cols-2 items-center">
|
<div className="mt-auto grid grid-cols-2 items-center">
|
||||||
<LikeButton likes={mii.likeCount} miiId={mii.id} isLiked={likedIds.has(mii.id)} abbreviate />
|
<LikeButton likes={mii.likeCount} miiId={mii.id} isLiked={likedIds.has(mii.id)} abbreviate />
|
||||||
|
|
||||||
|
|
@ -140,6 +188,27 @@ export default function MiiList({ parentPage, userId }: Props) {
|
||||||
<DeleteMiiButton miiId={mii.id} miiName={mii.name} likes={mii.likeCount} />
|
<DeleteMiiButton miiId={mii.id} miiName={mii.name} likes={mii.likeCount} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{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(`${import.meta.env.VITE_API_URL}/api/admin/accept-mii?id=${mii.id}`, { method: "POST", credentials: "include" });
|
||||||
|
}}
|
||||||
|
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.likeCount} />
|
||||||
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,73 +0,0 @@
|
||||||
import { useTransition } from "react";
|
|
||||||
import { useSelect } from "downshift";
|
|
||||||
import { Icon } from "@iconify/react";
|
|
||||||
import { useNavigate, useSearchParams } from "react-router";
|
|
||||||
|
|
||||||
type TimeRange = "day" | "week" | "month" | "year";
|
|
||||||
|
|
||||||
const items: { value: TimeRange | "all"; label: string }[] = [
|
|
||||||
{ value: "all", label: "all time" },
|
|
||||||
{ value: "day", label: "today" },
|
|
||||||
{ value: "week", label: "this week" },
|
|
||||||
{ value: "month", label: "this month" },
|
|
||||||
{ value: "year", label: "this year" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function TimeRangeSelect() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [searchParams] = useSearchParams();
|
|
||||||
const [, startTransition] = useTransition();
|
|
||||||
|
|
||||||
const currentRange = (searchParams.get("timeRange") as TimeRange) || "all";
|
|
||||||
const currentItem = items.find((i) => i.value === currentRange) || items[0];
|
|
||||||
|
|
||||||
const { isOpen, getToggleButtonProps, getMenuProps, getItemProps, highlightedIndex, selectedItem } = useSelect({
|
|
||||||
items,
|
|
||||||
selectedItem: currentItem,
|
|
||||||
itemToString: (item) => item?.label || "",
|
|
||||||
onSelectedItemChange: ({ selectedItem }) => {
|
|
||||||
if (!selectedItem) return;
|
|
||||||
|
|
||||||
const params = new URLSearchParams(searchParams);
|
|
||||||
params.set("page", "1");
|
|
||||||
|
|
||||||
if (selectedItem.value === "all") {
|
|
||||||
params.delete("timeRange");
|
|
||||||
} else {
|
|
||||||
params.set("timeRange", selectedItem.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
startTransition(() => {
|
|
||||||
navigate(`?${params.toString()}`, { preventScrollReset: true });
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative w-fit">
|
|
||||||
<button type="button" {...getToggleButtonProps()} aria-label="Time range dropdown" className="pill input w-full gap-1 justify-between! text-nowrap">
|
|
||||||
<Icon icon="mdi:clock-outline" className="size-5 mr-1" />
|
|
||||||
{selectedItem?.label || "all time"}
|
|
||||||
<Icon icon="tabler:chevron-down" className="ml-1 size-5" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<ul
|
|
||||||
{...getMenuProps()}
|
|
||||||
className={`absolute z-50 w-full bg-orange-200 border-2 border-orange-400 rounded-lg mt-1 shadow-lg max-h-60 overflow-y-auto ${
|
|
||||||
isOpen ? "block" : "hidden"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{isOpen &&
|
|
||||||
items.map((item, index) => (
|
|
||||||
<li
|
|
||||||
key={item.value}
|
|
||||||
{...getItemProps({ item, index })}
|
|
||||||
className={`px-4 py-1 cursor-pointer text-sm ${highlightedIndex === index ? "bg-black/15" : ""}`}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -20,7 +20,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
#root {
|
#root {
|
||||||
@apply antialiased flex flex-col items-center w-full min-h-screen;
|
@apply antialiased flex flex-col items-center w-screen h-screen;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pill {
|
.pill {
|
||||||
|
|
|
||||||
|
|
@ -54,36 +54,8 @@ export default function MiiPage() {
|
||||||
if (loading || !mii) return <div className="p-6 text-center">Loading...</div>;
|
if (loading || !mii) return <div className="p-6 text-center">Loading...</div>;
|
||||||
const images = [...Array.from({ length: mii.imageCount ?? 0 }, (_, index) => `${API_URL}/mii/${mii.id}/image?type=image${index}`)];
|
const images = [...Array.from({ length: mii.imageCount ?? 0 }, (_, index) => `${API_URL}/mii/${mii.id}/image?type=image${index}`)];
|
||||||
|
|
||||||
const metaTitle = `${mii.name} - TomodachiShare`;
|
|
||||||
const platformLabel = mii.platform === "SWITCH" ? "Switch Living the Dream" : "3DS";
|
|
||||||
const metaDescription = `Check out '${mii.name}', a ${platformLabel} Tomodachi Life Mii created by ${mii.user.name} on TomodachiShare with ${mii.likeCount ?? 0} likes.`;
|
|
||||||
const metaImage = `${import.meta.env.VITE_API_URL}/mii/${mii.id}/image?type=metadata`;
|
|
||||||
const metaImageAlt = `${mii.name}, ${mii.tags?.join(", ")} ${mii.gender} Mii character`;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<title>{metaTitle}</title>
|
|
||||||
<meta name="description" content={metaDescription} />
|
|
||||||
<meta name="keywords" content={["mii", "tomodachi life", "nintendo", "tomodachishare", ...mii.tags].join(", ")} />
|
|
||||||
<link rel="canonical" href={`${import.meta.env.VITE_BASE_URL}/mii/${mii.id}`} />
|
|
||||||
|
|
||||||
{/* Open Graph */}
|
|
||||||
<meta property="og:type" content="article" />
|
|
||||||
<meta property="og:title" content={metaTitle} />
|
|
||||||
<meta property="og:description" content={metaDescription} />
|
|
||||||
<meta property="og:image" content={metaImage} />
|
|
||||||
<meta property="og:image:alt" content={metaImageAlt} />
|
|
||||||
<meta property="article:published_time" content={new Date(mii.createdAt).toISOString()} />
|
|
||||||
<meta property="article:author" content={`@${mii.user.name}`} />
|
|
||||||
|
|
||||||
{/* Twitter / X */}
|
|
||||||
<meta name="twitter:card" content="summary_large_image" />
|
|
||||||
<meta name="twitter:title" content={metaTitle} />
|
|
||||||
<meta name="twitter:description" content={metaDescription} />
|
|
||||||
<meta name="twitter:image" content={metaImage} />
|
|
||||||
<meta name="twitter:image:alt" content={metaImageAlt} />
|
|
||||||
<meta name="twitter:creator" content={`@${mii.user.name}`} />
|
|
||||||
|
|
||||||
<div className="max-w-5xl w-full flex flex-col gap-4">
|
<div className="max-w-5xl w-full flex flex-col gap-4">
|
||||||
{mii.quarantined && (
|
{mii.quarantined && (
|
||||||
<div className="bg-red-100 border-2 border-red-400 rounded-2xl shadow-lg p-4 flex items-center gap-3 text-red-700">
|
<div className="bg-red-100 border-2 border-red-400 rounded-2xl shadow-lg p-4 flex items-center gap-3 text-red-700">
|
||||||
|
|
|
||||||
|
|
@ -13,10 +13,10 @@ export default function ProfileLayout() {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const $session = useStore(session);
|
const $session = useStore(session);
|
||||||
|
|
||||||
const userId = Number(id ?? $session?.user?.id);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if ($session === undefined) return; // session still loading
|
if ($session === undefined) return; // session still loading
|
||||||
|
|
||||||
|
const userId = id ?? $session?.user?.id;
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
navigate("/404");
|
navigate("/404");
|
||||||
return;
|
return;
|
||||||
|
|
@ -44,38 +44,14 @@ export default function ProfileLayout() {
|
||||||
return <div className="p-6 text-center">Loading...</div>;
|
return <div className="p-6 text-center">Loading...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isAdmin = userId === Number(import.meta.env.VITE_ADMIN_USER_ID);
|
const sessionUserId = $session?.user?.id ? Number($session.user.id) : null;
|
||||||
|
const page = location.pathname;
|
||||||
|
const isAdmin = sessionUserId === Number(import.meta.env.VITE_ADMIN_USER_ID);
|
||||||
const isContributor = import.meta.env.VITE_CONTRIBUTORS_USER_IDS?.split(",").includes(String(user?.id));
|
const isContributor = import.meta.env.VITE_CONTRIBUTORS_USER_IDS?.split(",").includes(String(user?.id));
|
||||||
const isOwnProfile = userId === user?.id;
|
const isOwnProfile = sessionUserId === user?.id;
|
||||||
|
|
||||||
const joinDate = new Date(user.createdAt).toLocaleDateString("en-US", { month: "long", year: "numeric" });
|
|
||||||
const metaTitle = `${user.name} - TomodachiShare`;
|
|
||||||
const metaDescription = `View ${user.name}'s profile on TomodachiShare. Creator of ${user._count.miis} Miis. Member since ${joinDate}.`;
|
|
||||||
const metaImage = user.image.startsWith("/profile")
|
|
||||||
? `${import.meta.env.VITE_API_URL}${user.image}`
|
|
||||||
: (user.image ?? `${import.meta.env.VITE_API_URL}/guest.png`);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<title>{metaTitle}</title>
|
|
||||||
<meta name="description" content={metaDescription} />
|
|
||||||
<meta name="keywords" content="mii, tomodachi life, nintendo, mii creator, mii collection, profile" />
|
|
||||||
<link rel="canonical" href={`${import.meta.env.VITE_BASE_URL}/profile/${user.id}`} />
|
|
||||||
|
|
||||||
{/* Open Graph */}
|
|
||||||
<meta property="og:type" content="profile" />
|
|
||||||
<meta property="og:title" content={metaTitle} />
|
|
||||||
<meta property="og:description" content={metaDescription} />
|
|
||||||
<meta property="og:image" content={metaImage} />
|
|
||||||
<meta property="og:profile:username" content={user.name} />
|
|
||||||
|
|
||||||
{/* Twitter / X */}
|
|
||||||
<meta name="twitter:card" content="summary" />
|
|
||||||
<meta name="twitter:title" content={metaTitle} />
|
|
||||||
<meta name="twitter:description" content={metaDescription} />
|
|
||||||
<meta name="twitter:image" content={metaImage} />
|
|
||||||
<meta name="twitter:creator" content={user.name} />
|
|
||||||
|
|
||||||
<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 */}
|
||||||
|
|
@ -134,19 +110,19 @@ export default function ProfileLayout() {
|
||||||
<span>Admin</span>
|
<span>Admin</span>
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
{isOwnProfile && location.pathname !== "/profile/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 && location.pathname !== "/profile/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>
|
||||||
)}
|
)}
|
||||||
{(location.pathname === "/profile/likes" || location.pathname === "/profile/settings") && (
|
{(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>
|
||||||
|
|
|
||||||
|
|
@ -342,26 +342,49 @@ export default function SubmitPage() {
|
||||||
|
|
||||||
{/* Makeup (switch only) */}
|
{/* Makeup (switch only) */}
|
||||||
<div className={`w-full grid grid-cols-3 items-start ${platform === "SWITCH" ? "" : "hidden"}`}>
|
<div className={`w-full grid grid-cols-3 items-start ${platform === "SWITCH" ? "" : "hidden"}`}>
|
||||||
<label className="font-semibold py-2">Face Paint</label>
|
<label htmlFor="makeup" className="font-semibold py-2">
|
||||||
|
Face Paint
|
||||||
|
</label>
|
||||||
|
|
||||||
<div className="col-span-2 flex flex-col gap-1.5">
|
<div className="col-span-2 flex gap-1">
|
||||||
{[
|
{/* Full Makeup */}
|
||||||
{ value: "FULL", label: "Full", desc: "Most of the face/features are covered", color: "pink" },
|
|
||||||
{ value: "PARTIAL", label: "Partial", desc: "Small designs that don't cover features, like marks, stickers, etc.", color: "purple" },
|
|
||||||
{ value: "NONE", label: "None", desc: "No face paint present.", color: "gray" },
|
|
||||||
].map(({ value, label, desc, color }) => (
|
|
||||||
<button
|
<button
|
||||||
key={value}
|
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setMakeup(value as MiiMakeup)}
|
onClick={() => setMakeup("FULL")}
|
||||||
className={`cursor-pointer rounded-xl text-left px-3 py-2 border-2 transition-all ${
|
aria-label="Full Face Paint"
|
||||||
makeup === value ? `bg-${color}-100 border-${color}-400 shadow-md` : "bg-white border-gray-300 hover:border-gray-400"
|
data-tooltip="Face covered more than 80%"
|
||||||
|
className={`cursor-pointer rounded-xl flex justify-center items-center size-11 text-4xl border-2 transition-all after:bg-pink-400! after:border-pink-400! before:border-b-pink-400! ${
|
||||||
|
makeup === "FULL" ? "bg-pink-100 border-pink-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className={`font-medium text-sm ${makeup === value ? `text-${color}-500` : "text-gray-500"}`}>{label}</div>
|
<Icon icon="mdi:palette" className="text-pink-400" />
|
||||||
<div className="text-xs text-gray-500 mt-0.5">{desc}</div>
|
</button>
|
||||||
|
|
||||||
|
{/* Partial Makeup */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMakeup("PARTIAL")}
|
||||||
|
aria-label="Partial Face Paint"
|
||||||
|
data-tooltip="For at least any face paint"
|
||||||
|
className={`cursor-pointer rounded-xl flex justify-center items-center size-11 text-4xl border-2 transition-all after:bg-purple-400! after:border-purple-400! before:border-b-purple-400! ${
|
||||||
|
makeup === "PARTIAL" ? "bg-purple-100 border-purple-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon icon="mdi:lipstick" className="text-purple-400" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* No Makeup */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMakeup("NONE")}
|
||||||
|
aria-label="No Face Paint"
|
||||||
|
data-tooltip="No Face Paint"
|
||||||
|
className={`cursor-pointer rounded-xl flex justify-center items-center size-11 text-4xl border-2 transition-all after:bg-gray-400! after:border-gray-400! before:border-b-gray-400! ${
|
||||||
|
makeup === "NONE" ? "bg-gray-200 border-gray-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon icon="codex:cross" className="text-gray-400" />
|
||||||
</button>
|
</button>
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -480,7 +503,7 @@ export default function SubmitPage() {
|
||||||
<MiiEditor instructions={instructions} />
|
<MiiEditor instructions={instructions} />
|
||||||
<SwitchSubmitTutorialButton />
|
<SwitchSubmitTutorialButton />
|
||||||
<span className="text-xs text-zinc-400 text-center px-32 max-sm:px-8">
|
<span className="text-xs text-zinc-400 text-center px-32 max-sm:px-8">
|
||||||
Mii editor may be inaccurate. Instructions are REALLY recommended, but you do not have to add every instruction.
|
Mii editor may be inaccurate. Instructions are recommended, but not required - you do not have to add every instruction.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,6 @@ export const idSchema = z.coerce.number({ error: "ID must be a number" }).int({
|
||||||
export const searchSchema = z.object({
|
export const searchSchema = z.object({
|
||||||
q: querySchema.optional(),
|
q: querySchema.optional(),
|
||||||
sort: z.enum(["likes", "newest", "oldest"], { error: "Sort must be either 'likes', 'newest', 'oldest'" }).default("newest"),
|
sort: z.enum(["likes", "newest", "oldest"], { error: "Sort must be either 'likes', 'newest', 'oldest'" }).default("newest"),
|
||||||
// todo: incorporate tagsSchema
|
|
||||||
tags: z
|
tags: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
|
|
@ -63,6 +62,7 @@ export const searchSchema = z.object({
|
||||||
makeup: z.enum(["FULL", "PARTIAL", "NONE"], { error: "Makeup must be either 'FULL', 'PARTIAL', or 'NONE'" }).optional(),
|
makeup: z.enum(["FULL", "PARTIAL", "NONE"], { error: "Makeup must be either 'FULL', 'PARTIAL', or 'NONE'" }).optional(),
|
||||||
allowCopying: z.coerce.boolean({ error: "Allow Copying must be either true or false" }).optional(),
|
allowCopying: z.coerce.boolean({ error: "Allow Copying must be either true or false" }).optional(),
|
||||||
quarantined: z.coerce.boolean({ error: "Quarantined must be either true or false" }).optional(),
|
quarantined: z.coerce.boolean({ error: "Quarantined must be either true or false" }).optional(),
|
||||||
|
// todo: incorporate tagsSchema
|
||||||
// Pages
|
// Pages
|
||||||
limit: z.coerce
|
limit: z.coerce
|
||||||
.number({ error: "Limit must be a number" })
|
.number({ error: "Limit must be a number" })
|
||||||
|
|
@ -74,8 +74,6 @@ export const searchSchema = z.object({
|
||||||
// Other
|
// Other
|
||||||
parentPage: z.string().optional(),
|
parentPage: z.string().optional(),
|
||||||
userId: idSchema.optional(),
|
userId: idSchema.optional(),
|
||||||
// Time range filter
|
|
||||||
timeRange: z.enum(["day", "week", "month", "year"], { error: "Time range must be either 'day', 'week', 'month', or 'year'" }).optional(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const userNameSchema = z
|
export const userNameSchema = z
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue