mirror of
https://github.com/trafficlunar/tomodachi-share.git
synced 2026-05-13 13:17:45 +00:00
Compare commits
11 commits
2584263606
...
97f0fda25c
| Author | SHA1 | Date | |
|---|---|---|---|
| 97f0fda25c | |||
| 896dc40553 | |||
| 9795849830 | |||
| 3e87d263da | |||
| 12203901e9 | |||
| 87b885a2f8 | |||
| 11df9261da | |||
| 46202b22b0 | |||
| ae266d5aa0 | |||
| 2f485dfca5 | |||
| f5391d63e6 |
62 changed files with 1898 additions and 2144 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
31
Dockerfile
31
Dockerfile
|
|
@ -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,17 +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 --chown=nextjs:nodejs /app/backend/.next/standalone ./
|
COPY --from=builder /app/backend/.next ./.next
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/backend/.next/static ./.next/static
|
|
||||||
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", "server.js"]
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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,44 +91,9 @@ 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") {
|
|
||||||
// Get all IDs that match the where conditions
|
|
||||||
const matchingIds = await prisma.mii.findMany({
|
|
||||||
where,
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
totalCount = matchingIds.length;
|
|
||||||
filteredCount = Math.max(0, Math.min(limit, totalCount - skip));
|
|
||||||
|
|
||||||
if (matchingIds.length === 0) return;
|
|
||||||
|
|
||||||
// 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 {
|
|
||||||
// Sorting by likes, newest, or oldest
|
// Sorting by likes, newest, or oldest
|
||||||
let orderBy: Prisma.MiiOrderByWithRelationInput[];
|
let orderBy: Prisma.MiiOrderByWithRelationInput[];
|
||||||
|
|
||||||
|
|
@ -144,9 +106,8 @@ export async function GET(request: NextRequest) {
|
||||||
orderBy = [{ createdAt: "desc" }, { name: "asc" }];
|
orderBy = [{ createdAt: "desc" }, { name: "asc" }];
|
||||||
}
|
}
|
||||||
|
|
||||||
[totalCount, filteredCount, miis] = await Promise.all([
|
[totalCount, miis] = await Promise.all([
|
||||||
prisma.mii.count({ where: { ...where } }), // TODO: User id
|
prisma.mii.count({ where: { ...where, userId } }),
|
||||||
prisma.mii.count({ where, skip, take: limit }),
|
|
||||||
prisma.mii.findMany({
|
prisma.mii.findMany({
|
||||||
where,
|
where,
|
||||||
orderBy,
|
orderBy,
|
||||||
|
|
@ -155,14 +116,12 @@ export async function GET(request: NextRequest) {
|
||||||
take: 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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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";
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,6 @@
|
||||||
"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"
|
||||||
},
|
},
|
||||||
|
|
@ -41,7 +40,6 @@
|
||||||
"@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",
|
||||||
"@types/seedrandom": "^3.0.8",
|
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"eslint": "^9.39.4",
|
"eslint": "^9.39.4",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
|
@ -1 +0,0 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="130.734" height="105.615" viewBox="0 0 34.59 27.944"><rect width="32.208" height="25.562" x="1.191" y="1.191" rx="1.874" fill="#f8f8f8" stroke="#ff8904" stroke-width="2.381" paint-order="stroke fill markers"/><rect width="29.369" height="22.49" x="2.611" y="2.727" rx=".966" fill="#c8c8c8" paint-order="stroke fill markers"/><g fill="#fef3c6"><rect width="13.371" height="20.989" x="17.918" y="3.478" rx=".423" paint-order="stroke fill markers"/><rect width="13.371" height="20.989" x="3.301" y="3.478" rx=".423" paint-order="stroke fill markers"/></g><g fill="#ff8904"><use href="#B" paint-order="stroke fill markers"/><circle cx="9.986" cy="13.076" r="5.512" paint-order="stroke fill markers"/><use href="#B" x="14.204" y="-0.093" paint-order="stroke fill markers"/><circle cx="24.191" cy="12.983" r="5.512" paint-order="stroke fill markers"/></g><g fill="none" stroke="#c8c8c8" stroke-linejoin="round"><rect width="13.791" height="20.704" x="17.295" y="3.62" ry="1.146" rx="1.095" stroke-width="1.786" paint-order="stroke fill markers"/><rect width="13.366" height="21.167" x="3.301" y="3.389" ry="1.146" rx="1.095" stroke-width="1.323" paint-order="stroke fill markers"/></g><defs ><path id="B" d="M15.03 24.516c0-2.307-.961-4.439-2.522-5.592s-3.483-1.153-5.044 0-2.522 3.285-2.522 5.592h5.044z"/></defs></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.3 KiB |
13
frontend/public/robots.txt
Normal file
13
frontend/public/robots.txt
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
User-Agent: *
|
||||||
|
Allow: /
|
||||||
|
Disallow: /*?*page=
|
||||||
|
Disallow: /profile*?*tags=
|
||||||
|
Disallow: /edit/*
|
||||||
|
Disallow: /profile/settings
|
||||||
|
Disallow: /random
|
||||||
|
Disallow: /submit
|
||||||
|
Disallow: /report/mii/*
|
||||||
|
Disallow: /report/user/*
|
||||||
|
Disallow: /admin
|
||||||
|
|
||||||
|
Sitemap: https://api.tomodachishare.com/sitemap.xml
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { Icon } from "@iconify/react";
|
import { Icon } from "@iconify/react";
|
||||||
|
import { Link } from "react-router";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
text: string;
|
text: string;
|
||||||
|
|
@ -12,16 +13,16 @@ export default function Description({ text, className }: Props) {
|
||||||
|
|
||||||
return (
|
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}`}>
|
<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}`}>
|
||||||
{parts.map(async (part, index) => {
|
{parts.map((part, index) => {
|
||||||
try {
|
try {
|
||||||
// Check if it's a URL
|
// Check if it's a URL
|
||||||
if (!urlRegex.test(part)) throw new Error("Not a URL");
|
if (!urlRegex.test(part)) throw new Error("Not a URL");
|
||||||
const url = new URL(part);
|
const url = new URL(part);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a
|
<Link
|
||||||
key={index}
|
key={index}
|
||||||
href={`/out?url=${encodeURIComponent(part)}`}
|
to={`/out?url=${encodeURIComponent(part)}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className="text-blue-700 underline break-all ml-1 inline-flex items-center group"
|
className="text-blue-700 underline break-all ml-1 inline-flex items-center group"
|
||||||
title={`Go to ${url.hostname}`}
|
title={`Go to ${url.hostname}`}
|
||||||
|
|
@ -30,7 +31,7 @@ export default function Description({ text, className }: Props) {
|
||||||
{url.pathname !== "/" ? url.pathname : ""}
|
{url.pathname !== "/" ? url.pathname : ""}
|
||||||
{url.search}
|
{url.search}
|
||||||
<Icon icon="mi:arrow-right-up" fontSize={16} className="transition group-hover:translate-x-0.5 group-hover:-translate-y-0.5" />
|
<Icon icon="mi:arrow-right-up" fontSize={16} className="transition group-hover:translate-x-0.5 group-hover:-translate-y-0.5" />
|
||||||
</a>
|
</Link>
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
// Normal text/Invalid URL fallback
|
// Normal text/Invalid URL fallback
|
||||||
|
|
|
||||||
|
|
@ -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 */}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
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 {
|
interface Props {
|
||||||
mii: any;
|
mii: any;
|
||||||
|
|
@ -13,10 +14,10 @@ export default function AuthorButtons({ mii }: Props) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<a aria-label="Edit Mii" href={`/edit/${mii.id}`}>
|
<Link aria-label="Edit Mii" to={`/edit/${mii.id}`}>
|
||||||
<Icon icon="mdi:pencil" />
|
<Icon icon="mdi:pencil" />
|
||||||
<span>Edit</span>
|
<span>Edit</span>
|
||||||
</a>
|
</Link>
|
||||||
<DeleteMiiButton miiId={mii.id} miiName={mii.name} likes={mii._count.likedBy ?? 0} inMiiPage />
|
<DeleteMiiButton miiId={mii.id} miiName={mii.name} likes={mii._count.likedBy ?? 0} inMiiPage />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { Icon } from "@iconify/react";
|
||||||
|
|
||||||
import LikeButton from "../like-button";
|
import 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 = () => {
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,10 @@ 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() {
|
export default function FilterMenu() {
|
||||||
const searchParams = new URLSearchParams(window.location.search);
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
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() {
|
export default function GenderSelect() {
|
||||||
const searchParams = new URLSearchParams(window.location.search);
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
const [, startTransition] = useTransition();
|
const [, startTransition] = useTransition();
|
||||||
|
|
||||||
const [selected, setSelected] = useState<MiiGender | null>((searchParams.get("gender") as MiiGender) ?? null);
|
const [selected, setSelected] = useState<MiiGender | null>((searchParams.get("gender") as MiiGender) ?? null);
|
||||||
|
|
@ -23,8 +25,7 @@ export default function GenderSelect() {
|
||||||
}
|
}
|
||||||
|
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
// router.push(`?${params.toString()}`, { scroll: false });
|
navigate(`?${params.toString()}`);
|
||||||
window.location.href = `?${params.toString()}`;
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
// );
|
|
||||||
// }
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
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() {
|
export default function MakeupSelect() {
|
||||||
const searchParams = new URLSearchParams(window.location.search);
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
const [, startTransition] = useTransition();
|
const [, startTransition] = useTransition();
|
||||||
|
|
||||||
const [selected, setSelected] = useState<MiiMakeup | null>((searchParams.get("makeup") as MiiMakeup) ?? null);
|
const [selected, setSelected] = useState<MiiMakeup | null>((searchParams.get("makeup") as MiiMakeup) ?? null);
|
||||||
|
|
@ -22,8 +24,7 @@ export default function MakeupSelect() {
|
||||||
}
|
}
|
||||||
|
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
// router.push(`?${params.toString()}`, { scroll: false });
|
navigate(`?${params.toString()}`);
|
||||||
window.location.href = `?${params.toString()}`;
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
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() {
|
export default function OtherFilters() {
|
||||||
const searchParams = new URLSearchParams(window.location.search);
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
const [, startTransition] = useTransition();
|
const [, startTransition] = useTransition();
|
||||||
|
|
||||||
const platform = (searchParams.get("platform") as MiiPlatform) || undefined;
|
const platform = (searchParams.get("platform") as MiiPlatform) || undefined;
|
||||||
|
|
@ -22,8 +25,7 @@ export default function OtherFilters() {
|
||||||
}
|
}
|
||||||
|
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
// router.push(`?${params.toString()}`, { scroll: false });
|
navigate(`?${params.toString()}`);
|
||||||
window.location.href = `?${params.toString()}`;
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -40,8 +42,7 @@ export default function OtherFilters() {
|
||||||
}
|
}
|
||||||
|
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
// router.push(`?${params.toString()}`, { scroll: false });
|
navigate(`?${params.toString()}`);
|
||||||
window.location.href = `?${params.toString()}`;
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
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() {
|
export default function PlatformSelect() {
|
||||||
const searchParams = new URLSearchParams(window.location.search);
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
const [, startTransition] = useTransition();
|
const [, startTransition] = useTransition();
|
||||||
|
|
||||||
const [selected, setSelected] = useState<MiiPlatform | null>((searchParams.get("platform") as MiiPlatform) ?? null);
|
const [selected, setSelected] = useState<MiiPlatform | null>((searchParams.get("platform") as MiiPlatform) ?? null);
|
||||||
|
|
@ -20,8 +22,7 @@ export default function PlatformSelect() {
|
||||||
}
|
}
|
||||||
|
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
// router.push(`?${params.toString()}`);
|
navigate(`?${params.toString()}`);
|
||||||
window.location.href = `?${params.toString()}`;
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,18 @@
|
||||||
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">
|
<div className="relative flex items-center justify-end gap-2 w-full md:max-w-2/3 max-md:justify-center">
|
||||||
<FilterSelect />
|
<FilterMenu />
|
||||||
<SortSelect />
|
<SortSelect />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,15 @@
|
||||||
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() {
|
export default function SortSelect() {
|
||||||
const searchParams = new URLSearchParams(window.location.search);
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
const [, startTransition] = useTransition();
|
const [, startTransition] = useTransition();
|
||||||
|
|
||||||
const currentSort = (searchParams.get("sort") as Sort) || "newest";
|
const currentSort = (searchParams.get("sort") as Sort) || "newest";
|
||||||
|
|
@ -22,13 +24,8 @@ export default function SortSelect() {
|
||||||
params.set("page", "1");
|
params.set("page", "1");
|
||||||
params.set("sort", selectedItem);
|
params.set("sort", selectedItem);
|
||||||
|
|
||||||
if (selectedItem == "random") {
|
|
||||||
params.set("seed", Math.floor(Math.random() * 1_000_000_000).toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
// router.push(`?${params.toString()}`, { scroll: false });
|
navigate(`?${params.toString()}`);
|
||||||
window.location.href = `?${params.toString()}`;
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
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 {
|
interface Props {
|
||||||
isExclude?: boolean;
|
isExclude?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TagFilter({ isExclude }: Props) {
|
export default function TagFilter({ isExclude }: Props) {
|
||||||
const searchParams = new URLSearchParams(window.location.search);
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
const [, startTransition] = useTransition();
|
const [, startTransition] = useTransition();
|
||||||
|
|
||||||
const rawTags = searchParams.get(isExclude ? "exclude" : "tags") || "";
|
const rawTags = searchParams.get(isExclude ? "exclude" : "tags") || "";
|
||||||
|
|
@ -45,8 +47,7 @@ export default function TagFilter({ isExclude }: Props) {
|
||||||
}
|
}
|
||||||
|
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
// router.push(`?${params.toString()}`, { scroll: false });
|
navigate(`?${params.toString()}`);
|
||||||
window.location.href = `?${params.toString()}`;
|
|
||||||
});
|
});
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [tags]);
|
}, [tags]);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
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 {
|
interface Props {
|
||||||
lastPage: number;
|
lastPage: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Pagination({ lastPage }: Props) {
|
export default function Pagination({ lastPage }: Props) {
|
||||||
const searchParams = new URLSearchParams(location.search);
|
const location = useLocation();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
const page = Number(searchParams.get("page") ?? 1);
|
const page = Number(searchParams.get("page") ?? 1);
|
||||||
|
|
||||||
const createPageUrl = useCallback(
|
const createPageUrl = useCallback(
|
||||||
|
|
@ -33,63 +35,63 @@ export default function Pagination({ lastPage }: Props) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center items-center w-full mt-8">
|
<div className="flex justify-center items-center w-full mt-8">
|
||||||
{/* Go to first page */}
|
{/* Go to first page */}
|
||||||
<a
|
<Link
|
||||||
href={page === 1 ? "#" : createPageUrl(1)}
|
to={page === 1 ? "#" : createPageUrl(1)}
|
||||||
aria-label="Go to First Page"
|
aria-label="Go to First Page"
|
||||||
aria-disabled={page === 1}
|
aria-disabled={page === 1}
|
||||||
tabIndex={page === 1 ? -1 : undefined}
|
tabIndex={page === 1 ? -1 : undefined}
|
||||||
className={`pill button bg-orange-100! p-0.5! aspect-square text-2xl ${page === 1 ? "pointer-events-none opacity-50" : "hover:bg-orange-400!"}`}
|
className={`pill button bg-orange-100! p-0.5! aspect-square text-2xl ${page === 1 ? "pointer-events-none opacity-50" : "hover:bg-orange-400!"}`}
|
||||||
>
|
>
|
||||||
<Icon icon="stash:chevron-double-left" />
|
<Icon icon="stash:chevron-double-left" />
|
||||||
</a>
|
</Link>
|
||||||
|
|
||||||
{/* Previous page */}
|
{/* Previous page */}
|
||||||
<a
|
<Link
|
||||||
href={page === 1 ? "#" : createPageUrl(page - 1)}
|
to={page === 1 ? "#" : createPageUrl(page - 1)}
|
||||||
aria-label="Go to Previous Page"
|
aria-label="Go to Previous Page"
|
||||||
aria-disabled={page === 1}
|
aria-disabled={page === 1}
|
||||||
tabIndex={page === 1 ? -1 : undefined}
|
tabIndex={page === 1 ? -1 : undefined}
|
||||||
className={`pill bg-orange-100! p-0.5! aspect-square text-2xl ${page === 1 ? "pointer-events-none opacity-50" : "hover:bg-orange-400!"}`}
|
className={`pill bg-orange-100! p-0.5! aspect-square text-2xl ${page === 1 ? "pointer-events-none opacity-50" : "hover:bg-orange-400!"}`}
|
||||||
>
|
>
|
||||||
<Icon icon="stash:chevron-left" />
|
<Icon icon="stash:chevron-left" />
|
||||||
</a>
|
</Link>
|
||||||
|
|
||||||
{/* Page numbers */}
|
{/* Page numbers */}
|
||||||
<div className="flex mx-2">
|
<div className="flex mx-2">
|
||||||
{numbers.map((number) => (
|
{numbers.map((number) => (
|
||||||
<a
|
<Link
|
||||||
key={number}
|
key={number}
|
||||||
href={createPageUrl(number)}
|
to={createPageUrl(number)}
|
||||||
aria-label={`Go to Page ${number}`}
|
aria-label={`Go to Page ${number}`}
|
||||||
aria-current={number === page ? "page" : undefined}
|
aria-current={number === page ? "page" : undefined}
|
||||||
className={`pill p-0! w-8 h-8 text-center rounded-md! ${number == page ? "bg-orange-400!" : "bg-orange-100! hover:bg-orange-400!"}`}
|
className={`pill p-0! w-8 h-8 text-center rounded-md! ${number == page ? "bg-orange-400!" : "bg-orange-100! hover:bg-orange-400!"}`}
|
||||||
>
|
>
|
||||||
{number}
|
{number}
|
||||||
</a>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Next page */}
|
{/* Next page */}
|
||||||
<a
|
<Link
|
||||||
href={page >= lastPage ? "#" : createPageUrl(page + 1)}
|
to={page >= lastPage ? "#" : createPageUrl(page + 1)}
|
||||||
aria-label="Go to Next Page"
|
aria-label="Go to Next Page"
|
||||||
aria-disabled={page >= lastPage}
|
aria-disabled={page >= lastPage}
|
||||||
tabIndex={page >= lastPage ? -1 : undefined}
|
tabIndex={page >= lastPage ? -1 : undefined}
|
||||||
className={`pill button bg-orange-100! p-0.5! aspect-square text-2xl ${page >= lastPage ? "pointer-events-none opacity-50" : "hover:bg-orange-400!"}`}
|
className={`pill button bg-orange-100! p-0.5! aspect-square text-2xl ${page >= lastPage ? "pointer-events-none opacity-50" : "hover:bg-orange-400!"}`}
|
||||||
>
|
>
|
||||||
<Icon icon="stash:chevron-right" />
|
<Icon icon="stash:chevron-right" />
|
||||||
</a>
|
</Link>
|
||||||
|
|
||||||
{/* Go to last page */}
|
{/* Go to last page */}
|
||||||
<a
|
<Link
|
||||||
href={page >= lastPage ? "#" : createPageUrl(lastPage)}
|
to={page >= lastPage ? "#" : createPageUrl(lastPage)}
|
||||||
aria-label="Go to Last Page"
|
aria-label="Go to Last Page"
|
||||||
aria-disabled={page >= lastPage}
|
aria-disabled={page >= lastPage}
|
||||||
tabIndex={page >= lastPage ? -1 : undefined}
|
tabIndex={page >= lastPage ? -1 : undefined}
|
||||||
className={`pill button bg-orange-100! p-0.5! aspect-square text-2xl ${page >= lastPage ? "pointer-events-none opacity-50" : "hover:bg-orange-400!"}`}
|
className={`pill button bg-orange-100! p-0.5! aspect-square text-2xl ${page >= lastPage ? "pointer-events-none opacity-50" : "hover:bg-orange-400!"}`}
|
||||||
>
|
>
|
||||||
<Icon icon="stash:chevron-double-right" />
|
<Icon icon="stash:chevron-double-right" />
|
||||||
</a>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -2,8 +2,10 @@ 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() {
|
export default function DeleteAccount() {
|
||||||
|
const navigate = useNavigate();
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
|
||||||
|
|
@ -17,7 +19,7 @@ export default function DeleteAccount() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
window.location.href = "/404";
|
navigate("/404");
|
||||||
};
|
};
|
||||||
|
|
||||||
const close = () => {
|
const close = () => {
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
|
|
||||||
|
|
@ -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[]) => {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
@ -1,16 +1,17 @@
|
||||||
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() {
|
export default function SearchBar() {
|
||||||
const searchParams = new URLSearchParams(window.location.search);
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
const [query, setQuery] = useState(searchParams.get("q") || "");
|
const [query, setQuery] = useState(searchParams.get("q") || "");
|
||||||
|
|
||||||
const handleSearch = () => {
|
const handleSearch = () => {
|
||||||
const result = querySchema.safeParse(query);
|
const result = querySchema.safeParse(query);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
// router.push("/", { scroll: false });
|
navigate("/", { preventScrollReset: true });
|
||||||
window.location.href = "/";
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -19,8 +20,7 @@ export default function SearchBar() {
|
||||||
params.set("q", query);
|
params.set("q", query);
|
||||||
params.set("page", "1");
|
params.set("page", "1");
|
||||||
|
|
||||||
// router.push(`/?${params.toString()}`, { scroll: false });
|
navigate(`/?${params.toString()}`, { preventScrollReset: true });
|
||||||
window.location.href = `/?${params.toString()}`;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||||
|
|
|
||||||
|
|
@ -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(() => {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
|
import Footer from "./components/footer";
|
||||||
|
import Header from "./components/header";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { ProgressProvider } from "@bprogress/react";
|
|
||||||
|
|
||||||
export default function Providers({ children }: { children: React.ReactNode }) {
|
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||||
// Calculate header height
|
// Calculate header height
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const header = document.querySelector("header");
|
const header = document.querySelector("header");
|
||||||
|
|
@ -24,8 +25,11 @@ export default function Providers({ children }: { children: React.ReactNode }) {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ProgressProvider height="4px" color="var(--color-amber-500)" options={{ showSpinner: false }} shallowRouting>
|
<>
|
||||||
{children}
|
<Header />
|
||||||
</ProgressProvider>
|
{/* <AdminBanner /> */}
|
||||||
|
<main className="px-4 py-8 max-w-7xl w-full grow flex flex-col">{children}</main>
|
||||||
|
<Footer />
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { StrictMode, Suspense } from "react";
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { 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>
|
|
||||||
<Suspense fallback={<div>Loading header...</div>}>
|
|
||||||
<Header />
|
|
||||||
</Suspense>
|
|
||||||
{/* <AdminBanner /> */}
|
|
||||||
<main className="px-4 py-8 max-w-7xl w-full grow flex flex-col">
|
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
|
<ProgressProvider height="4px" color="var(--color-amber-500)" options={{ showSpinner: false }} shallowRouting>
|
||||||
|
<Layout>
|
||||||
<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>
|
||||||
|
</Layout>
|
||||||
|
</ProgressProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</main>
|
|
||||||
<Footer />
|
|
||||||
</Providers>
|
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,17 @@
|
||||||
import { Icon } from "@iconify/react";
|
import { Icon } from "@iconify/react";
|
||||||
|
import { Link } from "react-router";
|
||||||
|
|
||||||
export default function NotFoundPage() {
|
export default function NotFoundPage() {
|
||||||
return <div className="grow flex items-center justify-center">
|
return (
|
||||||
|
<div className="grow flex items-center justify-center">
|
||||||
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-8 max-w-xs w-full text-center flex flex-col">
|
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-8 max-w-xs w-full text-center flex flex-col">
|
||||||
<h2 className="text-7xl font-black">404</h2>
|
<h2 className="text-7xl font-black">404</h2>
|
||||||
<p>Page not found - you swam off the island!</p>
|
<p>Page not found - you swam off the island!</p>
|
||||||
<a href="/" className="pill button gap-2 mt-8 w-fit self-center">
|
<Link to="/" className="pill button gap-2 mt-8 w-fit self-center">
|
||||||
<Icon icon="ic:round-home" fontSize={24} />
|
<Icon icon="ic:round-home" fontSize={24} />
|
||||||
Travel Back
|
Travel Back
|
||||||
</a>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
73
frontend/src/pages/out.tsx
Normal file
73
frontend/src/pages/out.tsx
Normal 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",
|
||||||
|
]);
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
8
frontend/src/pages/profile/index.tsx
Normal file
8
frontend/src/pages/profile/index.tsx
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { useParams } from "react-router";
|
||||||
|
import MiiList from "../../components/mii/list";
|
||||||
|
|
||||||
|
export default function ProfilePage() {
|
||||||
|
const { id } = useParams();
|
||||||
|
|
||||||
|
return <MiiList userId={Number(id)} />;
|
||||||
|
}
|
||||||
136
frontend/src/pages/profile/layout.tsx
Normal file
136
frontend/src/pages/profile/layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
frontend/src/pages/profile/likes.tsx
Normal file
15
frontend/src/pages/profile/likes.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import MiiList from "../../components/mii/list";
|
||||||
|
|
||||||
|
export default function ProfileLikesPage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex flex-col gap-4 mb-2">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold">My Likes</h2>
|
||||||
|
<p className="text-sm text-zinc-500">View every Mii you have liked on TomodachiShare.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<MiiList parentPage="likes" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import ProfileSettings from "../components/profile-settings";
|
import ProfileSettings from "../../components/profile-settings";
|
||||||
|
|
||||||
export default function ProfileSettingsPage() {
|
export default function ProfileSettingsPage() {
|
||||||
return <ProfileSettings currentDescription={null} />;
|
return <ProfileSettings currentDescription={null} />;
|
||||||
103
frontend/src/pages/report/mii.tsx
Normal file
103
frontend/src/pages/report/mii.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
111
frontend/src/pages/report/user.tsx
Normal file
111
frontend/src/pages/report/user.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,11 @@
|
||||||
|
import { useStore } from "@nanostores/react";
|
||||||
import SubmitForm from "../components/submit-form";
|
import SubmitForm from "../components/submit-form";
|
||||||
|
import { session } from "../session";
|
||||||
|
import { Navigate } from "react-router";
|
||||||
|
|
||||||
export default function SubmitPage() {
|
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 />;
|
return <SubmitForm />;
|
||||||
}
|
}
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
|
import { Link } from "react-router";
|
||||||
|
|
||||||
export default function TermsOfServicePage() {
|
export default function TermsOfServicePage() {
|
||||||
return <div className="bg-amber-50 border-2 border-amber-500 rounded-2xl p-6">
|
return (
|
||||||
|
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl p-6">
|
||||||
<h1 className="text-2xl font-bold">Terms of Service</h1>
|
<h1 className="text-2xl font-bold">Terms of Service</h1>
|
||||||
<h2 className="font-light">
|
<h2 className="font-light">
|
||||||
<strong className="font-medium">Effective Date:</strong> March 26, 2026
|
<strong className="font-medium">Effective Date:</strong> March 26, 2026
|
||||||
|
|
@ -13,7 +16,10 @@ export default function TermsOfServicePage() {
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1">
|
<p className="mt-1">
|
||||||
If you have any questions or concerns, please contact me at:{" "}
|
If you have any questions or concerns, please contact me at:{" "}
|
||||||
<a href="mailto:hello@trafficlunar.net" className="text-blue-700"> hello@trafficlunar.net </a>
|
<a href="mailto:hello@trafficlunar.net" className="text-blue-700">
|
||||||
|
{" "}
|
||||||
|
hello@trafficlunar.net{" "}
|
||||||
|
</a>
|
||||||
.
|
.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
@ -50,9 +56,15 @@ export default function TermsOfServicePage() {
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
To request deletion of your account and personal data, please refer to the{" "}
|
To request deletion of your account and personal data, please refer to the{" "}
|
||||||
<a href="/privacy" className="text-blue-700"> Privacy Policy </a>{" "}
|
<Link to="/privacy" className="text-blue-700">
|
||||||
|
{" "}
|
||||||
|
Privacy Policy{" "}
|
||||||
|
</Link>{" "}
|
||||||
(see "Data Deletion") or email me at{" "}
|
(see "Data Deletion") or email me at{" "}
|
||||||
<a href="mailto:hello@trafficlunar.net" className="text-blue-700"> hello@trafficlunar.net </a>
|
<a href="mailto:hello@trafficlunar.net" className="text-blue-700">
|
||||||
|
{" "}
|
||||||
|
hello@trafficlunar.net{" "}
|
||||||
|
</a>
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
</li>
|
</li>
|
||||||
|
|
@ -68,8 +80,8 @@ export default function TermsOfServicePage() {
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<p className="mb-2">
|
<p className="mb-2">
|
||||||
This service is provided "as is" and without any warranties. We are not responsible for any user-generated content or the actions of users
|
This service is provided "as is" and without any warranties. We are not responsible for any user-generated content or the actions of
|
||||||
on the site. You use the site at your own risk.
|
users on the site. You use the site at your own risk.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
We do not guarantee continuous or secure access to the service and are not liable for any damages resulting from interruptions, loss of data, or
|
We do not guarantee continuous or secure access to the service and are not liable for any damages resulting from interruptions, loss of data, or
|
||||||
|
|
@ -83,7 +95,10 @@ export default function TermsOfServicePage() {
|
||||||
<section>
|
<section>
|
||||||
<p className="mb-2">
|
<p className="mb-2">
|
||||||
If you believe that content uploaded to this site infringes on your copyright, you may submit a DMCA takedown request by emailing{" "}
|
If you believe that content uploaded to this site infringes on your copyright, you may submit a DMCA takedown request by emailing{" "}
|
||||||
<a href="mailto:hello@trafficlunar.net" className="text-blue-700"> hello@trafficlunar.net </a>{" "}
|
<a href="mailto:hello@trafficlunar.net" className="text-blue-700">
|
||||||
|
{" "}
|
||||||
|
hello@trafficlunar.net{" "}
|
||||||
|
</a>{" "}
|
||||||
or by reporting the Mii on its page.
|
or by reporting the Mii on its page.
|
||||||
</p>
|
</p>
|
||||||
<p className="mb-2">Please include:</p>
|
<p className="mb-2">Please include:</p>
|
||||||
|
|
@ -125,5 +140,6 @@ export default function TermsOfServicePage() {
|
||||||
</section>
|
</section>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>;
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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: {}
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
1
shared/src/types.d.ts
vendored
1
shared/src/types.d.ts
vendored
|
|
@ -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: {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue