Compare commits

...

30 commits

Author SHA1 Message Date
a42a4126ec fix: image crop broken (#37) 2026-04-19 16:43:46 +01:00
3d2de94acf fix: broken out page 2026-04-19 11:40:50 +01:00
aa20e931ee feat: add back likes and admin banners 2026-04-19 00:45:24 +01:00
0bd2d6d565 fix: better admin queue 2026-04-19 00:10:26 +01:00
50bad620ce
fix: 404 on profiles when not logged in 2026-04-18 20:27:49 +01:00
94eef81b93 fix: logo broken in metadata images, rename methods 2026-04-18 18:38:46 +01:00
59d2b0b2c1 fix: build errors 2026-04-18 18:28:49 +01:00
63dbaf13fa feat: add back edit page and fix profile settings 2026-04-18 18:25:10 +01:00
e81f054e3a fix: admin page issues 2026-04-18 01:09:53 +01:00
ce1c7a667a feat: primative admin page 2026-04-18 00:48:24 +01:00
97f0fda25c feat: reports 2026-04-17 20:15:02 +01:00
896dc40553 feat: show miis on profiles
and other changes
2026-04-17 19:51:17 +01:00
9795849830 fix: prevent people from going to login and submit without sufficient session
also fix build errors for the 1000th time
2026-04-17 18:34:56 +01:00
3e87d263da fix: build errors 2026-04-17 18:27:41 +01:00
12203901e9 feat: use react-router for links and redirects 2026-04-17 18:25:33 +01:00
87b885a2f8 fix: build errors 2026-04-17 17:24:15 +01:00
11df9261da fix: login issues 2026-04-17 17:20:51 +01:00
46202b22b0 fix: react error 482 2026-04-17 17:04:15 +01:00
ae266d5aa0 fix: idek anymore 2026-04-17 16:55:57 +01:00
2f485dfca5 fix: f5391d63 part 2 2026-04-17 16:50:44 +01:00
f5391d63e6 fix: react build errors 2026-04-17 16:41:40 +01:00
93f9f42e0b fix: build errors 2026-04-17 16:03:24 +01:00
efad557caf fix: build errors 2026-04-17 14:51:59 +01:00
466d0e5925 fix: 41d30b36 part 2 2026-04-17 14:48:14 +01:00
41d30b3683 fix: update dockerfile 2026-04-17 14:44:57 +01:00
8ef2b18424 fix: build errors 2026-04-17 14:35:44 +01:00
1d11cf3f99 feat: vite test 2026-04-17 14:24:40 +01:00
d208565a61 fix: build errors 2026-04-16 22:54:35 +01:00
59bc1d3ace fix: prerender mii and profile pages 2026-04-16 22:39:12 +01:00
84144c383c feat: astro test 2026-04-16 22:32:08 +01:00
270 changed files with 12529 additions and 6320 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

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

View file

@ -1,51 +1,39 @@
FROM node:23-alpine AS base FROM node:23-alpine AS base
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app WORKDIR /app
COPY package.json pnpm-lock.yaml* ./ RUN apk add --no-cache libc6-compat
COPY prisma ./prisma/ RUN corepack enable && corepack prepare pnpm@latest --activate
RUN corepack enable pnpm && pnpm i --frozen-lockfile FROM base AS deps
WORKDIR /app
COPY . .
RUN pnpm install --frozen-lockfile
# Rebuild the source code only when needed
FROM base AS builder FROM base AS builder
WORKDIR /app WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app /app
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
RUN cd backend && pnpm build
RUN corepack enable pnpm && pnpm prisma generate
RUN pnpm prisma migrate deploy
RUN pnpm run build
# Production image, copy all the files and run next
FROM base AS runner FROM base AS runner
WORKDIR /app WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
RUN addgroup --system --gid 1001 nodejs RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public COPY --from=builder /app/backend/public ./public
COPY --from=builder /app/backend/.next ./.next
COPY --from=builder --chown=nextjs:nodejs /app/backend/prisma ./prisma
# Create the uploads directory and set ownership RUN mkdir -p /app/.next/standalone/backend/uploads && chown -R nextjs:nodejs /app/.next/standalone/backend/uploads
RUN mkdir -p /app/uploads && chown -R nextjs:nodejs /app/uploads
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma
USER nextjs USER nextjs
EXPOSE 3000 EXPOSE 3000
ENV PORT=3000 CMD ["node", ".next/standalone/backend/server.js"]
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

View file

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

View file

@ -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

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

@ -0,0 +1,20 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
async headers() {
return [
{
source: "/api/:path*",
headers: [
{ key: "Access-Control-Allow-Origin", value: process.env.NEXT_PUBLIC_FRONTEND_URL || "http://localhost:4321" },
{ key: "Access-Control-Allow-Credentials", value: "true" },
{ key: "Access-Control-Allow-Methods", value: "GET,POST,DELETE,OPTIONS" },
{ key: "Access-Control-Allow-Headers", value: "Content-Type" },
],
},
];
},
};
export default nextConfig;

