Compare commits
7 commits
d208565a61
...
7f52773bd9
| Author | SHA1 | Date | |
|---|---|---|---|
| 7f52773bd9 | |||
| 0c2dcf3192 | |||
| b66fbd305a | |||
| c72dab1962 | |||
| 8ebc480233 | |||
| b00ce4dc3b | |||
|
|
8615a4d864 |
|
|
@ -5,7 +5,6 @@ REDIS_URL="redis://localhost:6379/0"
|
||||||
|
|
||||||
# Used for metadata, sitemaps, etc.
|
# Used for metadata, sitemaps, etc.
|
||||||
NEXT_PUBLIC_BASE_URL=http://localhost:3000
|
NEXT_PUBLIC_BASE_URL=http://localhost:3000
|
||||||
FRONTEND_URL=http://localhost:4321
|
|
||||||
|
|
||||||
CLOUDFLARE_ZONE_ID=XXXXXXXXXXXXXXXX
|
CLOUDFLARE_ZONE_ID=XXXXXXXXXXXXXXXX
|
||||||
CLOUDFLARE_API_TOKEN=XXXXXXXXXXXXXXXX
|
CLOUDFLARE_API_TOKEN=XXXXXXXXXXXXXXXX
|
||||||
6
.gitignore
vendored
|
|
@ -1,7 +1,7 @@
|
||||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
# dependencies
|
# dependencies
|
||||||
node_modules/
|
/node_modules
|
||||||
/.pnp
|
/.pnp
|
||||||
.pnp.*
|
.pnp.*
|
||||||
.yarn/*
|
.yarn/*
|
||||||
|
|
@ -14,8 +14,8 @@ node_modules/
|
||||||
/coverage
|
/coverage
|
||||||
|
|
||||||
# next.js
|
# next.js
|
||||||
.next/
|
/.next/
|
||||||
backend/out/
|
/out/
|
||||||
certificates/
|
certificates/
|
||||||
|
|
||||||
# production
|
# production
|
||||||
|
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
import type { NextConfig } from "next";
|
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
|
||||||
output: "standalone",
|
|
||||||
images: {
|
|
||||||
unoptimized: true,
|
|
||||||
},
|
|
||||||
async headers() {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
source: "/api/:path*",
|
|
||||||
headers: [
|
|
||||||
{ key: "Access-Control-Allow-Origin", value: process.env.FRONTEND_URL || "http://localhost:4321" },
|
|
||||||
{ key: "Access-Control-Allow-Credentials", value: "true" },
|
|
||||||
{ key: "Access-Control-Allow-Methods", value: "GET,POST,PATCH,DELETE,OPTIONS" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default nextConfig;
|
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
{
|
|
||||||
"name": "@tomodachi-share/backend",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"private": true,
|
|
||||||
"packageManager": "pnpm@10.33.0",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "next dev",
|
|
||||||
"build": "next build",
|
|
||||||
"start": "next start",
|
|
||||||
"lint": "next lint",
|
|
||||||
"postinstall": "prisma generate"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@2toad/profanity": "^3.3.0",
|
|
||||||
"@auth/prisma-adapter": "2.11.1",
|
|
||||||
"@bprogress/next": "^3.2.12",
|
|
||||||
"@hello-pangea/dnd": "^18.0.1",
|
|
||||||
"@prisma/client": "^6.19.2",
|
|
||||||
"bit-buffer": "^0.3.0",
|
|
||||||
"canvas-confetti": "^1.9.4",
|
|
||||||
"dayjs": "^1.11.20",
|
|
||||||
"downshift": "^9.3.2",
|
|
||||||
"embla-carousel-react": "^8.6.0",
|
|
||||||
"file-type": "^22.0.1",
|
|
||||||
"jsqr": "^1.4.0",
|
|
||||||
"next": "16.2.3",
|
|
||||||
"next-auth": "5.0.0-beta.30",
|
|
||||||
"qrcode-generator": "^2.0.4",
|
|
||||||
"react": "^19.2.5",
|
|
||||||
"react-dom": "^19.2.5",
|
|
||||||
"react-dropzone": "^15.0.0",
|
|
||||||
"react-image-crop": "^11.0.10",
|
|
||||||
"redis": "^5.11.0",
|
|
||||||
"satori": "^0.26.0",
|
|
||||||
"seedrandom": "^3.0.5",
|
|
||||||
"sharp": "^0.34.5",
|
|
||||||
"sjcl-with-all": "1.0.8",
|
|
||||||
"swr": "^2.4.1",
|
|
||||||
"zod": "^4.3.6"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@eslint/eslintrc": "^3.3.5",
|
|
||||||
"@iconify/react": "^6.0.2",
|
|
||||||
"@tailwindcss/postcss": "^4.2.2",
|
|
||||||
"@types/canvas-confetti": "^1.9.0",
|
|
||||||
"@types/node": "^25.6.0",
|
|
||||||
"@types/react": "^19.2.14",
|
|
||||||
"@types/react-dom": "^19.2.3",
|
|
||||||
"@types/seedrandom": "^3.0.8",
|
|
||||||
"@types/sjcl": "^1.0.34",
|
|
||||||
"eslint": "^10.2.0",
|
|
||||||
"eslint-config-next": "16.2.3",
|
|
||||||
"prisma": "^6.19.2",
|
|
||||||
"schema-dts": "^2.0.0",
|
|
||||||
"tailwindcss": "^4.2.2",
|
|
||||||
"typescript": "^6.0.2",
|
|
||||||
"@tomodachi-share/shared": "workspace:*"
|
|
||||||
},
|
|
||||||
"exports": {
|
|
||||||
".": "./src/types.d.ts"
|
|
||||||
},
|
|
||||||
"types": "./src/types.d.ts"
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
import { type NextRequest } from "next/server";
|
|
||||||
import { signIn } from "@/lib/auth";
|
|
||||||
|
|
||||||
export async function GET(req: NextRequest, { params }: { params: Promise<{ provider: string }> }) {
|
|
||||||
return signIn((await params).provider);
|
|
||||||
}
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { auth } from "@/lib/auth";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { idSchema } from "@tomodachi-share/shared/schemas";
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
|
||||||
const session = await auth();
|
|
||||||
const { id: slugId } = await params;
|
|
||||||
const parsed = idSchema.safeParse(slugId);
|
|
||||||
if (!parsed.success) return NextResponse.json({ error: parsed.error.issues[0].message }, { status: 400 });
|
|
||||||
const miiId = parsed.data;
|
|
||||||
|
|
||||||
const mii = await prisma.mii.findUnique({
|
|
||||||
where: {
|
|
||||||
id: miiId,
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
user: {
|
|
||||||
select: {
|
|
||||||
name: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
likedBy: session?.user
|
|
||||||
? {
|
|
||||||
where: {
|
|
||||||
userId: Number(session.user.id),
|
|
||||||
},
|
|
||||||
select: { userId: true },
|
|
||||||
}
|
|
||||||
: false,
|
|
||||||
_count: {
|
|
||||||
select: { likedBy: true }, // Get total like count
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json(mii);
|
|
||||||
}
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
|
|
||||||
import { auth } from "@/lib/auth";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { idSchema } from "@tomodachi-share/shared/schemas";
|
|
||||||
import { RateLimit } from "@/lib/rate-limit";
|
|
||||||
|
|
||||||
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
|
||||||
const session = await auth();
|
|
||||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
||||||
|
|
||||||
const rateLimit = new RateLimit(request, 100, "/api/mii/like");
|
|
||||||
const check = await rateLimit.handle();
|
|
||||||
if (check) return check;
|
|
||||||
|
|
||||||
const { id: slugId } = await params;
|
|
||||||
const parsed = idSchema.safeParse(slugId);
|
|
||||||
if (!parsed.success) return rateLimit.sendResponse({ error: parsed.error.issues[0].message }, 400);
|
|
||||||
const miiId = parsed.data;
|
|
||||||
|
|
||||||
const result = await prisma.$transaction(async (tx) => {
|
|
||||||
const existingLike = await tx.like.findUnique({
|
|
||||||
where: {
|
|
||||||
userId_miiId: {
|
|
||||||
userId: Number(session.user?.id),
|
|
||||||
miiId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (existingLike) {
|
|
||||||
// Remove the like if it exists
|
|
||||||
await tx.like.delete({
|
|
||||||
where: {
|
|
||||||
userId_miiId: {
|
|
||||||
userId: Number(session.user?.id),
|
|
||||||
miiId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Add a like if it doesn't exist
|
|
||||||
await tx.like.create({
|
|
||||||
data: {
|
|
||||||
userId: Number(session.user?.id),
|
|
||||||
miiId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const likeCount = await tx.like.count({
|
|
||||||
where: { miiId },
|
|
||||||
});
|
|
||||||
|
|
||||||
return { liked: !existingLike, count: likeCount };
|
|
||||||
});
|
|
||||||
|
|
||||||
return rateLimit.sendResponse({ success: true, liked: result.liked, count: result.count });
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { auth } from "@/lib/auth";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { RateLimit } from "@/lib/rate-limit";
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
const session = await auth();
|
|
||||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
||||||
|
|
||||||
const rateLimit = new RateLimit(request, 50, "/api/mii/like_get");
|
|
||||||
const check = await rateLimit.handle();
|
|
||||||
if (check) return check;
|
|
||||||
|
|
||||||
const idsParam = new URL(request.url).searchParams.get("ids");
|
|
||||||
if (!idsParam) return NextResponse.json({ error: "Missing IDs parameter" }, { status: 400 });
|
|
||||||
|
|
||||||
const ids = idsParam.split(",").map(Number).filter(Boolean);
|
|
||||||
if (!ids.length) return NextResponse.json({ error: "No valid IDs provided" }, { status: 400 });
|
|
||||||
if (ids.length > 100) return NextResponse.json({ error: "Too many IDs, maximum is 100" }, { status: 400 });
|
|
||||||
|
|
||||||
const liked = await prisma.like.findMany({
|
|
||||||
where: { userId: Number(session.user?.id), miiId: { in: ids } },
|
|
||||||
select: { miiId: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Return only Miis that are liked
|
|
||||||
return NextResponse.json(liked.map((l) => l.miiId));
|
|
||||||
}
|
|
||||||
|
|
@ -1,168 +0,0 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { auth } from "@/lib/auth";
|
|
||||||
import { searchSchema } from "@tomodachi-share/shared/schemas";
|
|
||||||
import { RateLimit } from "@/lib/rate-limit";
|
|
||||||
import { Prisma } from "@prisma/client";
|
|
||||||
import crypto from "crypto";
|
|
||||||
import seedrandom from "seedrandom";
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
const session = await auth();
|
|
||||||
const parsed = searchSchema.safeParse(Object.fromEntries(request.nextUrl.searchParams));
|
|
||||||
if (!parsed.success) return NextResponse.json({ error: parsed.error.issues[0].message }, { status: 400 });
|
|
||||||
|
|
||||||
const { q: query, sort, tags, exclude, platform, gender, makeup, allowCopying, quarantined, page = 1, limit = 24, seed, parentPage, userId } = parsed.data;
|
|
||||||
|
|
||||||
// My Likes page
|
|
||||||
let miiIdsLiked: number[] | undefined = undefined;
|
|
||||||
|
|
||||||
if (parentPage === "likes" && session?.user?.id) {
|
|
||||||
const likedMiis = await prisma.like.findMany({
|
|
||||||
where: { userId: Number(session.user.id) },
|
|
||||||
select: { miiId: true },
|
|
||||||
});
|
|
||||||
miiIdsLiked = likedMiis.map((like) => like.miiId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const where: Prisma.MiiWhereInput = {
|
|
||||||
// In queue logic
|
|
||||||
...(parentPage === "admin"
|
|
||||||
? { in_queue: true } // Only show queued Miis
|
|
||||||
: userId
|
|
||||||
? {
|
|
||||||
// Include queued Miis if user is on their profile
|
|
||||||
...(Number(session?.user?.id) === userId ? {} : { in_queue: false }),
|
|
||||||
userId,
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
// Don't show queued Miis on main page
|
|
||||||
in_queue: false,
|
|
||||||
}),
|
|
||||||
// Only show liked miis on likes page
|
|
||||||
...(parentPage === "likes" && miiIdsLiked && { id: { in: miiIdsLiked } }),
|
|
||||||
// Searching
|
|
||||||
...(query && {
|
|
||||||
OR: [{ name: { contains: query, mode: "insensitive" } }, { tags: { has: query } }, { description: { contains: query, mode: "insensitive" } }],
|
|
||||||
}),
|
|
||||||
// Tag filtering
|
|
||||||
...(tags && tags.length > 0 && { tags: { hasEvery: tags } }),
|
|
||||||
...(exclude && exclude.length > 0 && { NOT: { tags: { hasSome: exclude } } }),
|
|
||||||
// Platform
|
|
||||||
...(platform && { platform: { equals: platform } }),
|
|
||||||
// Gender
|
|
||||||
...(gender && { gender: { equals: gender } }),
|
|
||||||
// Allow Copying
|
|
||||||
...(allowCopying && { allowedCopying: true }),
|
|
||||||
// Makeup
|
|
||||||
...(makeup && { makeup: { equals: makeup } }),
|
|
||||||
// Quarantined
|
|
||||||
...(!quarantined && !userId && { quarantined: false }),
|
|
||||||
};
|
|
||||||
|
|
||||||
const select: Prisma.MiiSelect = {
|
|
||||||
id: true,
|
|
||||||
// Don't show when userId is specified
|
|
||||||
...(!userId && {
|
|
||||||
user: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
platform: true,
|
|
||||||
name: true,
|
|
||||||
imageCount: true,
|
|
||||||
tags: true,
|
|
||||||
createdAt: true,
|
|
||||||
gender: true,
|
|
||||||
makeup: true,
|
|
||||||
allowedCopying: true,
|
|
||||||
quarantined: true,
|
|
||||||
in_queue: true,
|
|
||||||
// Mii liked check
|
|
||||||
...(session?.user?.id && {
|
|
||||||
likedBy: {
|
|
||||||
where: { userId: Number(session.user.id) },
|
|
||||||
select: { userId: true },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
// Like count
|
|
||||||
_count: {
|
|
||||||
select: { likedBy: true },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const skip = (page - 1) * limit;
|
|
||||||
|
|
||||||
let totalCount: number;
|
|
||||||
let filteredCount: number;
|
|
||||||
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
|
|
||||||
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, filteredCount, miis] = await Promise.all([
|
|
||||||
prisma.mii.count({ where: { ...where } }), // TODO: User id
|
|
||||||
prisma.mii.count({ where, skip, take: limit }),
|
|
||||||
prisma.mii.findMany({
|
|
||||||
where,
|
|
||||||
orderBy,
|
|
||||||
select,
|
|
||||||
skip: (page - 1) * limit,
|
|
||||||
take: limit,
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const lastPage = Math.ceil(totalCount / limit);
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
miis,
|
|
||||||
totalCount,
|
|
||||||
filteredCount,
|
|
||||||
lastPage,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { idSchema } from "@tomodachi-share/shared/schemas";
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
|
||||||
const { id: slugId } = await params;
|
|
||||||
const parsed = idSchema.safeParse(slugId);
|
|
||||||
if (!parsed.success) return NextResponse.json({ error: parsed.error.issues[0].message }, { status: 400 });
|
|
||||||
const userId = parsed.data;
|
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({
|
|
||||||
where: {
|
|
||||||
id: userId,
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
_count: {
|
|
||||||
select: {
|
|
||||||
likes: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json(user);
|
|
||||||
}
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
export default function IndexPage() {
|
|
||||||
return (
|
|
||||||
<html>
|
|
||||||
<body>
|
|
||||||
<p>TomodachiShare API</p>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
export function deepMerge<T>(target: T, source: Partial<T>): T {
|
|
||||||
const output = structuredClone(target);
|
|
||||||
|
|
||||||
if (typeof source !== "object" || source === null) return output;
|
|
||||||
|
|
||||||
for (const key in source) {
|
|
||||||
const sourceValue = source[key];
|
|
||||||
const targetValue = (output as any)[key];
|
|
||||||
|
|
||||||
if (typeof sourceValue === "object" && sourceValue !== null && !Array.isArray(sourceValue)) {
|
|
||||||
(output as any)[key] = deepMerge(targetValue, sourceValue);
|
|
||||||
} else {
|
|
||||||
(output as any)[key] = sourceValue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return output;
|
|
||||||
}
|
|
||||||
2
backend/src/types.d.ts
vendored
|
|
@ -1,2 +0,0 @@
|
||||||
export type { User, Mii, Punishment, Prisma } from "@prisma/client";
|
|
||||||
export { MiiPlatform, MiiGender, MiiMakeup, ReportReason } from "@prisma/client";
|
|
||||||
24
frontend/.gitignore
vendored
|
|
@ -1,24 +0,0 @@
|
||||||
# build output
|
|
||||||
dist/
|
|
||||||
# generated types
|
|
||||||
.astro/
|
|
||||||
|
|
||||||
# dependencies
|
|
||||||
node_modules/
|
|
||||||
|
|
||||||
# logs
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
pnpm-debug.log*
|
|
||||||
|
|
||||||
|
|
||||||
# environment variables
|
|
||||||
.env
|
|
||||||
.env.production
|
|
||||||
|
|
||||||
# macOS-specific files
|
|
||||||
.DS_Store
|
|
||||||
|
|
||||||
# jetbrains setting folder
|
|
||||||
.idea/
|
|
||||||
4
frontend/.vscode/extensions.json
vendored
|
|
@ -1,4 +0,0 @@
|
||||||
{
|
|
||||||
"recommendations": ["astro-build.astro-vscode"],
|
|
||||||
"unwantedRecommendations": []
|
|
||||||
}
|
|
||||||
11
frontend/.vscode/launch.json
vendored
|
|
@ -1,11 +0,0 @@
|
||||||
{
|
|
||||||
"version": "0.2.0",
|
|
||||||
"configurations": [
|
|
||||||
{
|
|
||||||
"command": "./node_modules/.bin/astro dev",
|
|
||||||
"name": "Development server",
|
|
||||||
"request": "launch",
|
|
||||||
"type": "node-terminal"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
# Astro Starter Kit: Minimal
|
|
||||||
|
|
||||||
```sh
|
|
||||||
pnpm create astro@latest -- --template minimal
|
|
||||||
```
|
|
||||||
|
|
||||||
> 🧑🚀 **Seasoned astronaut?** Delete this file. Have fun!
|
|
||||||
|
|
||||||
## 🚀 Project Structure
|
|
||||||
|
|
||||||
Inside of your Astro project, you'll see the following folders and files:
|
|
||||||
|
|
||||||
```text
|
|
||||||
/
|
|
||||||
├── public/
|
|
||||||
├── src/
|
|
||||||
│ └── pages/
|
|
||||||
│ └── index.astro
|
|
||||||
└── package.json
|
|
||||||
```
|
|
||||||
|
|
||||||
Astro looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name.
|
|
||||||
|
|
||||||
There's nothing special about `src/components/`, but that's where we like to put any Astro/React/Vue/Svelte/Preact components.
|
|
||||||
|
|
||||||
Any static assets, like images, can be placed in the `public/` directory.
|
|
||||||
|
|
||||||
## 🧞 Commands
|
|
||||||
|
|
||||||
All commands are run from the root of the project, from a terminal:
|
|
||||||
|
|
||||||
| Command | Action |
|
|
||||||
| :------------------------ | :----------------------------------------------- |
|
|
||||||
| `pnpm install` | Installs dependencies |
|
|
||||||
| `pnpm dev` | Starts local dev server at `localhost:4321` |
|
|
||||||
| `pnpm build` | Build your production site to `./dist/` |
|
|
||||||
| `pnpm preview` | Preview your build locally, before deploying |
|
|
||||||
| `pnpm astro ...` | Run CLI commands like `astro add`, `astro check` |
|
|
||||||
| `pnpm astro -- --help` | Get help using the Astro CLI |
|
|
||||||
|
|
||||||
## 👀 Want to learn more?
|
|
||||||
|
|
||||||
Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
// @ts-check
|
|
||||||
import { defineConfig, fontProviders } from "astro/config";
|
|
||||||
|
|
||||||
import react from "@astrojs/react";
|
|
||||||
|
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
|
||||||
|
|
||||||
import icon from "astro-icon";
|
|
||||||
|
|
||||||
import swup from "@swup/astro";
|
|
||||||
|
|
||||||
// https://astro.build/config
|
|
||||||
export default defineConfig({
|
|
||||||
output: "static",
|
|
||||||
integrations: [react(), icon(), swup({ theme: false })],
|
|
||||||
|
|
||||||
vite: {
|
|
||||||
plugins: [tailwindcss()],
|
|
||||||
ssr: {
|
|
||||||
noExternal: ["@tomodachi-share/shared"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
fonts: [
|
|
||||||
{
|
|
||||||
provider: fontProviders.fontsource(),
|
|
||||||
name: "Lexend",
|
|
||||||
cssVariable: "--font-lexend",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
{
|
|
||||||
"name": "",
|
|
||||||
"type": "module",
|
|
||||||
"version": "0.0.1",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=22.12.0"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"dev": "astro dev",
|
|
||||||
"build": "astro build",
|
|
||||||
"preview": "astro preview",
|
|
||||||
"astro": "astro"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@astrojs/react": "^5.0.3",
|
|
||||||
"@bprogress/react": "^1.2.7",
|
|
||||||
"@hello-pangea/dnd": "^18.0.1",
|
|
||||||
"@iconify-json/ic": "^1.2.4",
|
|
||||||
"@iconify-json/material-icon-theme": "^1.2.59",
|
|
||||||
"@iconify-json/mdi": "^1.2.3",
|
|
||||||
"@iconify-json/stash": "^1.2.4",
|
|
||||||
"@nanostores/react": "^1.1.0",
|
|
||||||
"@swup/astro": "^1.8.0",
|
|
||||||
"@tailwindcss/vite": "^4.2.2",
|
|
||||||
"@tomodachi-share/shared": "workspace:*",
|
|
||||||
"@types/react": "^19.2.14",
|
|
||||||
"@types/react-dom": "^19.2.3",
|
|
||||||
"astro": "^6.1.7",
|
|
||||||
"canvas-confetti": "^1.9.4",
|
|
||||||
"dayjs": "^1.11.20",
|
|
||||||
"downshift": "^9.3.2",
|
|
||||||
"embla-carousel-react": "^8.6.0",
|
|
||||||
"jsqr": "^1.4.0",
|
|
||||||
"nanostores": "^1.2.0",
|
|
||||||
"qrcode-generator": "^2.0.4",
|
|
||||||
"react": "^19.2.5",
|
|
||||||
"react-dom": "^19.2.5",
|
|
||||||
"react-dropzone": "^15.0.0",
|
|
||||||
"react-image-crop": "^11.0.10",
|
|
||||||
"seedrandom": "^3.0.5",
|
|
||||||
"tailwindcss": "^4.2.2",
|
|
||||||
"zod": "^4.3.6"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@iconify/react": "^6.0.2",
|
|
||||||
"@types/canvas-confetti": "^1.9.0",
|
|
||||||
"@types/seedrandom": "^3.0.8",
|
|
||||||
"astro-icon": "^1.1.5"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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 |
|
|
@ -1,65 +0,0 @@
|
||||||
// import { useSearchParams } from "next/navigation";
|
|
||||||
// import { Suspense, useEffect, useState } from "react";
|
|
||||||
|
|
||||||
// import useSWR from "swr";
|
|
||||||
// import { Icon } from "@iconify/react";
|
|
||||||
|
|
||||||
// interface ApiResponse {
|
|
||||||
// message: string;
|
|
||||||
// }
|
|
||||||
|
|
||||||
// const fetcher = (url: string) => fetch(url).then((res) => res.json());
|
|
||||||
|
|
||||||
// function RedirectBanner() {
|
|
||||||
// const searchParams = useSearchParams();
|
|
||||||
// const from = searchParams.get("from");
|
|
||||||
// if (from !== "old-domain") return null;
|
|
||||||
|
|
||||||
// return (
|
|
||||||
// <div className="w-full h-10 bg-orange-300 border-y-2 border-y-orange-400 mt-1 pl-2 shadow-md flex justify-center items-center gap-2 text-orange-900 text-nowrap overflow-x-auto font-semibold max-sm:justify-start">
|
|
||||||
// <Icon icon="humbleicons:link" className="text-2xl min-w-6" />
|
|
||||||
// <span>We have moved URLs, welcome to tomodachishare.com!</span>
|
|
||||||
// </div>
|
|
||||||
// );
|
|
||||||
// }
|
|
||||||
|
|
||||||
// export default function AdminBanner() {
|
|
||||||
// const { data } = useSWR<ApiResponse>("/api/admin/banner", fetcher);
|
|
||||||
// const [shouldShow, setShouldShow] = useState(true);
|
|
||||||
|
|
||||||
// useEffect(() => {
|
|
||||||
// if (!data?.message) return;
|
|
||||||
|
|
||||||
// // Check if the current banner text was closed by the user
|
|
||||||
// const closedBanner = window.localStorage.getItem("closedBanner");
|
|
||||||
// setShouldShow(data.message !== closedBanner);
|
|
||||||
// }, [data]);
|
|
||||||
|
|
||||||
// const handleClose = () => {
|
|
||||||
// if (!data) return;
|
|
||||||
|
|
||||||
// // Close banner and remember it
|
|
||||||
// window.localStorage.setItem("closedBanner", data.message);
|
|
||||||
// setShouldShow(false);
|
|
||||||
// };
|
|
||||||
|
|
||||||
// return (
|
|
||||||
// <>
|
|
||||||
// {data && data.message && shouldShow && (
|
|
||||||
// <div className="relative w-full h-10 bg-orange-300 border-y-2 border-y-orange-400 mt-1 pl-2 shadow-md flex justify-center text-orange-900 text-nowrap overflow-x-auto font-semibold max-sm:justify-between">
|
|
||||||
// <div className="flex gap-2 h-full items-center w-fit">
|
|
||||||
// <Icon icon="humbleicons:exclamation" className="text-2xl min-w-6" />
|
|
||||||
// <span>{data.message}</span>
|
|
||||||
// </div>
|
|
||||||
|
|
||||||
// <button onClick={handleClose} className="min-sm:absolute right-2 cursor-pointer p-1.5">
|
|
||||||
// <Icon icon="humbleicons:times" className="text-2xl min-w-6" />
|
|
||||||
// </button>
|
|
||||||
// </div>
|
|
||||||
// )}
|
|
||||||
// <Suspense>
|
|
||||||
// <RedirectBanner />
|
|
||||||
// </Suspense>
|
|
||||||
// </>
|
|
||||||
// );
|
|
||||||
// }
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
// import { settings } from "@/lib/settings";
|
|
||||||
// import { useState } from "react";
|
|
||||||
|
|
||||||
// export default function ControlCenter() {
|
|
||||||
// const [canSubmit, setCanSubmit] = useState(settings.canSubmit);
|
|
||||||
// const [isQueueEnabled, setIsQeueueEnabled] = useState(settings.queueEnabled);
|
|
||||||
|
|
||||||
// const onClickSet = async () => {
|
|
||||||
// await fetch("/api/admin/can-submit", { method: "PATCH", body: JSON.stringify(canSubmit) });
|
|
||||||
// await fetch("/api/admin/queue", { method: "PATCH", body: JSON.stringify(isQueueEnabled) });
|
|
||||||
// };
|
|
||||||
|
|
||||||
// return (
|
|
||||||
// <div className="bg-orange-100 rounded-xl border-2 border-orange-400 p-2 flex flex-col gap-2">
|
|
||||||
// <div className="flex items-center gap-2">
|
|
||||||
// <input
|
|
||||||
// id="submit"
|
|
||||||
// type="checkbox"
|
|
||||||
// className="checkbox size-6!"
|
|
||||||
// placeholder="Enter banner text"
|
|
||||||
// checked={canSubmit}
|
|
||||||
// onChange={(e) => setCanSubmit(e.target.checked)}
|
|
||||||
// />
|
|
||||||
// <label htmlFor="submit">Enable Submissions</label>
|
|
||||||
// </div>
|
|
||||||
// <div className="flex items-center gap-2">
|
|
||||||
// <input
|
|
||||||
// id="queue"
|
|
||||||
// type="checkbox"
|
|
||||||
// className="checkbox size-6!"
|
|
||||||
// placeholder="Enter banner text"
|
|
||||||
// checked={isQueueEnabled}
|
|
||||||
// onChange={(e) => setIsQeueueEnabled(e.target.checked)}
|
|
||||||
// />
|
|
||||||
// <label htmlFor="queue">Enable Queue</label>
|
|
||||||
// </div>
|
|
||||||
|
|
||||||
// <div className="flex gap-2 self-end">
|
|
||||||
// <button type="submit" className="pill button" onClick={onClickSet}>
|
|
||||||
// Set
|
|
||||||
// </button>
|
|
||||||
// </div>
|
|
||||||
// </div>
|
|
||||||
// );
|
|
||||||
// }
|
|
||||||
|
|
@ -1,166 +0,0 @@
|
||||||
// import { Prisma } from "@prisma/client";
|
|
||||||
// import { useMemo, useRef, useState } from "react";
|
|
||||||
// import Carousel from "../carousel";
|
|
||||||
// import Link from "next/link";
|
|
||||||
// import { Icon } from "@iconify/react";
|
|
||||||
|
|
||||||
// interface Props {
|
|
||||||
// miis: Prisma.MiiGetPayload<{ include: { user: { select: { id: true; name: true } }; _count: { select: { likedBy: true } } } }>[];
|
|
||||||
// }
|
|
||||||
|
|
||||||
// type Decision = "accept" | "reject" | null;
|
|
||||||
|
|
||||||
// export default function Queue({ miis }: Props) {
|
|
||||||
// const [currentIndex, setCurrentIndex] = useState(4); // Current index in the miis array, not visible
|
|
||||||
// const [visibleMiis, setVisibleMiis] = useState(miis.slice(0, 4));
|
|
||||||
// const [decision, setDecision] = useState<Decision>(null);
|
|
||||||
// const [isAnimating, setIsAnimating] = useState(false);
|
|
||||||
|
|
||||||
// const [dragOffset, setDragOffset] = useState(0);
|
|
||||||
// const dragStart = useRef<number | null>(null);
|
|
||||||
// const isDragging = useRef(false);
|
|
||||||
|
|
||||||
// const rotations = useMemo(() => {
|
|
||||||
// const map: Record<string, number> = {};
|
|
||||||
// miis.forEach((mii) => {
|
|
||||||
// map[mii.id] = Math.random() * 15 - 5;
|
|
||||||
// });
|
|
||||||
// return map;
|
|
||||||
// }, [miis]);
|
|
||||||
|
|
||||||
// const handleDecision = (decision: Decision) => {
|
|
||||||
// if (isAnimating) return;
|
|
||||||
// setDecision(decision);
|
|
||||||
// setIsAnimating(true);
|
|
||||||
// setDragOffset(decision === "accept" ? -300 : 300);
|
|
||||||
|
|
||||||
// setTimeout(() => {
|
|
||||||
// setVisibleMiis((prev) => {
|
|
||||||
// const newQueue = prev.slice(1); // Remove first Mii
|
|
||||||
// if (miis[currentIndex]) newQueue.push(miis[currentIndex]); // Add a new Mii to the end of the list
|
|
||||||
// return newQueue;
|
|
||||||
// });
|
|
||||||
// setCurrentIndex((prev) => prev + 1);
|
|
||||||
// setDecision(null);
|
|
||||||
// setIsAnimating(false);
|
|
||||||
// setDragOffset(0);
|
|
||||||
// }, 500);
|
|
||||||
// };
|
|
||||||
|
|
||||||
// const onDragStart = (clientX: number) => {
|
|
||||||
// if (isAnimating) return;
|
|
||||||
// dragStart.current = clientX;
|
|
||||||
// isDragging.current = true;
|
|
||||||
// };
|
|
||||||
|
|
||||||
// const onDragMove = (clientX: number) => {
|
|
||||||
// if (!isDragging.current || !dragStart.current) return;
|
|
||||||
// setDragOffset(clientX - dragStart.current);
|
|
||||||
// };
|
|
||||||
|
|
||||||
// const onDragEnd = () => {
|
|
||||||
// if (!isDragging.current) return;
|
|
||||||
// isDragging.current = false;
|
|
||||||
|
|
||||||
// if (dragOffset < -80) handleDecision("accept");
|
|
||||||
// else if (dragOffset > 80) handleDecision("reject");
|
|
||||||
// else setDragOffset(0);
|
|
||||||
|
|
||||||
// dragStart.current = null;
|
|
||||||
// };
|
|
||||||
|
|
||||||
// return (
|
|
||||||
// <div className="w-full flex justify-center items-center gap-8 relative h-100 mt-4 mb-8">
|
|
||||||
// <button
|
|
||||||
// onClick={() => handleDecision("accept")}
|
|
||||||
// className="pointer-coarse:hidden aspect-square cursor-pointer size-12 bg-zinc-50 border-2 border-zinc-300 rounded-full flex justify-center items-center text-2xl text-zinc-500 shadow-xs"
|
|
||||||
// >
|
|
||||||
// <Icon icon="material-symbols:check-rounded" />
|
|
||||||
// </button>
|
|
||||||
|
|
||||||
// <div className="relative w-full max-w-96 h-96 aspect-square">
|
|
||||||
// {visibleMiis.map((mii, i) => {
|
|
||||||
// const isTopCard = i === 0;
|
|
||||||
|
|
||||||
// // Calculate rotation/opacity based on drag distance
|
|
||||||
// const dragRotation = isTopCard ? dragOffset / 10 : 0;
|
|
||||||
// const dragOpacity = isTopCard ? 1 - Math.min(Math.abs(dragOffset) / 300, 1) : undefined;
|
|
||||||
|
|
||||||
// return (
|
|
||||||
// <div
|
|
||||||
// key={mii.id}
|
|
||||||
// className={`absolute inset-0 flex flex-col bg-zinc-50 rounded-3xl border-2 shadow-lg p-[0.8rem] border-zinc-300 *:select-none
|
|
||||||
// ${!isDragging.current ? "transition-all duration-500" : "transition-none"}
|
|
||||||
// ${isTopCard ? "cursor-grab active:cursor-grabbing" : "pointer-events-none"}`}
|
|
||||||
// style={{
|
|
||||||
// transform: isTopCard
|
|
||||||
// ? `translate(${dragOffset}px, ${Math.abs(dragOffset) * 0.1}px) rotate(${rotations[mii.id] + dragRotation}deg)`
|
|
||||||
// : `translateY(${i * 10}px) rotate(${rotations[mii.id]}deg)`,
|
|
||||||
// zIndex: (visibleMiis.length - i) * 10,
|
|
||||||
// opacity: dragOpacity,
|
|
||||||
// }}
|
|
||||||
// onMouseDown={(e) => isTopCard && onDragStart(e.clientX)}
|
|
||||||
// onMouseMove={(e) => isTopCard && onDragMove(e.clientX)}
|
|
||||||
// onMouseUp={() => isTopCard && onDragEnd()}
|
|
||||||
// onMouseLeave={() => isTopCard && isDragging.current && onDragEnd()}
|
|
||||||
// onTouchStart={(e) => isTopCard && onDragStart(e.touches[0].clientX)}
|
|
||||||
// onTouchMove={(e) => isTopCard && onDragMove(e.touches[0].clientX)}
|
|
||||||
// onTouchEnd={() => isTopCard && onDragEnd()}
|
|
||||||
// >
|
|
||||||
// <Carousel
|
|
||||||
// images={[
|
|
||||||
// `/mii/${mii.id}/image?type=mii`,
|
|
||||||
// ...(mii.platform === "THREE_DS" ? [`/mii/${mii.id}/image?type=qr-code`] : [`/mii/${mii.id}/image?type=features`]),
|
|
||||||
// ...Array.from({ length: mii.imageCount }, (_, index) => `/mii/${mii.id}/image?type=image${index}`),
|
|
||||||
// ]}
|
|
||||||
// onlyButtons
|
|
||||||
// />
|
|
||||||
|
|
||||||
// <div className="p-4 flex flex-col gap-1 h-full">
|
|
||||||
// <div className="flex justify-between items-center">
|
|
||||||
// <Link
|
|
||||||
// href={`/mii/${mii.id}`}
|
|
||||||
// draggable={false}
|
|
||||||
// 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="-mr-3 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) => (
|
|
||||||
// <Link href={{ query: { tags: tag } }} draggable={false} key={tag} className="px-2 py-1 bg-orange-300 rounded-full text-xs">
|
|
||||||
// {tag}
|
|
||||||
// </Link>
|
|
||||||
// ))}
|
|
||||||
// </div>
|
|
||||||
|
|
||||||
// <div className="mt-auto grid grid-cols-2 gap-4 items-center">
|
|
||||||
// <p className="text-sm">{mii.createdAt.toLocaleString("en-GB", { timeZone: "UTC" })}</p>
|
|
||||||
|
|
||||||
// <Link href={`/profile/${mii.user.id}`} draggable={false} className="text-sm text-right overflow-hidden text-ellipsis whitespace-nowrap">
|
|
||||||
// @{mii.user?.name}
|
|
||||||
// </Link>
|
|
||||||
// </div>
|
|
||||||
// </div>
|
|
||||||
// </div>
|
|
||||||
// );
|
|
||||||
// })}
|
|
||||||
// </div>
|
|
||||||
|
|
||||||
// <button
|
|
||||||
// onClick={() => handleDecision("reject")}
|
|
||||||
// className="pointer-coarse:hidden aspect-square cursor-pointer size-12 bg-zinc-50 border-2 border-zinc-300 rounded-full flex justify-center items-center text-2xl text-zinc-500 shadow-xs"
|
|
||||||
// >
|
|
||||||
// <Icon icon="material-symbols:close-rounded" />
|
|
||||||
// </button>
|
|
||||||
// </div>
|
|
||||||
// );
|
|
||||||
// }
|
|
||||||
|
|
@ -1,202 +0,0 @@
|
||||||
// import { revalidatePath } from "next/cache";
|
|
||||||
|
|
||||||
// import { Icon } from "@iconify/react";
|
|
||||||
// import { ReportStatus } from "@prisma/client";
|
|
||||||
|
|
||||||
// import { prisma } from "@/lib/prisma";
|
|
||||||
// import ReportTabs from "./report-tabs";
|
|
||||||
|
|
||||||
// const PAGE_SIZE = 20;
|
|
||||||
|
|
||||||
// export default async function Reports({ searchParams }: { searchParams: { status?: string; page?: string } }) {
|
|
||||||
// const status = searchParams.status as ReportStatus | undefined;
|
|
||||||
// const page = Number(searchParams.page ?? 1);
|
|
||||||
|
|
||||||
// const [reports, total] = await Promise.all([
|
|
||||||
// prisma.report.findMany({
|
|
||||||
// where: status ? { status } : undefined,
|
|
||||||
// orderBy: { createdAt: "desc" },
|
|
||||||
// skip: (page - 1) * PAGE_SIZE,
|
|
||||||
// take: PAGE_SIZE,
|
|
||||||
// }),
|
|
||||||
// prisma.report.count({
|
|
||||||
// where: status ? { status } : undefined,
|
|
||||||
// }),
|
|
||||||
// ]);
|
|
||||||
|
|
||||||
// const totalPages = Math.ceil(total / PAGE_SIZE);
|
|
||||||
|
|
||||||
// const updateStatus = async (formData: FormData) => {
|
|
||||||
// "use server";
|
|
||||||
// const id = Number(formData.get("id"));
|
|
||||||
// const status = formData.get("status") as ReportStatus;
|
|
||||||
|
|
||||||
// await prisma.report.update({
|
|
||||||
// where: { id },
|
|
||||||
// data: { status },
|
|
||||||
// });
|
|
||||||
|
|
||||||
// revalidatePath("/admin");
|
|
||||||
// };
|
|
||||||
|
|
||||||
// return (
|
|
||||||
// <div className="bg-orange-100 rounded-xl border-2 border-orange-400">
|
|
||||||
// <ReportTabs status={status} />
|
|
||||||
|
|
||||||
// {/* Grid */}
|
|
||||||
// <div className="grid grid-cols-2 gap-2 p-2 max-lg:grid-cols-1">
|
|
||||||
// {reports.map((report) => (
|
|
||||||
// <div key={report.id} className="p-4 bg-white border border-orange-300 shadow-sm rounded-md">
|
|
||||||
// <div className="w-full overflow-x-scroll">
|
|
||||||
// <div className="flex gap-1 w-max">
|
|
||||||
// <span
|
|
||||||
// className={`text-xs font-semibold px-2 py-1 rounded-full border ${
|
|
||||||
// report.reportType == "USER" ? "bg-red-200 text-red-800 border-red-400" : "bg-cyan-200 text-cyan-800 border-cyan-400"
|
|
||||||
// }`}
|
|
||||||
// >
|
|
||||||
// {report.reportType}
|
|
||||||
// </span>
|
|
||||||
|
|
||||||
// <span
|
|
||||||
// className={`text-xs font-semibold px-2 py-1 rounded-full border ${
|
|
||||||
// report.status == "OPEN"
|
|
||||||
// ? "bg-orange-200 text-orange-800 border-orange-400"
|
|
||||||
// : report.status == "RESOLVED"
|
|
||||||
// ? "bg-green-200 text-green-800 border-green-400"
|
|
||||||
// : "bg-zinc-200 text-zinc-800 border-zinc-400"
|
|
||||||
// }`}
|
|
||||||
// >
|
|
||||||
// {report.status}
|
|
||||||
// </span>
|
|
||||||
|
|
||||||
// <span className="ml-2 flex items-center gap-1 text-sm text-zinc-500">
|
|
||||||
// <Icon icon="lucide:calendar" className="text-base" />
|
|
||||||
// {report.createdAt.toLocaleString("en-GB", {
|
|
||||||
// day: "2-digit",
|
|
||||||
// month: "long",
|
|
||||||
// year: "numeric",
|
|
||||||
// hour: "2-digit",
|
|
||||||
// minute: "2-digit",
|
|
||||||
// second: "2-digit",
|
|
||||||
// timeZone: "UTC",
|
|
||||||
// })}{" "}
|
|
||||||
// UTC
|
|
||||||
// </span>
|
|
||||||
// </div>
|
|
||||||
// </div>
|
|
||||||
|
|
||||||
// <div className="grid grid-cols-4 text-xs text-zinc-600 mt-4 max-sm:grid-cols-2">
|
|
||||||
// <div>
|
|
||||||
// <p>Target ID</p>
|
|
||||||
// <a href={report.reportType === "MII" ? `/mii/${report.targetId}` : `/profile/${report.targetId}`} className="text-blue-600 text-sm">
|
|
||||||
// {report.targetId}
|
|
||||||
// </a>
|
|
||||||
// </div>
|
|
||||||
|
|
||||||
// <div>
|
|
||||||
// <p>Creator ID</p>
|
|
||||||
// <a href={`/profile/${report.creatorId}`} className="text-blue-600 text-sm">
|
|
||||||
// {report.creatorId}
|
|
||||||
// </a>
|
|
||||||
// </div>
|
|
||||||
|
|
||||||
// <div>
|
|
||||||
// <p>Reporter</p>
|
|
||||||
// <a href={`/profile/${report.authorId}`} className="text-blue-600 text-sm">
|
|
||||||
// {report.authorId}
|
|
||||||
// </a>
|
|
||||||
// </div>
|
|
||||||
|
|
||||||
// <div>
|
|
||||||
// <p>Reason</p>
|
|
||||||
// <p className="font-medium text-black text-sm">{report.reason}</p>
|
|
||||||
// </div>
|
|
||||||
// </div>
|
|
||||||
|
|
||||||
// <div className="mt-4 border border-orange-200 bg-orange-100/50 rounded-md p-2">
|
|
||||||
// <p className="text-zinc-600 text-xs">Notes</p>
|
|
||||||
// <p>{report.reasonNotes}</p>
|
|
||||||
// </div>
|
|
||||||
|
|
||||||
// <div className="mt-4 flex gap-4">
|
|
||||||
// <form action={updateStatus}>
|
|
||||||
// <input type="hidden" name="id" value={report.id} />
|
|
||||||
// <input type="hidden" name="status" value={"OPEN"} />
|
|
||||||
|
|
||||||
// <button
|
|
||||||
// type="submit"
|
|
||||||
// aria-label="Open"
|
|
||||||
// className="cursor-pointer text-orange-400 flex items-center gap-1 p-1.5 rounded-lg transition-colors hover:bg-orange-400/15"
|
|
||||||
// >
|
|
||||||
// <Icon icon="mdi:alert-circle" className="text-xl" />
|
|
||||||
// <span className="text-sm">Open</span>
|
|
||||||
// </button>
|
|
||||||
// </form>
|
|
||||||
// <form action={updateStatus}>
|
|
||||||
// <input type="hidden" name="id" value={report.id} />
|
|
||||||
// <input type="hidden" name="status" value={"RESOLVED"} />
|
|
||||||
|
|
||||||
// <button
|
|
||||||
// type="submit"
|
|
||||||
// aria-label="Resolve"
|
|
||||||
// className="cursor-pointer text-green-500 flex items-center gap-1 p-1.5 rounded-lg transition-colors hover:bg-green-500/15"
|
|
||||||
// >
|
|
||||||
// <Icon icon="mdi:check-circle" className="text-xl" />
|
|
||||||
// <span className="text-sm">Resolve</span>
|
|
||||||
// </button>
|
|
||||||
// </form>
|
|
||||||
// <form action={updateStatus}>
|
|
||||||
// <input type="hidden" name="id" value={report.id} />
|
|
||||||
// <input type="hidden" name="status" value={"DISMISSED"} />
|
|
||||||
|
|
||||||
// <button
|
|
||||||
// type="submit"
|
|
||||||
// aria-label="Dismiss"
|
|
||||||
// className="cursor-pointer text-zinc-400 flex items-center gap-1 p-1.5 rounded-lg transition-colors hover:bg-zinc-400/15"
|
|
||||||
// >
|
|
||||||
// <Icon icon="mdi:close-circle" className="text-xl" />
|
|
||||||
// <span className="text-sm">Dismiss</span>
|
|
||||||
// </button>
|
|
||||||
// </form>
|
|
||||||
// </div>
|
|
||||||
// </div>
|
|
||||||
// ))}
|
|
||||||
// </div>
|
|
||||||
|
|
||||||
// {reports.length === 0 && (
|
|
||||||
// <div className="text-center py-12 text-gray-500">
|
|
||||||
// <p className="text-lg font-medium">No reports to display</p>
|
|
||||||
// <p className="text-sm">Reports will appear here when users submit them</p>
|
|
||||||
// </div>
|
|
||||||
// )}
|
|
||||||
|
|
||||||
// {/* Pagination */}
|
|
||||||
// {totalPages > 1 && (
|
|
||||||
// <div className="flex justify-between items-center p-3 border-t border-orange-300">
|
|
||||||
// <span className="text-sm text-orange-700">{total} total</span>
|
|
||||||
// <div className="flex items-center gap-3">
|
|
||||||
// {page > 1 && (
|
|
||||||
// <a
|
|
||||||
// href={`/admin?${new URLSearchParams({ ...(status && { status }), page: String(page - 1) })}`}
|
|
||||||
// className="text-sm px-3 py-1 rounded-full font-medium border bg-white text-orange-700 border-orange-300 hover:bg-orange-50 transition-colors"
|
|
||||||
// >
|
|
||||||
// Previous
|
|
||||||
// </a>
|
|
||||||
// )}
|
|
||||||
// <span className="text-sm text-orange-700">
|
|
||||||
// Page {page} of {totalPages}
|
|
||||||
// </span>
|
|
||||||
// {page < totalPages && (
|
|
||||||
// <a
|
|
||||||
// href={`/admin?${new URLSearchParams({ ...(status && { status }), page: String(page + 1) })}`}
|
|
||||||
// className="text-sm px-3 py-1 rounded-full font-medium border bg-white text-orange-700 border-orange-300 hover:bg-orange-50 transition-colors"
|
|
||||||
// >
|
|
||||||
// Next
|
|
||||||
// </a>
|
|
||||||
// )}
|
|
||||||
// </div>
|
|
||||||
// </div>
|
|
||||||
// )}
|
|
||||||
// </div>
|
|
||||||
// );
|
|
||||||
// }
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
---
|
|
||||||
import { Icon } from "astro-icon/components";
|
|
||||||
---
|
|
||||||
|
|
||||||
<footer class="mt-auto">
|
|
||||||
<div class="max-w-4xl mx-auto px-4 py-4">
|
|
||||||
{/* Main disclaimer */}
|
|
||||||
<div class="text-center mb-2">
|
|
||||||
<p class="text-sm text-zinc-600 font-medium">TomodachiShare is not affiliated with Nintendo</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Links section */}
|
|
||||||
<div class="flex flex-wrap justify-center items-center gap-x-4 text-sm max-sm:gap-x-12">
|
|
||||||
<a href="/terms-of-service" class="text-zinc-500 hover:text-zinc-700 transition-colors duration-200 hover:underline"> Terms of Service </a>
|
|
||||||
|
|
||||||
<span class="text-zinc-400 hidden sm:inline" aria-hidden="true">•</span>
|
|
||||||
|
|
||||||
<a href="/privacy" class="text-zinc-500 hover:text-zinc-700 transition-colors duration-200 hover:underline"> Privacy Policy </a>
|
|
||||||
|
|
||||||
<span class="text-zinc-400 hidden sm:inline" aria-hidden="true">•</span>
|
|
||||||
|
|
||||||
<a
|
|
||||||
href="https://discord.gg/48cXBFKvWQ"
|
|
||||||
target="_blank"
|
|
||||||
class="text-[#5865F2] hover:text-[#454FBF] transition-colors duration-200 hover:underline inline-flex items-end gap-1"
|
|
||||||
>
|
|
||||||
<Icon name="ic:baseline-discord" class="text-lg" />
|
|
||||||
Discord
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<span class="text-zinc-400 hidden sm:inline" aria-hidden="true"> • </span>
|
|
||||||
|
|
||||||
<a href="https://trafficlunar.net" target="_blank" class="text-zinc-500 hover:text-zinc-700 transition-colors duration-200 hover:underline group">
|
|
||||||
Made by <span class="text-orange-400 group-hover:text-orange-500 font-medium transition-colors duration-200">trafficlunar</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Copyright */}
|
|
||||||
<div class="text-center mt-4 mb-4">
|
|
||||||
<p class="text-xs text-zinc-400">© {new Date().getFullYear()} TomodachiShare. All rights reserved.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
import { Icon } from "@iconify/react";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import { useStore } from "@nanostores/react";
|
|
||||||
import { session } from "../session";
|
|
||||||
|
|
||||||
export default function HeaderProfile() {
|
|
||||||
const API_BASE_URL = import.meta.env.PUBLIC_API_URL;
|
|
||||||
const $session = useStore(session);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetch(`${API_BASE_URL}/api/auth/session`, { credentials: "include" })
|
|
||||||
.then((res) => {
|
|
||||||
if (!res.ok) throw new Error("Failed to get session");
|
|
||||||
return res.json();
|
|
||||||
})
|
|
||||||
.then((data) => {
|
|
||||||
session.set(data);
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
console.error(err);
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{!$session?.user ? (
|
|
||||||
<li>
|
|
||||||
<a href={"/login"} className="pill button h-full">
|
|
||||||
Login
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<li title="Your profile">
|
|
||||||
<a
|
|
||||||
href={`/profile/${$session?.user?.id}`}
|
|
||||||
aria-label="Go to profile"
|
|
||||||
className="pill button gap-2! p-0! h-full max-w-64"
|
|
||||||
data-tooltip="Your Profile"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={$session?.user?.image ?? "/guest.png"}
|
|
||||||
alt="profile picture"
|
|
||||||
width={40}
|
|
||||||
height={40}
|
|
||||||
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>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<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">
|
|
||||||
<Icon icon="ic:round-logout" fontSize={24} />
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
---
|
|
||||||
import { Icon } from "astro-icon/components";
|
|
||||||
import SearchBar from "./search-bar";
|
|
||||||
import HeaderProfile from "./header-profile";
|
|
||||||
---
|
|
||||||
|
|
||||||
<header
|
|
||||||
class="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 href={"/"} aria-label="Go to Home Page" class="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" />
|
|
||||||
TomodachiShare
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<div class="flex justify-center max-lg:justify-end max-md:justify-center">
|
|
||||||
<SearchBar client:only />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ul class="flex justify-end gap-3 items-center h-11 *:h-full max-lg:col-span-2 max-md:justify-center">
|
|
||||||
<li title="Random Mii">
|
|
||||||
<a
|
|
||||||
href={`${import.meta.env.PUBLIC_API_URL}/random`}
|
|
||||||
aria-label="Go to Random Link"
|
|
||||||
class="pill button p-0! h-full aspect-square"
|
|
||||||
data-tooltip="Go to a Random Mii"
|
|
||||||
>
|
|
||||||
<Icon name="mdi:dice-3" size={28} />
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href={"/submit"} class="pill button h-full"> Submit </a>
|
|
||||||
</li>
|
|
||||||
<HeaderProfile client:only />
|
|
||||||
</ul>
|
|
||||||
</header>
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
import { Icon } from "@iconify/react";
|
|
||||||
import DeleteMiiButton from "./delete-mii-button";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
mii: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AuthorButtons({ mii }: Props) {
|
|
||||||
// const session = useSession();
|
|
||||||
|
|
||||||
// if (!session.data || (Number(session.data.user?.id) !== mii.userId && Number(session.data.user?.id) !== Number(import.meta.env.NEXT_PUBLIC_ADMIN_USER_ID)))
|
|
||||||
// return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<a aria-label="Edit Mii" href={`/edit/${mii.id}`}>
|
|
||||||
<Icon icon="mdi:pencil" />
|
|
||||||
<span>Edit</span>
|
|
||||||
</a>
|
|
||||||
<DeleteMiiButton miiId={mii.id} miiName={mii.name} likes={mii._count.likedBy ?? 0} inMiiPage />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,79 +0,0 @@
|
||||||
import { Suspense, useEffect, useState } from "react";
|
|
||||||
import FilterMenu from "../mii/list/filter-menu";
|
|
||||||
import SortSelect from "../mii/list/sort-select";
|
|
||||||
import MiiGrid from "../mii/list/mii-grid";
|
|
||||||
import Pagination from "../pagination";
|
|
||||||
import Skeleton from "../mii/list/skeleton";
|
|
||||||
|
|
||||||
interface ApiResponse {
|
|
||||||
totalCount: number;
|
|
||||||
filteredCount: number;
|
|
||||||
miis: any[];
|
|
||||||
lastPage: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function IndexPage() {
|
|
||||||
const searchParams = new URLSearchParams(window.location.search);
|
|
||||||
const [data, setData] = useState<ApiResponse>();
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetch(`${import.meta.env.PUBLIC_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 (
|
|
||||||
<>
|
|
||||||
<h1 className="sr-only">
|
|
||||||
{searchParams.get("tags") ? `Miis tagged with '${searchParams.get("tags")}' - TomodachiShare` : "TomodachiShare - index mii list"}
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<p className="text-center mb-4">We're currently going through some major code changes therefore some features won't work.</p>
|
|
||||||
|
|
||||||
<Suspense fallback={<Skeleton />}>
|
|
||||||
{!loading && data ? (
|
|
||||||
<div className="w-full">
|
|
||||||
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex justify-between items-center gap-2 mb-2 max-md:flex-col">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{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,41 +0,0 @@
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import ProfileInformation from "../profile-information";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
id: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ProfilePage({ id }: Props) {
|
|
||||||
const [user, setUser] = useState<any>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetch(`${import.meta.env.PUBLIC_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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,456 +0,0 @@
|
||||||
// import { redirect } from "next/navigation";
|
|
||||||
|
|
||||||
// import { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
// import { FileWithPath } from "react-dropzone";
|
|
||||||
// import { Mii, MiiGender, MiiMakeup } from "@prisma/client";
|
|
||||||
// import { useSession } from "next-auth/react";
|
|
||||||
|
|
||||||
// import { nameSchema, tagsSchema } from "@tomodachi-share/shared/schemas";
|
|
||||||
// import { defaultInstructions, minifyInstructions } from "@/lib/switch";
|
|
||||||
// import { SwitchMiiInstructions } from "@tomodachi-share/shared";
|
|
||||||
|
|
||||||
// import TagSelector from "../tag-selector";
|
|
||||||
// import ImageList from "./image-list";
|
|
||||||
// import LikeButton from "../like-button";
|
|
||||||
// import Carousel from "../carousel";
|
|
||||||
// import SubmitButton from "../submit-button";
|
|
||||||
// import Dropzone from "../dropzone";
|
|
||||||
// import MiiEditor from "./mii-editor";
|
|
||||||
// import SwitchSubmitTutorialButton from "../tutorial/switch-submit";
|
|
||||||
// import { Icon } from "@iconify/react";
|
|
||||||
// import SwitchFileUpload from "./switch-file-upload";
|
|
||||||
|
|
||||||
// interface Props {
|
|
||||||
// mii: Mii;
|
|
||||||
// likes: number;
|
|
||||||
// }
|
|
||||||
|
|
||||||
// function deepMerge<T>(target: T, source: Partial<T>): T {
|
|
||||||
// const output = structuredClone(target);
|
|
||||||
|
|
||||||
// if (typeof source !== "object" || source === null) return output;
|
|
||||||
|
|
||||||
// for (const key in source) {
|
|
||||||
// const sourceValue = source[key];
|
|
||||||
// const targetValue = (output as any)[key];
|
|
||||||
|
|
||||||
// if (typeof sourceValue === "object" && sourceValue !== null && !Array.isArray(sourceValue)) {
|
|
||||||
// (output as any)[key] = deepMerge(targetValue, sourceValue);
|
|
||||||
// } else {
|
|
||||||
// (output as any)[key] = sourceValue;
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// return output;
|
|
||||||
// }
|
|
||||||
|
|
||||||
// export default function EditForm({ mii, likes }: Props) {
|
|
||||||
// const session = useSession();
|
|
||||||
// const [files, setFiles] = useState<FileWithPath[]>([]);
|
|
||||||
|
|
||||||
// const handleFilesChange: React.Dispatch<React.SetStateAction<FileWithPath[]>> = (updater) => {
|
|
||||||
// hasCustomImagesChanged.current = true;
|
|
||||||
// setFiles(updater);
|
|
||||||
// };
|
|
||||||
|
|
||||||
// const handleDrop = useCallback(
|
|
||||||
// (acceptedFiles: FileWithPath[]) => {
|
|
||||||
// if (files.length >= 3) return;
|
|
||||||
// hasCustomImagesChanged.current = true;
|
|
||||||
|
|
||||||
// setFiles((prev) => [...prev, ...acceptedFiles]);
|
|
||||||
// },
|
|
||||||
// [files.length],
|
|
||||||
// );
|
|
||||||
|
|
||||||
// const [error, setError] = useState<string | undefined>(undefined);
|
|
||||||
|
|
||||||
// const [name, setName] = useState(mii.name);
|
|
||||||
// const [tags, setTags] = useState(mii.tags);
|
|
||||||
// const [description, setDescription] = useState(mii.description);
|
|
||||||
// const [gender, setGender] = useState<MiiGender>(mii.gender ?? "MALE");
|
|
||||||
// const [makeup, setMakeup] = useState<MiiMakeup>(mii.makeup ?? "PARTIAL");
|
|
||||||
// const [miiPortraitUri, setMiiPortraitUri] = useState<string | undefined>(`/mii/${mii.id}/image?type=mii`);
|
|
||||||
// const [miiFeaturesUri, setMiiFeaturesUri] = useState<string | undefined>(`/mii/${mii.id}/image?type=features`);
|
|
||||||
// const [youtubeId, setYouTubeId] = useState(mii.youtubeId ?? "");
|
|
||||||
// const instructions = useRef<SwitchMiiInstructions>(deepMerge(defaultInstructions, (mii.instructions as object) ?? {}));
|
|
||||||
|
|
||||||
// const [quarantined, setQuarantined] = useState(mii.quarantined);
|
|
||||||
// const hasCustomImagesChanged = useRef(false);
|
|
||||||
// const hasMiiPortraitChanged = useRef(false);
|
|
||||||
// const hasMiiFeaturesChanged = useRef(false);
|
|
||||||
|
|
||||||
// const handleSubmit = async () => {
|
|
||||||
// // Validate before sending request
|
|
||||||
// const nameValidation = nameSchema.safeParse(name);
|
|
||||||
// if (!nameValidation.success) {
|
|
||||||
// setError(nameValidation.error.issues[0].message);
|
|
||||||
// return;
|
|
||||||
// }
|
|
||||||
// const tagsValidation = tagsSchema.safeParse(tags);
|
|
||||||
// if (!tagsValidation.success) {
|
|
||||||
// setError(tagsValidation.error.issues[0].message);
|
|
||||||
// return;
|
|
||||||
// }
|
|
||||||
|
|
||||||
// // Send request to server
|
|
||||||
// const formData = new FormData();
|
|
||||||
// if (name != mii.name) formData.append("name", name);
|
|
||||||
// if (tags != mii.tags) formData.append("tags", JSON.stringify(tags));
|
|
||||||
// if (description && description != mii.description) formData.append("description", description);
|
|
||||||
// if (gender != mii.gender) formData.append("gender", gender);
|
|
||||||
// if (makeup != mii.makeup) formData.append("makeup", makeup);
|
|
||||||
// if (miiPortraitUri) formData.append("miiPortraitUri", miiPortraitUri);
|
|
||||||
// if (quarantined != mii.quarantined) formData.append("quarantined", JSON.stringify(quarantined));
|
|
||||||
// if (youtubeId != mii.youtubeId) formData.append("youtubeId", youtubeId);
|
|
||||||
// if (minifyInstructions(structuredClone(instructions.current)) !== (mii.instructions as object))
|
|
||||||
// formData.append("instructions", JSON.stringify(instructions.current));
|
|
||||||
|
|
||||||
// if (hasCustomImagesChanged.current) {
|
|
||||||
// files.forEach((file, index) => {
|
|
||||||
// // image1, image2, etc.
|
|
||||||
// formData.append(`image${index + 1}`, file);
|
|
||||||
// });
|
|
||||||
// }
|
|
||||||
|
|
||||||
// // Switch pictures
|
|
||||||
// async function getBlob(uri: string): Promise<Blob | null> {
|
|
||||||
// const response = await fetch(uri);
|
|
||||||
// if (!response.ok) {
|
|
||||||
// setError("Failed to get Mii portrait/features screenshot. Did you upload one?");
|
|
||||||
// return null;
|
|
||||||
// }
|
|
||||||
|
|
||||||
// const blob = await response.blob();
|
|
||||||
// if (!blob.type.startsWith("image/")) {
|
|
||||||
// setError("Invalid image file found");
|
|
||||||
// return null;
|
|
||||||
// }
|
|
||||||
|
|
||||||
// return blob;
|
|
||||||
// }
|
|
||||||
|
|
||||||
// if (miiPortraitUri && hasMiiPortraitChanged.current) {
|
|
||||||
// const blob = await getBlob(miiPortraitUri);
|
|
||||||
// if (blob) formData.append("miiPortraitImage", blob);
|
|
||||||
// }
|
|
||||||
// if (miiFeaturesUri && hasMiiFeaturesChanged.current) {
|
|
||||||
// const blob = await getBlob(miiFeaturesUri);
|
|
||||||
// if (blob) formData.append("miiFeaturesImage", blob);
|
|
||||||
// }
|
|
||||||
|
|
||||||
// const response = await fetch(`/api/mii/${mii.id}/edit`, {
|
|
||||||
// method: "PATCH",
|
|
||||||
// body: formData,
|
|
||||||
// });
|
|
||||||
// const { error } = await response.json();
|
|
||||||
|
|
||||||
// if (!response.ok) {
|
|
||||||
// setError(error);
|
|
||||||
// return;
|
|
||||||
// }
|
|
||||||
|
|
||||||
// redirect(`/mii/${mii.id}`);
|
|
||||||
// };
|
|
||||||
|
|
||||||
// const handleMiiPortraitChange = (uri: string | undefined) => {
|
|
||||||
// hasMiiPortraitChanged.current = true;
|
|
||||||
// setMiiPortraitUri(uri);
|
|
||||||
// };
|
|
||||||
|
|
||||||
// const handleMiiFeaturesChange = (uri: string | undefined) => {
|
|
||||||
// hasMiiFeaturesChanged.current = true;
|
|
||||||
// setMiiFeaturesUri(uri);
|
|
||||||
// };
|
|
||||||
|
|
||||||
// // Load existing images - converts image URLs to File objects
|
|
||||||
// useEffect(() => {
|
|
||||||
// const loadExistingImages = async () => {
|
|
||||||
// try {
|
|
||||||
// const existing = await Promise.all(
|
|
||||||
// Array.from({ length: mii.imageCount }, async (_, index) => {
|
|
||||||
// const path = `/mii/${mii.id}/image?type=image${index}`;
|
|
||||||
// const response = await fetch(path);
|
|
||||||
// const blob = await response.blob();
|
|
||||||
|
|
||||||
// return Object.assign(new File([blob], `image${index}.png`, { type: "image/png" }), { path });
|
|
||||||
// }),
|
|
||||||
// );
|
|
||||||
|
|
||||||
// setFiles(existing);
|
|
||||||
// } catch (error) {
|
|
||||||
// console.error("Error loading existing images:", error);
|
|
||||||
// }
|
|
||||||
// };
|
|
||||||
|
|
||||||
// loadExistingImages();
|
|
||||||
// }, [mii.id, mii.imageCount]);
|
|
||||||
|
|
||||||
// return (
|
|
||||||
// <div className="flex justify-center gap-4 w-full max-lg:flex-col max-lg:items-center">
|
|
||||||
// <div className="flex justify-center">
|
|
||||||
// <div className="w-75 h-min flex flex-col bg-zinc-50 rounded-3xl border-2 border-zinc-300 shadow-lg p-3">
|
|
||||||
// <Carousel
|
|
||||||
// images={[
|
|
||||||
// miiPortraitUri ?? `/mii/${mii.id}/image?type=mii`,
|
|
||||||
// ...(mii.platform === "THREE_DS" ? [`/mii/${mii.id}/image?type=qr-code`] : [miiFeaturesUri ?? `/mii/${mii.id}/image?type=features`]),
|
|
||||||
// ...files.map((file) => URL.createObjectURL(file)),
|
|
||||||
// ]}
|
|
||||||
// />
|
|
||||||
|
|
||||||
// <div className="p-4 flex flex-col gap-1 h-full">
|
|
||||||
// <h1 className="font-bold text-2xl line-clamp-1" title={name}>
|
|
||||||
// {name || "Mii name"}
|
|
||||||
// </h1>
|
|
||||||
// <div id="tags" className="flex flex-wrap gap-1">
|
|
||||||
// {tags.length == 0 && <span className="px-2 py-1 bg-orange-300 rounded-full text-xs">tag</span>}
|
|
||||||
// {tags.map((tag) => (
|
|
||||||
// <span key={tag} className="px-2 py-1 bg-orange-300 rounded-full text-xs">
|
|
||||||
// {tag}
|
|
||||||
// </span>
|
|
||||||
// ))}
|
|
||||||
// </div>
|
|
||||||
|
|
||||||
// <div className="mt-auto">
|
|
||||||
// <LikeButton likes={likes} isLiked={false} abbreviate disabled />
|
|
||||||
// </div>
|
|
||||||
// </div>
|
|
||||||
// </div>
|
|
||||||
// </div>
|
|
||||||
|
|
||||||
// <div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex flex-col gap-2 max-w-2xl w-full">
|
|
||||||
// <div>
|
|
||||||
// <h2 className="text-2xl font-bold">Edit your Mii</h2>
|
|
||||||
// <p className="text-sm text-zinc-500">Make changes to your existing Mii.</p>
|
|
||||||
// </div>
|
|
||||||
|
|
||||||
// {/* Separator */}
|
|
||||||
// <div className="flex items-center gap-4 text-zinc-500 text-sm font-medium my-1">
|
|
||||||
// <hr className="grow border-zinc-300" />
|
|
||||||
// <span>Info</span>
|
|
||||||
// <hr className="grow border-zinc-300" />
|
|
||||||
// </div>
|
|
||||||
|
|
||||||
// <div className="w-full grid grid-cols-3 items-center">
|
|
||||||
// <label htmlFor="name" className="font-semibold">
|
|
||||||
// Name
|
|
||||||
// </label>
|
|
||||||
// <input
|
|
||||||
// id="name"
|
|
||||||
// type="text"
|
|
||||||
// className="pill input w-full col-span-2"
|
|
||||||
// minLength={2}
|
|
||||||
// maxLength={64}
|
|
||||||
// placeholder="Type your mii's name here..."
|
|
||||||
// value={name}
|
|
||||||
// onChange={(e) => setName(e.target.value)}
|
|
||||||
// />
|
|
||||||
// </div>
|
|
||||||
|
|
||||||
// <div className="w-full grid grid-cols-3 items-center">
|
|
||||||
// <label htmlFor="tags" className="font-semibold">
|
|
||||||
// Tags
|
|
||||||
// </label>
|
|
||||||
// <TagSelector tags={tags} setTags={setTags} showTagLimit />
|
|
||||||
// </div>
|
|
||||||
|
|
||||||
// <div className="w-full grid grid-cols-3 items-start">
|
|
||||||
// <label htmlFor="reason-note" className="font-semibold py-2">
|
|
||||||
// Description
|
|
||||||
// </label>
|
|
||||||
// <textarea
|
|
||||||
// rows={5}
|
|
||||||
// maxLength={512}
|
|
||||||
// placeholder="(optional) Type a description..."
|
|
||||||
// className="pill input rounded-xl! resize-none col-span-2 text-sm"
|
|
||||||
// value={description ?? ""}
|
|
||||||
// onChange={(e) => setDescription(e.target.value)}
|
|
||||||
// />
|
|
||||||
// </div>
|
|
||||||
|
|
||||||
// {session.data?.user?.id == import.meta.env.NEXT_PUBLIC_ADMIN_USER_ID && (
|
|
||||||
// <>
|
|
||||||
// <div className="w-full grid grid-cols-3 items-center">
|
|
||||||
// <label htmlFor="quarantined" className="font-semibold py-2">
|
|
||||||
// Quarantined
|
|
||||||
// </label>
|
|
||||||
|
|
||||||
// <div className="col-span-2 flex gap-1">
|
|
||||||
// <input type="checkbox" id="quarantined" className="checkbox-alt" checked={quarantined} onChange={(e) => setQuarantined(e.target.checked)} />
|
|
||||||
// </div>
|
|
||||||
// </div>
|
|
||||||
// </>
|
|
||||||
// )}
|
|
||||||
|
|
||||||
// {/* Makeup/Images/Instructions (Switch only) */}
|
|
||||||
// {mii.platform === "SWITCH" && (
|
|
||||||
// <>
|
|
||||||
// <div className="w-full grid grid-cols-3 items-start z-20">
|
|
||||||
// <label htmlFor="gender" className="font-semibold py-2">
|
|
||||||
// Gender
|
|
||||||
// </label>
|
|
||||||
// <div className="col-span-2 flex gap-1">
|
|
||||||
// <button
|
|
||||||
// type="button"
|
|
||||||
// onClick={() => setGender("MALE")}
|
|
||||||
// aria-label="Filter for Male Miis"
|
|
||||||
// data-tooltip="Male"
|
|
||||||
// className={`cursor-pointer rounded-xl flex justify-center items-center size-11 text-4xl border-2 transition-all after:bg-blue-400! after:border-blue-400! before:border-b-blue-400! ${
|
|
||||||
// gender === "MALE" ? "bg-blue-100 border-blue-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
|
|
||||||
// }`}
|
|
||||||
// >
|
|
||||||
// <Icon icon="foundation:male" className="text-blue-400" />
|
|
||||||
// </button>
|
|
||||||
|
|
||||||
// <button
|
|
||||||
// type="button"
|
|
||||||
// onClick={() => setGender("FEMALE")}
|
|
||||||
// aria-label="Filter for Female Miis"
|
|
||||||
// data-tooltip="Female"
|
|
||||||
// className={`cursor-pointer rounded-xl flex justify-center items-center size-11 text-4xl border-2 transition-all after:bg-pink-400! after:border-pink-400! before:border-b-pink-400! ${
|
|
||||||
// gender === "FEMALE" ? "bg-pink-100 border-pink-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
|
|
||||||
// }`}
|
|
||||||
// >
|
|
||||||
// <Icon icon="foundation:female" className="text-pink-400" />
|
|
||||||
// </button>
|
|
||||||
|
|
||||||
// <button
|
|
||||||
// type="button"
|
|
||||||
// onClick={() => setGender("NONBINARY")}
|
|
||||||
// aria-label="Filter for Nonbinary Miis"
|
|
||||||
// data-tooltip="Nonbinary"
|
|
||||||
// className={`cursor-pointer rounded-xl flex justify-center items-center size-11 text-4xl border-2 transition-all after:bg-purple-400! after:border-purple-400! before:border-b-purple-400! ${
|
|
||||||
// gender === "NONBINARY" ? "bg-purple-100 border-purple-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
|
|
||||||
// }`}
|
|
||||||
// >
|
|
||||||
// <Icon icon="mdi:gender-non-binary" className="text-purple-400" />
|
|
||||||
// </button>
|
|
||||||
// </div>
|
|
||||||
// </div>
|
|
||||||
|
|
||||||
// <div className="w-full grid grid-cols-3 items-start">
|
|
||||||
// <label htmlFor="makeup" className="font-semibold py-2">
|
|
||||||
// Face Paint
|
|
||||||
// </label>
|
|
||||||
|
|
||||||
// <div className="col-span-2 flex gap-1">
|
|
||||||
// {/* Full Makeup */}
|
|
||||||
// <button
|
|
||||||
// type="button"
|
|
||||||
// onClick={() => setMakeup("FULL")}
|
|
||||||
// aria-label="Full Face Paint"
|
|
||||||
// data-tooltip="Full Face Paint"
|
|
||||||
// className={`cursor-pointer rounded-xl flex justify-center items-center size-11 text-4xl border-2 transition-all after:bg-pink-400! after:border-pink-400! before:border-b-pink-400! ${
|
|
||||||
// makeup === "FULL" ? "bg-pink-100 border-pink-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
|
|
||||||
// }`}
|
|
||||||
// >
|
|
||||||
// <Icon icon="mdi:palette" className="text-pink-400" />
|
|
||||||
// </button>
|
|
||||||
|
|
||||||
// {/* Partial Makeup */}
|
|
||||||
// <button
|
|
||||||
// type="button"
|
|
||||||
// onClick={() => setMakeup("PARTIAL")}
|
|
||||||
// aria-label="Partial Face Paint"
|
|
||||||
// data-tooltip="Partial Face Paint"
|
|
||||||
// className={`cursor-pointer rounded-xl flex justify-center items-center size-11 text-4xl border-2 transition-all after:bg-purple-400! after:border-purple-400! before:border-b-purple-400! ${
|
|
||||||
// makeup === "PARTIAL" ? "bg-purple-100 border-purple-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
|
|
||||||
// }`}
|
|
||||||
// >
|
|
||||||
// <Icon icon="mdi:lipstick" className="text-purple-400" />
|
|
||||||
// </button>
|
|
||||||
|
|
||||||
// {/* No Makeup */}
|
|
||||||
// <button
|
|
||||||
// type="button"
|
|
||||||
// onClick={() => setMakeup("NONE")}
|
|
||||||
// aria-label="No Face Paint"
|
|
||||||
// data-tooltip="No Face Paint"
|
|
||||||
// className={`cursor-pointer rounded-xl flex justify-center items-center size-11 text-4xl border-2 transition-all after:bg-gray-400! after:border-gray-400! before:border-b-gray-400! ${
|
|
||||||
// makeup === "NONE" ? "bg-gray-200 border-gray-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
|
|
||||||
// }`}
|
|
||||||
// >
|
|
||||||
// <Icon icon="codex:cross" className="text-gray-400" />
|
|
||||||
// </button>
|
|
||||||
// </div>
|
|
||||||
// </div>
|
|
||||||
|
|
||||||
// {/* (Switch Only) Mii Portrait */}
|
|
||||||
// <div>
|
|
||||||
// {/* Separator */}
|
|
||||||
// <div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-8 mb-2">
|
|
||||||
// <hr className="grow border-zinc-300" />
|
|
||||||
// <span>Mii Portrait</span>
|
|
||||||
// <hr className="grow border-zinc-300" />
|
|
||||||
// </div>
|
|
||||||
|
|
||||||
// <div className="flex flex-col items-center gap-2">
|
|
||||||
// <SwitchFileUpload text="a screenshot of your Mii here" image={miiPortraitUri} setImage={handleMiiPortraitChange} forceCrop />
|
|
||||||
// <SwitchFileUpload text="a screenshot of your Mii's features here" image={miiFeaturesUri} setImage={handleMiiFeaturesChange} />
|
|
||||||
// <SwitchSubmitTutorialButton />
|
|
||||||
// </div>
|
|
||||||
|
|
||||||
// <p className="text-xs text-zinc-400 text-center mt-2">You must upload a screenshot of the features, check tutorial on how.</p>
|
|
||||||
// </div>
|
|
||||||
|
|
||||||
// <div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-8">
|
|
||||||
// <hr className="grow border-zinc-300" />
|
|
||||||
// <span>Instructions</span>
|
|
||||||
// <hr className="grow border-zinc-300" />
|
|
||||||
// </div>
|
|
||||||
|
|
||||||
// {/* YouTube */}
|
|
||||||
// <div className="w-full grid grid-cols-3 items-center">
|
|
||||||
// <label htmlFor="youtube" className="font-semibold">
|
|
||||||
// YouTube Video
|
|
||||||
// </label>
|
|
||||||
// <input
|
|
||||||
// id="youtube"
|
|
||||||
// type="text"
|
|
||||||
// className="pill input w-full col-span-2"
|
|
||||||
// minLength={2}
|
|
||||||
// maxLength={64}
|
|
||||||
// placeholder="Paste a URL or video ID..."
|
|
||||||
// value={youtubeId}
|
|
||||||
// onChange={(e) => {
|
|
||||||
// const val = e.target.value;
|
|
||||||
// const match = val.match(/(?:youtube\.com\/(?:watch\?v=|shorts\/|embed\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/);
|
|
||||||
// setYouTubeId(match ? match[1] : val);
|
|
||||||
// }}
|
|
||||||
// />
|
|
||||||
// </div>
|
|
||||||
|
|
||||||
// <MiiEditor instructions={instructions} />
|
|
||||||
// <SwitchSubmitTutorialButton />
|
|
||||||
// </>
|
|
||||||
// )}
|
|
||||||
|
|
||||||
// {/* Separator */}
|
|
||||||
// <div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-8">
|
|
||||||
// <hr className="grow border-zinc-300" />
|
|
||||||
// <span>Custom images</span>
|
|
||||||
// <hr className="grow border-zinc-300" />
|
|
||||||
// </div>
|
|
||||||
|
|
||||||
// <div className="max-w-md w-full self-center">
|
|
||||||
// <Dropzone onDrop={handleDrop}>
|
|
||||||
// <p className="text-center text-sm">
|
|
||||||
// Drag and drop your images here
|
|
||||||
// <br />
|
|
||||||
// or click to open
|
|
||||||
// </p>
|
|
||||||
// </Dropzone>
|
|
||||||
// </div>
|
|
||||||
|
|
||||||
// <ImageList files={files} setFiles={handleFilesChange} />
|
|
||||||
|
|
||||||
// <hr className="border-zinc-300 my-2" />
|
|
||||||
// <div className="flex justify-between items-center">
|
|
||||||
// {error && <span className="text-red-400 font-bold">Error: {error}</span>}
|
|
||||||
|
|
||||||
// <SubmitButton onClick={handleSubmit} text="Edit" className="ml-auto" />
|
|
||||||
// </div>
|
|
||||||
// </div>
|
|
||||||
// </div>
|
|
||||||
// );
|
|
||||||
// }
|
|
||||||
|
|
@ -1,91 +0,0 @@
|
||||||
---
|
|
||||||
import "./styles/global.css";
|
|
||||||
import "react-image-crop/dist/ReactCrop.css";
|
|
||||||
|
|
||||||
import Header from "./components/header.astro";
|
|
||||||
import Footer from "./components/footer.astro";
|
|
||||||
// import AdminBanner from "./components/admin/banner";
|
|
||||||
import Providers from "./components/provider";
|
|
||||||
import { Font } from "astro:assets";
|
|
||||||
// import SessionWrapper from "./components/SessionWrapper";
|
|
||||||
|
|
||||||
const baseUrl = import.meta.env.PUBLIC_BASE_URL;
|
|
||||||
|
|
||||||
const jsonLd = {
|
|
||||||
"@context": "https://schema.org",
|
|
||||||
"@type": "WebSite",
|
|
||||||
name: "TomodachiShare",
|
|
||||||
url: "https://tomodachishare.com",
|
|
||||||
description: "Discover and share Mii residents for your Tomodachi Life island!",
|
|
||||||
inLanguage: "en",
|
|
||||||
publisher: {
|
|
||||||
"@type": "Organization",
|
|
||||||
name: "TomodachiShare",
|
|
||||||
url: "https://tomodachishare.com",
|
|
||||||
logo: {
|
|
||||||
"@type": "ImageObject",
|
|
||||||
url: "https://tomodachishare.com/logo.png",
|
|
||||||
},
|
|
||||||
sameAs: ["https://trafficlunar.net", "https://twitter.com/trafficlunr", "https://bsky.app/profile/trafficlunar.net"],
|
|
||||||
},
|
|
||||||
potentialAction: {
|
|
||||||
"@type": "SearchAction",
|
|
||||||
target: "https://tomodachishare.com/?q={search_term_string}",
|
|
||||||
"query-input": "required name=search_term_string",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
---
|
|
||||||
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<Font cssVariable="--font-lexend" />
|
|
||||||
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
|
|
||||||
<!-- SEO -->
|
|
||||||
<title>TomodachiShare - home for Tomodachi Life Miis!</title>
|
|
||||||
<meta name="description" content="Discover and share Mii residents for your Tomodachi Life island!" />
|
|
||||||
<meta name="keywords" content="mii, tomodachi life, nintendo, tomodachishare, tomodachi-share, mii creator, mii collection" />
|
|
||||||
<meta name="robots" content="index, follow" />
|
|
||||||
|
|
||||||
<!-- OpenGraph -->
|
|
||||||
<meta property="og:site_name" content="TomodachiShare" />
|
|
||||||
<meta property="og:title" content="TomodachiShare" />
|
|
||||||
<meta property="og:description" content="Discover and share Mii residents for your Tomodachi Life island!" />
|
|
||||||
<meta property="og:image" content="/preview.png" />
|
|
||||||
<meta property="og:type" content="website" />
|
|
||||||
<meta property="og:url" content={baseUrl} />
|
|
||||||
|
|
||||||
<!-- Twitter -->
|
|
||||||
<meta name="twitter:card" content="summary_large_image" />
|
|
||||||
<meta name="twitter:title" content="TomodachiShare - Discover and Share Your Mii Residents" />
|
|
||||||
<meta name="twitter:description" content="Discover and share Mii residents for your Tomodachi Life island!" />
|
|
||||||
<meta name="twitter:image" content="/preview.png" />
|
|
||||||
<meta name="twitter:creator" content="@trafficlunr" />
|
|
||||||
|
|
||||||
<!-- JSON-LD -->
|
|
||||||
<script is:inline type="application/ld+json" set:html={JSON.stringify(jsonLd).replace(/</g, "\\u003c")} />
|
|
||||||
|
|
||||||
<!-- Analytics -->
|
|
||||||
{
|
|
||||||
import.meta.env.PROD && (
|
|
||||||
<script is:inline defer src="https://analytics.trafficlunar.net/script.js" data-website-id="bc530384-9b7d-471a-b2e3-f9859da50c24" />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body class="font-[Lexend] antialiased flex flex-col items-center min-h-screen">
|
|
||||||
<Providers client:load>
|
|
||||||
<!-- <SessionWrapper client:load> -->
|
|
||||||
<Header />
|
|
||||||
<!-- <AdminBanner client:load /> -->
|
|
||||||
|
|
||||||
<main class="px-4 py-8 max-w-7xl w-full grow flex flex-col">
|
|
||||||
<slot />
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<Footer />
|
|
||||||
<!-- </SessionWrapper> -->
|
|
||||||
</Providers>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
---
|
|
||||||
import { Icon } from "astro-icon/components";
|
|
||||||
import Layout from "../layout.astro";
|
|
||||||
---
|
|
||||||
|
|
||||||
<Layout>
|
|
||||||
<div class="grow flex items-center justify-center">
|
|
||||||
<div class="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 class="text-7xl font-black">404</h2>
|
|
||||||
<p>Page not found - you swam off the island!</p>
|
|
||||||
<a href="/" class="pill button gap-2 mt-8 w-fit self-center">
|
|
||||||
<Icon name="ic:round-home" size={24} />
|
|
||||||
Travel Back
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Layout>
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
---
|
|
||||||
import Layout from "../layout.astro";
|
|
||||||
import IndexPage from "../components/pages/index";
|
|
||||||
---
|
|
||||||
|
|
||||||
<Layout>
|
|
||||||
<!-- <Suspense fallback={<Skeleton />}> -->
|
|
||||||
<!-- <MiiList searchParams={Astro.url.searchParams} /> -->
|
|
||||||
<!-- </Suspense> -->
|
|
||||||
<IndexPage client:only />
|
|
||||||
</Layout>
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
---
|
|
||||||
import Layout from "../layout.astro";
|
|
||||||
import { Icon } from "astro-icon/components";
|
|
||||||
|
|
||||||
const API_BASE_URL = import.meta.env.PUBLIC_API_URL;
|
|
||||||
---
|
|
||||||
|
|
||||||
<Layout>
|
|
||||||
<div class="grow flex items-center justify-center">
|
|
||||||
<div class="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg px-10 py-12 max-w-md text-center">
|
|
||||||
<h1 class="text-3xl font-bold mb-4">Welcome to TomodachiShare!</h1>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-4 text-zinc-500 text-sm font-medium mb-8">
|
|
||||||
<hr class="grow border-zinc-300" />
|
|
||||||
<span>Choose your login method</span>
|
|
||||||
<hr class="grow border-zinc-300" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col items-center gap-2">
|
|
||||||
<a
|
|
||||||
href={`${API_BASE_URL}/api/auth/signin/discord`}
|
|
||||||
aria-label="Login with Discord"
|
|
||||||
class="pill button gap-2 px-3! bg-indigo-400! border-indigo-500! hover:bg-indigo-500!"
|
|
||||||
>
|
|
||||||
<Icon name="ic:baseline-discord" size={32} />
|
|
||||||
Login with Discord
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href={`${API_BASE_URL}/api/auth/signin/github`}
|
|
||||||
aria-label="Login with GitHub"
|
|
||||||
class="pill button gap-2 px-3! bg-zinc-700! border-zinc-800! hover:bg-zinc-800! text-white"
|
|
||||||
>
|
|
||||||
<Icon name="mdi:github" size={32} />
|
|
||||||
Login with GitHub
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href={`${API_BASE_URL}/api/auth/signin/google`}
|
|
||||||
aria-label="Login with Google"
|
|
||||||
class="pill button gap-2 px-3! bg-white! border-gray-300! hover:bg-gray-100! text-black! flex items-center"
|
|
||||||
>
|
|
||||||
<Icon name="material-icon-theme:google" size={32} />
|
|
||||||
Login with Google
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="mt-8 text-xs text-zinc-400">
|
|
||||||
By signing up, you agree to the{" "}
|
|
||||||
<a href="/terms-of-service" class="underline hover:text-zinc-600">Terms of Service</a>{" "}
|
|
||||||
and{" "}
|
|
||||||
<a href="/privacy" class="underline hover:text-zinc-600">Privacy Policy</a>.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Layout>
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
---
|
|
||||||
import MiiPage from "../../components/pages/mii";
|
|
||||||
import Layout from "../../layout.astro";
|
|
||||||
|
|
||||||
const { id } = Astro.params;
|
|
||||||
|
|
||||||
export async function getStaticPaths() {
|
|
||||||
return Array.from({ length: 30000 }, (_, i) => ({
|
|
||||||
params: { id: String(i + 1) },
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
---
|
|
||||||
|
|
||||||
<Layout>
|
|
||||||
<MiiPage client:load id={id} />
|
|
||||||
</Layout>
|
|
||||||
|
|
@ -1,104 +0,0 @@
|
||||||
---
|
|
||||||
import Layout from "../layout.astro";
|
|
||||||
---
|
|
||||||
|
|
||||||
<Layout>
|
|
||||||
<div class="bg-amber-50 border-2 border-amber-500 rounded-2xl p-6">
|
|
||||||
<h1 class="text-2xl font-bold">Privacy Policy</h1>
|
|
||||||
<h2 class="font-light">
|
|
||||||
<strong class="font-medium">Effective Date:</strong> 13 April 2026
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<hr class="border-black/20 mt-1 mb-4" />
|
|
||||||
|
|
||||||
<p>By using this website, you confirm that you understand and agree to this Privacy Policy.</p>
|
|
||||||
<p class="mt-1">
|
|
||||||
If you have any questions or concerns, please contact me at:{" "}
|
|
||||||
<a href="mailto:hello@trafficlunar.net" class="text-blue-700"> hello@trafficlunar.net </a>
|
|
||||||
.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<ul class="list-decimal ml-5 marker:text-xl marker:font-semibold">
|
|
||||||
<li>
|
|
||||||
<h3 class="text-xl font-semibold mt-6 mb-2">Information We Collect</h3>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<p class="mb-2">The following types of information are stored when you use this website:</p>
|
|
||||||
<ul class="list-disc list-inside">
|
|
||||||
<li>
|
|
||||||
<strong>Account Information:</strong> When you sign up or log in using Discord or Github, your name, e-mail, and profile picture are collected. Your
|
|
||||||
authentication tokens may also be temporarily stored to maintain your login session.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>Miis:</strong> We store any Miis you submit, including associated images (such as a picture of your Mii, QR codes, and custom images).
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>Interaction Data:</strong> The Miis you like.
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<h3 class="text-xl font-semibold mt-6 mb-2">Use of Cookies</h3>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<p class="mb-2">Cookies are necessary for user sessions and authentication. We do not use cookies for tracking or advertising purposes.</p>
|
|
||||||
</section>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<h3 class="text-xl font-semibold mt-6 mb-2">Analytics</h3>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<p class="mb-2">
|
|
||||||
We use{" "}
|
|
||||||
<a href="https://umami.is/" class="text-blue-700"> Umami </a>{" "}
|
|
||||||
to collect anonymous data about how users interact with the site. Umami is fully GDPR-compliant, and no personally identifiable information is collected
|
|
||||||
through this service.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<h3 class="text-xl font-semibold mt-6 mb-2">Data Sharing</h3>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<p class="mb-2">
|
|
||||||
We do not sell your personal data to third parties. Your data may be sent anonymously to self-hosted third-party services or trusted third-party
|
|
||||||
tools (such as analytics) but these services are used solely to keep the site functional.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<h3 class="text-xl font-semibold mt-6 mb-2">Your Rights</h3>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<p class="mb-2">As a user, you have the right to:</p>
|
|
||||||
<ul class="list-disc list-inside indent-4">
|
|
||||||
<li>Access the personal data we hold about you.</li>
|
|
||||||
<li>Request corrections to any inaccurate or incomplete information.</li>
|
|
||||||
<li>Request the deletion of your personal data.</li>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<h3 class="text-xl font-semibold mt-6 mb-2">Data Deletion</h3>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<p class="mb-2">
|
|
||||||
Your data, including your Miis, will be retained for as long as you have an account on the site. You may request that your data be deleted at any
|
|
||||||
time by going to your profile page, clicking the settings icon, and clicking the 'Delete Account' button. Upon clicking, your data will be
|
|
||||||
promptly removed from our servers.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<h3 class="text-xl font-semibold mt-6 mb-2">Changes to this Privacy Policy</h3>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<p class="mb-2">
|
|
||||||
This Privacy Policy may be updated from time to time. We encourage you to review this policy periodically to stay informed about your privacy.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</Layout>
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
---
|
|
||||||
import ProfilePage from "../../components/pages/profile";
|
|
||||||
import Layout from "../../layout.astro";
|
|
||||||
|
|
||||||
const { id } = Astro.params;
|
|
||||||
|
|
||||||
export async function getStaticPaths() {
|
|
||||||
return Array.from({ length: 50000 }, (_, i) => ({
|
|
||||||
params: { id: String(i + 1) },
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
---
|
|
||||||
|
|
||||||
<Layout>
|
|
||||||
<ProfilePage client:only id={id} />
|
|
||||||
</Layout>
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
---
|
|
||||||
import ProfileSettings from "../../components/profile-settings";
|
|
||||||
import Layout from "../../layout.astro";
|
|
||||||
---
|
|
||||||
|
|
||||||
<Layout>
|
|
||||||
<!-- <ProfileInformation client:only page="settings" /> -->
|
|
||||||
<ProfileSettings client:only currentDescription={null} />
|
|
||||||
</Layout>
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
---
|
|
||||||
import SubmitForm from "../components/submit-form";
|
|
||||||
import Layout from "../layout.astro";
|
|
||||||
---
|
|
||||||
|
|
||||||
<Layout>
|
|
||||||
<SubmitForm client:load />
|
|
||||||
</Layout>
|
|
||||||
|
|
@ -1,133 +0,0 @@
|
||||||
---
|
|
||||||
import Layout from "../layout.astro";
|
|
||||||
---
|
|
||||||
|
|
||||||
<Layout>
|
|
||||||
<div class="bg-amber-50 border-2 border-amber-500 rounded-2xl p-6">
|
|
||||||
<h1 class="text-2xl font-bold">Terms of Service</h1>
|
|
||||||
<h2 class="font-light">
|
|
||||||
<strong class="font-medium">Effective Date:</strong> March 26, 2026
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<hr class="border-black/20 mt-1 mb-4" />
|
|
||||||
|
|
||||||
<p>
|
|
||||||
By registering for, or using this service, you confirm that you understand and agree to the terms below. If you do not agree to these terms, you should
|
|
||||||
not use the service.
|
|
||||||
</p>
|
|
||||||
<p class="mt-1">
|
|
||||||
If you have any questions or concerns, please contact me at:{" "}
|
|
||||||
<a href="mailto:hello@trafficlunar.net" class="text-blue-700"> hello@trafficlunar.net </a>
|
|
||||||
.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<ul class="list-decimal ml-5 marker:text-xl marker:font-semibold">
|
|
||||||
<li>
|
|
||||||
<h3 class="text-xl font-semibold mt-6 mb-2">Usage Policy</h3>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<p class="mb-2">As a user of this site, you must abide by these guidelines:</p>
|
|
||||||
<ul class="list-disc list-inside indent-4">
|
|
||||||
<li>Nothing that would interfere with or gain unauthorized access to the website or its systems.</li>
|
|
||||||
<li>Nothing that is against the law in the United Kingdom.</li>
|
|
||||||
<li>No NSFW, violent, gory, or inappropriate Miis or images.</li>
|
|
||||||
<li>No spam.</li>
|
|
||||||
<li>No impersonation of others.</li>
|
|
||||||
<li>No malware, malicious links, or phishing content.</li>
|
|
||||||
<li>No harassment, hate speech, threats, or bullying towards others.</li>
|
|
||||||
<li>Miis must be high quality: for example, not following all instructions on the submit form correctly.</li>
|
|
||||||
<li>Avoid using inappropriate language. Profanity may be automatically censored.</li>
|
|
||||||
<li>No use of automated scripts, bots, or scrapers to access or interact with the site.</li>
|
|
||||||
</ul>
|
|
||||||
<p class="mt-2">
|
|
||||||
If you find anybody or a Mii breaking these rules, please report it by going to their page and clicking the "Report" button.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<h3 class="text-xl font-semibold mt-6 mb-2">Termination</h3>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<p class="mb-2">
|
|
||||||
We reserve the right to suspend or terminate your access to the site at any time if you violate these Terms of Service or engage in any activities
|
|
||||||
that disrupt the functionality of the site.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
To request deletion of your account and personal data, please refer to the{" "}
|
|
||||||
<a href="/privacy" class="text-blue-700"> Privacy Policy </a>{" "}
|
|
||||||
(see "Data Deletion") or email me at{" "}
|
|
||||||
<a href="mailto:hello@trafficlunar.net" class="text-blue-700"> hello@trafficlunar.net </a>
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<h3 class="text-xl font-semibold mt-6 mb-2">Eligibility</h3>
|
|
||||||
<section>
|
|
||||||
<p class="mb-2">By using this service, you confirm that you are at least 13 years old or have the consent of a parent or guardian.</p>
|
|
||||||
</section>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<li>
|
|
||||||
<h3 class="text-xl font-semibold mt-6 mb-2">Liability</h3>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<p class="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
|
|
||||||
on the site. You use the site at your own risk.
|
|
||||||
</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
|
|
||||||
unauthorized access.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<h3 class="text-xl font-semibold mt-6 mb-2">DMCA & Copyright</h3>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<p class="mb-2">
|
|
||||||
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" class="text-blue-700"> hello@trafficlunar.net </a>{" "}
|
|
||||||
or by reporting the Mii on its page.
|
|
||||||
</p>
|
|
||||||
<p class="mb-2">Please include:</p>
|
|
||||||
<ul class="list-disc list-inside indent-4">
|
|
||||||
<li>Your name and contact information</li>
|
|
||||||
<li>A description of the copyrighted work</li>
|
|
||||||
<li>A link to the allegedly infringing material</li>
|
|
||||||
<li>A statement that you have a good faith belief that the use is not authorized</li>
|
|
||||||
<li>
|
|
||||||
A statement that the information in the notice is accurate and, under penalty of perjury, that you are authorized to act on behalf of the
|
|
||||||
copyright owner
|
|
||||||
</li>
|
|
||||||
<li>Your electronic or physical signature</li>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<h3 class="text-xl font-semibold mt-6 mb-2">Nintendo Disclaimer</h3>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<p class="mb-2">
|
|
||||||
This site is not affiliated with, endorsed by, or associated with Nintendo in any way. "Mii" and all related character designs are
|
|
||||||
trademarks of Nintendo Co., Ltd.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
All Mii-related content is shared by users under the assumption that it does not violate any third-party rights. If you believe your rights have
|
|
||||||
been infringed, please see the DMCA section above.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<h3 class="text-xl font-semibold mt-6 mb-2">Changes to this Terms of Service</h3>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<p class="mb-2">
|
|
||||||
This Terms of Service may be updated from time to time. We encourage you to review the terms periodically to stay informed about the use of the
|
|
||||||
site. We may notify users via a site banner or other means if changes are made to the Terms of Service.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</Layout>
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
import { atom } from "nanostores";
|
|
||||||
|
|
||||||
interface SessionData {
|
|
||||||
user?: {
|
|
||||||
id: string;
|
|
||||||
image: string;
|
|
||||||
name: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const session = atom<SessionData | null>(null);
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
{
|
|
||||||
"extends": "astro/tsconfigs/strict",
|
|
||||||
"include": [
|
|
||||||
".astro/types.d.ts",
|
|
||||||
"**/*"
|
|
||||||
],
|
|
||||||
"exclude": [
|
|
||||||
"dist"
|
|
||||||
],
|
|
||||||
"compilerOptions": {
|
|
||||||
"jsx": "react-jsx",
|
|
||||||
"jsxImportSource": "react"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
21
next.config.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
output: "standalone",
|
||||||
|
images: {
|
||||||
|
unoptimized: true,
|
||||||
|
},
|
||||||
|
async headers() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
// Prevent Cloudflare from serving cached HTML for RSC navigation requests
|
||||||
|
source: "/:path*",
|
||||||
|
headers: [
|
||||||
|
{ key: "Vary", value: "RSC, Next-Router-State-Tree, Next-Router-Prefetch" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
65
package.json
|
|
@ -1,13 +1,56 @@
|
||||||
{
|
{
|
||||||
"name": "tomodachi-share",
|
"name": "tomodachi-share",
|
||||||
"version": "1.0.0",
|
"version": "0.1.0",
|
||||||
"description": "",
|
"private": true,
|
||||||
"main": "index.js",
|
"packageManager": "pnpm@10.33.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
"dev": "next dev",
|
||||||
},
|
"build": "next build",
|
||||||
"keywords": [],
|
"start": "next start",
|
||||||
"author": "",
|
"lint": "next lint",
|
||||||
"license": "ISC",
|
"postinstall": "prisma generate"
|
||||||
"packageManager": "pnpm@10.30.3"
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@2toad/profanity": "^3.3.0",
|
||||||
|
"@auth/prisma-adapter": "2.11.1",
|
||||||
|
"@bprogress/next": "^3.2.12",
|
||||||
|
"@hello-pangea/dnd": "^18.0.1",
|
||||||
|
"@prisma/client": "^6.19.2",
|
||||||
|
"bit-buffer": "^0.3.0",
|
||||||
|
"canvas-confetti": "^1.9.4",
|
||||||
|
"dayjs": "^1.11.20",
|
||||||
|
"downshift": "^9.3.2",
|
||||||
|
"embla-carousel-react": "^8.6.0",
|
||||||
|
"file-type": "^22.0.1",
|
||||||
|
"jsqr": "^1.4.0",
|
||||||
|
"next": "16.2.3",
|
||||||
|
"next-auth": "5.0.0-beta.30",
|
||||||
|
"qrcode-generator": "^2.0.4",
|
||||||
|
"react": "^19.2.5",
|
||||||
|
"react-dom": "^19.2.5",
|
||||||
|
"react-dropzone": "^15.0.0",
|
||||||
|
"react-image-crop": "^11.0.10",
|
||||||
|
"redis": "^5.11.0",
|
||||||
|
"satori": "^0.26.0",
|
||||||
|
"sharp": "^0.34.5",
|
||||||
|
"sjcl-with-all": "1.0.8",
|
||||||
|
"swr": "^2.4.1",
|
||||||
|
"zod": "^4.3.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/eslintrc": "^3.3.5",
|
||||||
|
"@iconify/react": "^6.0.2",
|
||||||
|
"@tailwindcss/postcss": "^4.2.2",
|
||||||
|
"@types/canvas-confetti": "^1.9.0",
|
||||||
|
"@types/node": "^25.6.0",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@types/sjcl": "^1.0.34",
|
||||||
|
"eslint": "^10.2.0",
|
||||||
|
"eslint-config-next": "16.2.3",
|
||||||
|
"prisma": "^6.19.2",
|
||||||
|
"schema-dts": "^2.0.0",
|
||||||
|
"tailwindcss": "^4.2.2",
|
||||||
|
"typescript": "^6.0.2"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
6951
pnpm-lock.yaml
|
|
@ -1,4 +0,0 @@
|
||||||
packages:
|
|
||||||
- "backend"
|
|
||||||
- "frontend"
|
|
||||||
- "shared"
|
|
||||||
2
prisma/migrations/20260416234406_pr_28/migration.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "miis_in_queue_quarantined_createdAt_idx" ON "miis"("in_queue", "quarantined", "createdAt" DESC);
|
||||||
|
|
@ -104,6 +104,7 @@ model Mii {
|
||||||
@@index([gender])
|
@@index([gender])
|
||||||
@@index([makeup])
|
@@index([makeup])
|
||||||
@@index([quarantined, id])
|
@@index([quarantined, id])
|
||||||
|
@@index([in_queue, quarantined, createdAt(sort: Desc)])
|
||||||
@@map("miis")
|
@@map("miis")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 536 B After Width: | Height: | Size: 536 B |
|
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 4 KiB After Width: | Height: | Size: 4 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 645 B After Width: | Height: | Size: 645 B |
|
Before Width: | Height: | Size: 873 KiB After Width: | Height: | Size: 873 KiB |
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 228 KiB After Width: | Height: | Size: 228 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 100 KiB |