feat: astro test

This commit is contained in:
trafficlunar 2026-04-16 22:32:08 +01:00
parent df6e31ba89
commit 84144c383c
262 changed files with 18993 additions and 2655 deletions

6
.gitignore vendored
View file

@ -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 @@
/coverage /coverage
# next.js # next.js
/.next/ .next/
/out/ backend/out/
certificates/ certificates/
# production # production

View file

@ -5,6 +5,7 @@ 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

22
backend/next.config.ts Normal file
View file

@ -0,0 +1,22 @@
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;

63
backend/package.json Normal file
View file

@ -0,0 +1,63 @@
{
"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"
}

5082
backend/pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load diff

View file

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { idSchema } from "@/lib/schemas"; import { idSchema } from "@tomodachi-share/shared/schemas";
export async function PATCH(request: NextRequest) { export async function PATCH(request: NextRequest) {
const session = await auth(); const session = await auth();

View file

@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { idSchema } from "@/lib/schemas"; import { idSchema } from "@tomodachi-share/shared/schemas";
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const session = await auth(); const session = await auth();

View file

@ -5,7 +5,7 @@ import dayjs from "dayjs";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { idSchema } from "@/lib/schemas"; import { idSchema } from "@tomodachi-share/shared/schemas";
import { PunishmentType } from "@prisma/client"; import { PunishmentType } from "@prisma/client";
const punishSchema = z.object({ const punishSchema = z.object({

View file

@ -3,7 +3,7 @@ import { profanity } from "@2toad/profanity";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { userNameSchema } from "@/lib/schemas"; import { userNameSchema } from "@tomodachi-share/shared/schemas";
import { RateLimit } from "@/lib/rate-limit"; import { RateLimit } from "@/lib/rate-limit";
export async function PATCH(request: NextRequest) { export async function PATCH(request: NextRequest) {

View file

@ -0,0 +1,6 @@
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);
}

View file

@ -5,7 +5,7 @@ import path from "path";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { idSchema } from "@/lib/schemas"; import { idSchema } from "@tomodachi-share/shared/schemas";
import { RateLimit } from "@/lib/rate-limit"; import { RateLimit } from "@/lib/rate-limit";
const uploadsDirectory = path.join(process.cwd(), "uploads", "mii"); const uploadsDirectory = path.join(process.cwd(), "uploads", "mii");

View file

@ -10,11 +10,10 @@ import { profanity } from "@2toad/profanity";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { idSchema, nameSchema, switchMiiInstructionsSchema, tagsSchema } from "@/lib/schemas"; import { idSchema, nameSchema, switchMiiInstructionsSchema, tagsSchema } from "@tomodachi-share/shared/schemas";
import { generateMetadataImage, validateImage } from "@/lib/images"; import { generateMetadataImage, validateImage } from "@/lib/images";
import { RateLimit } from "@/lib/rate-limit"; import { RateLimit } from "@/lib/rate-limit";
import { SwitchMiiInstructions } from "@/types"; import { minifyInstructions, SwitchMiiInstructions } from "@tomodachi-share/shared";
import { minifyInstructions } from "@/lib/switch";
import { settings } from "@/lib/settings"; import { settings } from "@/lib/settings";
const uploadsDirectory = path.join(process.cwd(), "uploads", "mii"); const uploadsDirectory = path.join(process.cwd(), "uploads", "mii");

View file

@ -0,0 +1,38 @@
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);
}

View file

@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { idSchema } from "@/lib/schemas"; import { idSchema } from "@tomodachi-share/shared/schemas";
import { RateLimit } from "@/lib/rate-limit"; import { RateLimit } from "@/lib/rate-limit";
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {

View file

@ -0,0 +1,168 @@
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,
});
}

View file

@ -0,0 +1,25 @@
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);
}

View file

@ -11,15 +11,13 @@ import { MiiGender, MiiMakeup, MiiPlatform } from "@prisma/client";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { nameSchema, switchMiiInstructionsSchema, tagsSchema } from "@/lib/schemas"; import { nameSchema, switchMiiInstructionsSchema, tagsSchema } from "@tomodachi-share/shared/schemas";
import { RateLimit } from "@/lib/rate-limit"; import { RateLimit } from "@/lib/rate-limit";
import { generateMetadataImage, validateImage } from "@/lib/images"; import { generateMetadataImage, validateImage } from "@/lib/images";
import { convertQrCode } from "@/lib/qr-codes"; import Mii from "../../../../../shared/src/mii.js/mii";
import Mii from "@/lib/mii.js/mii"; import { convertQrCode, minifyInstructions, ThreeDsTomodachiLifeMii } from "@tomodachi-share/shared";
import { ThreeDsTomodachiLifeMii } from "@/lib/three-ds-tomodachi-life-mii";
import { SwitchMiiInstructions } from "@/types"; import { SwitchMiiInstructions } from "@tomodachi-share/shared";
import { minifyInstructions } from "@/lib/switch";
import { settings } from "@/lib/settings"; import { settings } from "@/lib/settings";
const uploadsDirectory = path.join(process.cwd(), "uploads", "mii"); const uploadsDirectory = path.join(process.cwd(), "uploads", "mii");

