Compare commits
30 commits
79b19f4807
...
a42a4126ec
| Author | SHA1 | Date | |
|---|---|---|---|
| a42a4126ec | |||
| 3d2de94acf | |||
| aa20e931ee | |||
| 0bd2d6d565 | |||
| 50bad620ce | |||
| 94eef81b93 | |||
| 59d2b0b2c1 | |||
| 63dbaf13fa | |||
| e81f054e3a | |||
| ce1c7a667a | |||
| 97f0fda25c | |||
| 896dc40553 | |||
| 9795849830 | |||
| 3e87d263da | |||
| 12203901e9 | |||
| 87b885a2f8 | |||
| 11df9261da | |||
| 46202b22b0 | |||
| ae266d5aa0 | |||
| 2f485dfca5 | |||
| f5391d63e6 | |||
| 93f9f42e0b | |||
| efad557caf | |||
| 466d0e5925 | |||
| 41d30b3683 | |||
| 8ef2b18424 | |||
| 1d11cf3f99 | |||
| d208565a61 | |||
| 59bc1d3ace | |||
| 84144c383c |
6
.gitignore
vendored
|
|
@ -1,7 +1,7 @@
|
|||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
node_modules/
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
|
|
@ -14,8 +14,8 @@
|
|||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
.next/
|
||||
backend/out/
|
||||
certificates/
|
||||
|
||||
# production
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
# 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.
|
||||
|
||||
## Getting started
|
||||
|
|
|
|||
44
Dockerfile
|
|
@ -1,51 +1,39 @@
|
|||
FROM node:23-alpine AS base
|
||||
|
||||
FROM base AS deps
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json pnpm-lock.yaml* ./
|
||||
COPY prisma ./prisma/
|
||||
RUN apk add --no-cache libc6-compat
|
||||
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
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
COPY --from=deps /app /app
|
||||
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
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
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/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
|
||||
RUN mkdir -p /app/.next/standalone/backend/uploads && chown -R nextjs:nodejs /app/.next/standalone/backend/uploads
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
ENV PORT=3000
|
||||
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
CMD ["node", "server.js"]
|
||||
CMD ["node", ".next/standalone/backend/server.js"]
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<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>
|
||||
|
||||
<h1 align="center"><a href="https://tomodachishare.com">TomodachiShare</a></h1>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ REDIS_URL="redis://localhost:6379/0"
|
|||
|
||||
# Used for metadata, sitemaps, etc.
|
||||
NEXT_PUBLIC_BASE_URL=http://localhost:3000
|
||||
FRONTEND_URL=http://localhost:4321
|
||||
|
||||
CLOUDFLARE_ZONE_ID=XXXXXXXXXXXXXXXX
|
||||
CLOUDFLARE_API_TOKEN=XXXXXXXXXXXXXXXX
|
||||
20
backend/next.config.ts
Normal 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
|
|
@ -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
|
|
@ -104,7 +104,6 @@ model Mii {
|
|||
@@index([gender])
|
||||
@@index([makeup])
|
||||
@@index([quarantined, id])
|
||||
@@index([in_queue, quarantined, createdAt(sort: Desc)])
|
||||
@@map("miis")
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
|
@ -2,9 +2,9 @@ import { NextRequest, NextResponse } from "next/server";
|
|||
|
||||
import { auth } from "@/lib/auth";
|
||||
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();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
|
|
@ -1,13 +1,13 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { settings } from "@/lib/settings";
|
||||
import { settings } from "../../../../lib/settings";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({ success: true, value: settings.canSubmit });
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest) {
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
|
|
@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
|||
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { idSchema } from "@/lib/schemas";
|
||||
import { idSchema } from "@tomodachi-share/shared/schemas";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await auth();
|
||||
|
|
@ -5,7 +5,7 @@ import dayjs from "dayjs";
|
|||
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { idSchema } from "@/lib/schemas";
|
||||
import { idSchema } from "@tomodachi-share/shared/schemas";
|
||||
import { PunishmentType } from "@prisma/client";
|
||||
|
||||
const punishSchema = z.object({
|
||||
|
|
@ -1,13 +1,13 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { settings } from "@/lib/settings";
|
||||
import { settings } from "../../../../lib/settings";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({ success: true, value: settings.queueEnabled });
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest) {
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
|
|
@ -3,7 +3,7 @@ import { auth } from "@/lib/auth";
|
|||
import { prisma } from "@/lib/prisma";
|
||||
import { generateMetadataImage } from "@/lib/images";
|
||||
|
||||
export async function PATCH() {
|
||||
export async function POST() {
|
||||
const session = await auth();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
|
|
@ -6,7 +6,7 @@ import { auth } from "@/lib/auth";
|
|||
import { prisma } from "@/lib/prisma";
|
||||
import { RateLimit } from "@/lib/rate-limit";
|
||||
|
||||
export async function PATCH(request: NextRequest) {
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
|
|
@ -4,7 +4,7 @@ import { auth } from "@/lib/auth";
|
|||
import { prisma } from "@/lib/prisma";
|
||||
import { RateLimit } from "@/lib/rate-limit";
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session || !session.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
|
|
@ -3,10 +3,10 @@ import { profanity } from "@2toad/profanity";
|
|||
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { userNameSchema } from "@/lib/schemas";
|
||||
import { userNameSchema } from "@tomodachi-share/shared/schemas";
|
||||
import { RateLimit } from "@/lib/rate-limit";
|
||||
|
||||
export async function PATCH(request: NextRequest) {
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session || !session.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
|
|
@ -17,7 +17,7 @@ const formDataSchema = z.object({
|
|||
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();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
6
backend/src/app/api/auth/signin/[provider]/route.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -5,12 +5,12 @@ import path from "path";
|
|||
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { idSchema } from "@/lib/schemas";
|
||||
import { idSchema } from "@tomodachi-share/shared/schemas";
|
||||
import { RateLimit } from "@/lib/rate-limit";
|
||||
|
||||
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();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
|
|
@ -10,12 +10,11 @@ import { profanity } from "@2toad/profanity";
|
|||
|
||||
import { auth } from "@/lib/auth";
|
||||
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 { RateLimit } from "@/lib/rate-limit";
|
||||
import { SwitchMiiInstructions } from "@/types";
|
||||
import { minifyInstructions } from "@/lib/switch";
|
||||
import { settings } from "@/lib/settings";
|
||||
import { minifyInstructions, SwitchMiiInstructions } from "@tomodachi-share/shared";
|
||||
import { settings } from "../../../../../lib/settings";
|
||||
|
||||
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(),
|
||||
});
|
||||
|
||||
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();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
38
backend/src/app/api/mii/[id]/info/route.ts
Normal 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);
|
||||
}
|
||||
59
backend/src/app/api/mii/[id]/like/route.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
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 POST(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 });
|
||||
}
|
||||
28
backend/src/app/api/mii/has-liked/route.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
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,26 +1,15 @@
|
|||
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";
|
||||
|
||||
import { searchSchema } from "@/lib/schemas";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
import SortSelect from "./sort-select";
|
||||
import Pagination from "./pagination";
|
||||
import FilterMenu from "./filter-menu";
|
||||
import MiiGrid from "./mii-grid";
|
||||
|
||||
interface Props {
|
||||
searchParams: { [key: string]: string | string[] | undefined };
|
||||
userId?: number; // Profiles
|
||||
parentPage?: "likes" | "admin";
|
||||
}
|
||||
|
||||
export default async function MiiList({ searchParams, userId, parentPage }: Props) {
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await auth();
|
||||
const parsed = searchSchema.safeParse(searchParams);
|
||||
if (!parsed.success) return <h1>{parsed.error.issues[0].message}</h1>;
|
||||
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 } = parsed.data;
|
||||
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;
|
||||
|
|
@ -100,10 +89,12 @@ export default async function MiiList({ searchParams, userId, parentPage }: Prop
|
|||
_count: {
|
||||
select: { likedBy: true },
|
||||
},
|
||||
// Admin
|
||||
...(parentPage === "admin" && {
|
||||
description: true,
|
||||
}),
|
||||
};
|
||||
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
let totalCount: number;
|
||||
let miis: Prisma.MiiGetPayload<{ select: typeof select }>[];
|
||||
|
||||
|
|
@ -125,29 +116,16 @@ export default async function MiiList({ searchParams, userId, parentPage }: Prop
|
|||
where,
|
||||
orderBy,
|
||||
select,
|
||||
skip,
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
}),
|
||||
]);
|
||||
|
||||
const lastPage = Math.ceil(totalCount / limit);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex justify-between items-center gap-2 mb-2 max-md:flex-col">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl font-bold text-amber-900">{totalCount}</span>
|
||||
<span className="text-lg text-amber-700">{totalCount === 1 ? "Mii" : "Miis"}</span>
|
||||
</div>
|
||||
|
||||
<div className="relative flex items-center justify-end gap-2 w-full md:max-w-2/3 max-md:justify-center">
|
||||
<FilterMenu />
|
||||
<SortSelect />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MiiGrid miis={miis} userId={userId} parentPage={parentPage} />
|
||||
<Pagination lastPage={lastPage} />
|
||||
</div>
|
||||
);
|
||||
return NextResponse.json({
|
||||
miis,
|
||||
totalCount,
|
||||
lastPage,
|
||||
});
|
||||
}
|
||||
25
backend/src/app/api/profile/[id]/info/route.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@ import { auth } from "@/lib/auth";
|
|||
import { RateLimit } from "@/lib/rate-limit";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
|
|
@ -11,16 +11,14 @@ import { MiiGender, MiiMakeup, MiiPlatform } from "@prisma/client";
|
|||
|
||||
import { auth } from "@/lib/auth";
|
||||
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 { generateMetadataImage, validateImage } from "@/lib/images";
|
||||
import { convertQrCode } from "@/lib/qr-codes";
|
||||
import Mii from "@/lib/mii.js/mii";
|
||||
import { ThreeDsTomodachiLifeMii } from "@/lib/three-ds-tomodachi-life-mii";
|
||||
import Mii from "../../../../../shared/src/mii.js/mii";
|
||||
import { convertQrCode, minifyInstructions, ThreeDsTomodachiLifeMii } from "@tomodachi-share/shared";
|
||||
|
||||
import { SwitchMiiInstructions } from "@/types";
|
||||
import { minifyInstructions } from "@/lib/switch";
|
||||
import { settings } from "@/lib/settings";
|
||||
import { SwitchMiiInstructions } from "@tomodachi-share/shared";
|
||||
import { settings } from "../../../lib/settings";
|
||||
|
||||
const uploadsDirectory = path.join(process.cwd(), "uploads", "mii");
|
||||
|
||||
|
|
@ -220,8 +218,7 @@ export async function POST(request: NextRequest) {
|
|||
// Download the image of the Mii (3DS)
|
||||
if (platform === "THREE_DS") {
|
||||
const studioUrl = conversion?.mii.studioUrl({ width: 512 });
|
||||
if (!studioUrl || new URL(studioUrl).hostname !== "studio.mii.nintendo.com") throw new Error("Invalid studio URL");
|
||||
const studioResponse = await fetch(studioUrl);
|
||||
const studioResponse = await fetch(studioUrl!);
|
||||
|
||||
if (!studioResponse.ok) {
|
||||
throw new Error(`Failed to fetch Mii image ${studioResponse.status}`);
|
||||
|
|
@ -5,7 +5,7 @@ import fs from "fs/promises";
|
|||
import path from "path";
|
||||
import { z } from "zod";
|
||||
|
||||
import { idSchema } from "@/lib/schemas";
|
||||
import { idSchema } from "@tomodachi-share/shared/schemas";
|
||||
import { RateLimit } from "@/lib/rate-limit";
|
||||
import { generateMetadataImage } from "@/lib/images";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
|
@ -20,7 +20,7 @@ const searchParamsSchema = z.object({
|
|||
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const rateLimit = new RateLimit(request, 200, "/mii/image");
|
||||
const check = await rateLimit.handleByIp();
|
||||
const check = await rateLimit.handle();
|
||||
if (check) return check;
|
||||
|
||||
const { id: slugId } = await params;
|
||||
|
|
@ -107,12 +107,9 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
|||
});
|
||||
}
|
||||
|
||||
// mii, features are purged on edit; qr-code is immutable. imageN isn't purged, so keep its TTL short.
|
||||
const isStableType = imageType === "mii" || imageType === "qr-code" || imageType === "features";
|
||||
|
||||
return rateLimit.sendResponse(buffer, 200, {
|
||||
"Content-Type": "image/png",
|
||||
"X-Robots-Tag": "noindex, noimageindex, nofollow",
|
||||
"Cache-Control": isStableType ? "public, max-age=3600, stale-while-revalidate=86400" : "public, max-age=60, stale-while-revalidate=30",
|
||||
"Cache-Control": "public, max-age=60, stale-while-revalidate=30",
|
||||
});
|
||||
}
|
||||
|
|
@ -8,7 +8,7 @@ import dayjs from "dayjs";
|
|||
import { auth } from "@/lib/auth";
|
||||
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 = {
|
||||
title: "Exiled - TomodachiShare",
|
||||
|
|
@ -109,7 +109,7 @@ export default async function ExiledPage() {
|
|||
{activePunishment.type !== "PERM_EXILE" ? (
|
||||
<>
|
||||
<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
|
|
@ -0,0 +1,9 @@
|
|||
export default function IndexPage() {
|
||||
return (
|
||||
<html>
|
||||
<body>
|
||||
<p>TomodachiShare API</p>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@ import { NextRequest, NextResponse } from "next/server";
|
|||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
import { idSchema } from "@/lib/schemas";
|
||||
import { idSchema } from "@tomodachi-share/shared/schemas";
|
||||
import { RateLimit } from "@/lib/rate-limit";
|
||||
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
|
|
@ -15,6 +15,6 @@ export default async function RandomPage() {
|
|||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!randomMii) redirect("/");
|
||||
redirect(`/mii/${randomMii.id}`);
|
||||
if (!randomMii) redirect(process.env.NEXT_PUBLIC_FRONTEND_URL || "http://localhost:4321");
|
||||
redirect(`${process.env.NEXT_PUBLIC_FRONTEND_URL}/mii/${randomMii.id}`);
|
||||
}
|
||||
|
|
@ -9,15 +9,28 @@ import { prisma } from "@/lib/prisma";
|
|||
export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||
adapter: PrismaAdapter(prisma),
|
||||
providers: [Discord, Github({ issuer: "https://github.com/login/oauth" }), Google],
|
||||
pages: {
|
||||
signIn: "/login",
|
||||
trustHost: true,
|
||||
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: {
|
||||
async signIn({ user, account, profile }) {
|
||||
async signIn({ user }) {
|
||||
const blacklist = process.env.BLACKLISTED_EMAILS ? process.env.BLACKLISTED_EMAILS.split(",").map((item) => item.trim().toLowerCase()) : [];
|
||||
const email = user?.email?.toLowerCase();
|
||||
if (!email) return false;
|
||||
if (account?.provider === "google" && (profile as { email_verified?: boolean })?.email_verified === false) return false;
|
||||
if (blacklist?.some((blocked) => email.endsWith(blocked))) return false;
|
||||
return true;
|
||||
},
|
||||
|
|
@ -29,5 +42,9 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
|
|||
}
|
||||
return session;
|
||||
},
|
||||
|
||||
async redirect({ url, baseUrl }) {
|
||||
return process.env.NEXT_PUBLIC_FRONTEND_URL ?? "http://localhost:4321";
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -190,7 +190,7 @@ export async function generateMetadataImage(mii: Mii, author: string): Promise<{
|
|||
|
||||
{/* Watermark */}
|
||||
<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..? */}
|
||||
<span tw="ml-2 font-black text-xl" style={{ color: "#FF8904" }}>
|
||||
TomodachiShare
|
||||
|
|
@ -68,10 +68,9 @@ export class RateLimit {
|
|||
|
||||
return { success, limit: this.maxRequests, remaining, expires: expireAt };
|
||||
} catch (error) {
|
||||
// Fail open — don't block users when Redis is unreachable
|
||||
console.error("Rate limit check failed", error);
|
||||
return {
|
||||
success: true,
|
||||
success: false,
|
||||
limit: this.maxRequests,
|
||||
remaining: this.maxRequests,
|
||||
expires: expireAt,
|
||||
|
|
@ -107,13 +106,4 @@ export class RateLimit {
|
|||
if (!this.data.success) return this.sendResponse({ error: "Rate limit exceeded. Please try again later." }, 429);
|
||||
return;
|
||||
}
|
||||
|
||||
// IP-only variant — skips the session lookup for anonymous read paths like images
|
||||
async handleByIp(): Promise<NextResponse<object | unknown> | undefined> {
|
||||
const ip = this.request.headers.get("CF-Connecting-IP") || this.request.headers.get("X-Forwarded-For")?.split(",")[0] || "anonymous";
|
||||
this.data = await this.check(ip);
|
||||
|
||||
if (!this.data.success) return this.sendResponse({ error: "Rate limit exceeded. Please try again later." }, 429);
|
||||
return;
|
||||
}
|
||||
}
|
||||
2
backend/src/types.d.ts
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export type { User, Mii, Punishment, Prisma } from "@prisma/client";
|
||||
export { MiiPlatform, MiiGender, MiiMakeup, ReportReason } from "@prisma/client";
|
||||
|
|
@ -23,6 +23,20 @@
|
|||
"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"]
|
||||
}
|
||||
24
frontend/.gitignore
vendored
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 364 KiB After Width: | Height: | Size: 364 KiB |
1
frontend/public/favicon.svg
Normal 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 |
|
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 |
1
frontend/public/logo.svg
Normal 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 |
|
Before Width: | Height: | Size: 645 B After Width: | Height: | Size: 645 B |
|
Before Width: | Height: | Size: 873 KiB After Width: | Height: | Size: 873 KiB |
13
frontend/public/robots.txt
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
User-Agent: *
|
||||
Allow: /
|
||||
Disallow: /*?*page=
|
||||
Disallow: /profile*?*tags=
|
||||
Disallow: /edit/*
|
||||
Disallow: /profile/settings
|
||||
Disallow: /random
|
||||
Disallow: /submit
|
||||
Disallow: /report/mii/*
|
||||
Disallow: /report/user/*
|
||||
Disallow: /admin
|
||||
|
||||
Sitemap: https://api.tomodachishare.com/sitemap.xml
|
||||
|
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 |