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
272 changed files with 12518 additions and 6239 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

@ -104,7 +104,6 @@ model Mii {
@@index([gender]) @@index([gender])
@@index([makeup]) @@index([makeup])
@@index([quarantined, id]) @@index([quarantined, id])
@@index([in_queue, quarantined, createdAt(sort: Desc)])
@@map("miis") @@map("miis")
} }

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

@ -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 });
}

View 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));
}

View file

@ -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 { Prisma } from "@prisma/client";
import { searchSchema } from "@/lib/schemas"; export async function GET(request: NextRequest) {
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) {
const session = await auth(); const session = await auth();
const parsed = searchSchema.safeParse(searchParams); const parsed = searchSchema.safeParse(Object.fromEntries(request.nextUrl.searchParams));
if (!parsed.success) return <h1>{parsed.error.issues[0].message}</h1>; 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 // My Likes page
let miiIdsLiked: number[] | undefined = undefined; let miiIdsLiked: number[] | undefined = undefined;
@ -100,10 +89,12 @@ export default async function MiiList({ searchParams, userId, parentPage }: Prop
_count: { _count: {
select: { likedBy: true }, select: { likedBy: true },
}, },
// Admin
...(parentPage === "admin" && {
description: true,
}),
}; };
const skip = (page - 1) * limit;
let totalCount: number; let totalCount: number;
let miis: Prisma.MiiGetPayload<{ select: typeof select }>[]; let miis: Prisma.MiiGetPayload<{ select: typeof select }>[];
@ -125,29 +116,16 @@ export default async function MiiList({ searchParams, userId, parentPage }: Prop
where, where,
orderBy, orderBy,
select, select,
skip, skip: (page - 1) * limit,
take: limit, take: limit,
}), }),
]); ]);
const lastPage = Math.ceil(totalCount / limit); const lastPage = Math.ceil(totalCount / limit);
return ( return NextResponse.json({
<div className="w-full"> miis,
<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"> totalCount,
<div className="flex items-center gap-2"> lastPage,
<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>
);
} }

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");
@ -220,8 +218,7 @@ export async function POST(request: NextRequest) {
// Download the image of the Mii (3DS) // Download the image of the Mii (3DS)
if (platform === "THREE_DS") { if (platform === "THREE_DS") {
const studioUrl = conversion?.mii.studioUrl({ width: 512 }); 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) { if (!studioResponse.ok) {
throw new Error(`Failed to fetch Mii image ${studioResponse.status}`); throw new Error(`Failed to fetch Mii image ${studioResponse.status}`);

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";
@ -20,7 +20,7 @@ const searchParamsSchema = z.object({
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const rateLimit = new RateLimit(request, 200, "/mii/image"); const rateLimit = new RateLimit(request, 200, "/mii/image");
const check = await rateLimit.handleByIp(); const check = await rateLimit.handle();
if (check) return check; if (check) return check;
const { id: slugId } = await params; 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, { return rateLimit.sendResponse(buffer, 200, {
"Content-Type": "image/png", "Content-Type": "image/png",
"X-Robots-Tag": "noindex, noimageindex, nofollow", "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",
}); });
} }

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,15 +9,28 @@ 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, account, profile }) { async signIn({ user }) {
const blacklist = process.env.BLACKLISTED_EMAILS ? process.env.BLACKLISTED_EMAILS.split(",").map((item) => item.trim().toLowerCase()) : []; const blacklist = process.env.BLACKLISTED_EMAILS ? process.env.BLACKLISTED_EMAILS.split(",").map((item) => item.trim().toLowerCase()) : [];
const email = user?.email?.toLowerCase(); const email = user?.email?.toLowerCase();
if (!email) return false; 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; if (blacklist?.some((blocked) => email.endsWith(blocked))) return false;
return true; return true;
}, },
@ -29,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

View file

@ -68,10 +68,9 @@ export class RateLimit {
return { success, limit: this.maxRequests, remaining, expires: expireAt }; return { success, limit: this.maxRequests, remaining, expires: expireAt };
} catch (error) { } catch (error) {
// Fail open — don't block users when Redis is unreachable
console.error("Rate limit check failed", error); console.error("Rate limit check failed", error);
return { return {
success: true, success: false,
limit: this.maxRequests, limit: this.maxRequests,
remaining: this.maxRequests, remaining: this.maxRequests,
expires: expireAt, 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); if (!this.data.success) return this.sendResponse({ error: "Rate limit exceeded. Please try again later." }, 429);
return; 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
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