View file

@ -5,7 +5,7 @@ import fs from "fs/promises";
import path from "path"; import path from "path";
import { z } from "zod"; import { z } from "zod";
import { idSchema } from "@/lib/schemas"; import { idSchema } from "@tomodachi-share/shared/schemas";
import { RateLimit } from "@/lib/rate-limit"; import { RateLimit } from "@/lib/rate-limit";
import { generateMetadataImage } from "@/lib/images"; import { generateMetadataImage } from "@/lib/images";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";

View file

@ -8,7 +8,7 @@ import dayjs from "dayjs";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import ReturnToIsland from "@/components/admin/return-to-island"; // import ReturnToIsland from "@/components/admin/return-to-island";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Exiled - TomodachiShare", title: "Exiled - TomodachiShare",
@ -109,7 +109,7 @@ export default async function ExiledPage() {
{activePunishment.type !== "PERM_EXILE" ? ( {activePunishment.type !== "PERM_EXILE" ? (
<> <>
<p className="mb-2">Once your punishment ends, you can return by checking the box below.</p> <p className="mb-2">Once your punishment ends, you can return by checking the box below.</p>
<ReturnToIsland hasExpired={hasExpired} /> {/* <ReturnToIsland hasExpired={hasExpired} /> */}
</> </>
) : ( ) : (
<> <>

9
backend/src/app/page.tsx Normal file
View file

@ -0,0 +1,9 @@
export default function IndexPage() {
return (
<html>
<body>
<p>TomodachiShare API</p>
</body>
</html>
);
}

View file

@ -3,7 +3,7 @@ import { NextRequest, NextResponse } from "next/server";
import fs from "fs/promises"; import fs from "fs/promises";
import path from "path"; import path from "path";
import { idSchema } from "@/lib/schemas"; import { idSchema } from "@tomodachi-share/shared/schemas";
import { RateLimit } from "@/lib/rate-limit"; import { RateLimit } from "@/lib/rate-limit";
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {

View file

@ -15,6 +15,6 @@ export default async function RandomPage() {
select: { id: true }, select: { id: true },
}); });
if (!randomMii) redirect("/"); if (!randomMii) redirect(process.env.NEXT_PUBLIC_FRONTEND_URL || "http://localhost:4321");
redirect(`/mii/${randomMii.id}`); redirect(`${process.env.NEXT_PUBLIC_FRONTEND_URL}/mii/${randomMii.id}`);
} }

View file

@ -1,14 +1,8 @@
"use client";
import Image from "next/image";
import { redirect } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import { Mii, ReportReason } from "@prisma/client"; import { type Mii, ReportReason } from "@tomodachi-share/backend";
import ReasonSelector from "./reason-selector"; import ReasonSelector from "./reason-selector";
import SubmitButton from "../submit-button"; import SubmitButton from "../submit-button";
import LikeButton from "../like-button";
interface Props { interface Props {
mii: Mii; mii: Mii;
@ -32,7 +26,8 @@ export default function ReportMiiForm({ mii, likes }: Props) {
return; return;
} }
redirect(`/`); // redirect(`/`);
window.location.href = "https://tomodachishare.com";
}; };
return ( return (
@ -45,10 +40,10 @@ export default function ReportMiiForm({ mii, likes }: Props) {
<hr className="border-zinc-300" /> <hr className="border-zinc-300" />
<div className="bg-orange-100 rounded-xl border-2 border-orange-400 flex"> <div className="bg-orange-100 rounded-xl border-2 border-orange-400 flex">
<Image src={`/mii/${mii.id}/image?type=mii`} alt="mii image" width={128} height={128} /> <img src={`/mii/${mii.id}/image?type=mii`} alt="mii image" width={128} height={128} />
<div className="p-4"> <div className="p-4">
<p className="text-xl font-bold line-clamp-1">{mii.name}</p> <p className="text-xl font-bold line-clamp-1">{mii.name}</p>
<LikeButton likes={likes} isLiked={true} disabled /> {/* <LikeButton likes={likes} isLiked={true} disabled /> */}
</div> </div>
</div> </div>

View file

@ -1,5 +1,3 @@
"use client";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import { ReportReason } from "@prisma/client"; import { ReportReason } from "@prisma/client";
import { useSelect } from "downshift"; import { useSelect } from "downshift";

View file

@ -1,13 +1,9 @@
"use client";
import { redirect } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import { ReportReason, User } from "@prisma/client";
import ProfilePicture from "../profile-picture";
import ReasonSelector from "./reason-selector"; import ReasonSelector from "./reason-selector";
import SubmitButton from "../submit-button"; import SubmitButton from "../submit-button";
import { ReportReason } from "@prisma/client";
import { User } from "next-auth";
interface Props { interface Props {
user: User; user: User;
@ -30,7 +26,7 @@ export default function ReportUserForm({ user }: Props) {
return; return;
} }
redirect(`/`); window.location.href = "https://tomodachishare.com";
}; };
return ( return (
@ -43,7 +39,7 @@ export default function ReportUserForm({ user }: Props) {
<hr className="border-zinc-300" /> <hr className="border-zinc-300" />
<div className="bg-orange-100 rounded-xl border-2 border-orange-400 flex p-4 gap-4"> <div className="bg-orange-100 rounded-xl border-2 border-orange-400 flex p-4 gap-4">
<ProfilePicture src={user.image ?? "/guest.png"} width={96} height={96} className="aspect-square rounded-full border-2 border-orange-400" /> <image src={user.image ?? "/guest.png"} width={96} height={96} className="aspect-square rounded-full border-2 border-orange-400" />
<p className="text-xl font-bold overflow-hidden text-ellipsis">{user.name}</p> <p className="text-xl font-bold overflow-hidden text-ellipsis">{user.name}</p>
</div> </div>

View file

@ -1,5 +1,3 @@
"use client";
import { useState } from "react"; import { useState } from "react";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";

View file

@ -9,8 +9,21 @@ import { prisma } from "@/lib/prisma";
export const { handlers, signIn, signOut, auth } = NextAuth({ export const { handlers, signIn, signOut, auth } = NextAuth({
adapter: PrismaAdapter(prisma), adapter: PrismaAdapter(prisma),
providers: [Discord, Github({ issuer: "https://github.com/login/oauth" }), Google], providers: [Discord, Github({ issuer: "https://github.com/login/oauth" }), Google],
pages: { trustHost: true,
signIn: "/login", cookies: {
sessionToken: {
name: process.env.NODE_ENV === "production" ? "__Secure-next-auth.session-token" : "next-auth.session-token",
options: {
httpOnly: true,
sameSite: "none",
path: "/",
secure: true,
},
},
},
session: {
strategy: "database",
maxAge: 30 * 24 * 60 * 60,
}, },
callbacks: { callbacks: {
async signIn({ user }) { async signIn({ user }) {
@ -28,5 +41,9 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
} }
return session; return session;
}, },
async redirect({ url, baseUrl }) {
return process.env.FRONTEND_URL ?? "http://localhost:4321";
},
}, },
}); });

18
backend/src/lib/utils.ts Normal file
View file

@ -0,0 +1,18 @@
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 Normal file
View file

@ -0,0 +1,2 @@
export type { User, Mii, Punishment, Prisma } from "@prisma/client";
export { MiiPlatform, MiiGender, MiiMakeup, ReportReason } from "@prisma/client";

View file

@ -23,6 +23,20 @@
"sjcl-with-all": ["./node_modules/@types/sjcl"] "sjcl-with-all": ["./node_modules/@types/sjcl"]
} }
}, },
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"], "include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"../shared/src/constants.ts",
"../frontend/src/lib/abbreviation.ts",
"../shared/src/qr-codes.ts",
"../shared/src/three-ds-tomodachi-life-mii.ts",
"../shared/src/types.d.ts",
"../shared/src/switch.ts",
"../frontend/src/components/provider.tsx",
"../shared/src/schemas.ts"
],
"exclude": ["node_modules"] "exclude": ["node_modules"]
} }

24
frontend/.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# 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 Normal file
View file

@ -0,0 +1,4 @@
{
"recommendations": ["astro-build.astro-vscode"],
"unwantedRecommendations": []
}

11
frontend/.vscode/launch.json vendored Normal file
View file

@ -0,0 +1,11 @@
{
"version": "0.2.0",
"configurations": [
{
"command": "./node_modules/.bin/astro dev",
"name": "Development server",
"request": "launch",
"type": "node-terminal"
}
]
}

43
frontend/README.md Normal file
View file

@ -0,0 +1,43 @@
# 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).

30
frontend/astro.config.mjs Normal file
View file

@ -0,0 +1,30 @@
// @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",
},
],
});

51
frontend/package.json Normal file
View file

@ -0,0 +1,51 @@
{
"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/backend": "workspace:*",
"@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"
}
}

4479
frontend/pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load diff

View file

Before

Width:  |  Height:  |  Size: 364 KiB

After

Width:  |  Height:  |  Size: 364 KiB

View file

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

View file

Before

Width:  |  Height:  |  Size: 536 B

After

Width:  |  Height:  |  Size: 536 B

View file

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

View file

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 7.1 KiB

View file

Before

Width:  |  Height:  |  Size: 4 KiB

After

Width:  |  Height:  |  Size: 4 KiB

1
frontend/public/logo.svg Normal file
View file

@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 1.3 KiB

Some files were not shown because too many files have changed in this diff Show more