Compare commits

...

11 commits

Author SHA1 Message Date
97f0fda25c feat: reports 2026-04-17 20:15:02 +01:00
896dc40553 feat: show miis on profiles
and other changes
2026-04-17 19:51:17 +01:00
9795849830 fix: prevent people from going to login and submit without sufficient session
also fix build errors for the 1000th time
2026-04-17 18:34:56 +01:00
3e87d263da fix: build errors 2026-04-17 18:27:41 +01:00
12203901e9 feat: use react-router for links and redirects 2026-04-17 18:25:33 +01:00
87b885a2f8 fix: build errors 2026-04-17 17:24:15 +01:00
11df9261da fix: login issues 2026-04-17 17:20:51 +01:00
46202b22b0 fix: react error 482 2026-04-17 17:04:15 +01:00
ae266d5aa0 fix: idek anymore 2026-04-17 16:55:57 +01:00
2f485dfca5 fix: f5391d63 part 2 2026-04-17 16:50:44 +01:00
f5391d63e6 fix: react build errors 2026-04-17 16:41:40 +01:00
62 changed files with 1896 additions and 2141 deletions

View file

@ -1,5 +1,7 @@
# TomodachiShare Development Instructions # TomodachiShare Development Instructions
This is probably outdated.
Welcome to the TomodachiShare development guide! This project uses [pnpm](https://pnpm.io/) for package management, [Next.js](https://nextjs.org/) with the app router for the front-end and back-end, [Prisma](https://prisma.io) for the database, [TailwindCSS](https://tailwindcss.com/) for styling, and [TypeScript](https://www.typescriptlang.org/) for type safety. Welcome to the TomodachiShare development guide! This project uses [pnpm](https://pnpm.io/) for package management, [Next.js](https://nextjs.org/) with the app router for the front-end and back-end, [Prisma](https://prisma.io) for the database, [TailwindCSS](https://tailwindcss.com/) for styling, and [TypeScript](https://www.typescriptlang.org/) for type safety.
## Getting started ## Getting started

View file

@ -5,37 +5,17 @@ WORKDIR /app
RUN apk add --no-cache libc6-compat RUN apk add --no-cache libc6-compat
RUN corepack enable && corepack prepare pnpm@latest --activate RUN corepack enable && corepack prepare pnpm@latest --activate
# -------------------------
# Install dependencies
# -------------------------
FROM base AS deps FROM base AS deps
WORKDIR /app WORKDIR /app
COPY . . COPY . .
RUN pnpm install --frozen-lockfile RUN pnpm install --frozen-lockfile
# -------------------------
# Build stage
# -------------------------
FROM base AS builder FROM base AS builder
WORKDIR /app WORKDIR /app
COPY --from=deps /app /app COPY --from=deps /app /app
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
# Build backend workspace
RUN cd backend && pnpm build RUN cd backend && pnpm build
# -------------------------
# Production stage
# -------------------------
FROM base AS runner FROM base AS runner
WORKDIR /app WORKDIR /app
@ -48,16 +28,12 @@ ENV HOSTNAME=0.0.0.0
RUN addgroup --system --gid 1001 nodejs RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs RUN adduser --system --uid 1001 nextjs
# Copy Next/Backend output
COPY --from=builder /app/backend/public ./public COPY --from=builder /app/backend/public ./public
COPY --from=builder /app/backend/.next ./.next COPY --from=builder /app/backend/.next ./.next
COPY --from=builder --chown=nextjs:nodejs /app/backend/prisma ./prisma COPY --from=builder --chown=nextjs:nodejs /app/backend/prisma ./prisma
# uploads dir RUN mkdir -p /app/.next/standalone/backend/uploads && chown -R nextjs:nodejs /app/.next/standalone/backend/uploads
RUN mkdir -p /app/uploads && chown -R nextjs:nodejs /app/uploads
USER nextjs USER nextjs
EXPOSE 3000 EXPOSE 3000
CMD ["node", ".next/standalone/backend/server.js"] CMD ["node", ".next/standalone/backend/server.js"]

View file

@ -1,5 +1,5 @@
<p align="center"> <p align="center">
<img src="public/logo.svg" alt="TomodachiShare Logo" width="128" /> <img src="backend/public/logo.svg" alt="TomodachiShare Logo" width="128" />
</p> </p>
<h1 align="center"><a href="https://tomodachishare.com">TomodachiShare</a></h1> <h1 align="center"><a href="https://tomodachishare.com">TomodachiShare</a></h1>

View file

@ -13,42 +13,30 @@
"dependencies": { "dependencies": {
"@2toad/profanity": "^3.3.0", "@2toad/profanity": "^3.3.0",
"@auth/prisma-adapter": "2.11.1", "@auth/prisma-adapter": "2.11.1",
"@bprogress/next": "^3.2.12",
"@hello-pangea/dnd": "^18.0.1",
"@prisma/client": "^6.19.2", "@prisma/client": "^6.19.2",
"bit-buffer": "^0.3.0", "bit-buffer": "^0.3.0",
"canvas-confetti": "^1.9.4",
"dayjs": "^1.11.20", "dayjs": "^1.11.20",
"downshift": "^9.3.2", "downshift": "^9.3.2",
"embla-carousel-react": "^8.6.0",
"file-type": "^22.0.1", "file-type": "^22.0.1",
"jsqr": "^1.4.0",
"next": "16.2.3", "next": "16.2.3",
"next-auth": "5.0.0-beta.30", "next-auth": "5.0.0-beta.30",
"qrcode-generator": "^2.0.4", "qrcode-generator": "^2.0.4",
"react": "^19.2.5", "react": "^19.2.5",
"react-dom": "^19.2.5", "react-dom": "^19.2.5",
"react-dropzone": "^15.0.0",
"react-image-crop": "^11.0.10",
"redis": "^5.11.0", "redis": "^5.11.0",
"satori": "^0.26.0", "satori": "^0.26.0",
"seedrandom": "^3.0.5",
"sharp": "^0.34.5", "sharp": "^0.34.5",
"sjcl-with-all": "1.0.8", "sjcl-with-all": "1.0.8",
"swr": "^2.4.1",
"zod": "^4.3.6", "zod": "^4.3.6",
"@tomodachi-share/shared": "workspace:*" "@tomodachi-share/shared": "workspace:*"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.3.5", "@eslint/eslintrc": "^3.3.5",
"@iconify/react": "^6.0.2", "@iconify/react": "^6.0.2",
"@tailwindcss/postcss": "^4.2.2", "@tailwindcss/postcss": "^4.2.2",
"@types/canvas-confetti": "^1.9.0",
"@types/node": "^25.6.0", "@types/node": "^25.6.0",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/seedrandom": "^3.0.8",
"@types/sjcl": "^1.0.34", "@types/sjcl": "^1.0.34",
"eslint": "^10.2.0", "eslint": "^10.2.0",
"eslint-config-next": "16.2.3", "eslint-config-next": "16.2.3",

View file

@ -2,17 +2,14 @@ import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { searchSchema } from "@tomodachi-share/shared/schemas"; import { searchSchema } from "@tomodachi-share/shared/schemas";
import { RateLimit } from "@/lib/rate-limit";
import { Prisma } from "@prisma/client"; import { Prisma } from "@prisma/client";
import crypto from "crypto";
import seedrandom from "seedrandom";
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const session = await auth(); const session = await auth();
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;
@ -94,75 +91,37 @@ export async function GET(request: NextRequest) {
}, },
}; };
const skip = (page - 1) * limit;
let totalCount: number; let totalCount: number;
let filteredCount: number;
let miis: Prisma.MiiGetPayload<{ select: typeof select }>[]; let miis: Prisma.MiiGetPayload<{ select: typeof select }>[];
if (sort === "random") { // Sorting by likes, newest, or oldest
// Get all IDs that match the where conditions let orderBy: Prisma.MiiOrderByWithRelationInput[];
const matchingIds = await prisma.mii.findMany({
where,
select: { id: true },
});
totalCount = matchingIds.length; if (sort === "likes") {
filteredCount = Math.max(0, Math.min(limit, totalCount - skip)); orderBy = [{ likedBy: { _count: "desc" } }, { name: "asc" }];
} else if (sort === "oldest") {
if (matchingIds.length === 0) return; orderBy = [{ createdAt: "asc" }, { name: "asc" }];
// Use seed for consistent random results
const randomSeed = seed || crypto.randomInt(0, 1_000_000_000);
const rng = seedrandom(randomSeed.toString());
// 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 { } else {
// Sorting by likes, newest, or oldest // default to newest
let orderBy: Prisma.MiiOrderByWithRelationInput[]; orderBy = [{ createdAt: "desc" }, { name: "asc" }];
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, filteredCount, miis] = await Promise.all([
prisma.mii.count({ where: { ...where } }), // TODO: User id
prisma.mii.count({ where, skip, take: limit }),
prisma.mii.findMany({
where,
orderBy,
select,
skip: (page - 1) * limit,
take: limit,
}),
]);
} }
[totalCount, miis] = await Promise.all([
prisma.mii.count({ where: { ...where, userId } }),
prisma.mii.findMany({
where,
orderBy,
select,
skip: (page - 1) * limit,
take: limit,
}),
]);
const lastPage = Math.ceil(totalCount / limit); const lastPage = Math.ceil(totalCount / limit);
return NextResponse.json({ return NextResponse.json({
miis, miis,
totalCount, totalCount,
filteredCount,
lastPage, lastPage,
}); });
} }

View file

@ -1,47 +0,0 @@
import { Metadata } from "next";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import ReportMiiForm from "@/components/report/mii-form";
interface Props {
params: Promise<{ id: string }>;
}
export const metadata: Metadata = {
title: "Report Mii - TomodachiShare",
description: "Report a Mii on TomodachiShare",
robots: {
index: false,
follow: false,
},
};
export default async function ReportMiiPage({ params }: Props) {
const session = await auth();
const { id } = await params;
const mii = await prisma.mii.findUnique({
where: {
id: Number(id),
},
include: {
_count: {
select: {
likedBy: true,
},
},
},
});
if (!session) redirect("/login");
if (!mii) redirect("/404");
return (
<div className="flex justify-center w-full">
<ReportMiiForm mii={mii} likes={mii._count.likedBy} />
</div>
);
}

View file

@ -1,40 +0,0 @@
import { Metadata } from "next";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import ReportUserForm from "@/components/report/user-form";
interface Props {
params: Promise<{ id: string }>;
}
export const metadata: Metadata = {
title: "Report User - TomodachiShare",
description: "Report a user on TomodachiShare",
robots: {
index: false,
follow: false,
},
};
export default async function ReportUserPage({ params }: Props) {
const session = await auth();
const { id } = await params;
const user = await prisma.user.findUnique({
where: {
id: Number(id),
},
});
if (!session) redirect("/login");
if (!user) redirect("/404");
return (
<div className="flex justify-center w-full">
<ReportUserForm user={user} />
</div>
);
}

View file

@ -1,12 +0,0 @@
import type { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: "*",
allow: "/",
disallow: ["/*?*page=", "/profile*?*tags=", "/edit/*", "/profile/settings", "/random", "/submit", "/report/mii/*", "/report/user/*", "/admin"],
},
sitemap: `${process.env.NEXT_PUBLIC_BASE_URL}/sitemap.xml`,
};
}

View file

@ -1,81 +0,0 @@
"use client";
import { useState } from "react";
import ReasonSelector from "./reason-selector";
import SubmitButton from "../submit-button";
import { Mii, ReportReason } from "@prisma/client";
interface Props {
mii: Mii;
likes: number;
}
export default function ReportMiiForm({ mii, likes }: Props) {
const [reason, setReason] = useState<ReportReason>();
const [notes, setNotes] = useState<string>();
const [error, setError] = useState<string | undefined>(undefined);
const handleSubmit = async () => {
const response = await fetch(`/api/report`, {
method: "POST",
body: JSON.stringify({ id: mii.id, type: "mii", reason: reason?.toLowerCase(), notes }),
});
const { error } = await response.json();
if (!response.ok) {
setError(error);
return;
}
// redirect(`/`);
window.location.href = "https://tomodachishare.com";
};
return (
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex flex-col gap-4 w-full max-w-2xl">
<div>
<h2 className="text-2xl font-bold">Report a Mii</h2>
<p className="text-sm text-zinc-500">If you encounter a rule-breaking Mii, please report it here</p>
</div>
<hr className="border-zinc-300" />
<div className="bg-orange-100 rounded-xl border-2 border-orange-400 flex">
<img src={`/mii/${mii.id}/image?type=mii`} alt="mii image" width={128} height={128} />
<div className="p-4">
<p className="text-xl font-bold line-clamp-1">{mii.name}</p>
{/* <LikeButton likes={likes} isLiked={true} disabled /> */}
</div>
</div>
<div className="w-full grid grid-cols-3 items-center">
<label htmlFor="reason" className="font-semibold">
Reason
</label>
<ReasonSelector reason={reason} setReason={setReason} />
</div>
<div className="w-full grid grid-cols-3">
<label htmlFor="reason-note" className="font-semibold">
Reason notes
</label>
<textarea
rows={3}
maxLength={256}
placeholder="Type notes here for the report..."
className="pill input rounded-xl! resize-none col-span-2"
value={notes}
onChange={(e) => setNotes(e.target.value)}
/>
</div>
<hr className="border-zinc-300" />
<div className="flex justify-between items-center">
{error && <span className="text-red-400 font-bold">Error: {error}</span>}
<SubmitButton onClick={handleSubmit} className="ml-auto" />
</div>
</div>
);
}

View file

@ -1,83 +0,0 @@
"use client";
import { useState } from "react";
import ReasonSelector from "./reason-selector";
import SubmitButton from "../submit-button";
import { ReportReason, User } from "@prisma/client";
import Image from "next/image";
interface Props {
user: User;
}
export default function ReportUserForm({ user }: Props) {
const [reason, setReason] = useState<ReportReason>();
const [notes, setNotes] = useState<string>();
const [error, setError] = useState<string | undefined>(undefined);
const handleSubmit = async () => {
const response = await fetch(`/api/report`, {
method: "POST",
body: JSON.stringify({ id: user.id, type: "user", reason: reason?.toLowerCase(), notes }),
});
const { error } = await response.json();
if (!response.ok) {
setError(error);
return;
}
window.location.href = "https://tomodachishare.com";
};
return (
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex flex-col gap-4 w-full max-w-2xl">
<div>
<h2 className="text-2xl font-bold">Report a User</h2>
<p className="text-sm text-zinc-500">If you encounter a user causing issues, please report them here</p>
</div>
<hr className="border-zinc-300" />
<div className="bg-orange-100 rounded-xl border-2 border-orange-400 flex p-4 gap-4">
<Image
src={user.image ?? "/guest.png"}
alt="profile picture"
width={96}
height={96}
className="aspect-square rounded-full border-2 border-orange-400"
/>
<p className="text-xl font-bold overflow-hidden text-ellipsis">{user.name}</p>
</div>
<div className="w-full grid grid-cols-3 items-center">
<label htmlFor="reason" className="font-semibold">
Reason
</label>
<ReasonSelector reason={reason} setReason={setReason} />
</div>
<div className="w-full grid grid-cols-3">
<label htmlFor="reason-note" className="font-semibold">
Reason notes
</label>
<textarea
rows={3}
maxLength={256}
placeholder="Type notes here for the report..."
className="pill input rounded-xl! resize-none col-span-2"
value={notes}
onChange={(e) => setNotes(e.target.value)}
/>
</div>
<hr className="border-zinc-300" />
<div className="flex justify-between items-center">
{error && <span className="text-red-400 font-bold">Error: {error}</span>}
<SubmitButton onClick={handleSubmit} className="ml-auto" />
</div>
</div>
);
}

View file

@ -1,33 +0,0 @@
"use client";
import { useState } from "react";
import { Icon } from "@iconify/react";
interface Props {
onClick: () => void | Promise<void>;
disabled?: boolean;
text?: string;
className?: string;
}
export default function SubmitButton({ onClick, disabled = false, text = "Submit", className }: Props) {
const [isLoading, setIsLoading] = useState(false);
const handleClick = async (event: React.FormEvent) => {
event.preventDefault();
setIsLoading(true);
try {
await onClick();
} finally {
setIsLoading(false);
}
};
return (
<button type="submit" aria-label={text} onClick={handleClick} disabled={disabled} className={`pill button w-min ${className}`}>
{text}
{isLoading && <Icon icon="svg-spinners:180-ring-with-bg" className="ml-2" />}
</button>
);
}

View file

@ -43,7 +43,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
}, },
async redirect({ url, baseUrl }) { async redirect({ url, baseUrl }) {
return process.env.FRONTEND_URL ?? "http://localhost:4321"; return process.env.NEXT_PUBLIC_FRONTEND_URL ?? "http://localhost:4321";
}, },
}, },
}); });