52
backend/package.json Normal file
View file

@ -0,0 +1,52 @@
{
"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",
"@prisma/client": "^6.19.2",
"bit-buffer": "^0.3.0",
"dayjs": "^1.11.20",
"downshift": "^9.3.2",
"file-type": "^22.0.1",
"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",
"redis": "^5.11.0",
"satori": "^0.26.0",
"sharp": "^0.34.5",
"sjcl-with-all": "1.0.8",
"zod": "^4.3.6",
"@tomodachi-share/shared": "workspace:*"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.5",
"@iconify/react": "^6.0.2",
"@tailwindcss/postcss": "^4.2.2",
"@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"
},
"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,9 +2,9 @@ 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 POST(request: NextRequest) {
const session = await auth(); const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });

View file

@ -1,13 +1,13 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { settings } from "@/lib/settings"; import { settings } from "../../../../lib/settings";
export async function GET() { export async function GET() {
return NextResponse.json({ success: true, value: settings.canSubmit }); return NextResponse.json({ success: true, value: settings.canSubmit });
} }
export async function PATCH(request: NextRequest) { export async function POST(request: NextRequest) {
const session = await auth(); const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });

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

@ -1,13 +1,13 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { settings } from "@/lib/settings"; import { settings } from "../../../../lib/settings";
export async function GET() { export async function GET() {
return NextResponse.json({ success: true, value: settings.queueEnabled }); return NextResponse.json({ success: true, value: settings.queueEnabled });
} }
export async function PATCH(request: NextRequest) { export async function POST(request: NextRequest) {
const session = await auth(); const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });

View file

@ -3,7 +3,7 @@ import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { generateMetadataImage } from "@/lib/images"; import { generateMetadataImage } from "@/lib/images";
export async function PATCH() { export async function POST() {
const session = await auth(); const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });

View file

@ -6,7 +6,7 @@ import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { RateLimit } from "@/lib/rate-limit"; import { RateLimit } from "@/lib/rate-limit";
export async function PATCH(request: NextRequest) { export async function POST(request: NextRequest) {
const session = await auth(); const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });

View file

@ -4,7 +4,7 @@ import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { RateLimit } from "@/lib/rate-limit"; import { RateLimit } from "@/lib/rate-limit";
export async function DELETE(request: NextRequest) { export async function POST(request: NextRequest) {
const session = await auth(); const session = await auth();
if (!session || !session.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); if (!session || !session.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });

View file

@ -3,10 +3,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 { 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 POST(request: NextRequest) {
const session = await auth(); const session = await auth();
if (!session || !session.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); if (!session || !session.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });

View file

@ -17,7 +17,7 @@ const formDataSchema = z.object({
image: z.union([z.instanceof(File), z.any()]).optional(), image: z.union([z.instanceof(File), z.any()]).optional(),
}); });
export async function PATCH(request: NextRequest) { export async function POST(request: NextRequest) {
const session = await auth(); const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });

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,12 +5,12 @@ 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");
export async function DELETE(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await auth(); const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });

View file

@ -10,12 +10,11 @@ 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");
@ -42,7 +41,7 @@ const editSchema = z.object({
image3: z.union([z.instanceof(File), z.any()]).optional(), image3: z.union([z.instanceof(File), z.any()]).optional(),
}); });
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await auth(); const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });

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,10 +2,10 @@ 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 POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await auth(); const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });

View file