View file

@ -1,54 +1,52 @@
{ {
"name": "frontend", "name": "frontend",
"private": true, "private": true,
"version": "0.0.0", "version": "0.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@bprogress/react": "^1.2.7", "@bprogress/react": "^1.2.7",
"@fontsource-variable/lexend": "^5.2.11", "@fontsource-variable/lexend": "^5.2.11",
"@hello-pangea/dnd": "^18.0.1", "@hello-pangea/dnd": "^18.0.1",
"@nanostores/react": "^1.1.0", "@nanostores/react": "^1.1.0",
"@tailwindcss/vite": "^4.2.2", "@tailwindcss/vite": "^4.2.2",
"@tomodachi-share/shared": "workspace:*", "@tomodachi-share/shared": "workspace:*",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"canvas-confetti": "^1.9.4", "canvas-confetti": "^1.9.4",
"dayjs": "^1.11.20", "dayjs": "^1.11.20",
"downshift": "^9.3.2", "downshift": "^9.3.2",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"jsqr": "^1.4.0", "jsqr": "^1.4.0",
"nanostores": "^1.2.0", "nanostores": "^1.2.0",
"qrcode-generator": "^2.0.4", "qrcode-generator": "^2.0.4",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-dropzone": "^15.0.0", "react-dropzone": "^15.0.0",
"react-image-crop": "^11.0.10", "react-image-crop": "^11.0.10",
"react-router": "^7.14.1", "react-router": "^7.14.1",
"seedrandom": "^3.0.5", "tailwindcss": "^4.2.2",
"tailwindcss": "^4.2.2", "zod": "^4.3.6"
"zod": "^4.3.6" },
}, "devDependencies": {
"devDependencies": { "@eslint/js": "^9.39.4",
"@eslint/js": "^9.39.4", "@iconify/react": "^6.0.2",
"@iconify/react": "^6.0.2", "@types/canvas-confetti": "^1.9.0",
"@types/canvas-confetti": "^1.9.0", "@types/node": "^24.12.2",
"@types/node": "^24.12.2", "@types/react": "^19.2.14",
"@types/react": "^19.2.14", "@types/react-dom": "^19.2.3",
"@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1",
"@types/seedrandom": "^3.0.8", "eslint": "^9.39.4",
"@vitejs/plugin-react": "^6.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"eslint": "^9.39.4", "eslint-plugin-react-refresh": "^0.5.2",
"eslint-plugin-react-hooks": "^7.0.1", "globals": "^17.4.0",
"eslint-plugin-react-refresh": "^0.5.2", "typescript": "~6.0.2",
"globals": "^17.4.0", "typescript-eslint": "^8.58.0",
"typescript": "~6.0.2", "vite": "^8.0.4"
"typescript-eslint": "^8.58.0", }
"vite": "^8.0.4"
}
} }

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

@ -0,0 +1,13 @@
User-Agent: *
Allow: /
Disallow: /*?*page=
Disallow: /profile*?*tags=
Disallow: /edit/*
Disallow: /profile/settings
Disallow: /random
Disallow: /submit
Disallow: /report/mii/*
Disallow: /report/user/*
Disallow: /admin
Sitemap: https://api.tomodachishare.com/sitemap.xml

View file

@ -1,42 +1,43 @@
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import { Link } from "react-router";
interface Props {
text: string; interface Props {
className?: string; text: string;
} className?: string;
}
// Adds fancy formatting to links
export default function Description({ text, className }: Props) { // Adds fancy formatting to links
const urlRegex = /(https?:\/\/[^\s]+)/g; export default function Description({ text, className }: Props) {
const parts = text.split(urlRegex); const urlRegex = /(https?:\/\/[^\s]+)/g;
const parts = text.split(urlRegex);
return (
<p className={`text-sm mt-2 bg-white/50 p-3 rounded-lg border border-orange-200 whitespace-break-spaces max-h-54 overflow-y-auto ${className}`}> return (
{parts.map(async (part, index) => { <p className={`text-sm mt-2 bg-white/50 p-3 rounded-lg border border-orange-200 whitespace-break-spaces max-h-54 overflow-y-auto ${className}`}>
try { {parts.map((part, index) => {
// Check if it's a URL try {
if (!urlRegex.test(part)) throw new Error("Not a URL"); // Check if it's a URL
const url = new URL(part); if (!urlRegex.test(part)) throw new Error("Not a URL");
const url = new URL(part);
return (
<a return (
key={index} <Link
href={`/out?url=${encodeURIComponent(part)}`} key={index}
target="_blank" to={`/out?url=${encodeURIComponent(part)}`}
className="text-blue-700 underline break-all ml-1 inline-flex items-center group" target="_blank"
title={`Go to ${url.hostname}`} className="text-blue-700 underline break-all ml-1 inline-flex items-center group"
> title={`Go to ${url.hostname}`}
{url.hostname} >
{url.pathname !== "/" ? url.pathname : ""} {url.hostname}
{url.search} {url.pathname !== "/" ? url.pathname : ""}
<Icon icon="mi:arrow-right-up" fontSize={16} className="transition group-hover:translate-x-0.5 group-hover:-translate-y-0.5" /> {url.search}
</a> <Icon icon="mi:arrow-right-up" fontSize={16} className="transition group-hover:translate-x-0.5 group-hover:-translate-y-0.5" />
); </Link>
} catch { );
// Normal text/Invalid URL fallback } catch {
return <span key={index}>{part}</span>; // Normal text/Invalid URL fallback
} return <span key={index}>{part}</span>;
})} }
</p> })}
); </p>
} );
}

View file

@ -1,4 +1,5 @@
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import { Link } from "react-router";
export default function Footer() { export default function Footer() {
return ( return (
@ -11,38 +12,42 @@ export default function Footer() {
{/* Links section */} {/* Links section */}
<div className="flex flex-wrap justify-center items-center gap-x-4 text-sm max-sm:gap-x-12"> <div className="flex flex-wrap justify-center items-center gap-x-4 text-sm max-sm:gap-x-12">
<a href="/terms-of-service" className="text-zinc-500 hover:text-zinc-700 transition-colors duration-200 hover:underline"> <Link to="/terms-of-service" className="text-zinc-500 hover:text-zinc-700 transition-colors duration-200 hover:underline">
Terms of Service Terms of Service
</a> </Link>
<span className="text-zinc-400 hidden sm:inline" aria-hidden="true"> <span className="text-zinc-400 hidden sm:inline" aria-hidden="true">
</span> </span>
<a href="/privacy" className="text-zinc-500 hover:text-zinc-700 transition-colors duration-200 hover:underline"> <Link to="/privacy" className="text-zinc-500 hover:text-zinc-700 transition-colors duration-200 hover:underline">
Privacy Policy Privacy Policy
</a> </Link>
<span className="text-zinc-400 hidden sm:inline" aria-hidden="true"> <span className="text-zinc-400 hidden sm:inline" aria-hidden="true">
</span> </span>
<a <Link
href="https://discord.gg/48cXBFKvWQ" to="https://discord.gg/48cXBFKvWQ"
target="_blank" target="_blank"
className="text-[#5865F2] hover:text-[#454FBF] transition-colors duration-200 hover:underline inline-flex items-end gap-1" className="text-[#5865F2] hover:text-[#454FBF] transition-colors duration-200 hover:underline inline-flex items-end gap-1"
> >
<Icon icon="ic:baseline-discord" className="text-lg" /> <Icon icon="ic:baseline-discord" className="text-lg" />
Discord Discord
</a> </Link>
<span className="text-zinc-400 hidden sm:inline" aria-hidden="true"> <span className="text-zinc-400 hidden sm:inline" aria-hidden="true">
</span> </span>
<a href="https://trafficlunar.net" target="_blank" className="text-zinc-500 hover:text-zinc-700 transition-colors duration-200 hover:underline group"> <Link
to="https://trafficlunar.net"
target="_blank"
className="text-zinc-500 hover:text-zinc-700 transition-colors duration-200 hover:underline group"
>
Made by <span className="text-orange-400 group-hover:text-orange-500 font-medium transition-colors duration-200">trafficlunar</span> Made by <span className="text-orange-400 group-hover:text-orange-500 font-medium transition-colors duration-200">trafficlunar</span>
</a> </Link>
</div> </div>
{/* Copyright */} {/* Copyright */}

View file