@ -0,0 +1,131 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { auth } from "@/lib/auth";
import { searchSchema } from "@tomodachi-share/shared/schemas";
import { Prisma } from "@prisma/client";
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, 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 },
},
// Admin
...(parentPage === "admin" && {
description: true,
}),
};
let totalCount: number;
let miis: Prisma.MiiGetPayload<{ select: typeof select }>[];
// Sorting by likes, newest, or oldest
let orderBy: Prisma.MiiOrderByWithRelationInput[];
if (sort === "likes") {
orderBy = [{ likedBy: { _count: "desc" } }, { name: "asc" }];
} else if (sort === "oldest") {
orderBy = [{ createdAt: "asc" }, { name: "asc" }];
} else {
// default to newest
orderBy = [{ createdAt: "desc" }, { name: "asc" }];
}
[totalCount, miis] = await Promise.all([
prisma.mii.count({ where: { ...where, userId } }),
prisma.mii.findMany({
where,
orderBy,
select,
skip: (page - 1) * limit,
take: limit,
}),
]);
const lastPage = Math.ceil(totalCount / limit);
return NextResponse.json({
miis,
totalCount,
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

@ -4,7 +4,7 @@ import { auth } from "@/lib/auth";
import { RateLimit } from "@/lib/rate-limit"; import { RateLimit } from "@/lib/rate-limit";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
export async function DELETE(request: NextRequest) { export async function POST(request: NextRequest) {
const session = await auth(); const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });

View file

@ -11,16 +11,14 @@ 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

@ -9,8 +9,22 @@ 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,
domain: process.env.NODE_ENV === "production" ? ".tomodachishare.com" : "localhost",
},
},
},
session: {
strategy: "database",
maxAge: 30 * 24 * 60 * 60,
}, },
callbacks: { callbacks: {
async signIn({ user }) { async signIn({ user }) {
@ -28,5 +42,9 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
} }
return session; return session;
}, },
async redirect({ url, baseUrl }) {
return process.env.NEXT_PUBLIC_FRONTEND_URL ?? "http://localhost:4321";
},
}, },
}); });

View file

@ -190,7 +190,7 @@ export async function generateMetadataImage(mii: Mii, author: string): Promise<{
{/* Watermark */} {/* Watermark */}
<div tw="absolute bottom-0 right-0 flex items-center"> <div tw="absolute bottom-0 right-0 flex items-center">
<img src={`${process.env.NEXT_PUBLIC_BASE_URL}/logo.svg`} height={32} /> <img src={`${process.env.NEXT_PUBLIC_FRONTEND_URL}/logo.svg`} height={32} />
{/* I tried using text-orange-400 but it wasn't correct..? */} {/* I tried using text-orange-400 but it wasn't correct..? */}
<span tw="ml-2 font-black text-xl" style={{ color: "#FF8904" }}> <span tw="ml-2 font-black text-xl" style={{ color: "#FF8904" }}>
TomodachiShare TomodachiShare

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 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

73
frontend/README.md Normal file
View file

@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

23
frontend/eslint.config.js Normal file
View file

@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

63
frontend/index.html Normal file
View file

@ -0,0 +1,63 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TomodachiShare</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" />
<!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<!-- Open Graph -->
<meta property="og:type" content="website" />
<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:url" content="https://tomodachishare.com" />
<meta property="og:site_name" content="TomodachiShare" />
<meta property="og:locale" content="en_US" />
<!-- 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" />
<script type="application/ld+json">
{
"@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"
}
}
</script>
<link href="/src/index.css" rel="stylesheet" />
<script defer src="https://analytics.trafficlunar.net/script.js" data-website-id="bc530384-9b7d-471a-b2e3-f9859da50c24"></script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

52
frontend/package.json Normal file
View file

@ -0,0 +1,52 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@bprogress/react": "^1.2.7",
"@fontsource-variable/lexend": "^5.2.11",
"@hello-pangea/dnd": "^18.0.1",
"@nanostores/react": "^1.1.0",
"@tailwindcss/vite": "^4.2.2",
"@tomodachi-share/shared": "workspace:*",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"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.4",
"react-dom": "^19.2.4",
"react-dropzone": "^15.0.0",
"react-image-crop": "^11.0.10",
"react-router": "^7.14.1",
"tailwindcss": "^4.2.2",
"zod": "^4.3.6"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@iconify/react": "^6.0.2",
"@types/canvas-confetti": "^1.9.0",
"@types/node": "^24.12.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"typescript": "~6.0.2",
"typescript-eslint": "^8.58.0",
"vite": "^8.0.4"
}
}

View file

Before

Width:  |  Height:  |  Size: 364 KiB

After

Width:  |  Height:  |  Size: 364 KiB

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

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

View file

Before

Width:  |  Height:  |  Size: 645 B

After

Width:  |  Height:  |  Size: 645 B

View file

Before

Width:  |  Height:  |  Size: 873 KiB

After

Width:  |  Height:  |  Size: 873 KiB

View file

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

View file

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 86 KiB

View file

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 106 KiB

View file

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 118 KiB

View file

Before

Width:  |  Height:  |  Size: 228 KiB

After

Width:  |  Height:  |  Size: 228 KiB

View file

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 85 KiB

View file

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 76 KiB

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