@ -2,6 +2,7 @@ import { Icon } from "@iconify/react";
import { useEffect } from "react"; import { useEffect } from "react";
import { useStore } from "@nanostores/react"; import { useStore } from "@nanostores/react";
import { session } from "../session"; import { session } from "../session";
import { Link } from "react-router";
export default function HeaderProfile() { export default function HeaderProfile() {
const API_BASE_URL = import.meta.env.VITE_API_URL; const API_BASE_URL = import.meta.env.VITE_API_URL;
@ -25,15 +26,15 @@ export default function HeaderProfile() {
<> <>
{!$session?.user ? ( {!$session?.user ? (
<li> <li>
<a href={"/login"} className="pill button h-full"> <Link to={"/login"} className="pill button h-full">
Login Login
</a> </Link>
</li> </li>
) : ( ) : (
<> <>
<li title="Your profile"> <li title="Your profile">
<a <Link
href={`/profile/${$session?.user?.id}`} to={`/profile/${$session?.user?.id}`}
aria-label="Go to profile" aria-label="Go to profile"
className="pill button gap-2! p-0! h-full max-w-64" className="pill button gap-2! p-0! h-full max-w-64"
data-tooltip="Your Profile" data-tooltip="Your Profile"
@ -46,12 +47,12 @@ export default function HeaderProfile() {
className="rounded-full aspect-square object-cover h-full bg-white outline-2 outline-orange-400" className="rounded-full aspect-square object-cover h-full bg-white outline-2 outline-orange-400"
/> />
<span className="pr-4 overflow-hidden whitespace-nowrap text-ellipsis w-full">{$session?.user?.name ?? "unknown"}</span> <span className="pr-4 overflow-hidden whitespace-nowrap text-ellipsis w-full">{$session?.user?.name ?? "unknown"}</span>
</a> </Link>
</li> </li>
<li title="Logout"> <li title="Logout">
<a href={`${API_BASE_URL}/api/auth/signout`} aria-label="Log Out" className="pill button p-2! aspect-square h-full" data-tooltip="Log Out"> <Link to={`${API_BASE_URL}/api/auth/signout`} aria-label="Log Out" className="pill button p-2! aspect-square h-full" data-tooltip="Log Out">
<Icon icon="ic:round-logout" fontSize={24} /> <Icon icon="ic:round-logout" fontSize={24} />
</a> </Link>
</li> </li>
</> </>
)} )}

View file

@ -1,18 +1,19 @@
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import SearchBar from "./search-bar"; import SearchBar from "./search-bar";
import HeaderProfile from "./header-profile"; import HeaderProfile from "./header-profile";
import { Link } from "react-router";
export default function Header() { export default function Header() {
return ( return (
<header className="sticky top-0 z-50 w-full p-4 grid grid-cols-3 gap-2 gap-x-4 items-center bg-amber-50 border-b-4 border-amber-500 shadow-md max-lg:grid-cols-2 max-md:grid-cols-1"> <header className="sticky top-0 z-50 w-full p-4 grid grid-cols-3 gap-2 gap-x-4 items-center bg-amber-50 border-b-4 border-amber-500 shadow-md max-lg:grid-cols-2 max-md:grid-cols-1">
<a <Link
href={"/"} to={"/"}
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
</a> </Link>
<div className="flex justify-center max-lg:justify-end max-md:justify-center"> <div className="flex justify-center max-lg:justify-end max-md:justify-center">
<SearchBar /> <SearchBar />
@ -20,20 +21,19 @@ export default function Header() {
<ul className="flex justify-end gap-3 items-center h-11 *:h-full max-lg:col-span-2 max-md:justify-center"> <ul className="flex justify-end gap-3 items-center h-11 *:h-full max-lg:col-span-2 max-md:justify-center">
<li title="Random Mii"> <li title="Random Mii">
<a <Link
href={`${import.meta.env.VITE_API_URL}/random`} to={`${import.meta.env.VITE_API_URL}/random`}
aria-label="Go to Random Link" aria-label="Go to Random Link"
className="pill button p-0! h-full aspect-square" className="pill button p-0! h-full aspect-square"
data-tooltip="Go to a Random Mii" data-tooltip="Go to a Random Mii"
> >
<Icon icon="mdi:dice-3" fontSize={28} /> <Icon icon="mdi:dice-3" fontSize={28} />
</a> </Link>
</li> </li>
<li> <li>
<a href={"/submit"} className="pill button h-full"> <Link to={"/submit"} className="pill button h-full">
{" "} Submit
Submit{" "} </Link>
</a>
</li> </li>
<HeaderProfile /> <HeaderProfile />
</ul> </ul>

View file

@ -1,23 +1,24 @@
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import DeleteMiiButton from "./delete-mii-button"; import DeleteMiiButton from "./delete-mii-button";
import { Link } from "react-router";
interface Props {
mii: any; interface Props {
} mii: any;
}
export default function AuthorButtons({ mii }: Props) {
// const session = useSession(); export default function AuthorButtons({ mii }: Props) {
// const session = useSession();
// if (!session.data || (Number(session.data.user?.id) !== mii.userId && Number(session.data.user?.id) !== Number(import.meta.env.NEXT_PUBLIC_ADMIN_USER_ID)))
// return null; // if (!session.data || (Number(session.data.user?.id) !== mii.userId && Number(session.data.user?.id) !== Number(import.meta.env.NEXT_PUBLIC_ADMIN_USER_ID)))
// return null;
return (
<> return (
<a aria-label="Edit Mii" href={`/edit/${mii.id}`}> <>
<Icon icon="mdi:pencil" /> <Link aria-label="Edit Mii" to={`/edit/${mii.id}`}>
<span>Edit</span> <Icon icon="mdi:pencil" />
</a> <span>Edit</span>
<DeleteMiiButton miiId={mii.id} miiName={mii.name} likes={mii._count.likedBy ?? 0} inMiiPage /> </Link>
</> <DeleteMiiButton miiId={mii.id} miiName={mii.name} likes={mii._count.likedBy ?? 0} inMiiPage />
); </>
} );
}

View file

@ -4,6 +4,7 @@ import { Icon } from "@iconify/react";
import LikeButton from "../like-button"; import LikeButton from "../like-button";
import SubmitButton from "../submit-button"; import SubmitButton from "../submit-button";
import { useNavigate } from "react-router";
interface Props { interface Props {
miiId: number; miiId: number;
@ -13,6 +14,7 @@ interface Props {
} }
export default function DeleteMiiButton({ miiId, miiName, likes, inMiiPage }: Props) { export default function DeleteMiiButton({ miiId, miiName, likes, inMiiPage }: Props) {
const navigate = useNavigate();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [isVisible, setIsVisible] = useState(false); const [isVisible, setIsVisible] = useState(false);
@ -28,7 +30,7 @@ export default function DeleteMiiButton({ miiId, miiName, likes, inMiiPage }: Pr
} }
close(); close();
window.location.reload(); // I would use router.refresh() here but the Mii list doesn't update navigate(0);
}; };
const close = () => { const close = () => {

View file

@ -1,132 +1,133 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import PlatformSelect from "./platform-select"; import PlatformSelect from "./platform-select";
import TagFilter from "./tag-filter"; import TagFilter from "./tag-filter";
import GenderSelect from "./gender-select"; import GenderSelect from "./gender-select";
import OtherFilters from "./other-filters"; import OtherFilters from "./other-filters";
import MakeupSelect from "./makeup-select"; import MakeupSelect from "./makeup-select";
import type { MiiGender, MiiMakeup, MiiPlatform } from "@tomodachi-share/shared"; import type { MiiGender, MiiMakeup, MiiPlatform } from "@tomodachi-share/shared";
import { useSearchParams } from "react-router";
export default function FilterMenu() {
const searchParams = new URLSearchParams(window.location.search); export default function FilterMenu() {
const [searchParams] = useSearchParams();
const [isOpen, setIsOpen] = useState(false);
const [isVisible, setIsVisible] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [isVisible, setIsVisible] = useState(false);
const platform = (searchParams.get("platform") as MiiPlatform) || undefined;
const gender = (searchParams.get("gender") as MiiGender) || undefined; const platform = (searchParams.get("platform") as MiiPlatform) || undefined;
const makeup = (searchParams.get("makeup") as MiiMakeup) || undefined; const gender = (searchParams.get("gender") as MiiGender) || undefined;
const rawTags = searchParams.get("tags") || ""; const makeup = (searchParams.get("makeup") as MiiMakeup) || undefined;
const rawExclude = searchParams.get("exclude") || ""; const rawTags = searchParams.get("tags") || "";
const allowCopying = (searchParams.get("allowCopying") as unknown as boolean) || false; const rawExclude = searchParams.get("exclude") || "";
const allowCopying = (searchParams.get("allowCopying") as unknown as boolean) || false;
const tags = useMemo(
() => const tags = useMemo(
rawTags () =>
? rawTags rawTags
.split(",") ? rawTags
.map((tag) => tag.trim()) .split(",")
.filter((tag) => tag.length > 0) .map((tag) => tag.trim())
: [], .filter((tag) => tag.length > 0)
[rawTags], : [],
); [rawTags],
const exclude = useMemo( );
() => const exclude = useMemo(
rawExclude () =>
? rawExclude rawExclude
.split(",") ? rawExclude
.map((tag) => tag.trim()) .split(",")
.filter((tag) => tag.length > 0) .map((tag) => tag.trim())
: [], .filter((tag) => tag.length > 0)
[rawExclude], : [],
); [rawExclude],
);
const [filterCount, setFilterCount] = useState(tags.length);
const [filterCount, setFilterCount] = useState(tags.length);
// Filter menu button handler
const handleClick = () => { // Filter menu button handler
if (!isOpen) { const handleClick = () => {
setIsOpen(true); if (!isOpen) {
// slight delay to trigger animation setIsOpen(true);
setTimeout(() => setIsVisible(true), 10); // slight delay to trigger animation
} else { setTimeout(() => setIsVisible(true), 10);
setIsVisible(false); } else {
setTimeout(() => { setIsVisible(false);
setIsOpen(false); setTimeout(() => {
}, 200); setIsOpen(false);
} }, 200);
}; }
};
// Count all active filters
useEffect(() => { // Count all active filters
let count = tags.length + exclude.length; useEffect(() => {
if (platform) count++; let count = tags.length + exclude.length;
if (gender) count++; if (platform) count++;
if (allowCopying) count++; if (gender) count++;
if (makeup) count++; if (allowCopying) count++;
if (makeup) count++;
setFilterCount(count);
}, [tags, exclude, platform, gender, allowCopying, makeup]); setFilterCount(count);
}, [tags, exclude, platform, gender, allowCopying, makeup]);
return (
<div className="relative"> return (
<button className="pill button gap-2" onClick={handleClick}> <div className="relative">
<Icon icon="mdi:filter" className="text-xl" /> <button className="pill button gap-2" onClick={handleClick}>
Filter <Icon icon="mdi:filter" className="text-xl" />
<span className="w-5">({filterCount})</span> Filter
</button> <span className="w-5">({filterCount})</span>
</button>
{isOpen && (
<div {isOpen && (
className={`absolute w-80 left-0 top-full mt-8 z-40 flex flex-col items-center bg-orange-50 <div
border-2 border-amber-500 rounded-2xl shadow-lg p-4 transition-discrete duration-200 ${isVisible ? "translate-y-0 opacity-100" : "-translate-y-2 opacity-0"}`} className={`absolute w-80 left-0 top-full mt-8 z-40 flex flex-col items-center bg-orange-50
> border-2 border-amber-500 rounded-2xl shadow-lg p-4 transition-discrete duration-200 ${isVisible ? "translate-y-0 opacity-100" : "-translate-y-2 opacity-0"}`}
{/* Arrow */} >
<div className="absolute bottom-full left-1/6 -translate-x-1/2 size-0 border-8 border-transparent border-b-amber-500"></div> {/* Arrow */}
<div className="absolute bottom-full left-1/6 -translate-x-1/2 size-0 border-8 border-transparent border-b-amber-500"></div>
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium w-full mb-2">
<hr className="grow border-zinc-300" /> <div className="flex items-center gap-4 text-zinc-500 text-sm font-medium w-full mb-2">
<span>Platform</span> <hr className="grow border-zinc-300" />
<hr className="grow border-zinc-300" /> <span>Platform</span>
</div> <hr className="grow border-zinc-300" />
<PlatformSelect /> </div>
<PlatformSelect />
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium w-full mt-2 mb-1">
<hr className="grow border-zinc-300" /> <div className="flex items-center gap-4 text-zinc-500 text-sm font-medium w-full mt-2 mb-1">
<span>Gender</span> <hr className="grow border-zinc-300" />
<hr className="grow border-zinc-300" /> <span>Gender</span>
</div> <hr className="grow border-zinc-300" />
<GenderSelect /> </div>
<GenderSelect />
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium w-full mt-2 mb-2">
<hr className="grow border-zinc-300" /> <div className="flex items-center gap-4 text-zinc-500 text-sm font-medium w-full mt-2 mb-2">
<span>Tags Include</span> <hr className="grow border-zinc-300" />
<hr className="grow border-zinc-300" /> <span>Tags Include</span>
</div> <hr className="grow border-zinc-300" />
<TagFilter /> </div>
<TagFilter />
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium w-full mt-2 mb-2">
<hr className="grow border-zinc-300" /> <div className="flex items-center gap-4 text-zinc-500 text-sm font-medium w-full mt-2 mb-2">
<span>Tags Exclude</span> <hr className="grow border-zinc-300" />
<hr className="grow border-zinc-300" /> <span>Tags Exclude</span>
</div> <hr className="grow border-zinc-300" />
<TagFilter isExclude /> </div>
<TagFilter isExclude />
{platform !== "THREE_DS" && (
<> {platform !== "THREE_DS" && (
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium w-full mt-2 mb-1"> <>
<hr className="grow border-zinc-300" /> <div className="flex items-center gap-4 text-zinc-500 text-sm font-medium w-full mt-2 mb-1">
<span>Face Paint</span> <hr className="grow border-zinc-300" />
<hr className="grow border-zinc-300" /> <span>Face Paint</span>
</div> <hr className="grow border-zinc-300" />
<MakeupSelect /> </div>
</> <MakeupSelect />
)} </>
)}
<OtherFilters />
</div> <OtherFilters />
)} </div>
</div> )}
); </div>
} );
}

View file

@ -1,72 +1,73 @@
import { useState, useTransition } from "react"; import { useState, useTransition } from "react";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import type { MiiGender, MiiPlatform } from "@tomodachi-share/shared"; import type { MiiGender, MiiPlatform } from "@tomodachi-share/shared";
import { useNavigate, useSearchParams } from "react-router";
export default function GenderSelect() {
const searchParams = new URLSearchParams(window.location.search); export default function GenderSelect() {
const [, startTransition] = useTransition(); const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [selected, setSelected] = useState<MiiGender | null>((searchParams.get("gender") as MiiGender) ?? null); const [, startTransition] = useTransition();
const platform = (searchParams.get("platform") as MiiPlatform) || undefined;
const [selected, setSelected] = useState<MiiGender | null>((searchParams.get("gender") as MiiGender) ?? null);
const handleClick = (gender: MiiGender) => { const platform = (searchParams.get("platform") as MiiPlatform) || undefined;
const filter = selected === gender ? null : gender;
setSelected(filter); const handleClick = (gender: MiiGender) => {
const filter = selected === gender ? null : gender;
const params = new URLSearchParams(searchParams); setSelected(filter);
params.set("page", "1");
const params = new URLSearchParams(searchParams);
if (filter) { params.set("page", "1");
params.set("gender", filter);
} else { if (filter) {
params.delete("gender"); params.set("gender", filter);
} } else {
params.delete("gender");
startTransition(() => { }
// router.push(`?${params.toString()}`, { scroll: false });
window.location.href = `?${params.toString()}`; startTransition(() => {
}); navigate(`?${params.toString()}`);
}; });
};
return (
<div className="flex gap-0.5 w-fit"> return (
<button <div className="flex gap-0.5 w-fit">
onClick={() => handleClick("MALE")} <button
aria-label="Filter for Male Miis" onClick={() => handleClick("MALE")}
data-tooltip-span aria-label="Filter for Male Miis"
className={`cursor-pointer rounded-xl flex justify-center items-center size-13 text-5xl border-2 transition-all ${ data-tooltip-span
selected === "MALE" ? "bg-blue-100 border-blue-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400" className={`cursor-pointer rounded-xl flex justify-center items-center size-13 text-5xl border-2 transition-all ${
}`} selected === "MALE" ? "bg-blue-100 border-blue-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
> }`}
<div className="tooltip bg-blue-400! border-blue-400! before:border-b-blue-400!">Male</div> >
<Icon icon="foundation:male" className="text-blue-400" /> <div className="tooltip bg-blue-400! border-blue-400! before:border-b-blue-400!">Male</div>
</button> <Icon icon="foundation:male" className="text-blue-400" />
</button>
<button
onClick={() => handleClick("FEMALE")} <button
aria-label="Filter for Female Miis" onClick={() => handleClick("FEMALE")}
data-tooltip-span aria-label="Filter for Female Miis"
className={`cursor-pointer rounded-xl flex justify-center items-center size-13 text-5xl border-2 transition-all ${ data-tooltip-span
selected === "FEMALE" ? "bg-pink-100 border-pink-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400" className={`cursor-pointer rounded-xl flex justify-center items-center size-13 text-5xl border-2 transition-all ${
}`} selected === "FEMALE" ? "bg-pink-100 border-pink-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
> }`}
<div className="tooltip bg-pink-400! border-pink-400! before:border-b-pink-400!">Female</div> >
<Icon icon="foundation:female" className="text-pink-400" /> <div className="tooltip bg-pink-400! border-pink-400! before:border-b-pink-400!">Female</div>
</button> <Icon icon="foundation:female" className="text-pink-400" />
</button>
{platform !== "THREE_DS" && (
<button {platform !== "THREE_DS" && (
onClick={() => handleClick("NONBINARY")} <button
aria-label="Filter for Nonbinary Miis" onClick={() => handleClick("NONBINARY")}
data-tooltip-span aria-label="Filter for Nonbinary Miis"
className={`cursor-pointer rounded-xl flex justify-center items-center size-13 text-5xl border-2 transition-all ${ data-tooltip-span
selected === "NONBINARY" ? "bg-purple-100 border-purple-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400" className={`cursor-pointer rounded-xl flex justify-center items-center size-13 text-5xl border-2 transition-all ${
}`} selected === "NONBINARY" ? "bg-purple-100 border-purple-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
> }`}
<div className="tooltip bg-purple-400! border-purple-400! before:border-b-purple-400!">Nonbinary</div> >
<Icon icon="mdi:gender-non-binary" className="text-purple-400" /> <div className="tooltip bg-purple-400! border-purple-400! before:border-b-purple-400!">Nonbinary</div>
</button> <Icon icon="mdi:gender-non-binary" className="text-purple-400" />
)} </button>
</div> )}
); </div>
} );
}

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,72 +1,73 @@
import { useState, useTransition } from "react"; import { useState, useTransition } from "react";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import type { MiiMakeup } from "@tomodachi-share/shared"; import type { MiiMakeup } from "@tomodachi-share/shared";
import { useNavigate, useSearchParams } from "react-router";
export default function MakeupSelect() {
const searchParams = new URLSearchParams(window.location.search); export default function MakeupSelect() {
const [, startTransition] = useTransition(); const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [selected, setSelected] = useState<MiiMakeup | null>((searchParams.get("makeup") as MiiMakeup) ?? null); const [, startTransition] = useTransition();
const handleClick = (makeup: MiiMakeup) => { const [selected, setSelected] = useState<MiiMakeup | null>((searchParams.get("makeup") as MiiMakeup) ?? null);
const filter = selected === makeup ? null : makeup;
setSelected(filter); const handleClick = (makeup: MiiMakeup) => {
const filter = selected === makeup ? null : makeup;
const params = new URLSearchParams(searchParams); setSelected(filter);
params.set("page", "1");
const params = new URLSearchParams(searchParams);
if (filter) { params.set("page", "1");
params.set("makeup", filter);
} else { if (filter) {
params.delete("makeup"); params.set("makeup", filter);
} } else {
params.delete("makeup");
startTransition(() => { }
// router.push(`?${params.toString()}`, { scroll: false });
window.location.href = `?${params.toString()}`; startTransition(() => {
}); navigate(`?${params.toString()}`);
}; });
};
return (
<div className="flex gap-0.5 w-fit"> return (
{/* Full Makeup */} <div className="flex gap-0.5 w-fit">
<button {/* Full Makeup */}
onClick={() => handleClick("FULL")} <button
aria-label="Filter for Full Face Paint" onClick={() => handleClick("FULL")}
data-tooltip-span aria-label="Filter for Full Face Paint"
className={`cursor-pointer rounded-xl flex justify-center items-center size-13 text-5xl border-2 transition-all ${ data-tooltip-span
selected === "FULL" ? "bg-pink-100 border-pink-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400" className={`cursor-pointer rounded-xl flex justify-center items-center size-13 text-5xl border-2 transition-all ${
}`} selected === "FULL" ? "bg-pink-100 border-pink-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
> }`}
<div className="tooltip bg-pink-400! border-pink-400! before:border-b-pink-400!">Full Face Paint</div> >
<Icon icon="mdi:palette" className="text-pink-400" /> <div className="tooltip bg-pink-400! border-pink-400! before:border-b-pink-400!">Full Face Paint</div>
</button> <Icon icon="mdi:palette" className="text-pink-400" />
</button>
{/* Partial Makeup */}
<button {/* Partial Makeup */}
onClick={() => handleClick("PARTIAL")} <button
aria-label="Filter for Partial Face Paint" onClick={() => handleClick("PARTIAL")}
data-tooltip-span aria-label="Filter for Partial Face Paint"
className={`cursor-pointer rounded-xl flex justify-center items-center size-13 text-5xl border-2 transition-all ${ data-tooltip-span
selected === "PARTIAL" ? "bg-purple-100 border-purple-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400" className={`cursor-pointer rounded-xl flex justify-center items-center size-13 text-5xl border-2 transition-all ${
}`} selected === "PARTIAL" ? "bg-purple-100 border-purple-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
> }`}
<div className="tooltip bg-purple-400! border-purple-400! before:border-b-purple-400!">Partial Face Paint</div> >
<Icon icon="mdi:lipstick" className="text-purple-400" /> <div className="tooltip bg-purple-400! border-purple-400! before:border-b-purple-400!">Partial Face Paint</div>
</button> <Icon icon="mdi:lipstick" className="text-purple-400" />
</button>
{/* No Makeup */}
<button {/* No Makeup */}
onClick={() => handleClick("NONE")} <button
aria-label="Filter for No Face Paint" onClick={() => handleClick("NONE")}
data-tooltip-span aria-label="Filter for No Face Paint"
className={`cursor-pointer rounded-xl flex justify-center items-center size-13 text-5xl border-2 transition-all ${ data-tooltip-span
selected === "NONE" ? "bg-gray-200 border-gray-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400" className={`cursor-pointer rounded-xl flex justify-center items-center size-13 text-5xl border-2 transition-all ${
}`} selected === "NONE" ? "bg-gray-200 border-gray-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
> }`}
<div className="tooltip bg-gray-400! border-gray-400! before:border-b-gray-400!">No Face Paint</div> >
<Icon icon="codex:cross" className="text-gray-400" /> <div className="tooltip bg-gray-400! border-gray-400! before:border-b-gray-400!">No Face Paint</div>
</button> <Icon icon="codex:cross" className="text-gray-400" />
</div> </button>
); </div>
} );
}

View file

@ -1,104 +0,0 @@
import { Icon } from "@iconify/react";
import LikeButton from "../../like-button";
import DeleteMiiButton from "../delete-mii-button";
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>
)}
<a href={`/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"
/>
</a>
<div className="p-4 flex flex-col gap-1 h-full">
<div className="flex justify-between">
<a href={`/mii/${mii.id}`} className="relative font-bold text-2xl line-clamp-1 w-full text-ellipsis wrap-break-word" title={mii.name}>
{mii.name}
</a>
<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) => (
<a href={`?tags=${tag}`} key={tag} className="px-2 py-1 bg-orange-300 rounded-full text-xs">
{tag}
</a>
))}
</div>
<div className="mt-auto grid grid-cols-2 items-center">
<LikeButton likes={mii._count.likedBy} miiId={mii.id} isLiked={false} abbreviate />
{!userId && (
<a href={`/profile/${mii.user?.id}`} className="text-sm text-right overflow-hidden text-ellipsis whitespace-nowrap">
@{mii.user?.name}
</a>
)}
{/* {userId && Number(session.data?.user?.id) == userId && (
<div className="flex gap-1 text-2xl justify-end text-zinc-400">
<a href={`/edit/${mii.id}`} title="Edit Mii" aria-label="Edit Mii" data-tooltip="Edit">
<Icon icon="mdi:pencil" />
</a>
<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,79 +1,80 @@
import type { MiiPlatform } from "@tomodachi-share/shared"; import type { MiiPlatform } from "@tomodachi-share/shared";
import { type ChangeEvent, useState, useTransition } from "react"; import { type ChangeEvent, useState, useTransition } from "react";
import { useLocation, useNavigate, useSearchParams } from "react-router";
export default function OtherFilters() {
const searchParams = new URLSearchParams(window.location.search); export default function OtherFilters() {
const [, startTransition] = useTransition(); const location = useLocation();
const navigate = useNavigate();
const platform = (searchParams.get("platform") as MiiPlatform) || undefined; const [searchParams] = useSearchParams();
const [allowCopying, setAllowCopying] = useState<boolean>((searchParams.get("allowCopying") as unknown as boolean) ?? false); const [, startTransition] = useTransition();
const [quarantined, setQuarantined] = useState<boolean>((searchParams.get("quarantined") as unknown as boolean) ?? false);
const platform = (searchParams.get("platform") as MiiPlatform) || undefined;
const handleChangeAllowCopying = (e: ChangeEvent<HTMLInputElement>) => { const [allowCopying, setAllowCopying] = useState<boolean>((searchParams.get("allowCopying") as unknown as boolean) ?? false);
setAllowCopying(e.target.checked); const [quarantined, setQuarantined] = useState<boolean>((searchParams.get("quarantined") as unknown as boolean) ?? false);
const params = new URLSearchParams(searchParams); const handleChangeAllowCopying = (e: ChangeEvent<HTMLInputElement>) => {
params.set("page", "1"); setAllowCopying(e.target.checked);
if (!allowCopying) { const params = new URLSearchParams(searchParams);
params.set("allowCopying", "true"); params.set("page", "1");
} else {
params.delete("allowCopying"); if (!allowCopying) {
} params.set("allowCopying", "true");
} else {
startTransition(() => { params.delete("allowCopying");
// router.push(`?${params.toString()}`, { scroll: false }); }
window.location.href = `?${params.toString()}`;
}); startTransition(() => {
}; navigate(`?${params.toString()}`);
});
const handleChangeQuarantined = (e: ChangeEvent<HTMLInputElement>) => { };
setQuarantined(e.target.checked);
const handleChangeQuarantined = (e: ChangeEvent<HTMLInputElement>) => {
const params = new URLSearchParams(searchParams); setQuarantined(e.target.checked);
params.set("page", "1");
const params = new URLSearchParams(searchParams);
if (!quarantined) { params.set("page", "1");
params.set("quarantined", "true");
} else { if (!quarantined) {
params.delete("quarantined"); params.set("quarantined", "true");
} } else {
params.delete("quarantined");
startTransition(() => { }
// router.push(`?${params.toString()}`, { scroll: false });
window.location.href = `?${params.toString()}`; startTransition(() => {
}); navigate(`?${params.toString()}`);
}; });
};
const showAllowCopying = platform !== "SWITCH";
const showQuarantined = !location.pathname.startsWith("/profile"); const showAllowCopying = platform !== "SWITCH";
const showQuarantined = !location.pathname.startsWith("/profile");
if (!showAllowCopying && !showQuarantined) return null;
if (!showAllowCopying && !showQuarantined) return null;
return (
<> return (
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium w-full mt-2 mb-1"> <>
<hr className="grow border-zinc-300" /> <div className="flex items-center gap-4 text-zinc-500 text-sm font-medium w-full mt-2 mb-1">
<span>Other</span> <hr className="grow border-zinc-300" />
<hr className="grow border-zinc-300" /> <span>Other</span>
</div> <hr className="grow border-zinc-300" />
</div>
{showAllowCopying && (
<div className="flex justify-between items-center w-full mb-1"> {showAllowCopying && (
<label htmlFor="allowCopying" className="text-sm"> <div className="flex justify-between items-center w-full mb-1">
Allow Copying <label htmlFor="allowCopying" className="text-sm">
</label> Allow Copying
<input type="checkbox" id="allowCopying" className="checkbox-alt" checked={allowCopying} onChange={handleChangeAllowCopying} /> </label>
</div> <input type="checkbox" id="allowCopying" className="checkbox-alt" checked={allowCopying} onChange={handleChangeAllowCopying} />
)} </div>
{showQuarantined && ( )}
<div className="flex justify-between items-center w-full"> {showQuarantined && (
<label htmlFor="quarantined" className="text-sm"> <div className="flex justify-between items-center w-full">
Show Controversial Miis <label htmlFor="quarantined" className="text-sm">
</label> Show Controversial Miis
<input type="checkbox" id="quarantined" className="checkbox-alt" checked={quarantined} onChange={handleChangeQuarantined} /> </label>
</div> <input type="checkbox" id="quarantined" className="checkbox-alt" checked={quarantined} onChange={handleChangeQuarantined} />
)} </div>
</> )}
); </>
} );
}

View file

@ -1,55 +1,56 @@
import { useState, useTransition } from "react"; import { useState, useTransition } from "react";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import type { MiiPlatform } from "@tomodachi-share/shared"; import type { MiiPlatform } from "@tomodachi-share/shared";
import { useNavigate, useSearchParams } from "react-router";
export default function PlatformSelect() {
const searchParams = new URLSearchParams(window.location.search); export default function PlatformSelect() {
const [, startTransition] = useTransition(); const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [selected, setSelected] = useState<MiiPlatform | null>((searchParams.get("platform") as MiiPlatform) ?? null); const [, startTransition] = useTransition();
const handleClick = (platform: MiiPlatform) => { const [selected, setSelected] = useState<MiiPlatform | null>((searchParams.get("platform") as MiiPlatform) ?? null);
const filter = selected === platform ? null : platform;
setSelected(filter); const handleClick = (platform: MiiPlatform) => {
const filter = selected === platform ? null : platform;
const params = new URLSearchParams(searchParams); setSelected(filter);
if (filter) {
params.set("platform", filter); const params = new URLSearchParams(searchParams);
} else { if (filter) {
params.delete("platform"); params.set("platform", filter);
} } else {
params.delete("platform");
startTransition(() => { }
// router.push(`?${params.toString()}`);
window.location.href = `?${params.toString()}`; startTransition(() => {
}); navigate(`?${params.toString()}`);
}; });
};
return (
<div className="grid grid-cols-2 gap-0.5 w-fit"> return (
<button <div className="grid grid-cols-2 gap-0.5 w-fit">
onClick={() => handleClick("THREE_DS")} <button
aria-label="Filter for 3DS Miis" onClick={() => handleClick("THREE_DS")}
data-tooltip-span aria-label="Filter for 3DS Miis"
className={`cursor-pointer rounded-xl flex justify-center items-center size-13 text-3xl border-2 transition-all ${ data-tooltip-span
selected === "THREE_DS" ? "bg-sky-100 border-sky-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400" className={`cursor-pointer rounded-xl flex justify-center items-center size-13 text-3xl border-2 transition-all ${
}`} selected === "THREE_DS" ? "bg-sky-100 border-sky-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
> }`}
<div className="tooltip bg-sky-400! border-sky-400! before:border-b-sky-400!">3DS</div> >
<Icon icon="cib:nintendo-3ds" className="text-sky-400" /> <div className="tooltip bg-sky-400! border-sky-400! before:border-b-sky-400!">3DS</div>
</button> <Icon icon="cib:nintendo-3ds" className="text-sky-400" />
</button>
<button
onClick={() => handleClick("SWITCH")} <button
aria-label="Filter for Switch Miis" onClick={() => handleClick("SWITCH")}
data-tooltip-span aria-label="Filter for Switch Miis"
className={`cursor-pointer rounded-xl flex justify-center items-center size-13 text-3xl border-2 transition-all ${ data-tooltip-span
selected === "SWITCH" ? "bg-red-100 border-red-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400" className={`cursor-pointer rounded-xl flex justify-center items-center size-13 text-3xl border-2 transition-all ${
}`} selected === "SWITCH" ? "bg-red-100 border-red-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
> }`}
<div className="tooltip bg-red-400! border-red-400! before:border-b-red-400!">Switch</div> >
<Icon icon="cib:nintendo-switch" className="text-red-400" /> <div className="tooltip bg-red-400! border-red-400! before:border-b-red-400!">Switch</div>
</button> <Icon icon="cib:nintendo-switch" className="text-red-400" />
</div> </button>
); </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

@ -1,61 +1,58 @@
import { useTransition } from "react"; import { useTransition } from "react";
import { useSelect } from "downshift"; import { useSelect } from "downshift";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import { useNavigate, useSearchParams } from "react-router";
type Sort = "likes" | "newest" | "oldest" | "random";
type Sort = "likes" | "newest" | "oldest";
const items = ["likes", "newest", "oldest", "random"];
const items = ["likes", "newest", "oldest"];
export default function SortSelect() {
const searchParams = new URLSearchParams(window.location.search); export default function SortSelect() {
const [, startTransition] = useTransition(); const navigate = useNavigate();
const [searchParams] = useSearchParams();
const currentSort = (searchParams.get("sort") as Sort) || "newest"; const [, startTransition] = useTransition();
const { isOpen, getToggleButtonProps, getMenuProps, getItemProps, highlightedIndex, selectedItem } = useSelect({ const currentSort = (searchParams.get("sort") as Sort) || "newest";
items,
selectedItem: currentSort, const { isOpen, getToggleButtonProps, getMenuProps, getItemProps, highlightedIndex, selectedItem } = useSelect({
onSelectedItemChange: ({ selectedItem }) => { items,
if (!selectedItem) return; selectedItem: currentSort,
onSelectedItemChange: ({ selectedItem }) => {
const params = new URLSearchParams(searchParams); if (!selectedItem) return;
params.set("page", "1");
params.set("sort", selectedItem); const params = new URLSearchParams(searchParams);
params.set("page", "1");
if (selectedItem == "random") { params.set("sort", selectedItem);
params.set("seed", Math.floor(Math.random() * 1_000_000_000).toString());
} startTransition(() => {
navigate(`?${params.toString()}`);
startTransition(() => { });
// router.push(`?${params.toString()}`, { scroll: false }); },
window.location.href = `?${params.toString()}`; });
});
}, return (
}); <div className="relative w-fit">
{/* Toggle button to open the dropdown */}
return ( <button type="button" {...getToggleButtonProps()} aria-label="Sort dropdown" className="pill input w-full gap-1 justify-between! text-nowrap">
<div className="relative w-fit"> <span>Sort by </span>
{/* Toggle button to open the dropdown */} {selectedItem || "Select a way to sort"}
<button type="button" {...getToggleButtonProps()} aria-label="Sort dropdown" className="pill input w-full gap-1 justify-between! text-nowrap"> <Icon icon="tabler:chevron-down" className="ml-2 size-5" />
<span>Sort by </span> </button>
{selectedItem || "Select a way to sort"}
<Icon icon="tabler:chevron-down" className="ml-2 size-5" /> {/* Dropdown menu */}
</button> <ul
{...getMenuProps()}
{/* Dropdown menu */} 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 ${
<ul isOpen ? "block" : "hidden"
{...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} {...getItemProps({ item, index })} className={`px-4 py-1 cursor-pointer text-sm ${highlightedIndex === index ? "bg-black/15" : ""}`}>
{isOpen && {item}
items.map((item, index) => ( </li>
<li key={item} {...getItemProps({ item, index })} className={`px-4 py-1 cursor-pointer text-sm ${highlightedIndex === index ? "bg-black/15" : ""}`}> ))}
{item} </ul>
</li> </div>
))} );
</ul> }
</div>
);
}

View file

@ -1,59 +1,60 @@
import { useEffect, useMemo, useState, useTransition } from "react"; import { useEffect, useMemo, useState, useTransition } from "react";
import TagSelector from "../../tag-selector"; import TagSelector from "../../tag-selector";
import { useNavigate, useSearchParams } from "react-router";
interface Props {
isExclude?: boolean; interface Props {
} isExclude?: boolean;
}
export default function TagFilter({ isExclude }: Props) {
const searchParams = new URLSearchParams(window.location.search); export default function TagFilter({ isExclude }: Props) {
const [, startTransition] = useTransition(); const navigate = useNavigate();
const [searchParams] = useSearchParams();
const rawTags = searchParams.get(isExclude ? "exclude" : "tags") || ""; const [, startTransition] = useTransition();
const preexistingTags = useMemo(
() => const rawTags = searchParams.get(isExclude ? "exclude" : "tags") || "";
rawTags const preexistingTags = useMemo(
? rawTags () =>
.split(",") rawTags
.map((tag) => tag.trim()) ? rawTags
.filter((tag) => tag.length > 0) .split(",")
: [], .map((tag) => tag.trim())
[rawTags], .filter((tag) => tag.length > 0)
); : [],
[rawTags],
const [tags, setTags] = useState<string[]>(preexistingTags); );
// Sync state if the URL tags change (e.g. via navigation) const [tags, setTags] = useState<string[]>(preexistingTags);
useEffect(() => {
setTags(preexistingTags); // Sync state if the URL tags change (e.g. via navigation)
}, [preexistingTags]); useEffect(() => {
setTags(preexistingTags);
// Redirect automatically on tags change }, [preexistingTags]);
useEffect(() => {
const urlTags = preexistingTags.join(","); // Redirect automatically on tags change
const stateTags = tags.join(","); useEffect(() => {
const urlTags = preexistingTags.join(",");
if (urlTags === stateTags) return; const stateTags = tags.join(",");
const params = new URLSearchParams(searchParams); if (urlTags === stateTags) return;
params.set("page", "1");
const params = new URLSearchParams(searchParams);
if (tags.length > 0) { params.set("page", "1");
params.set(isExclude ? "exclude" : "tags", stateTags);
} else { if (tags.length > 0) {
params.delete(isExclude ? "exclude" : "tags"); params.set(isExclude ? "exclude" : "tags", stateTags);
} } else {
params.delete(isExclude ? "exclude" : "tags");
startTransition(() => { }
// router.push(`?${params.toString()}`, { scroll: false });
window.location.href = `?${params.toString()}`; startTransition(() => {
}); navigate(`?${params.toString()}`);
// eslint-disable-next-line react-hooks/exhaustive-deps });
}, [tags]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, [tags]);
return (
<div className="w-72"> return (
<TagSelector tags={tags} setTags={setTags} isExclude={isExclude} /> <div className="w-72">
</div> <TagSelector tags={tags} setTags={setTags} isExclude={isExclude} />
); </div>
} );
}

View file

@ -1,6 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import { Link } from "react-router";
interface Props { interface Props {
miiId: number; miiId: number;
@ -13,7 +14,7 @@ export default function ShareMiiButton({ miiId }: Props) {
const [hasCopiedUrl, setHasCopiedUrl] = useState(false); const [hasCopiedUrl, setHasCopiedUrl] = useState(false);
const [hasCopiedImage, setHasCopiedImage] = useState(false); const [hasCopiedImage, setHasCopiedImage] = useState(false);
const url = `${import.meta.env.PUBLIC_BASE_URL}/mii/${miiId}`; const url = `${import.meta.env.VITE_BASE_URL}/mii/${miiId}`;
const handleCopyUrl = async () => { const handleCopyUrl = async () => {
await navigator.clipboard.writeText(url); await navigator.clipboard.writeText(url);
@ -128,15 +129,15 @@ export default function ShareMiiButton({ miiId }: Props) {
<div className="flex justify-end gap-2 mt-4"> <div className="flex justify-end gap-2 mt-4">
<div className="flex gap-2 w-full"> <div className="flex gap-2 w-full">
{/* Save button */} {/* Save button */}
<a <Link
href={`${import.meta.env.VITE_API_URL}/mii/${miiId}/image?type=metadata`} to={`${import.meta.env.VITE_API_URL}/mii/${miiId}/image?type=metadata`}
className="pill button p-0! aspect-square size-11 cursor-pointer text-xl" className="pill button p-0! aspect-square size-11 cursor-pointer text-xl"
aria-label="Save Image" aria-label="Save Image"
data-tooltip="Save Image" data-tooltip="Save Image"
download={"hello.png"} download={"hello.png"}
> >
<Icon icon="material-symbols:save-rounded" /> <Icon icon="material-symbols:save-rounded" />
</a> </Link>
{/* Copy button */} {/* Copy button */}
<button <button

View file

@ -1,95 +1,97 @@
import { useCallback, useMemo } from "react"; import { useCallback, useMemo } from "react";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import { Link, useLocation, useSearchParams } from "react-router";
interface Props {
lastPage: number; interface Props {
} lastPage: number;
}
export default function Pagination({ lastPage }: Props) {
const searchParams = new URLSearchParams(location.search); export default function Pagination({ lastPage }: Props) {
const page = Number(searchParams.get("page") ?? 1); const location = useLocation();
const [searchParams] = useSearchParams();
const createPageUrl = useCallback( const page = Number(searchParams.get("page") ?? 1);
(pageNumber: number) => {
const params = new URLSearchParams(searchParams); const createPageUrl = useCallback(
params.set("page", pageNumber.toString()); (pageNumber: number) => {
return `${location.pathname}?${params.toString()}`; const params = new URLSearchParams(searchParams);
}, params.set("page", pageNumber.toString());
[searchParams, location.pathname], return `${location.pathname}?${params.toString()}`;
); },
[searchParams, location.pathname],
const numbers = useMemo(() => { );
const result = [];
const numbers = useMemo(() => {
// Always show 5 pages, centering around the current page when possible const result = [];
const start = Math.max(1, Math.min(page - 2, lastPage - 4));
const end = Math.min(lastPage, start + 4); // Always show 5 pages, centering around the current page when possible
const start = Math.max(1, Math.min(page - 2, lastPage - 4));
for (let i = start; i <= end; i++) result.push(i); const end = Math.min(lastPage, start + 4);
return result; for (let i = start; i <= end; i++) result.push(i);
}, [page, lastPage]);
return result;
return ( }, [page, lastPage]);
<div className="flex justify-center items-center w-full mt-8">
{/* Go to first page */} return (
<a <div className="flex justify-center items-center w-full mt-8">
href={page === 1 ? "#" : createPageUrl(1)} {/* Go to first page */}
aria-label="Go to First Page" <Link
aria-disabled={page === 1} to={page === 1 ? "#" : createPageUrl(1)}
tabIndex={page === 1 ? -1 : undefined} aria-label="Go to First Page"
className={`pill button bg-orange-100! p-0.5! aspect-square text-2xl ${page === 1 ? "pointer-events-none opacity-50" : "hover:bg-orange-400!"}`} aria-disabled={page === 1}
> tabIndex={page === 1 ? -1 : undefined}
<Icon icon="stash:chevron-double-left" /> className={`pill button bg-orange-100! p-0.5! aspect-square text-2xl ${page === 1 ? "pointer-events-none opacity-50" : "hover:bg-orange-400!"}`}
</a> >
<Icon icon="stash:chevron-double-left" />
{/* Previous page */} </Link>
<a
href={page === 1 ? "#" : createPageUrl(page - 1)} {/* Previous page */}
aria-label="Go to Previous Page" <Link
aria-disabled={page === 1} to={page === 1 ? "#" : createPageUrl(page - 1)}
tabIndex={page === 1 ? -1 : undefined} aria-label="Go to Previous Page"
className={`pill bg-orange-100! p-0.5! aspect-square text-2xl ${page === 1 ? "pointer-events-none opacity-50" : "hover:bg-orange-400!"}`} aria-disabled={page === 1}
> tabIndex={page === 1 ? -1 : undefined}
<Icon icon="stash:chevron-left" /> className={`pill bg-orange-100! p-0.5! aspect-square text-2xl ${page === 1 ? "pointer-events-none opacity-50" : "hover:bg-orange-400!"}`}
</a> >
<Icon icon="stash:chevron-left" />
{/* Page numbers */} </Link>
<div className="flex mx-2">
{numbers.map((number) => ( {/* Page numbers */}
<a <div className="flex mx-2">
key={number} {numbers.map((number) => (
href={createPageUrl(number)} <Link
aria-label={`Go to Page ${number}`} key={number}
aria-current={number === page ? "page" : undefined} to={createPageUrl(number)}
className={`pill p-0! w-8 h-8 text-center rounded-md! ${number == page ? "bg-orange-400!" : "bg-orange-100! hover:bg-orange-400!"}`} aria-label={`Go to Page ${number}`}
> aria-current={number === page ? "page" : undefined}
{number} className={`pill p-0! w-8 h-8 text-center rounded-md! ${number == page ? "bg-orange-400!" : "bg-orange-100! hover:bg-orange-400!"}`}
</a> >
))} {number}
</div> </Link>
))}
{/* Next page */} </div>
<a
href={page >= lastPage ? "#" : createPageUrl(page + 1)} {/* Next page */}
aria-label="Go to Next Page" <Link
aria-disabled={page >= lastPage} to={page >= lastPage ? "#" : createPageUrl(page + 1)}
tabIndex={page >= lastPage ? -1 : undefined} aria-label="Go to Next Page"
className={`pill button bg-orange-100! p-0.5! aspect-square text-2xl ${page >= lastPage ? "pointer-events-none opacity-50" : "hover:bg-orange-400!"}`} aria-disabled={page >= lastPage}
> tabIndex={page >= lastPage ? -1 : undefined}
<Icon icon="stash:chevron-right" /> className={`pill button bg-orange-100! p-0.5! aspect-square text-2xl ${page >= lastPage ? "pointer-events-none opacity-50" : "hover:bg-orange-400!"}`}
</a> >
<Icon icon="stash:chevron-right" />
{/* Go to last page */} </Link>
<a
href={page >= lastPage ? "#" : createPageUrl(lastPage)} {/* Go to last page */}
aria-label="Go to Last Page" <Link
aria-disabled={page >= lastPage} to={page >= lastPage ? "#" : createPageUrl(lastPage)}
tabIndex={page >= lastPage ? -1 : undefined} aria-label="Go to Last Page"
className={`pill button bg-orange-100! p-0.5! aspect-square text-2xl ${page >= lastPage ? "pointer-events-none opacity-50" : "hover:bg-orange-400!"}`} aria-disabled={page >= lastPage}
> tabIndex={page >= lastPage ? -1 : undefined}
<Icon icon="stash:chevron-double-right" /> className={`pill button bg-orange-100! p-0.5! aspect-square text-2xl ${page >= lastPage ? "pointer-events-none opacity-50" : "hover:bg-orange-400!"}`}
</a> >
</div> <Icon icon="stash:chevron-double-right" />
); </Link>
} </div>
);
}

View file

@ -1,93 +0,0 @@
import { Icon } from "@iconify/react";
import Description from "./description";
import { useStore } from "@nanostores/react";
import { session } from "../session";
interface Props {
user?: any;
page?: "settings" | "likes";
}
export default function ProfileInformation({ user, page }: Props) {
const $session = useStore(session);
const currentUser = user ?? $session?.user;
const isAdmin = currentUser?.id === Number(import.meta.env.PUBLIC_ADMIN_USER_ID);
const isContributor = import.meta.env.PUBLIC_CONTRIBUTORS_USER_IDS?.split(",").includes(user.id);
const isOwnProfile = currentUser?.id === user.id;
return (
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex gap-4 mb-2 max-md:flex-col">
<div className="flex w-full gap-4 overflow-x-scroll">
{/* Profile picture */}
<a href={`/profile/${user.id}`} className="size-28 aspect-square">
<img src={user.image ?? "/guest.png"} className="rounded-full bg-white border-2 border-orange-400 shadow max-md:self-center" />
</a>
{/* User information */}
<div className="flex flex-col w-full relative py-3">
<div className="flex items-center gap-2">
<h1 className="text-3xl font-extrabold wrap-break-word">{user.name}</h1>
{isAdmin && (
<div data-tooltip="Admin" className="text-orange-400">
<Icon icon="mdi:shield-moon" className="text-2xl" />
</div>
)}
{isContributor && (
<div data-tooltip="Contributor" className="text-orange-400">
<Icon icon="mingcute:group-fill" className="text-2xl" />
</div>
)}
</div>
<h2 className="text-black/60 text-sm font-semibold wrap-break-word">ID: {user?.id}</h2>
<div className="mt-3 text-sm flex gap-8">
<h4 title={`${new Date(user.createdAt).toLocaleTimeString("en-GB", { timeZone: "UTC" })} UTC`}>
<span className="font-medium">Created:</span>{" "}
{new Date(user.createdAt).toLocaleDateString("en-GB", { month: "long", day: "2-digit", year: "numeric" })}
</h4>
<h4>
Liked <span className="font-bold">{user._count.likes}</span> Miis
</h4>
</div>
{user.description && <Description text={user.description} className="max-h-32!" />}
</div>
</div>
{/* Buttons */}
<div className="flex gap-1 w-fit text-3xl text-orange-400 max-md:place-self-center *:size-17 *:flex *:flex-col *:items-center *:gap-1 **:transition-discrete **:duration-150 *:hover:brightness-75 *:hover:scale-[1.08] *:[&_span]:text-sm">
{!isOwnProfile && (
<a aria-label="Report User" href={`${import.meta.env.VITE_API_URL}/report/user/${user.id}`}>
<Icon icon="material-symbols:flag-rounded" />
<span>Report</span>
</a>
)}
{isOwnProfile && isAdmin && (
<a aria-label="Go to Admin" href="/admin">
<Icon icon="mdi:shield-moon" />
<span>Admin</span>
</a>
)}
{/* {isOwnProfile && page !== "likes" && (
<a aria-label="Go to My Likes" href="/profile/likes">
<Icon icon="icon-park-solid:like" />
<span>My Likes</span>
</a>
)} */}
{isOwnProfile && page !== "settings" && (
<a aria-label="Go to Settings" href="/profile/settings">
<Icon icon="material-symbols:settings-rounded" />
<span>Settings</span>
</a>
)}
{page && (
<a aria-label="Go Back to Profile" href={`/profile/${user.id}`}>
<Icon icon="tabler:chevron-left" />
<span>Back</span>
</a>
)}
</div>
</div>
);
}

View file

@ -1,81 +1,83 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import SubmitButton from "../submit-button"; import SubmitButton from "../submit-button";
import { useNavigate } from "react-router";
export default function DeleteAccount() {
const [isOpen, setIsOpen] = useState(false); export default function DeleteAccount() {
const [isVisible, setIsVisible] = useState(false); const navigate = useNavigate();
const [isOpen, setIsOpen] = useState(false);
const [error, setError] = useState<string | undefined>(undefined); const [isVisible, setIsVisible] = useState(false);
const handleSubmit = async () => { const [error, setError] = useState<string | undefined>(undefined);
const response = await fetch("/api/auth/delete", { method: "DELETE" });
if (!response.ok) { const handleSubmit = async () => {
const { error } = await response.json(); const response = await fetch("/api/auth/delete", { method: "DELETE" });
setError(error); if (!response.ok) {
return; const { error } = await response.json();
} setError(error);
return;
window.location.href = "/404"; }
};
navigate("/404");
const close = () => { };
setIsVisible(false);
setTimeout(() => { const close = () => {
setIsOpen(false); setIsVisible(false);
}, 300); setTimeout(() => {
}; setIsOpen(false);
}, 300);
useEffect(() => { };
if (isOpen) {
// slight delay to trigger animation useEffect(() => {
setTimeout(() => setIsVisible(true), 10); if (isOpen) {
} // slight delay to trigger animation
}, [isOpen]); setTimeout(() => setIsVisible(true), 10);
}
return ( }, [isOpen]);
<>
<button onClick={() => setIsOpen(true)} className="pill button w-fit h-min ml-auto bg-red-400! border-red-500! hover:bg-red-500!"> return (
Delete Account <>
</button> <button onClick={() => setIsOpen(true)} className="pill button w-fit h-min ml-auto bg-red-400! border-red-500! hover:bg-red-500!">
Delete Account
{isOpen && </button>
createPortal(
<div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-(--header-height) flex items-center justify-center z-40"> {isOpen &&
<div createPortal(
onClick={close} <div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-(--header-height) flex items-center justify-center z-40">
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${ <div
isVisible ? "opacity-100" : "opacity-0" onClick={close}
}`} className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${
/> isVisible ? "opacity-100" : "opacity-0"
}`}
<div />
className={`z-50 bg-orange-50 border-2 border-amber-500 rounded-2xl shadow-lg p-6 w-full max-w-md transition-discrete duration-300 flex flex-col ${
isVisible ? "scale-100 opacity-100" : "scale-75 opacity-0" <div
}`} className={`z-50 bg-orange-50 border-2 border-amber-500 rounded-2xl shadow-lg p-6 w-full max-w-md transition-discrete duration-300 flex flex-col ${
> isVisible ? "scale-100 opacity-100" : "scale-75 opacity-0"
<div className="flex justify-between items-center mb-2"> }`}
<h2 className="text-xl font-bold">Delete Account</h2> >
<button onClick={close} aria-label="Close" className="text-red-400 hover:text-red-500 text-2xl cursor-pointer"> <div className="flex justify-between items-center mb-2">
<Icon icon="material-symbols:close-rounded" /> <h2 className="text-xl font-bold">Delete Account</h2>
</button> <button onClick={close} aria-label="Close" className="text-red-400 hover:text-red-500 text-2xl cursor-pointer">
</div> <Icon icon="material-symbols:close-rounded" />
</button>
<p className="text-sm text-zinc-500">Are you sure? This is permanent and will remove all uploaded Miis. This action cannot be undone.</p> </div>
{error && <span className="text-red-400 font-bold mt-2">Error: {error}</span>} <p className="text-sm text-zinc-500">Are you sure? This is permanent and will remove all uploaded Miis. This action cannot be undone.</p>
<div className="flex justify-end gap-2 mt-4"> {error && <span className="text-red-400 font-bold mt-2">Error: {error}</span>}
<button onClick={close} className="pill button">
Cancel <div className="flex justify-end gap-2 mt-4">
</button> <button onClick={close} className="pill button">
<SubmitButton onClick={handleSubmit} text="Delete" className="bg-red-400! border-red-500! hover:bg-red-500!" /> Cancel
</div> </button>
</div> <SubmitButton onClick={handleSubmit} text="Delete" className="bg-red-400! border-red-500! hover:bg-red-500!" />
</div>, </div>
document.body, </div>
)} </div>,
</> document.body,
); )}
} </>
);
}

View file

@ -6,12 +6,14 @@ import ProfilePictureSettings from "./profile-picture";
import SubmitDialogButton from "./submit-dialog-button"; import SubmitDialogButton from "./submit-dialog-button";
import DeleteAccount from "./delete-account"; import DeleteAccount from "./delete-account";
import z from "zod"; import z from "zod";
import { useNavigate } from "react-router";
interface Props { interface Props {
currentDescription: string | null | undefined; currentDescription: string | null | undefined;
} }
export default function ProfileSettings({ currentDescription }: Props) { export default function ProfileSettings({ currentDescription }: Props) {
const navigate = useNavigate();
const [description, setDescription] = useState(currentDescription); const [description, setDescription] = useState(currentDescription);
const [name, setName] = useState(""); const [name, setName] = useState("");
@ -39,7 +41,7 @@ export default function ProfileSettings({ currentDescription }: Props) {
} }
close(); close();
window.location.reload(); navigate(0);
}; };
const handleSubmitNameChange = async (close: () => void) => { const handleSubmitNameChange = async (close: () => void) => {
@ -63,7 +65,7 @@ export default function ProfileSettings({ currentDescription }: Props) {
} }
close(); close();
window.location.reload(); navigate(0);
}; };
return ( return (

View file

@ -6,8 +6,10 @@ import dayjs from "dayjs";
import SubmitDialogButton from "./submit-dialog-button"; import SubmitDialogButton from "./submit-dialog-button";
import Dropzone from "../dropzone"; import Dropzone from "../dropzone";
import { useNavigate } from "react-router";
export default function ProfilePictureSettings() { export default function ProfilePictureSettings() {
const navigate = useNavigate();
const [error, setError] = useState<string | undefined>(undefined); const [error, setError] = useState<string | undefined>(undefined);
const [newPicture, setNewPicture] = useState<FileWithPath | undefined>(); const [newPicture, setNewPicture] = useState<FileWithPath | undefined>();
@ -30,7 +32,7 @@ export default function ProfilePictureSettings() {
} }
close(); close();
location.reload(); navigate(0);
}; };
const handleDrop = useCallback((acceptedFiles: FileWithPath[]) => { const handleDrop = useCallback((acceptedFiles: FileWithPath[]) => {

View file

@ -1,7 +1,5 @@
"use client";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import { ReportReason } from "@prisma/client"; import type { ReportReason } from "@tomodachi-share/shared";
import { useSelect } from "downshift"; import { useSelect } from "downshift";
interface Props { interface Props {

View file

@ -1,50 +1,50 @@
import { useState } from "react"; import { useState } from "react";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import { querySchema } from "@tomodachi-share/shared/schemas"; import { querySchema } from "@tomodachi-share/shared/schemas";
import { useNavigate, useSearchParams } from "react-router";
export default function SearchBar() {
const searchParams = new URLSearchParams(window.location.search); export default function SearchBar() {
const [query, setQuery] = useState(searchParams.get("q") || ""); const navigate = useNavigate();
const [searchParams] = useSearchParams();
const handleSearch = () => { const [query, setQuery] = useState(searchParams.get("q") || "");
const result = querySchema.safeParse(query);
if (!result.success) { const handleSearch = () => {
// router.push("/", { scroll: false }); const result = querySchema.safeParse(query);
window.location.href = "/"; if (!result.success) {
return; navigate("/", { preventScrollReset: true });
} return;
}
// Clone current search params and add query param
const params = new URLSearchParams(searchParams.toString()); // Clone current search params and add query param
params.set("q", query); const params = new URLSearchParams(searchParams.toString());
params.set("page", "1"); params.set("q", query);
params.set("page", "1");
// router.push(`/?${params.toString()}`, { scroll: false });
window.location.href = `/?${params.toString()}`; navigate(`/?${params.toString()}`, { preventScrollReset: true });
}; };
const handleKeyDown = (event: React.KeyboardEvent) => { const handleKeyDown = (event: React.KeyboardEvent) => {
if (event.key === "Enter") handleSearch(); if (event.key === "Enter") handleSearch();
}; };
return ( return (
<div className="max-w-md w-full flex rounded-xl focus-within:ring-[3px] ring-orange-400/50 transition shadow-md"> <div className="max-w-md w-full flex rounded-xl focus-within:ring-[3px] ring-orange-400/50 transition shadow-md">
<input <input
type="text" type="text"
placeholder="Search..." placeholder="Search..."
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
className="bg-orange-200 border-2 border-orange-400 py-2 px-3 rounded-l-xl outline-0 w-full placeholder:text-black/40" className="bg-orange-200 border-2 border-orange-400 py-2 px-3 rounded-l-xl outline-0 w-full placeholder:text-black/40"
/> />
<button <button
onClick={handleSearch} onClick={handleSearch}
aria-label="Search" aria-label="Search"
data-tooltip="Search" data-tooltip="Search"
className="bg-orange-400 p-2 w-12 rounded-r-xl flex justify-center items-center cursor-pointer text-2xl" className="bg-orange-400 p-2 w-12 rounded-r-xl flex justify-center items-center cursor-pointer text-2xl"
> >
<Icon icon="ic:baseline-search" /> <Icon icon="ic:baseline-search" />
</button> </button>
</div> </div>
); );
} }

View file

@ -21,8 +21,10 @@ import Carousel from "../carousel";
import SubmitButton from "../submit-button"; import SubmitButton from "../submit-button";
import Dropzone from "../dropzone"; import Dropzone from "../dropzone";
import type { MiiPlatform, MiiGender, MiiMakeup } from "@tomodachi-share/shared"; import type { MiiPlatform, MiiGender, MiiMakeup } from "@tomodachi-share/shared";
import { useNavigate } from "react-router";
export default function SubmitForm() { export default function SubmitForm() {
const navigate = useNavigate();
const [files, setFiles] = useState<FileWithPath[]>([]); const [files, setFiles] = useState<FileWithPath[]>([]);
const handleDrop = useCallback( const handleDrop = useCallback(
@ -113,7 +115,7 @@ export default function SubmitForm() {
return; return;
} }
window.location.href = `/mii/${id}`; navigate(`/mii/${id}`);
}; };
useEffect(() => { useEffect(() => {

View file

@ -1,31 +1,35 @@
import { useEffect } from "react"; import Footer from "./components/footer";
import { ProgressProvider } from "@bprogress/react"; import Header from "./components/header";
import { useEffect } from "react";
export default function Providers({ children }: { children: React.ReactNode }) {
// Calculate header height export default function Layout({ children }: { children: React.ReactNode }) {
useEffect(() => { // Calculate header height
const header = document.querySelector("header"); useEffect(() => {
if (!header) return; const header = document.querySelector("header");
if (!header) return;
const updateHeaderHeight = () => {
document.documentElement.style.setProperty("--header-height", `${header.offsetHeight}px`); const updateHeaderHeight = () => {
}; document.documentElement.style.setProperty("--header-height", `${header.offsetHeight}px`);
};
const resizeObserver = new ResizeObserver(updateHeaderHeight);
resizeObserver.observe(header); const resizeObserver = new ResizeObserver(updateHeaderHeight);
window.addEventListener("resize", updateHeaderHeight); resizeObserver.observe(header);
window.addEventListener("resize", updateHeaderHeight);
updateHeaderHeight();
updateHeaderHeight();
return () => {
resizeObserver.disconnect(); return () => {
window.removeEventListener("resize", updateHeaderHeight); resizeObserver.disconnect();
}; window.removeEventListener("resize", updateHeaderHeight);
}, []); };
}, []);
return (
<ProgressProvider height="4px" color="var(--color-amber-500)" options={{ showSpinner: false }} shallowRouting> return (
{children} <>
</ProgressProvider> <Header />
); {/* <AdminBanner /> */}
} <main className="px-4 py-8 max-w-7xl w-full grow flex flex-col">{children}</main>
<Footer />
</>
);
}

View file

@ -1,4 +1,4 @@
import { StrictMode, Suspense } from "react"; import { StrictMode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { BrowserRouter, Route, Routes } from "react-router"; import { BrowserRouter, Route, Routes } from "react-router";
import "./index.css"; import "./index.css";
@ -8,40 +8,45 @@ 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 Providers from "./components/provider.tsx"; import { ProgressProvider } from "@bprogress/react";
import Header from "./components/header.tsx"; import LinkOutPage from "./pages/out.tsx";
import Footer from "./components/footer.tsx"; import Layout from "./layout.tsx";
import ProfileLayout from "./pages/profile/layout.tsx";
import ProfileLikesPage from "./pages/profile/likes.tsx";
import ReportMiiPage from "./pages/report/mii.tsx";
import ReportUserPage from "./pages/report/user.tsx";
createRoot(document.getElementById("root")!).render( createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>
<Providers> <BrowserRouter>
<Suspense fallback={<div>Loading header...</div>}> <ProgressProvider height="4px" color="var(--color-amber-500)" options={{ showSpinner: false }} shallowRouting>
<Header /> <Layout>
</Suspense>
{/* <AdminBanner /> */}
<main className="px-4 py-8 max-w-7xl w-full grow flex flex-col">
<BrowserRouter>
<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 />} />
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route path="/report">
<Route path="mii/:id" element={<ReportMiiPage />} />
<Route path="profile/:id" element={<ReportUserPage />} />
</Route>
<Route path="/out" element={<LinkOutPage />} />
<Route path="/privacy" element={<PrivacyPage />} /> <Route path="/privacy" element={<PrivacyPage />} />
<Route path="/terms-of-service" element={<TermsOfServicePage />} /> <Route path="/terms-of-service" element={<TermsOfServicePage />} />
<Route path="*" element={<NotFoundPage />} /> <Route path="*" element={<NotFoundPage />} />
</Routes> </Routes>
</BrowserRouter> </Layout>
</main> </ProgressProvider>
<Footer /> </BrowserRouter>
</Providers>
</StrictMode>, </StrictMode>,
); );

View file

@ -1,79 +1,16 @@
import { Suspense, useEffect, useState } from "react"; import { useSearchParams } from "react-router";
import FilterMenu from "../components/mii/list/filter-menu"; import MiiList from "../components/mii/list";
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";
interface ApiResponse {
totalCount: number;
filteredCount: number;
miis: any[];
lastPage: number;
}
export default function IndexPage() { export default function IndexPage() {
const searchParams = new URLSearchParams(location.search); 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);
});
}, []);
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">
{data.totalCount == data.filteredCount ? (
<>
<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>
</>
) : (
<>
<span className="text-2xl font-bold text-amber-900">{data.filteredCount}</span>
<span className="text-sm text-amber-700">of</span>
<span className="text-lg font-semibold text-amber-800">{data.totalCount}</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>
<MiiGrid miis={data.miis} />
<Pagination lastPage={data.lastPage} />
</div>
) : (
<p>No Miis found :( Has the server died?</p>
)}
</Suspense>
</> </>
); );
} }

View file

@ -1,6 +1,13 @@
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import { useStore } from "@nanostores/react";
import { Link, Navigate } from "react-router";
import { session } from "../session";
export default function LoginPage() { 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; const API_URL = import.meta.env.VITE_API_URL;
return ( return (
@ -15,41 +22,41 @@ export default function LoginPage() {
</div> </div>
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
<a <Link
href={`${API_URL}/api/auth/signin/discord`} to={`${API_URL}/api/auth/signin/discord`}
aria-label="Login with Discord" aria-label="Login with Discord"
className="pill button gap-2 px-3! bg-indigo-400! border-indigo-500! hover:bg-indigo-500!" className="pill button gap-2 px-3! bg-indigo-400! border-indigo-500! hover:bg-indigo-500!"
> >
<Icon icon="ic:baseline-discord" fontSize={32} /> <Icon icon="ic:baseline-discord" fontSize={32} />
Login with Discord Login with Discord
</a> </Link>
<a <Link
href={`${API_URL}/api/auth/signin/github`} to={`${API_URL}/api/auth/signin/github`}
aria-label="Login with GitHub" aria-label="Login with GitHub"
className="pill button gap-2 px-3! bg-zinc-700! border-zinc-800! hover:bg-zinc-800! text-white" className="pill button gap-2 px-3! bg-zinc-700! border-zinc-800! hover:bg-zinc-800! text-white"
> >
<Icon icon="mdi:github" fontSize={32} /> <Icon icon="mdi:github" fontSize={32} />
Login with GitHub Login with GitHub
</a> </Link>
<a <Link
href={`${API_URL}/api/auth/signin/google`} to={`${API_URL}/api/auth/signin/google`}
aria-label="Login with Google" aria-label="Login with Google"
className="pill button gap-2 px-3! bg-white! border-gray-300! hover:bg-gray-100! text-black! flex items-center" className="pill button gap-2 px-3! bg-white! border-gray-300! hover:bg-gray-100! text-black! flex items-center"
> >
<Icon icon="material-icon-theme:google" fontSize={32} /> <Icon icon="material-icon-theme:google" fontSize={32} />
Login with Google Login with Google
</a> </Link>
</div> </div>
<p className="mt-8 text-xs text-zinc-400"> <p className="mt-8 text-xs text-zinc-400">
By signing up, you agree to the{" "} By signing up, you agree to the{" "}
<a href="/terms-of-service" className="underline hover:text-zinc-600"> <Link to="/terms-of-service" className="underline hover:text-zinc-600">
Terms of Service Terms of Service
</a>{" "} </Link>{" "}
and{" "} and{" "}
<a href="/privacy" className="underline hover:text-zinc-600"> <Link to="/privacy" className="underline hover:text-zinc-600">
Privacy Policy Privacy Policy
</a> </Link>
. .
</p> </p>
</div> </div>

View file

@ -9,10 +9,11 @@ import SwitchAddMiiTutorialButton from "../components/tutorial/switch-add-mii";
import MiiInstructions from "../components/mii/instructions"; import MiiInstructions from "../components/mii/instructions";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Link, useParams } from "react-router"; import { Link, useNavigate, useParams } from "react-router";
export default function MiiPage() { export default function MiiPage() {
const { id } = useParams(); const { id } = useParams();
const navigate = useNavigate();
const [mii, setMii] = useState<any>(null); const [mii, setMii] = useState<any>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -31,15 +32,15 @@ export default function MiiPage() {
.catch((err) => { .catch((err) => {
console.error(err); console.error(err);
setLoading(false); setLoading(false);
window.location.href = "/404"; navigate("/404");
}); });
}, []); }, [id]);
if (loading || !mii) { if (loading || !mii) {
return <div className="p-6 text-center">Loading...</div>; return <div className="p-6 text-center">Loading...</div>;
} }
const images = [...Array.from({ length: mii.imageCount }, (_, 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}`)];
return ( return (
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
@ -253,12 +254,14 @@ export default function MiiPage() {
{/* Submission name */} {/* Submission name */}
<h1 className="text-4xl font-extrabold wrap-break-word whitespace-break-spaces text-amber-700 flex-1 min-w-0">{mii.name}</h1> <h1 className="text-4xl font-extrabold wrap-break-word whitespace-break-spaces text-amber-700 flex-1 min-w-0">{mii.name}</h1>
{/* Like button */} {/* Like button */}
<LikeButton likes={mii._count.likedBy ?? 0} miiId={mii.id} isLiked={(mii.likedBy ?? []).length > 0} big /> <LikeButton likes={mii._count?.likedBy ?? 0} miiId={mii.id} isLiked={false} big />
</div> </div>
{/* Tags */} {/* Tags */}
<div id="tags" className="flex flex-wrap gap-1 mt-1 *:px-2 *:py-1 *:bg-orange-300 *:rounded-full *:text-xs"> <div id="tags" className="flex flex-wrap gap-1 mt-1 *:px-2 *:py-1 *:bg-orange-300 *:rounded-full *:text-xs">
{mii.tags.map((tag: string) => ( {mii.tags.map((tag: string) => (
<Link to={`/tags=${tag}`}>{tag}</Link> <Link to={`/tags=${tag}`} key={tag}>
{tag}
</Link>
))} ))}
</div> </div>
@ -291,7 +294,7 @@ export default function MiiPage() {
{/* <AuthorButtons mii={mii} /> */} {/* <AuthorButtons mii={mii} /> */}
<ShareMiiButton miiId={mii.id} /> <ShareMiiButton miiId={mii.id} />
<Link aria-label="Report Mii" to={`${API_URL}/report/mii/${mii.id}`}> <Link aria-label="Report Mii" to={`/report/mii/${mii.id}`}>
<Icon icon="material-symbols:flag-rounded" /> <Icon icon="material-symbols:flag-rounded" />
<span>Report</span> <span>Report</span>
</Link> </Link>
@ -333,8 +336,11 @@ export default function MiiPage() {
{images.length > 0 ? ( {images.length > 0 ? (
<div className="grid grid-cols-3 gap-2 w-full max-md:grid-cols-2 max-[24rem]:grid-cols-1"> <div className="grid grid-cols-3 gap-2 w-full max-md:grid-cols-2 max-[24rem]:grid-cols-1">
{images.map((src) => ( {images.map((src, index) => (
<div className="relative aspect-3/2 rounded-xl bg-black/65 border-2 border-amber-400 shadow-md overflow-hidden transition hover:shadow-lg shadow-black/30"> <div
key={index}
className="relative aspect-3/2 rounded-xl bg-black/65 border-2 border-amber-400 shadow-md overflow-hidden transition hover:shadow-lg shadow-black/30"
>
<img <img
src={src} src={src}
alt="mii screenshot background blur" alt="mii screenshot background blur"

View file

@ -1,14 +1,17 @@
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import { Link } from "react-router";
export default function NotFoundPage() {
return <div className="grow flex items-center justify-center"> export default function NotFoundPage() {
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-8 max-w-xs w-full text-center flex flex-col"> return (
<h2 className="text-7xl font-black">404</h2> <div className="grow flex items-center justify-center">
<p>Page not found - you swam off the island!</p> <div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-8 max-w-xs w-full text-center flex flex-col">
<a href="/" className="pill button gap-2 mt-8 w-fit self-center"> <h2 className="text-7xl font-black">404</h2>
<Icon icon="ic:round-home" fontSize={24} /> <p>Page not found - you swam off the island!</p>
Travel Back <Link to="/" className="pill button gap-2 mt-8 w-fit self-center">
</a> <Icon icon="ic:round-home" fontSize={24} />
</div> Travel Back
</div> </Link>
} </div>
</div>
);
}

View file

@ -0,0 +1,73 @@
import { Icon } from "@iconify/react";
import { Link, useNavigate, useSearchParams } from "react-router";
export default function LinkOutPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const url = searchParams.get("url");
if (!url) {
navigate("/");
return null;
}
let parsed: URL;
try {
parsed = new URL(url);
} catch {
navigate("/"); // redirect if URL is invalid
return null;
}
if (!["http:", "https:"].includes(parsed.protocol)) {
navigate("/");
return null;
}
const isSafe = Array.from(SAFE_LINKS).some((domain) => parsed.hostname === domain || parsed.hostname.endsWith(`.${domain}`));
if (isSafe) {
navigate(url);
return null;
}
return (
<div className="grow flex items-center justify-center">
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg py-8 px-6 max-w-md w-full text-center flex flex-col items-center">
<h2 className="text-3xl font-black flex items-center gap-2 mb-1">
<Icon icon="mingcute:alert-fill" className="text-5xl" />
Warning
</h2>
<p>You're attempting to leave TomodachiShare island! The destination website is potentially dangerous.</p>
<div className="bg-zinc-100 border border-zinc-300 rounded-md p-2 break-all w-full mt-4">
<code className="font-mono text-sm">{url}</code>
</div>
<div className="flex justify-center gap-2">
<Link to="/" className="pill button gap-2 mt-8 w-fit self-center bg-zinc-100! border-zinc-300! hover:bg-zinc-300!">
<Icon icon="ic:round-home" fontSize={24} />
Travel Back
</Link>
<Link to={url} target="_blank" rel="noopener noreferrer" className="pill button gap-2 mt-8 w-fit self-center">
<Icon icon="ic:round-open-in-new" fontSize={21} />
Continue
</Link>
</div>
</div>
</div>
);
}
const SAFE_LINKS = new Set([
"tomodachishare.com",
"trafficlunar.net",
"youtube.com",
"youtu.be",
"twitter.com",
"x.com",
"reddit.com",
"tiktok.com",
"tumblr.com",
"instagram.com",
"wikipedia.org",
]);

View file

@ -1,39 +0,0 @@
import { useEffect, useState } from "react";
import ProfileInformation from "../components/profile-information";
import { useParams } from "react-router";
export default function ProfilePage() {
const { id } = useParams();
const [user, setUser] = useState<any>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`${import.meta.env.VITE_API_URL}/api/profile/${id}/info`)
.then((res) => {
if (!res.ok) throw new Error("Failed to fetch profile");
return res.json();
})
.then((data) => {
setUser(data);
setLoading(false);
})
.catch((err) => {
console.error(err);
setLoading(false);
window.location.href = "/404";
});
}, []);
if (loading || !user) {
return <div className="p-6 text-center">Loading...</div>;
}
return (
<div>
<ProfileInformation user={user} />
{/* <Suspense fallback={<Skeleton />}>
<MiiList searchParams={await searchParams} userId={user.id} />
</Suspense> */}
</div>
);
}

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

@ -0,0 +1,136 @@
import { Outlet, useNavigate, useParams } from "react-router";
import { useEffect, useState } from "react";
import { useStore } from "@nanostores/react";
import { session } from "../../session";
import { Icon } from "@iconify/react";
import { Link } from "react-router";
import Description from "../../components/description";
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(() => {
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();
})
.then((data) => {
setUser(data);
setLoading(false);
})
.catch((err) => {
console.error(err);
setLoading(false);
navigate("/404");
});
}, [id, $session]);
if (loading || !user) {
return <div className="p-6 text-center">Loading...</div>;
}
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;
return (
<div>
<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.startsWith("/profile") ? `${import.meta.env.VITE_API_URL}${user.image}` : user.image}
onError={(e) => {
e.currentTarget.onerror = null; // Prevent infinite loops
e.currentTarget.src = "/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">
<div className="flex items-center gap-2">
<h1 className="text-3xl font-extrabold wrap-break-word">{user.name}</h1>
{isAdmin && (
<div data-tooltip="Admin" className="text-orange-400">
<Icon icon="mdi:shield-moon" className="text-2xl" />
</div>
)}
{isContributor && (
<div data-tooltip="Contributor" className="text-orange-400">
<Icon icon="mingcute:group-fill" className="text-2xl" />
</div>
)}
</div>
<h2 className="text-black/60 text-sm font-semibold wrap-break-word">ID: {user?.id}</h2>
<div className="mt-3 text-sm flex gap-8">
<h4 title={`${new Date(user.createdAt).toLocaleTimeString("en-GB", { timeZone: "UTC" })} UTC`}>
<span className="font-medium">Created:</span>{" "}
{new Date(user.createdAt).toLocaleDateString("en-GB", { month: "long", day: "2-digit", year: "numeric" })}
</h4>
<h4>
Liked <span className="font-bold">{user._count.likes}</span> Miis
</h4>
</div>
{user.description && <Description text={user.description} className="max-h-32!" />}
</div>
</div>
{/* Buttons */}
<div className="flex gap-1 w-fit text-3xl text-orange-400 max-md:place-self-center *:size-17 *:flex *:flex-col *:items-center *:gap-1 **:transition-discrete **:duration-150 *:hover:brightness-75 *:hover:scale-[1.08] *:[&_span]:text-sm">
{!isOwnProfile && (
<Link aria-label="Report User" to={`/report/profile/${user.id}`}>
<Icon icon="material-symbols:flag-rounded" />
<span>Report</span>
</Link>
)}
{isOwnProfile && isAdmin && (
<Link aria-label="Go to Admin" to="/admin">
<Icon icon="mdi:shield-moon" />
<span>Admin</span>
</Link>
)}
{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 !== "/profile/settings" && (
<Link aria-label="Go to Settings" to="/profile/settings">
<Icon icon="material-symbols:settings-rounded" />
<span>Settings</span>
</Link>
)}
{(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>
</Link>
)}
</div>
</div>
<Outlet />
</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

@ -0,0 +1,103 @@
import { useNavigate, useParams } from "react-router";
import ReasonSelector from "../../components/reason-selector";
import { useEffect, useState } from "react";
import { type ReportReason } from "@tomodachi-share/shared";
import SubmitButton from "../../components/submit-button";
export default function ReportMiiPage() {
const { id } = useParams();
const navigate = useNavigate();
const [reason, setReason] = useState<ReportReason>();
const [notes, setNotes] = useState<string>();
const [error, setError] = useState<string | undefined>(undefined);
const [mii, setMii] = useState<any>(null);
const [loading, setLoading] = useState(true);
const API_URL = import.meta.env.VITE_API_URL;
useEffect(() => {
fetch(`${API_URL}/api/mii/${id}/info`)
.then((res) => {
if (!res.ok) throw new Error("Failed to fetch Mii");
return res.json();
})
.then((data) => {
setMii(data);
setLoading(false);
})
.catch((err) => {
console.error(err);
setLoading(false);
navigate("/404");
});
}, [id]);
if (loading || !mii) {
return <div className="p-6 text-center">Loading...</div>;
}
const handleSubmit = async () => {
const response = await fetch(`${import.meta.env.VITE_API_URL}/api/report`, {
method: "POST",
body: JSON.stringify({ id: mii.id, type: "mii", reason: reason?.toLowerCase(), notes }),
credentials: "include",
});
const { error } = await response.json();
if (!response.ok) {
setError(error);
return;
}
navigate("/");
};
return (
<div className="grow flex items-center justify-center">
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex flex-col gap-4 w-full max-w-2xl">
<div>
<h2 className="text-2xl font-bold">Report a Mii</h2>
<p className="text-sm text-zinc-500">If you encounter a rule-breaking Mii, please report it here</p>
</div>
<hr className="border-zinc-300" />
<div className="bg-orange-100 rounded-xl border-2 border-orange-400 flex">
<img src={`${import.meta.env.VITE_API_URL}/mii/${mii.id}/image?type=mii`} alt="mii image" width={128} height={128} />
<div className="p-4">
<p className="text-xl font-bold line-clamp-1">{mii.name}</p>
</div>
</div>
<div className="w-full grid grid-cols-3 items-center">
<label htmlFor="reason" className="font-semibold">
Reason
</label>
<ReasonSelector reason={reason} setReason={setReason} />
</div>
<div className="w-full grid grid-cols-3">
<label htmlFor="reason-note" className="font-semibold">
Reason notes
</label>
<textarea
rows={3}
maxLength={256}
placeholder="Type notes here for the report..."
className="pill input rounded-xl! resize-none col-span-2"
value={notes}
onChange={(e) => setNotes(e.target.value)}
/>
</div>
<hr className="border-zinc-300" />
<div className="flex justify-between items-center">
{error && <span className="text-red-400 font-bold">Error: {error}</span>}
<SubmitButton onClick={handleSubmit} className="ml-auto" />
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,111 @@
import { useNavigate, useParams } from "react-router";
import ReasonSelector from "../../components/reason-selector";
import { useEffect, useState } from "react";
import { type ReportReason } from "@tomodachi-share/shared";
import SubmitButton from "../../components/submit-button";
export default function ReportUserPage() {
const { id } = useParams();
const navigate = useNavigate();
const [reason, setReason] = useState<ReportReason>();
const [notes, setNotes] = useState<string>();
const [error, setError] = useState<string | undefined>(undefined);
const [user, setUser] = useState<any>(null);
const [loading, setLoading] = useState(true);
const API_URL = import.meta.env.VITE_API_URL;
useEffect(() => {
fetch(`${API_URL}/api/profile/${id}/info`)
.then((res) => {
if (!res.ok) throw new Error("Failed to fetch profile");
return res.json();
})
.then((data) => {
setUser(data);
setLoading(false);
})
.catch((err) => {
console.error(err);
setLoading(false);
navigate("/404");
});
}, [id]);
if (loading || !user) {
return <div className="p-6 text-center">Loading...</div>;
}
const handleSubmit = async () => {
const response = await fetch(`${import.meta.env.VITE_API_URL}/api/report`, {
method: "POST",
body: JSON.stringify({ id: user.id, type: "user", reason: reason?.toLowerCase(), notes }),
credentials: "include",
});
const { error } = await response.json();
if (!response.ok) {
setError(error);
return;
}
navigate("/");
};
return (
<div className="grow flex items-center justify-center">
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex flex-col gap-4 w-full max-w-2xl">
<div>
<h2 className="text-2xl font-bold">Report a User</h2>
<p className="text-sm text-zinc-500">If you encounter a user causing issues, please report them here</p>
</div>
<hr className="border-zinc-300" />
<div className="bg-orange-100 rounded-xl border-2 border-orange-400 flex p-4 gap-4">
<img
src={user.image.startsWith("/profile") ? `${import.meta.env.VITE_API_URL}${user.image}` : user.image}
onError={(e) => {
e.currentTarget.onerror = null; // Prevent infinite loops
e.currentTarget.src = "/guest.png";
}}
alt="profile picture"
width={96}
height={96}
className="aspect-square rounded-full border-2 border-orange-400"
/>
<p className="text-xl font-bold overflow-hidden text-ellipsis">{user.name}</p>
</div>
<div className="w-full grid grid-cols-3 items-center">
<label htmlFor="reason" className="font-semibold">
Reason
</label>
<ReasonSelector reason={reason} setReason={setReason} />
</div>
<div className="w-full grid grid-cols-3">
<label htmlFor="reason-note" className="font-semibold">
Reason notes
</label>
<textarea
rows={3}
maxLength={256}
placeholder="Type notes here for the report..."
className="pill input rounded-xl! resize-none col-span-2"
value={notes}
onChange={(e) => setNotes(e.target.value)}
/>
</div>
<hr className="border-zinc-300" />
<div className="flex justify-between items-center">
{error && <span className="text-red-400 font-bold">Error: {error}</span>}
<SubmitButton onClick={handleSubmit} className="ml-auto" />
</div>
</div>
</div>
);
}

View file

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

View file

@ -1,129 +1,145 @@
export default function TermsOfServicePage() { import { Link } from "react-router";
return <div className="bg-amber-50 border-2 border-amber-500 rounded-2xl p-6">
<h1 className="text-2xl font-bold">Terms of Service</h1> export default function TermsOfServicePage() {
<h2 className="font-light"> return (
<strong className="font-medium">Effective Date:</strong> March 26, 2026 <div className="bg-amber-50 border-2 border-amber-500 rounded-2xl p-6">
</h2> <h1 className="text-2xl font-bold">Terms of Service</h1>
<h2 className="font-light">
<hr className="border-black/20 mt-1 mb-4" /> <strong className="font-medium">Effective Date:</strong> March 26, 2026
</h2>
<p>
By registering for, or using this service, you confirm that you understand and agree to the terms below. If you do not agree to these terms, you should <hr className="border-black/20 mt-1 mb-4" />
not use the service.
</p> <p>
<p className="mt-1"> By registering for, or using this service, you confirm that you understand and agree to the terms below. If you do not agree to these terms, you should
If you have any questions or concerns, please contact me at:{" "} not use the service.
<a href="mailto:hello@trafficlunar.net" className="text-blue-700"> hello@trafficlunar.net </a> </p>
. <p className="mt-1">
</p> If you have any questions or concerns, please contact me at:{" "}
<a href="mailto:hello@trafficlunar.net" className="text-blue-700">
<ul className="list-decimal ml-5 marker:text-xl marker:font-semibold"> {" "}
<li> hello@trafficlunar.net{" "}
<h3 className="text-xl font-semibold mt-6 mb-2">Usage Policy</h3> </a>
.
<section> </p>
<p className="mb-2">As a user of this site, you must abide by these guidelines:</p>
<ul className="list-disc list-inside indent-4"> <ul className="list-decimal ml-5 marker:text-xl marker:font-semibold">
<li>Nothing that would interfere with or gain unauthorized access to the website or its systems.</li> <li>
<li>Nothing that is against the law in the United Kingdom.</li> <h3 className="text-xl font-semibold mt-6 mb-2">Usage Policy</h3>
<li>No NSFW, violent, gory, or inappropriate Miis or images.</li>
<li>No spam.</li> <section>
<li>No impersonation of others.</li> <p className="mb-2">As a user of this site, you must abide by these guidelines:</p>
<li>No malware, malicious links, or phishing content.</li> <ul className="list-disc list-inside indent-4">
<li>No harassment, hate speech, threats, or bullying towards others.</li> <li>Nothing that would interfere with or gain unauthorized access to the website or its systems.</li>
<li>Miis must be high quality: for example, not following all instructions on the submit form correctly.</li> <li>Nothing that is against the law in the United Kingdom.</li>
<li>Avoid using inappropriate language. Profanity may be automatically censored.</li> <li>No NSFW, violent, gory, or inappropriate Miis or images.</li>
<li>No use of automated scripts, bots, or scrapers to access or interact with the site.</li> <li>No spam.</li>
</ul> <li>No impersonation of others.</li>
<p className="mt-2"> <li>No malware, malicious links, or phishing content.</li>
If you find anybody or a Mii breaking these rules, please report it by going to their page and clicking the &quot;Report&quot; button. <li>No harassment, hate speech, threats, or bullying towards others.</li>
</p> <li>Miis must be high quality: for example, not following all instructions on the submit form correctly.</li>
</section> <li>Avoid using inappropriate language. Profanity may be automatically censored.</li>
</li> <li>No use of automated scripts, bots, or scrapers to access or interact with the site.</li>
<li> </ul>
<h3 className="text-xl font-semibold mt-6 mb-2">Termination</h3> <p className="mt-2">
If you find anybody or a Mii breaking these rules, please report it by going to their page and clicking the &quot;Report&quot; button.
<section> </p>
<p className="mb-2"> </section>
We reserve the right to suspend or terminate your access to the site at any time if you violate these Terms of Service or engage in any activities </li>
that disrupt the functionality of the site. <li>
</p> <h3 className="text-xl font-semibold mt-6 mb-2">Termination</h3>
<p>
To request deletion of your account and personal data, please refer to the{" "} <section>
<a href="/privacy" className="text-blue-700"> Privacy Policy </a>{" "} <p className="mb-2">
(see &quot;Data Deletion&quot;) or email me at{" "} We reserve the right to suspend or terminate your access to the site at any time if you violate these Terms of Service or engage in any activities
<a href="mailto:hello@trafficlunar.net" className="text-blue-700"> hello@trafficlunar.net </a> that disrupt the functionality of the site.
</p> </p>
</section> <p>
</li> To request deletion of your account and personal data, please refer to the{" "}
<li> <Link to="/privacy" className="text-blue-700">
<h3 className="text-xl font-semibold mt-6 mb-2">Eligibility</h3> {" "}
<section> Privacy Policy{" "}
<p className="mb-2">By using this service, you confirm that you are at least 13 years old or have the consent of a parent or guardian.</p> </Link>{" "}
</section> (see &quot;Data Deletion&quot;) or email me at{" "}
</li> <a href="mailto:hello@trafficlunar.net" className="text-blue-700">
{" "}
<li> hello@trafficlunar.net{" "}
<h3 className="text-xl font-semibold mt-6 mb-2">Liability</h3> </a>
</p>
<section> </section>
<p className="mb-2"> </li>
This service is provided &quot;as is&quot; and without any warranties. We are not responsible for any user-generated content or the actions of users <li>
on the site. You use the site at your own risk. <h3 className="text-xl font-semibold mt-6 mb-2">Eligibility</h3>
</p> <section>
<p> <p className="mb-2">By using this service, you confirm that you are at least 13 years old or have the consent of a parent or guardian.</p>
We do not guarantee continuous or secure access to the service and are not liable for any damages resulting from interruptions, loss of data, or </section>
unauthorized access. </li>
</p>
</section> <li>
</li> <h3 className="text-xl font-semibold mt-6 mb-2">Liability</h3>
<li>
<h3 className="text-xl font-semibold mt-6 mb-2">DMCA & Copyright</h3> <section>
<p className="mb-2">
<section> This service is provided &quot;as is&quot; and without any warranties. We are not responsible for any user-generated content or the actions of
<p className="mb-2"> users on the site. You use the site at your own risk.
If you believe that content uploaded to this site infringes on your copyright, you may submit a DMCA takedown request by emailing{" "} </p>
<a href="mailto:hello@trafficlunar.net" className="text-blue-700"> hello@trafficlunar.net </a>{" "} <p>
or by reporting the Mii on its page. We do not guarantee continuous or secure access to the service and are not liable for any damages resulting from interruptions, loss of data, or
</p> unauthorized access.
<p className="mb-2">Please include:</p> </p>
<ul className="list-disc list-inside indent-4"> </section>
<li>Your name and contact information</li> </li>
<li>A description of the copyrighted work</li> <li>
<li>A link to the allegedly infringing material</li> <h3 className="text-xl font-semibold mt-6 mb-2">DMCA & Copyright</h3>
<li>A statement that you have a good faith belief that the use is not authorized</li>
<li> <section>
A statement that the information in the notice is accurate and, under penalty of perjury, that you are authorized to act on behalf of the <p className="mb-2">
copyright owner If you believe that content uploaded to this site infringes on your copyright, you may submit a DMCA takedown request by emailing{" "}
</li> <a href="mailto:hello@trafficlunar.net" className="text-blue-700">
<li>Your electronic or physical signature</li> {" "}
</ul> hello@trafficlunar.net{" "}
</section> </a>{" "}
</li> or by reporting the Mii on its page.
<li> </p>
<h3 className="text-xl font-semibold mt-6 mb-2">Nintendo Disclaimer</h3> <p className="mb-2">Please include:</p>
<ul className="list-disc list-inside indent-4">
<section> <li>Your name and contact information</li>
<p className="mb-2"> <li>A description of the copyrighted work</li>
This site is not affiliated with, endorsed by, or associated with Nintendo in any way. &quot;Mii&quot; and all related character designs are <li>A link to the allegedly infringing material</li>
trademarks of Nintendo Co., Ltd. <li>A statement that you have a good faith belief that the use is not authorized</li>
</p> <li>
<p> A statement that the information in the notice is accurate and, under penalty of perjury, that you are authorized to act on behalf of the
All Mii-related content is shared by users under the assumption that it does not violate any third-party rights. If you believe your rights have copyright owner
been infringed, please see the DMCA section above. </li>
</p> <li>Your electronic or physical signature</li>
</section> </ul>
</li> </section>
<li> </li>
<h3 className="text-xl font-semibold mt-6 mb-2">Changes to this Terms of Service</h3> <li>
<h3 className="text-xl font-semibold mt-6 mb-2">Nintendo Disclaimer</h3>
<section>
<p className="mb-2"> <section>
This Terms of Service may be updated from time to time. We encourage you to review the terms periodically to stay informed about the use of the <p className="mb-2">
site. We may notify users via a site banner or other means if changes are made to the Terms of Service. This site is not affiliated with, endorsed by, or associated with Nintendo in any way. &quot;Mii&quot; and all related character designs are
</p> trademarks of Nintendo Co., Ltd.
</section> </p>
</li> <p>
</ul> All Mii-related content is shared by users under the assumption that it does not violate any third-party rights. If you believe your rights have
</div>; been infringed, please see the DMCA section above.
} </p>
</section>
</li>
<li>
<h3 className="text-xl font-semibold mt-6 mb-2">Changes to this Terms of Service</h3>
<section>
<p className="mb-2">
This Terms of Service may be updated from time to time. We encourage you to review the terms periodically to stay informed about the use of the
site. We may notify users via a site banner or other means if changes are made to the Terms of Service.
</p>
</section>
</li>
</ul>
</div>
);
}

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);

View file

@ -16,12 +16,6 @@ importers:
'@auth/prisma-adapter': '@auth/prisma-adapter':
specifier: 2.11.1 specifier: 2.11.1
version: 2.11.1(@prisma/client@6.19.3(prisma@6.19.3(typescript@6.0.2))(typescript@6.0.2)) version: 2.11.1(@prisma/client@6.19.3(prisma@6.19.3(typescript@6.0.2))(typescript@6.0.2))
'@bprogress/next':
specifier: ^3.2.12
version: 3.2.12(next@16.2.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@hello-pangea/dnd':
specifier: ^18.0.1
version: 18.0.1(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@prisma/client': '@prisma/client':
specifier: ^6.19.2 specifier: ^6.19.2
version: 6.19.3(prisma@6.19.3(typescript@6.0.2))(typescript@6.0.2) version: 6.19.3(prisma@6.19.3(typescript@6.0.2))(typescript@6.0.2)
@ -31,24 +25,15 @@ importers:
bit-buffer: bit-buffer:
specifier: ^0.3.0 specifier: ^0.3.0
version: 0.3.0 version: 0.3.0
canvas-confetti:
specifier: ^1.9.4
version: 1.9.4
dayjs: dayjs:
specifier: ^1.11.20 specifier: ^1.11.20
version: 1.11.20 version: 1.11.20
downshift: downshift:
specifier: ^9.3.2 specifier: ^9.3.2
version: 9.3.2(react@19.2.5) version: 9.3.2(react@19.2.5)
embla-carousel-react:
specifier: ^8.6.0
version: 8.6.0(react@19.2.5)
file-type: file-type:
specifier: ^22.0.1 specifier: ^22.0.1
version: 22.0.1 version: 22.0.1
jsqr:
specifier: ^1.4.0
version: 1.4.0
next: next:
specifier: 16.2.3 specifier: 16.2.3
version: 16.2.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5) version: 16.2.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
@ -64,30 +49,18 @@ importers:
react-dom: react-dom:
specifier: ^19.2.5 specifier: ^19.2.5
version: 19.2.5(react@19.2.5) version: 19.2.5(react@19.2.5)
react-dropzone:
specifier: ^15.0.0
version: 15.0.0(react@19.2.5)
react-image-crop:
specifier: ^11.0.10
version: 11.0.10(react@19.2.5)
redis: redis:
specifier: ^5.11.0 specifier: ^5.11.0
version: 5.12.1 version: 5.12.1
satori: satori:
specifier: ^0.26.0 specifier: ^0.26.0
version: 0.26.0 version: 0.26.0
seedrandom:
specifier: ^3.0.5
version: 3.0.5
sharp: sharp:
specifier: ^0.34.5 specifier: ^0.34.5
version: 0.34.5 version: 0.34.5
sjcl-with-all: sjcl-with-all:
specifier: 1.0.8 specifier: 1.0.8
version: 1.0.8 version: 1.0.8
swr:
specifier: ^2.4.1
version: 2.4.1(react@19.2.5)
zod: zod:
specifier: ^4.3.6 specifier: ^4.3.6
version: 4.3.6 version: 4.3.6
@ -101,9 +74,6 @@ importers:
'@tailwindcss/postcss': '@tailwindcss/postcss':
specifier: ^4.2.2 specifier: ^4.2.2
version: 4.2.2 version: 4.2.2
'@types/canvas-confetti':
specifier: ^1.9.0
version: 1.9.0
'@types/node': '@types/node':
specifier: ^25.6.0 specifier: ^25.6.0
version: 25.6.0 version: 25.6.0
@ -113,9 +83,6 @@ importers:
'@types/react-dom': '@types/react-dom':
specifier: ^19.2.3 specifier: ^19.2.3
version: 19.2.3(@types/react@19.2.14) version: 19.2.3(@types/react@19.2.14)
'@types/seedrandom':
specifier: ^3.0.8
version: 3.0.8
'@types/sjcl': '@types/sjcl':
specifier: ^1.0.34 specifier: ^1.0.34
version: 1.0.34 version: 1.0.34
@ -200,9 +167,6 @@ importers:
react-router: react-router:
specifier: ^7.14.1 specifier: ^7.14.1
version: 7.14.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5) version: 7.14.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
seedrandom:
specifier: ^3.0.5
version: 3.0.5
tailwindcss: tailwindcss:
specifier: ^4.2.2 specifier: ^4.2.2
version: 4.2.2 version: 4.2.2
@ -222,9 +186,6 @@ importers:
'@types/node': '@types/node':
specifier: ^24.12.2 specifier: ^24.12.2
version: 24.12.2 version: 24.12.2
'@types/seedrandom':
specifier: ^3.0.8
version: 3.0.8
'@vitejs/plugin-react': '@vitejs/plugin-react':
specifier: ^6.0.1 specifier: ^6.0.1
version: 6.0.1(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(terser@5.46.1)) version: 6.0.1(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(terser@5.46.1))
@ -392,13 +353,6 @@ packages:
'@bprogress/core@1.3.4': '@bprogress/core@1.3.4':
resolution: {integrity: sha512-q/AqpurI/1uJzOrQROuZWixn/+ARekh+uvJGwLCP6HQ/EqAX4SkvNf618tSBxL4NysC0MwqAppb/mRw6Tzi61w==} resolution: {integrity: sha512-q/AqpurI/1uJzOrQROuZWixn/+ARekh+uvJGwLCP6HQ/EqAX4SkvNf618tSBxL4NysC0MwqAppb/mRw6Tzi61w==}
'@bprogress/next@3.2.12':
resolution: {integrity: sha512-/ZvNwbAd0ty9QiQwCfT2AfwWVdAaEyCPx5RUz3CfiiJS/OLBohhDz/IC/srhwK9GnXeXavvtiUrpKzN5GJDwlw==}
peerDependencies:
next: '>=13.0.0'
react: '>=18.0.0'
react-dom: '>=18.0.0'
'@bprogress/react@1.2.7': '@bprogress/react@1.2.7':
resolution: {integrity: sha512-MqJfHW+R5CQeWqyqrLxUjdBRHk24Xl63OkBLo5DMWqUqocUikRTfCIc/jtQQbPk7BRfdr5OP3Lx7YlfQ9QOZMQ==} resolution: {integrity: sha512-MqJfHW+R5CQeWqyqrLxUjdBRHk24Xl63OkBLo5DMWqUqocUikRTfCIc/jtQQbPk7BRfdr5OP3Lx7YlfQ9QOZMQ==}
peerDependencies: peerDependencies:
@ -1252,9 +1206,6 @@ packages:
'@types/react@19.2.14': '@types/react@19.2.14':
resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
'@types/seedrandom@3.0.8':
resolution: {integrity: sha512-TY1eezMU2zH2ozQoAFAQFOPpvP15g+ZgSfTZt31AUUH/Rxtnz3H+A/Sv1Snw2/amp//omibc+AEkTaA8KUeOLQ==}
'@types/sjcl@1.0.34': '@types/sjcl@1.0.34':
resolution: {integrity: sha512-bQHEeK5DTQRunIfQeUMgtpPsNNCcZyQ9MJuAfW1I7iN0LDunTc78Fu17STbLMd7KiEY/g2zHVApippa70h6HoQ==} resolution: {integrity: sha512-bQHEeK5DTQRunIfQeUMgtpPsNNCcZyQ9MJuAfW1I7iN0LDunTc78Fu17STbLMd7KiEY/g2zHVApippa70h6HoQ==}
@ -1717,10 +1668,6 @@ packages:
defu@6.1.7: defu@6.1.7:
resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==}
dequal@2.0.3:
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
engines: {node: '>=6'}
destr@2.0.5: destr@2.0.5:
resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==}
@ -2801,9 +2748,6 @@ packages:
schema-dts@2.0.0: schema-dts@2.0.0:
resolution: {integrity: sha512-t7NoCy3Rn5GHGx6p7s1qIYK/AeIb8ZxJNR9WUNFkwMv2CiiGZBmqqYWc2FlZVm5ZbiHMY4OvBWhj7QtyrFO2Jw==} resolution: {integrity: sha512-t7NoCy3Rn5GHGx6p7s1qIYK/AeIb8ZxJNR9WUNFkwMv2CiiGZBmqqYWc2FlZVm5ZbiHMY4OvBWhj7QtyrFO2Jw==}
seedrandom@3.0.5:
resolution: {integrity: sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==}
semver@6.3.1: semver@6.3.1:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true hasBin: true
@ -2936,11 +2880,6 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
swr@2.4.1:
resolution: {integrity: sha512-2CC6CiKQtEwaEeNiqWTAw9PGykW8SR5zZX8MZk6TeAvEAnVS7Visz8WzphqgtQ8v2xz/4Q5K+j+SeMaKXeeQIA==}
peerDependencies:
react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
tailwindcss@4.2.2: tailwindcss@4.2.2:
resolution: {integrity: sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==} resolution: {integrity: sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==}
@ -3277,14 +3216,6 @@ snapshots:
'@bprogress/core@1.3.4': {} '@bprogress/core@1.3.4': {}
'@bprogress/next@3.2.12(next@16.2.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@bprogress/core': 1.3.4
'@bprogress/react': 1.2.7(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
next: 16.2.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
react: 19.2.5
react-dom: 19.2.5(react@19.2.5)
'@bprogress/react@1.2.7(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': '@bprogress/react@1.2.7(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies: dependencies:
'@bprogress/core': 1.3.4 '@bprogress/core': 1.3.4
@ -3927,8 +3858,6 @@ snapshots:
dependencies: dependencies:
csstype: 3.2.3 csstype: 3.2.3
'@types/seedrandom@3.0.8': {}
'@types/sjcl@1.0.34': {} '@types/sjcl@1.0.34': {}
'@types/use-sync-external-store@0.0.6': {} '@types/use-sync-external-store@0.0.6': {}
@ -4429,8 +4358,6 @@ snapshots:
defu@6.1.7: {} defu@6.1.7: {}
dequal@2.0.3: {}
destr@2.0.5: {} destr@2.0.5: {}
detect-libc@2.1.2: {} detect-libc@2.1.2: {}
@ -5722,8 +5649,6 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- typescript - typescript
seedrandom@3.0.5: {}
semver@6.3.1: {} semver@6.3.1: {}
semver@7.7.4: {} semver@7.7.4: {}
@ -5908,12 +5833,6 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {} supports-preserve-symlinks-flag@1.0.0: {}
swr@2.4.1(react@19.2.5):
dependencies:
dequal: 2.0.3
react: 19.2.5
use-sync-external-store: 1.6.0(react@19.2.5)
tailwindcss@4.2.2: {} tailwindcss@4.2.2: {}
tapable@2.3.2: {} tapable@2.3.2: {}

View file

@ -2,4 +2,4 @@ export * from "./constants";
export * from "./qr-codes"; export * from "./qr-codes";
export * from "./switch"; export * from "./switch";
export * from "./three-ds-tomodachi-life-mii"; export * from "./three-ds-tomodachi-life-mii";
export type { SwitchMiiInstructions, MiiGender, MiiMakeup, MiiPlatform } from "./types"; export type { SwitchMiiInstructions, MiiGender, MiiMakeup, MiiPlatform, ReportReason } from "./types";

View file

@ -38,7 +38,7 @@ 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", "random"], { error: "Sort must be either 'likes', 'newest', 'oldest', or 'random'" }).default("newest"), sort: z.enum(["likes", "newest", "oldest"], { error: "Sort must be either 'likes', 'newest', 'oldest'" }).default("newest"),
tags: z tags: z
.string() .string()
.optional() .optional()
@ -71,8 +71,6 @@ export const searchSchema = z.object({
.max(100, { error: "Limit cannot be more than 100" }) .max(100, { error: "Limit cannot be more than 100" })
.optional(), .optional(),
page: z.coerce.number({ error: "Page must be a number" }).int({ error: "Page must be an integer" }).min(1, { error: "Page must be at least 1" }).optional(), page: z.coerce.number({ error: "Page must be a number" }).int({ error: "Page must be an integer" }).min(1, { error: "Page must be at least 1" }).optional(),
// Random sort
seed: z.coerce.number({ error: "Seed must be a number" }).int({ error: "Seed must be an integer" }).optional(),
// Other // Other
parentPage: z.string().optional(), parentPage: z.string().optional(),
userId: idSchema.optional(), userId: idSchema.optional(),

View file

@ -1,6 +1,7 @@
type MiiGender = "MALE" | "FEMALE" | "NONBINARY"; type MiiGender = "MALE" | "FEMALE" | "NONBINARY";
type MiiPlatform = "THREE_DS" | "SWITCH"; type MiiPlatform = "THREE_DS" | "SWITCH";
type MiiMakeup = "FULL" | "PARTIAL" | "NONE"; type MiiMakeup = "FULL" | "PARTIAL" | "NONE";
type ReportReason = "INAPPROPRIATE" | "SPAM" | "BAD_QUALITY" | "OTHER";
export interface SwitchMiiInstructions { export interface SwitchMiiInstructions {
head: { head: {