Compare commits

..

7 commits

Author SHA1 Message Date
7f52773bd9 fix: build errors AGAIN
aefusuhgurg
2026-04-17 01:19:21 +01:00
0c2dcf3192 fix: build errors 2026-04-17 01:14:58 +01:00
b66fbd305a temp disable likes 2026-04-17 01:11:49 +01:00
c72dab1962 refactor: remove random sort 2026-04-17 00:54:13 +01:00
8ebc480233 feat: prisma migration 2026-04-17 00:44:26 +01:00
b00ce4dc3b
Merge pull request #28 from AlexHelo/fix/cloudflare-caching-and-query-performance
Fix Cloudflare RSC caching bug and reduce database load
2026-04-17 00:39:44 +01:00
AlexHelo
8615a4d864 Fix Cloudflare RSC caching bug and reduce database load 2026-04-16 17:28:37 -06:00
265 changed files with 2766 additions and 19158 deletions

View file

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

6
.gitignore vendored
View file

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

View file

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

View file

@ -1,63 +0,0 @@
{
"name": "@tomodachi-share/backend",
"version": "0.1.0",
"private": true,
"packageManager": "pnpm@10.33.0",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"postinstall": "prisma generate"
},
"dependencies": {
"@2toad/profanity": "^3.3.0",
"@auth/prisma-adapter": "2.11.1",
"@bprogress/next": "^3.2.12",
"@hello-pangea/dnd": "^18.0.1",
"@prisma/client": "^6.19.2",
"bit-buffer": "^0.3.0",
"canvas-confetti": "^1.9.4",
"dayjs": "^1.11.20",
"downshift": "^9.3.2",
"embla-carousel-react": "^8.6.0",
"file-type": "^22.0.1",
"jsqr": "^1.4.0",
"next": "16.2.3",
"next-auth": "5.0.0-beta.30",
"qrcode-generator": "^2.0.4",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-dropzone": "^15.0.0",
"react-image-crop": "^11.0.10",
"redis": "^5.11.0",
"satori": "^0.26.0",
"seedrandom": "^3.0.5",
"sharp": "^0.34.5",
"sjcl-with-all": "1.0.8",
"swr": "^2.4.1",
"zod": "^4.3.6"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.5",
"@iconify/react": "^6.0.2",
"@tailwindcss/postcss": "^4.2.2",
"@types/canvas-confetti": "^1.9.0",
"@types/node": "^25.6.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/seedrandom": "^3.0.8",
"@types/sjcl": "^1.0.34",
"eslint": "^10.2.0",
"eslint-config-next": "16.2.3",
"prisma": "^6.19.2",
"schema-dts": "^2.0.0",
"tailwindcss": "^4.2.2",
"typescript": "^6.0.2",
"@tomodachi-share/shared": "workspace:*"
},
"exports": {
".": "./src/types.d.ts"
},
"types": "./src/types.d.ts"
}

File diff suppressed because it is too large Load diff

View file

@ -1,6 +0,0 @@
import { type NextRequest } from "next/server";
import { signIn } from "@/lib/auth";
export async function GET(req: NextRequest, { params }: { params: Promise<{ provider: string }> }) {
return signIn((await params).provider);
}

View file

@ -1,38 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { idSchema } from "@tomodachi-share/shared/schemas";
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await auth();
const { id: slugId } = await params;
const parsed = idSchema.safeParse(slugId);
if (!parsed.success) return NextResponse.json({ error: parsed.error.issues[0].message }, { status: 400 });
const miiId = parsed.data;
const mii = await prisma.mii.findUnique({
where: {
id: miiId,
},
include: {
user: {
select: {
name: true,
},
},
likedBy: session?.user
? {
where: {
userId: Number(session.user.id),
},
select: { userId: true },
}
: false,
_count: {
select: { likedBy: true }, // Get total like count
},
},
});
return NextResponse.json(mii);
}

View file

@ -1,59 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { idSchema } from "@tomodachi-share/shared/schemas";
import { RateLimit } from "@/lib/rate-limit";
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const rateLimit = new RateLimit(request, 100, "/api/mii/like");
const check = await rateLimit.handle();
if (check) return check;
const { id: slugId } = await params;
const parsed = idSchema.safeParse(slugId);
if (!parsed.success) return rateLimit.sendResponse({ error: parsed.error.issues[0].message }, 400);
const miiId = parsed.data;
const result = await prisma.$transaction(async (tx) => {
const existingLike = await tx.like.findUnique({
where: {
userId_miiId: {
userId: Number(session.user?.id),
miiId,
},
},
});
if (existingLike) {
// Remove the like if it exists
await tx.like.delete({
where: {
userId_miiId: {
userId: Number(session.user?.id),
miiId,
},
},
});
} else {
// Add a like if it doesn't exist
await tx.like.create({
data: {
userId: Number(session.user?.id),
miiId,
},
});
}
const likeCount = await tx.like.count({
where: { miiId },
});
return { liked: !existingLike, count: likeCount };
});
return rateLimit.sendResponse({ success: true, liked: result.liked, count: result.count });
}

View file

@ -1,28 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { RateLimit } from "@/lib/rate-limit";
export async function GET(request: NextRequest) {
const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const rateLimit = new RateLimit(request, 50, "/api/mii/like_get");
const check = await rateLimit.handle();
if (check) return check;
const idsParam = new URL(request.url).searchParams.get("ids");
if (!idsParam) return NextResponse.json({ error: "Missing IDs parameter" }, { status: 400 });
const ids = idsParam.split(",").map(Number).filter(Boolean);
if (!ids.length) return NextResponse.json({ error: "No valid IDs provided" }, { status: 400 });
if (ids.length > 100) return NextResponse.json({ error: "Too many IDs, maximum is 100" }, { status: 400 });
const liked = await prisma.like.findMany({
where: { userId: Number(session.user?.id), miiId: { in: ids } },
select: { miiId: true },
});
// Return only Miis that are liked
return NextResponse.json(liked.map((l) => l.miiId));
}

View file

@ -1,168 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { auth } from "@/lib/auth";
import { searchSchema } from "@tomodachi-share/shared/schemas";
import { RateLimit } from "@/lib/rate-limit";
import { Prisma } from "@prisma/client";
import crypto from "crypto";
import seedrandom from "seedrandom";
export async function GET(request: NextRequest) {
const session = await auth();
const parsed = searchSchema.safeParse(Object.fromEntries(request.nextUrl.searchParams));
if (!parsed.success) return NextResponse.json({ error: parsed.error.issues[0].message }, { status: 400 });
const { q: query, sort, tags, exclude, platform, gender, makeup, allowCopying, quarantined, page = 1, limit = 24, seed, parentPage, userId } = parsed.data;
// My Likes page
let miiIdsLiked: number[] | undefined = undefined;
if (parentPage === "likes" && session?.user?.id) {
const likedMiis = await prisma.like.findMany({
where: { userId: Number(session.user.id) },
select: { miiId: true },
});
miiIdsLiked = likedMiis.map((like) => like.miiId);
}
const where: Prisma.MiiWhereInput = {
// In queue logic
...(parentPage === "admin"
? { in_queue: true } // Only show queued Miis
: userId
? {
// Include queued Miis if user is on their profile
...(Number(session?.user?.id) === userId ? {} : { in_queue: false }),
userId,
}
: {
// Don't show queued Miis on main page
in_queue: false,
}),
// Only show liked miis on likes page
...(parentPage === "likes" && miiIdsLiked && { id: { in: miiIdsLiked } }),
// Searching
...(query && {
OR: [{ name: { contains: query, mode: "insensitive" } }, { tags: { has: query } }, { description: { contains: query, mode: "insensitive" } }],
}),
// Tag filtering
...(tags && tags.length > 0 && { tags: { hasEvery: tags } }),
...(exclude && exclude.length > 0 && { NOT: { tags: { hasSome: exclude } } }),
// Platform
...(platform && { platform: { equals: platform } }),
// Gender
...(gender && { gender: { equals: gender } }),
// Allow Copying
...(allowCopying && { allowedCopying: true }),
// Makeup
...(makeup && { makeup: { equals: makeup } }),
// Quarantined
...(!quarantined && !userId && { quarantined: false }),
};
const select: Prisma.MiiSelect = {
id: true,
// Don't show when userId is specified
...(!userId && {
user: {
select: {
id: true,
name: true,
},
},
}),
platform: true,
name: true,
imageCount: true,
tags: true,
createdAt: true,
gender: true,
makeup: true,
allowedCopying: true,
quarantined: true,
in_queue: true,
// Mii liked check
...(session?.user?.id && {
likedBy: {
where: { userId: Number(session.user.id) },
select: { userId: true },
},
}),
// Like count
_count: {
select: { likedBy: true },
},
};
const skip = (page - 1) * limit;
let totalCount: number;
let filteredCount: number;
let miis: Prisma.MiiGetPayload<{ select: typeof select }>[];
if (sort === "random") {
// Get all IDs that match the where conditions
const matchingIds = await prisma.mii.findMany({
where,
select: { id: true },
});
totalCount = matchingIds.length;
filteredCount = Math.max(0, Math.min(limit, totalCount - skip));
if (matchingIds.length === 0) return;
// Use seed for consistent random results
const randomSeed = seed || crypto.randomInt(0, 1_000_000_000);
const rng = seedrandom(randomSeed.toString());
// Randomize all IDs using the Durstenfeld algorithm
for (let i = matchingIds.length - 1; i > 0; i--) {
const j = Math.floor(rng() * (i + 1));
[matchingIds[i], matchingIds[j]] = [matchingIds[j], matchingIds[i]];
}
// Convert to number[] array
const selectedIds = matchingIds.slice(skip, skip + limit).map((i) => i.id);
miis = await prisma.mii.findMany({
where: {
id: { in: selectedIds },
},
select,
});
} else {
// Sorting by likes, newest, or oldest
let orderBy: Prisma.MiiOrderByWithRelationInput[];
if (sort === "likes") {
orderBy = [{ likedBy: { _count: "desc" } }, { name: "asc" }];
} else if (sort === "oldest") {
orderBy = [{ createdAt: "asc" }, { name: "asc" }];
} else {
// default to newest
orderBy = [{ createdAt: "desc" }, { name: "asc" }];
}
[totalCount, filteredCount, miis] = await Promise.all([
prisma.mii.count({ where: { ...where } }), // TODO: User id
prisma.mii.count({ where, skip, take: limit }),
prisma.mii.findMany({
where,
orderBy,
select,
skip: (page - 1) * limit,
take: limit,
}),
]);
}
const lastPage = Math.ceil(totalCount / limit);
return NextResponse.json({
miis,
totalCount,
filteredCount,
lastPage,
});
}

View file

@ -1,25 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { idSchema } from "@tomodachi-share/shared/schemas";
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id: slugId } = await params;
const parsed = idSchema.safeParse(slugId);
if (!parsed.success) return NextResponse.json({ error: parsed.error.issues[0].message }, { status: 400 });
const userId = parsed.data;
const user = await prisma.user.findUnique({
where: {
id: userId,
},
include: {
_count: {
select: {
likes: true,
},
},
},
});
return NextResponse.json(user);
}

View file

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

View file

@ -1,31 +0,0 @@
import { useState } from "react";
import { Icon } from "@iconify/react";
interface Props {
onClick: () => void | Promise<void>;
disabled?: boolean;
text?: string;
className?: string;
}
export default function SubmitButton({ onClick, disabled = false, text = "Submit", className }: Props) {
const [isLoading, setIsLoading] = useState(false);
const handleClick = async (event: React.FormEvent) => {
event.preventDefault();
setIsLoading(true);
try {
await onClick();
} finally {
setIsLoading(false);
}
};
return (
<button type="submit" aria-label={text} onClick={handleClick} disabled={disabled} className={`pill button w-min ${className}`}>
{text}
{isLoading && <Icon icon="svg-spinners:180-ring-with-bg" className="ml-2" />}
</button>
);
}

View file

@ -1,18 +0,0 @@
export function deepMerge<T>(target: T, source: Partial<T>): T {
const output = structuredClone(target);
if (typeof source !== "object" || source === null) return output;
for (const key in source) {
const sourceValue = source[key];
const targetValue = (output as any)[key];
if (typeof sourceValue === "object" && sourceValue !== null && !Array.isArray(sourceValue)) {
(output as any)[key] = deepMerge(targetValue, sourceValue);
} else {
(output as any)[key] = sourceValue;
}
}
return output;
}

View file

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

24
frontend/.gitignore vendored
View file

@ -1,24 +0,0 @@
# build output
dist/
# generated types
.astro/
# dependencies
node_modules/
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# environment variables
.env
.env.production
# macOS-specific files
.DS_Store
# jetbrains setting folder
.idea/

View file

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

View file

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

View file

@ -1,43 +0,0 @@
# Astro Starter Kit: Minimal
```sh
pnpm create astro@latest -- --template minimal
```
> 🧑‍🚀 **Seasoned astronaut?** Delete this file. Have fun!
## 🚀 Project Structure
Inside of your Astro project, you'll see the following folders and files:
```text
/
├── public/
├── src/
│ └── pages/
│ └── index.astro
└── package.json
```
Astro looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name.
There's nothing special about `src/components/`, but that's where we like to put any Astro/React/Vue/Svelte/Preact components.
Any static assets, like images, can be placed in the `public/` directory.
## 🧞 Commands
All commands are run from the root of the project, from a terminal:
| Command | Action |
| :------------------------ | :----------------------------------------------- |
| `pnpm install` | Installs dependencies |
| `pnpm dev` | Starts local dev server at `localhost:4321` |
| `pnpm build` | Build your production site to `./dist/` |
| `pnpm preview` | Preview your build locally, before deploying |
| `pnpm astro ...` | Run CLI commands like `astro add`, `astro check` |
| `pnpm astro -- --help` | Get help using the Astro CLI |
## 👀 Want to learn more?
Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).

View file

@ -1,30 +0,0 @@
// @ts-check
import { defineConfig, fontProviders } from "astro/config";
import react from "@astrojs/react";
import tailwindcss from "@tailwindcss/vite";
import icon from "astro-icon";
import swup from "@swup/astro";
// https://astro.build/config
export default defineConfig({
output: "static",
integrations: [react(), icon(), swup({ theme: false })],
vite: {
plugins: [tailwindcss()],
ssr: {
noExternal: ["@tomodachi-share/shared"],
},
},
fonts: [
{
provider: fontProviders.fontsource(),
name: "Lexend",
cssVariable: "--font-lexend",
},
],
});

View file

@ -1,50 +0,0 @@
{
"name": "",
"type": "module",
"version": "0.0.1",
"engines": {
"node": ">=22.12.0"
},
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro"
},
"dependencies": {
"@astrojs/react": "^5.0.3",
"@bprogress/react": "^1.2.7",
"@hello-pangea/dnd": "^18.0.1",
"@iconify-json/ic": "^1.2.4",
"@iconify-json/material-icon-theme": "^1.2.59",
"@iconify-json/mdi": "^1.2.3",
"@iconify-json/stash": "^1.2.4",
"@nanostores/react": "^1.1.0",
"@swup/astro": "^1.8.0",
"@tailwindcss/vite": "^4.2.2",
"@tomodachi-share/shared": "workspace:*",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"astro": "^6.1.7",
"canvas-confetti": "^1.9.4",
"dayjs": "^1.11.20",
"downshift": "^9.3.2",
"embla-carousel-react": "^8.6.0",
"jsqr": "^1.4.0",
"nanostores": "^1.2.0",
"qrcode-generator": "^2.0.4",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-dropzone": "^15.0.0",
"react-image-crop": "^11.0.10",
"seedrandom": "^3.0.5",
"tailwindcss": "^4.2.2",
"zod": "^4.3.6"
},
"devDependencies": {
"@iconify/react": "^6.0.2",
"@types/canvas-confetti": "^1.9.0",
"@types/seedrandom": "^3.0.8",
"astro-icon": "^1.1.5"
}
}

File diff suppressed because it is too large Load diff

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="130.734" height="105.615" viewBox="0 0 34.59 27.944"><rect width="32.208" height="25.562" x="1.191" y="1.191" rx="1.874" fill="#f8f8f8" stroke="#ff8904" stroke-width="2.381" paint-order="stroke fill markers"/><rect width="29.369" height="22.49" x="2.611" y="2.727" rx=".966" fill="#c8c8c8" paint-order="stroke fill markers"/><g fill="#fef3c6"><rect width="13.371" height="20.989" x="17.918" y="3.478" rx=".423" paint-order="stroke fill markers"/><rect width="13.371" height="20.989" x="3.301" y="3.478" rx=".423" paint-order="stroke fill markers"/></g><g fill="#ff8904"><use href="#B" paint-order="stroke fill markers"/><circle cx="9.986" cy="13.076" r="5.512" paint-order="stroke fill markers"/><use href="#B" x="14.204" y="-0.093" paint-order="stroke fill markers"/><circle cx="24.191" cy="12.983" r="5.512" paint-order="stroke fill markers"/></g><g fill="none" stroke="#c8c8c8" stroke-linejoin="round"><rect width="13.791" height="20.704" x="17.295" y="3.62" ry="1.146" rx="1.095" stroke-width="1.786" paint-order="stroke fill markers"/><rect width="13.366" height="21.167" x="3.301" y="3.389" ry="1.146" rx="1.095" stroke-width="1.323" paint-order="stroke fill markers"/></g><defs ><path id="B" d="M15.03 24.516c0-2.307-.961-4.439-2.522-5.592s-3.483-1.153-5.044 0-2.522 3.285-2.522 5.592h5.044z"/></defs></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -1,65 +0,0 @@
// import { useSearchParams } from "next/navigation";
// import { Suspense, useEffect, useState } from "react";
// import useSWR from "swr";
// import { Icon } from "@iconify/react";
// interface ApiResponse {
// message: string;
// }
// const fetcher = (url: string) => fetch(url).then((res) => res.json());
// function RedirectBanner() {
// const searchParams = useSearchParams();
// const from = searchParams.get("from");
// if (from !== "old-domain") return null;
// return (
// <div className="w-full h-10 bg-orange-300 border-y-2 border-y-orange-400 mt-1 pl-2 shadow-md flex justify-center items-center gap-2 text-orange-900 text-nowrap overflow-x-auto font-semibold max-sm:justify-start">
// <Icon icon="humbleicons:link" className="text-2xl min-w-6" />
// <span>We have moved URLs, welcome to tomodachishare.com!</span>
// </div>
// );
// }
// export default function AdminBanner() {
// const { data } = useSWR<ApiResponse>("/api/admin/banner", fetcher);
// const [shouldShow, setShouldShow] = useState(true);
// useEffect(() => {
// if (!data?.message) return;
// // Check if the current banner text was closed by the user
// const closedBanner = window.localStorage.getItem("closedBanner");
// setShouldShow(data.message !== closedBanner);
// }, [data]);
// const handleClose = () => {
// if (!data) return;
// // Close banner and remember it
// window.localStorage.setItem("closedBanner", data.message);
// setShouldShow(false);
// };
// return (
// <>
// {data && data.message && shouldShow && (
// <div className="relative w-full h-10 bg-orange-300 border-y-2 border-y-orange-400 mt-1 pl-2 shadow-md flex justify-center text-orange-900 text-nowrap overflow-x-auto font-semibold max-sm:justify-between">
// <div className="flex gap-2 h-full items-center w-fit">
// <Icon icon="humbleicons:exclamation" className="text-2xl min-w-6" />
// <span>{data.message}</span>
// </div>
// <button onClick={handleClose} className="min-sm:absolute right-2 cursor-pointer p-1.5">
// <Icon icon="humbleicons:times" className="text-2xl min-w-6" />
// </button>
// </div>
// )}
// <Suspense>
// <RedirectBanner />
// </Suspense>
// </>
// );
// }

View file

@ -1,45 +0,0 @@
// import { settings } from "@/lib/settings";
// import { useState } from "react";
// export default function ControlCenter() {
// const [canSubmit, setCanSubmit] = useState(settings.canSubmit);
// const [isQueueEnabled, setIsQeueueEnabled] = useState(settings.queueEnabled);
// const onClickSet = async () => {
// await fetch("/api/admin/can-submit", { method: "PATCH", body: JSON.stringify(canSubmit) });
// await fetch("/api/admin/queue", { method: "PATCH", body: JSON.stringify(isQueueEnabled) });
// };
// return (
// <div className="bg-orange-100 rounded-xl border-2 border-orange-400 p-2 flex flex-col gap-2">
// <div className="flex items-center gap-2">
// <input
// id="submit"
// type="checkbox"
// className="checkbox size-6!"
// placeholder="Enter banner text"
// checked={canSubmit}
// onChange={(e) => setCanSubmit(e.target.checked)}
// />
// <label htmlFor="submit">Enable Submissions</label>
// </div>
// <div className="flex items-center gap-2">
// <input
// id="queue"
// type="checkbox"
// className="checkbox size-6!"
// placeholder="Enter banner text"
// checked={isQueueEnabled}
// onChange={(e) => setIsQeueueEnabled(e.target.checked)}
// />
// <label htmlFor="queue">Enable Queue</label>
// </div>
// <div className="flex gap-2 self-end">
// <button type="submit" className="pill button" onClick={onClickSet}>
// Set
// </button>
// </div>
// </div>
// );
// }

View file

@ -1,166 +0,0 @@
// import { Prisma } from "@prisma/client";
// import { useMemo, useRef, useState } from "react";
// import Carousel from "../carousel";
// import Link from "next/link";
// import { Icon } from "@iconify/react";
// interface Props {
// miis: Prisma.MiiGetPayload<{ include: { user: { select: { id: true; name: true } }; _count: { select: { likedBy: true } } } }>[];
// }
// type Decision = "accept" | "reject" | null;
// export default function Queue({ miis }: Props) {
// const [currentIndex, setCurrentIndex] = useState(4); // Current index in the miis array, not visible
// const [visibleMiis, setVisibleMiis] = useState(miis.slice(0, 4));
// const [decision, setDecision] = useState<Decision>(null);
// const [isAnimating, setIsAnimating] = useState(false);
// const [dragOffset, setDragOffset] = useState(0);
// const dragStart = useRef<number | null>(null);
// const isDragging = useRef(false);
// const rotations = useMemo(() => {
// const map: Record<string, number> = {};
// miis.forEach((mii) => {
// map[mii.id] = Math.random() * 15 - 5;
// });
// return map;
// }, [miis]);
// const handleDecision = (decision: Decision) => {
// if (isAnimating) return;
// setDecision(decision);
// setIsAnimating(true);
// setDragOffset(decision === "accept" ? -300 : 300);
// setTimeout(() => {
// setVisibleMiis((prev) => {
// const newQueue = prev.slice(1); // Remove first Mii
// if (miis[currentIndex]) newQueue.push(miis[currentIndex]); // Add a new Mii to the end of the list
// return newQueue;
// });
// setCurrentIndex((prev) => prev + 1);
// setDecision(null);
// setIsAnimating(false);
// setDragOffset(0);
// }, 500);
// };
// const onDragStart = (clientX: number) => {
// if (isAnimating) return;
// dragStart.current = clientX;
// isDragging.current = true;
// };
// const onDragMove = (clientX: number) => {
// if (!isDragging.current || !dragStart.current) return;
// setDragOffset(clientX - dragStart.current);
// };
// const onDragEnd = () => {
// if (!isDragging.current) return;
// isDragging.current = false;
// if (dragOffset < -80) handleDecision("accept");
// else if (dragOffset > 80) handleDecision("reject");
// else setDragOffset(0);
// dragStart.current = null;
// };
// return (
// <div className="w-full flex justify-center items-center gap-8 relative h-100 mt-4 mb-8">
// <button
// onClick={() => handleDecision("accept")}
// className="pointer-coarse:hidden aspect-square cursor-pointer size-12 bg-zinc-50 border-2 border-zinc-300 rounded-full flex justify-center items-center text-2xl text-zinc-500 shadow-xs"
// >
// <Icon icon="material-symbols:check-rounded" />
// </button>
// <div className="relative w-full max-w-96 h-96 aspect-square">
// {visibleMiis.map((mii, i) => {
// const isTopCard = i === 0;
// // Calculate rotation/opacity based on drag distance
// const dragRotation = isTopCard ? dragOffset / 10 : 0;
// const dragOpacity = isTopCard ? 1 - Math.min(Math.abs(dragOffset) / 300, 1) : undefined;
// return (
// <div
// key={mii.id}
// className={`absolute inset-0 flex flex-col bg-zinc-50 rounded-3xl border-2 shadow-lg p-[0.8rem] border-zinc-300 *:select-none
// ${!isDragging.current ? "transition-all duration-500" : "transition-none"}
// ${isTopCard ? "cursor-grab active:cursor-grabbing" : "pointer-events-none"}`}
// style={{
// transform: isTopCard
// ? `translate(${dragOffset}px, ${Math.abs(dragOffset) * 0.1}px) rotate(${rotations[mii.id] + dragRotation}deg)`
// : `translateY(${i * 10}px) rotate(${rotations[mii.id]}deg)`,
// zIndex: (visibleMiis.length - i) * 10,
// opacity: dragOpacity,
// }}
// onMouseDown={(e) => isTopCard && onDragStart(e.clientX)}
// onMouseMove={(e) => isTopCard && onDragMove(e.clientX)}
// onMouseUp={() => isTopCard && onDragEnd()}
// onMouseLeave={() => isTopCard && isDragging.current && onDragEnd()}
// onTouchStart={(e) => isTopCard && onDragStart(e.touches[0].clientX)}
// onTouchMove={(e) => isTopCard && onDragMove(e.touches[0].clientX)}
// onTouchEnd={() => isTopCard && onDragEnd()}
// >
// <Carousel
// images={[
// `/mii/${mii.id}/image?type=mii`,
// ...(mii.platform === "THREE_DS" ? [`/mii/${mii.id}/image?type=qr-code`] : [`/mii/${mii.id}/image?type=features`]),
// ...Array.from({ length: mii.imageCount }, (_, index) => `/mii/${mii.id}/image?type=image${index}`),
// ]}
// onlyButtons
// />
// <div className="p-4 flex flex-col gap-1 h-full">
// <div className="flex justify-between items-center">
// <Link
// href={`/mii/${mii.id}`}
// draggable={false}
// className="relative font-bold text-2xl line-clamp-1 w-full text-ellipsis wrap-break-word"
// title={mii.name}
// >
// {mii.name}
// </Link>
// <div title={mii.platform === "SWITCH" ? "Switch" : "3DS"} className="-mr-3 text-[1.25rem] opacity-25">
// {mii.platform === "SWITCH" ? (
// <Icon icon="cib:nintendo-switch" className="text-red-400" />
// ) : (
// <Icon icon="cib:nintendo-3ds" className="text-sky-400" />
// )}
// </div>
// </div>
// <div id="tags" className="flex flex-wrap gap-1">
// {mii.tags.map((tag) => (
// <Link href={{ query: { tags: tag } }} draggable={false} key={tag} className="px-2 py-1 bg-orange-300 rounded-full text-xs">
// {tag}
// </Link>
// ))}
// </div>
// <div className="mt-auto grid grid-cols-2 gap-4 items-center">
// <p className="text-sm">{mii.createdAt.toLocaleString("en-GB", { timeZone: "UTC" })}</p>
// <Link href={`/profile/${mii.user.id}`} draggable={false} className="text-sm text-right overflow-hidden text-ellipsis whitespace-nowrap">
// @{mii.user?.name}
// </Link>
// </div>
// </div>
// </div>
// );
// })}
// </div>
// <button
// onClick={() => handleDecision("reject")}
// className="pointer-coarse:hidden aspect-square cursor-pointer size-12 bg-zinc-50 border-2 border-zinc-300 rounded-full flex justify-center items-center text-2xl text-zinc-500 shadow-xs"
// >
// <Icon icon="material-symbols:close-rounded" />
// </button>
// </div>
// );
// }

View file

@ -1,202 +0,0 @@
// import { revalidatePath } from "next/cache";
// import { Icon } from "@iconify/react";
// import { ReportStatus } from "@prisma/client";
// import { prisma } from "@/lib/prisma";
// import ReportTabs from "./report-tabs";
// const PAGE_SIZE = 20;
// export default async function Reports({ searchParams }: { searchParams: { status?: string; page?: string } }) {
// const status = searchParams.status as ReportStatus | undefined;
// const page = Number(searchParams.page ?? 1);
// const [reports, total] = await Promise.all([
// prisma.report.findMany({
// where: status ? { status } : undefined,
// orderBy: { createdAt: "desc" },
// skip: (page - 1) * PAGE_SIZE,
// take: PAGE_SIZE,
// }),
// prisma.report.count({
// where: status ? { status } : undefined,
// }),
// ]);
// const totalPages = Math.ceil(total / PAGE_SIZE);
// const updateStatus = async (formData: FormData) => {
// "use server";
// const id = Number(formData.get("id"));
// const status = formData.get("status") as ReportStatus;
// await prisma.report.update({
// where: { id },
// data: { status },
// });
// revalidatePath("/admin");
// };
// return (
// <div className="bg-orange-100 rounded-xl border-2 border-orange-400">
// <ReportTabs status={status} />
// {/* Grid */}
// <div className="grid grid-cols-2 gap-2 p-2 max-lg:grid-cols-1">
// {reports.map((report) => (
// <div key={report.id} className="p-4 bg-white border border-orange-300 shadow-sm rounded-md">
// <div className="w-full overflow-x-scroll">
// <div className="flex gap-1 w-max">
// <span
// className={`text-xs font-semibold px-2 py-1 rounded-full border ${
// report.reportType == "USER" ? "bg-red-200 text-red-800 border-red-400" : "bg-cyan-200 text-cyan-800 border-cyan-400"
// }`}
// >
// {report.reportType}
// </span>
// <span
// className={`text-xs font-semibold px-2 py-1 rounded-full border ${
// report.status == "OPEN"
// ? "bg-orange-200 text-orange-800 border-orange-400"
// : report.status == "RESOLVED"
// ? "bg-green-200 text-green-800 border-green-400"
// : "bg-zinc-200 text-zinc-800 border-zinc-400"
// }`}
// >
// {report.status}
// </span>
// <span className="ml-2 flex items-center gap-1 text-sm text-zinc-500">
// <Icon icon="lucide:calendar" className="text-base" />
// {report.createdAt.toLocaleString("en-GB", {
// day: "2-digit",
// month: "long",
// year: "numeric",
// hour: "2-digit",
// minute: "2-digit",
// second: "2-digit",
// timeZone: "UTC",
// })}{" "}
// UTC
// </span>
// </div>
// </div>
// <div className="grid grid-cols-4 text-xs text-zinc-600 mt-4 max-sm:grid-cols-2">
// <div>
// <p>Target ID</p>
// <a href={report.reportType === "MII" ? `/mii/${report.targetId}` : `/profile/${report.targetId}`} className="text-blue-600 text-sm">
// {report.targetId}
// </a>
// </div>
// <div>
// <p>Creator ID</p>
// <a href={`/profile/${report.creatorId}`} className="text-blue-600 text-sm">
// {report.creatorId}
// </a>
// </div>
// <div>
// <p>Reporter</p>
// <a href={`/profile/${report.authorId}`} className="text-blue-600 text-sm">
// {report.authorId}
// </a>
// </div>
// <div>
// <p>Reason</p>
// <p className="font-medium text-black text-sm">{report.reason}</p>
// </div>
// </div>
// <div className="mt-4 border border-orange-200 bg-orange-100/50 rounded-md p-2">
// <p className="text-zinc-600 text-xs">Notes</p>
// <p>{report.reasonNotes}</p>
// </div>
// <div className="mt-4 flex gap-4">
// <form action={updateStatus}>
// <input type="hidden" name="id" value={report.id} />
// <input type="hidden" name="status" value={"OPEN"} />
// <button
// type="submit"
// aria-label="Open"
// className="cursor-pointer text-orange-400 flex items-center gap-1 p-1.5 rounded-lg transition-colors hover:bg-orange-400/15"
// >
// <Icon icon="mdi:alert-circle" className="text-xl" />
// <span className="text-sm">Open</span>
// </button>
// </form>
// <form action={updateStatus}>
// <input type="hidden" name="id" value={report.id} />
// <input type="hidden" name="status" value={"RESOLVED"} />
// <button
// type="submit"
// aria-label="Resolve"
// className="cursor-pointer text-green-500 flex items-center gap-1 p-1.5 rounded-lg transition-colors hover:bg-green-500/15"
// >
// <Icon icon="mdi:check-circle" className="text-xl" />
// <span className="text-sm">Resolve</span>
// </button>
// </form>
// <form action={updateStatus}>
// <input type="hidden" name="id" value={report.id} />
// <input type="hidden" name="status" value={"DISMISSED"} />
// <button
// type="submit"
// aria-label="Dismiss"
// className="cursor-pointer text-zinc-400 flex items-center gap-1 p-1.5 rounded-lg transition-colors hover:bg-zinc-400/15"
// >
// <Icon icon="mdi:close-circle" className="text-xl" />
// <span className="text-sm">Dismiss</span>
// </button>
// </form>
// </div>
// </div>
// ))}
// </div>
// {reports.length === 0 && (
// <div className="text-center py-12 text-gray-500">
// <p className="text-lg font-medium">No reports to display</p>
// <p className="text-sm">Reports will appear here when users submit them</p>
// </div>
// )}
// {/* Pagination */}
// {totalPages > 1 && (
// <div className="flex justify-between items-center p-3 border-t border-orange-300">
// <span className="text-sm text-orange-700">{total} total</span>
// <div className="flex items-center gap-3">
// {page > 1 && (
// <a
// href={`/admin?${new URLSearchParams({ ...(status && { status }), page: String(page - 1) })}`}
// className="text-sm px-3 py-1 rounded-full font-medium border bg-white text-orange-700 border-orange-300 hover:bg-orange-50 transition-colors"
// >
// Previous
// </a>
// )}
// <span className="text-sm text-orange-700">
// Page {page} of {totalPages}
// </span>
// {page < totalPages && (
// <a
// href={`/admin?${new URLSearchParams({ ...(status && { status }), page: String(page + 1) })}`}
// className="text-sm px-3 py-1 rounded-full font-medium border bg-white text-orange-700 border-orange-300 hover:bg-orange-50 transition-colors"
// >
// Next
// </a>
// )}
// </div>
// </div>
// )}
// </div>
// );
// }

View file

@ -1,43 +0,0 @@
---
import { Icon } from "astro-icon/components";
---
<footer class="mt-auto">
<div class="max-w-4xl mx-auto px-4 py-4">
{/* Main disclaimer */}
<div class="text-center mb-2">
<p class="text-sm text-zinc-600 font-medium">TomodachiShare is not affiliated with Nintendo</p>
</div>
{/* Links section */}
<div class="flex flex-wrap justify-center items-center gap-x-4 text-sm max-sm:gap-x-12">
<a href="/terms-of-service" class="text-zinc-500 hover:text-zinc-700 transition-colors duration-200 hover:underline"> Terms of Service </a>
<span class="text-zinc-400 hidden sm:inline" aria-hidden="true">•</span>
<a href="/privacy" class="text-zinc-500 hover:text-zinc-700 transition-colors duration-200 hover:underline"> Privacy Policy </a>
<span class="text-zinc-400 hidden sm:inline" aria-hidden="true">•</span>
<a
href="https://discord.gg/48cXBFKvWQ"
target="_blank"
class="text-[#5865F2] hover:text-[#454FBF] transition-colors duration-200 hover:underline inline-flex items-end gap-1"
>
<Icon name="ic:baseline-discord" class="text-lg" />
Discord
</a>
<span class="text-zinc-400 hidden sm:inline" aria-hidden="true"> • </span>
<a href="https://trafficlunar.net" target="_blank" class="text-zinc-500 hover:text-zinc-700 transition-colors duration-200 hover:underline group">
Made by <span class="text-orange-400 group-hover:text-orange-500 font-medium transition-colors duration-200">trafficlunar</span>
</a>
</div>
{/* Copyright */}
<div class="text-center mt-4 mb-4">
<p class="text-xs text-zinc-400">© {new Date().getFullYear()} TomodachiShare. All rights reserved.</p>
</div>
</div>
</footer>

View file

@ -1,60 +0,0 @@
import { Icon } from "@iconify/react";
import { useEffect } from "react";
import { useStore } from "@nanostores/react";
import { session } from "../session";
export default function HeaderProfile() {
const API_BASE_URL = import.meta.env.PUBLIC_API_URL;
const $session = useStore(session);
useEffect(() => {
fetch(`${API_BASE_URL}/api/auth/session`, { credentials: "include" })
.then((res) => {
if (!res.ok) throw new Error("Failed to get session");
return res.json();
})
.then((data) => {
session.set(data);
})
.catch((err) => {
console.error(err);
});
}, []);
return (
<>
{!$session?.user ? (
<li>
<a href={"/login"} className="pill button h-full">
Login
</a>
</li>
) : (
<>
<li title="Your profile">
<a
href={`/profile/${$session?.user?.id}`}
aria-label="Go to profile"
className="pill button gap-2! p-0! h-full max-w-64"
data-tooltip="Your Profile"
>
<img
src={$session?.user?.image ?? "/guest.png"}
alt="profile picture"
width={40}
height={40}
className="rounded-full aspect-square object-cover h-full bg-white outline-2 outline-orange-400"
/>
<span className="pr-4 overflow-hidden whitespace-nowrap text-ellipsis w-full">{$session?.user?.name ?? "unknown"}</span>
</a>
</li>
<li title="Logout">
<a href={`${API_BASE_URL}/api/auth/signout`} aria-label="Log Out" className="pill button p-2! aspect-square h-full" data-tooltip="Log Out">
<Icon icon="ic:round-logout" fontSize={24} />
</a>
</li>
</>
)}
</>
);
}

View file

@ -1,35 +0,0 @@
---
import { Icon } from "astro-icon/components";
import SearchBar from "./search-bar";
import HeaderProfile from "./header-profile";
---
<header
class="sticky top-0 z-50 w-full p-4 grid grid-cols-3 gap-2 gap-x-4 items-center bg-amber-50 border-b-4 border-amber-500 shadow-md max-lg:grid-cols-2 max-md:grid-cols-1"
>
<a href={"/"} aria-label="Go to Home Page" class="font-black text-3xl text-orange-400 flex items-center gap-2 max-md:justify-center max-md:col-span-2">
<img src="/logo.svg" width={56} height={45} alt="logo" />
TomodachiShare
</a>
<div class="flex justify-center max-lg:justify-end max-md:justify-center">
<SearchBar client:only />
</div>
<ul class="flex justify-end gap-3 items-center h-11 *:h-full max-lg:col-span-2 max-md:justify-center">
<li title="Random Mii">
<a
href={`${import.meta.env.PUBLIC_API_URL}/random`}
aria-label="Go to Random Link"
class="pill button p-0! h-full aspect-square"
data-tooltip="Go to a Random Mii"
>
<Icon name="mdi:dice-3" size={28} />
</a>
</li>
<li>
<a href={"/submit"} class="pill button h-full"> Submit </a>
</li>
<HeaderProfile client:only />
</ul>
</header>

View file

@ -1,23 +0,0 @@
import { Icon } from "@iconify/react";
import DeleteMiiButton from "./delete-mii-button";
interface Props {
mii: any;
}
export default function AuthorButtons({ mii }: Props) {
// const session = useSession();
// if (!session.data || (Number(session.data.user?.id) !== mii.userId && Number(session.data.user?.id) !== Number(import.meta.env.NEXT_PUBLIC_ADMIN_USER_ID)))
// return null;
return (
<>
<a aria-label="Edit Mii" href={`/edit/${mii.id}`}>
<Icon icon="mdi:pencil" />
<span>Edit</span>
</a>
<DeleteMiiButton miiId={mii.id} miiName={mii.name} likes={mii._count.likedBy ?? 0} inMiiPage />
</>
);
}

View file

@ -1,79 +0,0 @@
import { Suspense, useEffect, useState } from "react";
import FilterMenu from "../mii/list/filter-menu";
import SortSelect from "../mii/list/sort-select";
import MiiGrid from "../mii/list/mii-grid";
import Pagination from "../pagination";
import Skeleton from "../mii/list/skeleton";
interface ApiResponse {
totalCount: number;
filteredCount: number;
miis: any[];
lastPage: number;
}
export default function IndexPage() {
const searchParams = new URLSearchParams(window.location.search);
const [data, setData] = useState<ApiResponse>();
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`${import.meta.env.PUBLIC_API_URL}/api/mii/list?${searchParams.toString()}`)
.then((res) => {
if (!res.ok) throw new Error("Failed to fetch Miis");
return res.json();
})
.then((data) => {
setData(data);
setLoading(false);
})
.catch((err) => {
console.error(err);
setLoading(false);
});
}, []);
return (
<>
<h1 className="sr-only">
{searchParams.get("tags") ? `Miis tagged with '${searchParams.get("tags")}' - TomodachiShare` : "TomodachiShare - index mii list"}
</h1>
<p className="text-center mb-4">We're currently going through some major code changes therefore some features won't work.</p>
<Suspense fallback={<Skeleton />}>
{!loading && data ? (
<div className="w-full">
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex justify-between items-center gap-2 mb-2 max-md:flex-col">
<div className="flex items-center gap-2">
{data.totalCount == data.filteredCount ? (
<>
<span className="text-2xl font-bold text-amber-900">{data.totalCount}</span>
<span className="text-lg text-amber-700">{data.totalCount === 1 ? "Mii" : "Miis"}</span>
</>
) : (
<>
<span className="text-2xl font-bold text-amber-900">{data.filteredCount}</span>
<span className="text-sm text-amber-700">of</span>
<span className="text-lg font-semibold text-amber-800">{data.totalCount}</span>
<span className="text-lg text-amber-700">Miis</span>
</>
)}
</div>
<div className="relative flex items-center justify-end gap-2 w-full md:max-w-2/3 max-md:justify-center">
<FilterMenu />
<SortSelect />
</div>
</div>
<MiiGrid miis={data.miis} />
<Pagination lastPage={data.lastPage} />
</div>
) : (
<p>No Miis found :( Has the server died?</p>
)}
</Suspense>
</>
);
}

View file

@ -1,41 +0,0 @@
import { useEffect, useState } from "react";
import ProfileInformation from "../profile-information";
interface Props {
id: string;
}
export default function ProfilePage({ id }: Props) {
const [user, setUser] = useState<any>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`${import.meta.env.PUBLIC_API_URL}/api/profile/${id}/info`)
.then((res) => {
if (!res.ok) throw new Error("Failed to fetch profile");
return res.json();
})
.then((data) => {
setUser(data);
setLoading(false);
})
.catch((err) => {
console.error(err);
setLoading(false);
window.location.href = "/404";
});
}, []);
if (loading || !user) {
return <div className="p-6 text-center">Loading...</div>;
}
return (
<div>
<ProfileInformation user={user} />
{/* <Suspense fallback={<Skeleton />}>
<MiiList searchParams={await searchParams} userId={user.id} />
</Suspense> */}
</div>
);
}

View file

@ -1,456 +0,0 @@
// import { redirect } from "next/navigation";
// import { useCallback, useEffect, useRef, useState } from "react";
// import { FileWithPath } from "react-dropzone";
// import { Mii, MiiGender, MiiMakeup } from "@prisma/client";
// import { useSession } from "next-auth/react";
// import { nameSchema, tagsSchema } from "@tomodachi-share/shared/schemas";
// import { defaultInstructions, minifyInstructions } from "@/lib/switch";
// import { SwitchMiiInstructions } from "@tomodachi-share/shared";
// import TagSelector from "../tag-selector";
// import ImageList from "./image-list";
// import LikeButton from "../like-button";
// import Carousel from "../carousel";
// import SubmitButton from "../submit-button";
// import Dropzone from "../dropzone";
// import MiiEditor from "./mii-editor";
// import SwitchSubmitTutorialButton from "../tutorial/switch-submit";
// import { Icon } from "@iconify/react";
// import SwitchFileUpload from "./switch-file-upload";
// interface Props {
// mii: Mii;
// likes: number;
// }
// function deepMerge<T>(target: T, source: Partial<T>): T {
// const output = structuredClone(target);
// if (typeof source !== "object" || source === null) return output;
// for (const key in source) {
// const sourceValue = source[key];
// const targetValue = (output as any)[key];
// if (typeof sourceValue === "object" && sourceValue !== null && !Array.isArray(sourceValue)) {
// (output as any)[key] = deepMerge(targetValue, sourceValue);
// } else {
// (output as any)[key] = sourceValue;
// }
// }
// return output;
// }
// export default function EditForm({ mii, likes }: Props) {
// const session = useSession();
// const [files, setFiles] = useState<FileWithPath[]>([]);
// const handleFilesChange: React.Dispatch<React.SetStateAction<FileWithPath[]>> = (updater) => {
// hasCustomImagesChanged.current = true;
// setFiles(updater);
// };
// const handleDrop = useCallback(
// (acceptedFiles: FileWithPath[]) => {
// if (files.length >= 3) return;
// hasCustomImagesChanged.current = true;
// setFiles((prev) => [...prev, ...acceptedFiles]);
// },
// [files.length],
// );
// const [error, setError] = useState<string | undefined>(undefined);
// const [name, setName] = useState(mii.name);
// const [tags, setTags] = useState(mii.tags);
// const [description, setDescription] = useState(mii.description);
// const [gender, setGender] = useState<MiiGender>(mii.gender ?? "MALE");
// const [makeup, setMakeup] = useState<MiiMakeup>(mii.makeup ?? "PARTIAL");
// const [miiPortraitUri, setMiiPortraitUri] = useState<string | undefined>(`/mii/${mii.id}/image?type=mii`);
// const [miiFeaturesUri, setMiiFeaturesUri] = useState<string | undefined>(`/mii/${mii.id}/image?type=features`);
// const [youtubeId, setYouTubeId] = useState(mii.youtubeId ?? "");
// const instructions = useRef<SwitchMiiInstructions>(deepMerge(defaultInstructions, (mii.instructions as object) ?? {}));
// const [quarantined, setQuarantined] = useState(mii.quarantined);
// const hasCustomImagesChanged = useRef(false);
// const hasMiiPortraitChanged = useRef(false);
// const hasMiiFeaturesChanged = useRef(false);
// const handleSubmit = async () => {
// // Validate before sending request
// const nameValidation = nameSchema.safeParse(name);
// if (!nameValidation.success) {
// setError(nameValidation.error.issues[0].message);
// return;
// }
// const tagsValidation = tagsSchema.safeParse(tags);
// if (!tagsValidation.success) {
// setError(tagsValidation.error.issues[0].message);
// return;
// }
// // Send request to server
// const formData = new FormData();
// if (name != mii.name) formData.append("name", name);
// if (tags != mii.tags) formData.append("tags", JSON.stringify(tags));
// if (description && description != mii.description) formData.append("description", description);
// if (gender != mii.gender) formData.append("gender", gender);
// if (makeup != mii.makeup) formData.append("makeup", makeup);
// if (miiPortraitUri) formData.append("miiPortraitUri", miiPortraitUri);
// if (quarantined != mii.quarantined) formData.append("quarantined", JSON.stringify(quarantined));
// if (youtubeId != mii.youtubeId) formData.append("youtubeId", youtubeId);
// if (minifyInstructions(structuredClone(instructions.current)) !== (mii.instructions as object))
// formData.append("instructions", JSON.stringify(instructions.current));
// if (hasCustomImagesChanged.current) {
// files.forEach((file, index) => {
// // image1, image2, etc.
// formData.append(`image${index + 1}`, file);
// });
// }
// // Switch pictures
// async function getBlob(uri: string): Promise<Blob | null> {
// const response = await fetch(uri);
// if (!response.ok) {
// setError("Failed to get Mii portrait/features screenshot. Did you upload one?");
// return null;
// }
// const blob = await response.blob();
// if (!blob.type.startsWith("image/")) {
// setError("Invalid image file found");
// return null;
// }
// return blob;
// }
// if (miiPortraitUri && hasMiiPortraitChanged.current) {
// const blob = await getBlob(miiPortraitUri);
// if (blob) formData.append("miiPortraitImage", blob);
// }
// if (miiFeaturesUri && hasMiiFeaturesChanged.current) {
// const blob = await getBlob(miiFeaturesUri);
// if (blob) formData.append("miiFeaturesImage", blob);
// }
// const response = await fetch(`/api/mii/${mii.id}/edit`, {
// method: "PATCH",
// body: formData,
// });
// const { error } = await response.json();
// if (!response.ok) {
// setError(error);
// return;
// }
// redirect(`/mii/${mii.id}`);
// };
// const handleMiiPortraitChange = (uri: string | undefined) => {
// hasMiiPortraitChanged.current = true;
// setMiiPortraitUri(uri);
// };
// const handleMiiFeaturesChange = (uri: string | undefined) => {
// hasMiiFeaturesChanged.current = true;
// setMiiFeaturesUri(uri);
// };
// // Load existing images - converts image URLs to File objects
// useEffect(() => {
// const loadExistingImages = async () => {
// try {
// const existing = await Promise.all(
// Array.from({ length: mii.imageCount }, async (_, index) => {
// const path = `/mii/${mii.id}/image?type=image${index}`;
// const response = await fetch(path);
// const blob = await response.blob();
// return Object.assign(new File([blob], `image${index}.png`, { type: "image/png" }), { path });
// }),
// );
// setFiles(existing);
// } catch (error) {
// console.error("Error loading existing images:", error);
// }
// };
// loadExistingImages();
// }, [mii.id, mii.imageCount]);
// return (
// <div className="flex justify-center gap-4 w-full max-lg:flex-col max-lg:items-center">
// <div className="flex justify-center">
// <div className="w-75 h-min flex flex-col bg-zinc-50 rounded-3xl border-2 border-zinc-300 shadow-lg p-3">
// <Carousel
// images={[
// miiPortraitUri ?? `/mii/${mii.id}/image?type=mii`,
// ...(mii.platform === "THREE_DS" ? [`/mii/${mii.id}/image?type=qr-code`] : [miiFeaturesUri ?? `/mii/${mii.id}/image?type=features`]),
// ...files.map((file) => URL.createObjectURL(file)),
// ]}
// />
// <div className="p-4 flex flex-col gap-1 h-full">
// <h1 className="font-bold text-2xl line-clamp-1" title={name}>
// {name || "Mii name"}
// </h1>
// <div id="tags" className="flex flex-wrap gap-1">
// {tags.length == 0 && <span className="px-2 py-1 bg-orange-300 rounded-full text-xs">tag</span>}
// {tags.map((tag) => (
// <span key={tag} className="px-2 py-1 bg-orange-300 rounded-full text-xs">
// {tag}
// </span>
// ))}
// </div>
// <div className="mt-auto">
// <LikeButton likes={likes} isLiked={false} abbreviate disabled />
// </div>
// </div>
// </div>
// </div>
// <div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex flex-col gap-2 max-w-2xl w-full">
// <div>
// <h2 className="text-2xl font-bold">Edit your Mii</h2>
// <p className="text-sm text-zinc-500">Make changes to your existing Mii.</p>
// </div>
// {/* Separator */}
// <div className="flex items-center gap-4 text-zinc-500 text-sm font-medium my-1">
// <hr className="grow border-zinc-300" />
// <span>Info</span>
// <hr className="grow border-zinc-300" />
// </div>
// <div className="w-full grid grid-cols-3 items-center">
// <label htmlFor="name" className="font-semibold">
// Name
// </label>
// <input
// id="name"
// type="text"
// className="pill input w-full col-span-2"
// minLength={2}
// maxLength={64}
// placeholder="Type your mii's name here..."
// value={name}
// onChange={(e) => setName(e.target.value)}
// />
// </div>
// <div className="w-full grid grid-cols-3 items-center">
// <label htmlFor="tags" className="font-semibold">
// Tags
// </label>
// <TagSelector tags={tags} setTags={setTags} showTagLimit />
// </div>
// <div className="w-full grid grid-cols-3 items-start">
// <label htmlFor="reason-note" className="font-semibold py-2">
// Description
// </label>
// <textarea
// rows={5}
// maxLength={512}
// placeholder="(optional) Type a description..."
// className="pill input rounded-xl! resize-none col-span-2 text-sm"
// value={description ?? ""}
// onChange={(e) => setDescription(e.target.value)}
// />
// </div>
// {session.data?.user?.id == import.meta.env.NEXT_PUBLIC_ADMIN_USER_ID && (
// <>
// <div className="w-full grid grid-cols-3 items-center">
// <label htmlFor="quarantined" className="font-semibold py-2">
// Quarantined
// </label>
// <div className="col-span-2 flex gap-1">
// <input type="checkbox" id="quarantined" className="checkbox-alt" checked={quarantined} onChange={(e) => setQuarantined(e.target.checked)} />
// </div>
// </div>
// </>
// )}
// {/* Makeup/Images/Instructions (Switch only) */}
// {mii.platform === "SWITCH" && (
// <>
// <div className="w-full grid grid-cols-3 items-start z-20">
// <label htmlFor="gender" className="font-semibold py-2">
// Gender
// </label>
// <div className="col-span-2 flex gap-1">
// <button
// type="button"
// onClick={() => setGender("MALE")}
// aria-label="Filter for Male Miis"
// data-tooltip="Male"
// className={`cursor-pointer rounded-xl flex justify-center items-center size-11 text-4xl border-2 transition-all after:bg-blue-400! after:border-blue-400! before:border-b-blue-400! ${
// gender === "MALE" ? "bg-blue-100 border-blue-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
// }`}
// >
// <Icon icon="foundation:male" className="text-blue-400" />
// </button>
// <button
// type="button"
// onClick={() => setGender("FEMALE")}
// aria-label="Filter for Female Miis"
// data-tooltip="Female"
// className={`cursor-pointer rounded-xl flex justify-center items-center size-11 text-4xl border-2 transition-all after:bg-pink-400! after:border-pink-400! before:border-b-pink-400! ${
// gender === "FEMALE" ? "bg-pink-100 border-pink-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
// }`}
// >
// <Icon icon="foundation:female" className="text-pink-400" />
// </button>
// <button
// type="button"
// onClick={() => setGender("NONBINARY")}
// aria-label="Filter for Nonbinary Miis"
// data-tooltip="Nonbinary"
// className={`cursor-pointer rounded-xl flex justify-center items-center size-11 text-4xl border-2 transition-all after:bg-purple-400! after:border-purple-400! before:border-b-purple-400! ${
// gender === "NONBINARY" ? "bg-purple-100 border-purple-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
// }`}
// >
// <Icon icon="mdi:gender-non-binary" className="text-purple-400" />
// </button>
// </div>
// </div>
// <div className="w-full grid grid-cols-3 items-start">
// <label htmlFor="makeup" className="font-semibold py-2">
// Face Paint
// </label>
// <div className="col-span-2 flex gap-1">
// {/* Full Makeup */}
// <button
// type="button"
// onClick={() => setMakeup("FULL")}
// aria-label="Full Face Paint"
// data-tooltip="Full Face Paint"
// className={`cursor-pointer rounded-xl flex justify-center items-center size-11 text-4xl border-2 transition-all after:bg-pink-400! after:border-pink-400! before:border-b-pink-400! ${
// makeup === "FULL" ? "bg-pink-100 border-pink-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
// }`}
// >
// <Icon icon="mdi:palette" className="text-pink-400" />
// </button>
// {/* Partial Makeup */}
// <button
// type="button"
// onClick={() => setMakeup("PARTIAL")}
// aria-label="Partial Face Paint"
// data-tooltip="Partial Face Paint"
// className={`cursor-pointer rounded-xl flex justify-center items-center size-11 text-4xl border-2 transition-all after:bg-purple-400! after:border-purple-400! before:border-b-purple-400! ${
// makeup === "PARTIAL" ? "bg-purple-100 border-purple-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
// }`}
// >
// <Icon icon="mdi:lipstick" className="text-purple-400" />
// </button>
// {/* No Makeup */}
// <button
// type="button"
// onClick={() => setMakeup("NONE")}
// aria-label="No Face Paint"
// data-tooltip="No Face Paint"
// className={`cursor-pointer rounded-xl flex justify-center items-center size-11 text-4xl border-2 transition-all after:bg-gray-400! after:border-gray-400! before:border-b-gray-400! ${
// makeup === "NONE" ? "bg-gray-200 border-gray-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
// }`}
// >
// <Icon icon="codex:cross" className="text-gray-400" />
// </button>
// </div>
// </div>
// {/* (Switch Only) Mii Portrait */}
// <div>
// {/* Separator */}
// <div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-8 mb-2">
// <hr className="grow border-zinc-300" />
// <span>Mii Portrait</span>
// <hr className="grow border-zinc-300" />
// </div>
// <div className="flex flex-col items-center gap-2">
// <SwitchFileUpload text="a screenshot of your Mii here" image={miiPortraitUri} setImage={handleMiiPortraitChange} forceCrop />
// <SwitchFileUpload text="a screenshot of your Mii's features here" image={miiFeaturesUri} setImage={handleMiiFeaturesChange} />
// <SwitchSubmitTutorialButton />
// </div>
// <p className="text-xs text-zinc-400 text-center mt-2">You must upload a screenshot of the features, check tutorial on how.</p>
// </div>
// <div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-8">
// <hr className="grow border-zinc-300" />
// <span>Instructions</span>
// <hr className="grow border-zinc-300" />
// </div>
// {/* YouTube */}
// <div className="w-full grid grid-cols-3 items-center">
// <label htmlFor="youtube" className="font-semibold">
// YouTube Video
// </label>
// <input
// id="youtube"
// type="text"
// className="pill input w-full col-span-2"
// minLength={2}
// maxLength={64}
// placeholder="Paste a URL or video ID..."
// value={youtubeId}
// onChange={(e) => {
// const val = e.target.value;
// const match = val.match(/(?:youtube\.com\/(?:watch\?v=|shorts\/|embed\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/);
// setYouTubeId(match ? match[1] : val);
// }}
// />
// </div>
// <MiiEditor instructions={instructions} />
// <SwitchSubmitTutorialButton />
// </>
// )}
// {/* Separator */}
// <div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-8">
// <hr className="grow border-zinc-300" />
// <span>Custom images</span>
// <hr className="grow border-zinc-300" />
// </div>
// <div className="max-w-md w-full self-center">
// <Dropzone onDrop={handleDrop}>
// <p className="text-center text-sm">
// Drag and drop your images here
// <br />
// or click to open
// </p>
// </Dropzone>
// </div>
// <ImageList files={files} setFiles={handleFilesChange} />
// <hr className="border-zinc-300 my-2" />
// <div className="flex justify-between items-center">
// {error && <span className="text-red-400 font-bold">Error: {error}</span>}
// <SubmitButton onClick={handleSubmit} text="Edit" className="ml-auto" />
// </div>
// </div>
// </div>
// );
// }

View file

@ -1,91 +0,0 @@
---
import "./styles/global.css";
import "react-image-crop/dist/ReactCrop.css";
import Header from "./components/header.astro";
import Footer from "./components/footer.astro";
// import AdminBanner from "./components/admin/banner";
import Providers from "./components/provider";
import { Font } from "astro:assets";
// import SessionWrapper from "./components/SessionWrapper";
const baseUrl = import.meta.env.PUBLIC_BASE_URL;
const jsonLd = {
"@context": "https://schema.org",
"@type": "WebSite",
name: "TomodachiShare",
url: "https://tomodachishare.com",
description: "Discover and share Mii residents for your Tomodachi Life island!",
inLanguage: "en",
publisher: {
"@type": "Organization",
name: "TomodachiShare",
url: "https://tomodachishare.com",
logo: {
"@type": "ImageObject",
url: "https://tomodachishare.com/logo.png",
},
sameAs: ["https://trafficlunar.net", "https://twitter.com/trafficlunr", "https://bsky.app/profile/trafficlunar.net"],
},
potentialAction: {
"@type": "SearchAction",
target: "https://tomodachishare.com/?q={search_term_string}",
"query-input": "required name=search_term_string",
},
};
---
<html lang="en">
<head>
<Font cssVariable="--font-lexend" />
<meta charset="UTF-8" />
<!-- SEO -->
<title>TomodachiShare - home for Tomodachi Life Miis!</title>
<meta name="description" content="Discover and share Mii residents for your Tomodachi Life island!" />
<meta name="keywords" content="mii, tomodachi life, nintendo, tomodachishare, tomodachi-share, mii creator, mii collection" />
<meta name="robots" content="index, follow" />
<!-- OpenGraph -->
<meta property="og:site_name" content="TomodachiShare" />
<meta property="og:title" content="TomodachiShare" />
<meta property="og:description" content="Discover and share Mii residents for your Tomodachi Life island!" />
<meta property="og:image" content="/preview.png" />
<meta property="og:type" content="website" />
<meta property="og:url" content={baseUrl} />
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="TomodachiShare - Discover and Share Your Mii Residents" />
<meta name="twitter:description" content="Discover and share Mii residents for your Tomodachi Life island!" />
<meta name="twitter:image" content="/preview.png" />
<meta name="twitter:creator" content="@trafficlunr" />
<!-- JSON-LD -->
<script is:inline type="application/ld+json" set:html={JSON.stringify(jsonLd).replace(/</g, "\\u003c")} />
<!-- Analytics -->
{
import.meta.env.PROD && (
<script is:inline defer src="https://analytics.trafficlunar.net/script.js" data-website-id="bc530384-9b7d-471a-b2e3-f9859da50c24" />
)
}
</head>
<body class="font-[Lexend] antialiased flex flex-col items-center min-h-screen">
<Providers client:load>
<!-- <SessionWrapper client:load> -->
<Header />
<!-- <AdminBanner client:load /> -->
<main class="px-4 py-8 max-w-7xl w-full grow flex flex-col">
<slot />
</main>
<Footer />
<!-- </SessionWrapper> -->
</Providers>
</body>
</html>

View file

@ -1,17 +0,0 @@
---
import { Icon } from "astro-icon/components";
import Layout from "../layout.astro";
---
<Layout>
<div class="grow flex items-center justify-center">
<div class="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-8 max-w-xs w-full text-center flex flex-col">
<h2 class="text-7xl font-black">404</h2>
<p>Page not found - you swam off the island!</p>
<a href="/" class="pill button gap-2 mt-8 w-fit self-center">
<Icon name="ic:round-home" size={24} />
Travel Back
</a>
</div>
</div>
</Layout>

View file

@ -1,11 +0,0 @@
---
import Layout from "../layout.astro";
import IndexPage from "../components/pages/index";
---
<Layout>
<!-- <Suspense fallback={<Skeleton />}> -->
<!-- <MiiList searchParams={Astro.url.searchParams} /> -->
<!-- </Suspense> -->
<IndexPage client:only />
</Layout>

View file

@ -1,54 +0,0 @@
---
import Layout from "../layout.astro";
import { Icon } from "astro-icon/components";
const API_BASE_URL = import.meta.env.PUBLIC_API_URL;
---
<Layout>
<div class="grow flex items-center justify-center">
<div class="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg px-10 py-12 max-w-md text-center">
<h1 class="text-3xl font-bold mb-4">Welcome to TomodachiShare!</h1>
<div class="flex items-center gap-4 text-zinc-500 text-sm font-medium mb-8">
<hr class="grow border-zinc-300" />
<span>Choose your login method</span>
<hr class="grow border-zinc-300" />
</div>
<div class="flex flex-col items-center gap-2">
<a
href={`${API_BASE_URL}/api/auth/signin/discord`}
aria-label="Login with Discord"
class="pill button gap-2 px-3! bg-indigo-400! border-indigo-500! hover:bg-indigo-500!"
>
<Icon name="ic:baseline-discord" size={32} />
Login with Discord
</a>
<a
href={`${API_BASE_URL}/api/auth/signin/github`}
aria-label="Login with GitHub"
class="pill button gap-2 px-3! bg-zinc-700! border-zinc-800! hover:bg-zinc-800! text-white"
>
<Icon name="mdi:github" size={32} />
Login with GitHub
</a>
<a
href={`${API_BASE_URL}/api/auth/signin/google`}
aria-label="Login with Google"
class="pill button gap-2 px-3! bg-white! border-gray-300! hover:bg-gray-100! text-black! flex items-center"
>
<Icon name="material-icon-theme:google" size={32} />
Login with Google
</a>
</div>
<p class="mt-8 text-xs text-zinc-400">
By signing up, you agree to the{" "}
<a href="/terms-of-service" class="underline hover:text-zinc-600">Terms of Service</a>{" "}
and{" "}
<a href="/privacy" class="underline hover:text-zinc-600">Privacy Policy</a>.
</p>
</div>
</div>
</Layout>

View file

@ -1,16 +0,0 @@
---
import MiiPage from "../../components/pages/mii";
import Layout from "../../layout.astro";
const { id } = Astro.params;
export async function getStaticPaths() {
return Array.from({ length: 30000 }, (_, i) => ({
params: { id: String(i + 1) },
}));
}
---
<Layout>
<MiiPage client:load id={id} />
</Layout>

View file

@ -1,104 +0,0 @@
---
import Layout from "../layout.astro";
---
<Layout>
<div class="bg-amber-50 border-2 border-amber-500 rounded-2xl p-6">
<h1 class="text-2xl font-bold">Privacy Policy</h1>
<h2 class="font-light">
<strong class="font-medium">Effective Date:</strong> 13 April 2026
</h2>
<hr class="border-black/20 mt-1 mb-4" />
<p>By using this website, you confirm that you understand and agree to this Privacy Policy.</p>
<p class="mt-1">
If you have any questions or concerns, please contact me at:{" "}
<a href="mailto:hello@trafficlunar.net" class="text-blue-700"> hello@trafficlunar.net </a>
.
</p>
<ul class="list-decimal ml-5 marker:text-xl marker:font-semibold">
<li>
<h3 class="text-xl font-semibold mt-6 mb-2">Information We Collect</h3>
<section>
<p class="mb-2">The following types of information are stored when you use this website:</p>
<ul class="list-disc list-inside">
<li>
<strong>Account Information:</strong> When you sign up or log in using Discord or Github, your name, e-mail, and profile picture are collected. Your
authentication tokens may also be temporarily stored to maintain your login session.
</li>
<li>
<strong>Miis:</strong> We store any Miis you submit, including associated images (such as a picture of your Mii, QR codes, and custom images).
</li>
<li>
<strong>Interaction Data:</strong> The Miis you like.
</li>
</ul>
</section>
</li>
<li>
<h3 class="text-xl font-semibold mt-6 mb-2">Use of Cookies</h3>
<section>
<p class="mb-2">Cookies are necessary for user sessions and authentication. We do not use cookies for tracking or advertising purposes.</p>
</section>
</li>
<li>
<h3 class="text-xl font-semibold mt-6 mb-2">Analytics</h3>
<section>
<p class="mb-2">
We use{" "}
<a href="https://umami.is/" class="text-blue-700"> Umami </a>{" "}
to collect anonymous data about how users interact with the site. Umami is fully GDPR-compliant, and no personally identifiable information is collected
through this service.
</p>
</section>
</li>
<li>
<h3 class="text-xl font-semibold mt-6 mb-2">Data Sharing</h3>
<section>
<p class="mb-2">
We do not sell your personal data to third parties. Your data may be sent anonymously to self-hosted third-party services or trusted third-party
tools (such as analytics) but these services are used solely to keep the site functional.
</p>
</section>
</li>
<li>
<h3 class="text-xl font-semibold mt-6 mb-2">Your Rights</h3>
<section>
<p class="mb-2">As a user, you have the right to:</p>
<ul class="list-disc list-inside indent-4">
<li>Access the personal data we hold about you.</li>
<li>Request corrections to any inaccurate or incomplete information.</li>
<li>Request the deletion of your personal data.</li>
</ul>
</section>
</li>
<li>
<h3 class="text-xl font-semibold mt-6 mb-2">Data Deletion</h3>
<section>
<p class="mb-2">
Your data, including your Miis, will be retained for as long as you have an account on the site. You may request that your data be deleted at any
time by going to your profile page, clicking the settings icon, and clicking the &apos;Delete Account&apos; button. Upon clicking, your data will be
promptly removed from our servers.
</p>
</section>
</li>
<li>
<h3 class="text-xl font-semibold mt-6 mb-2">Changes to this Privacy Policy</h3>
<section>
<p class="mb-2">
This Privacy Policy may be updated from time to time. We encourage you to review this policy periodically to stay informed about your privacy.
</p>
</section>
</li>
</ul>
</div>
</Layout>

View file

@ -1,16 +0,0 @@
---
import ProfilePage from "../../components/pages/profile";
import Layout from "../../layout.astro";
const { id } = Astro.params;
export async function getStaticPaths() {
return Array.from({ length: 50000 }, (_, i) => ({
params: { id: String(i + 1) },
}));
}
---
<Layout>
<ProfilePage client:only id={id} />
</Layout>

View file

@ -1,9 +0,0 @@
---
import ProfileSettings from "../../components/profile-settings";
import Layout from "../../layout.astro";
---
<Layout>
<!-- <ProfileInformation client:only page="settings" /> -->
<ProfileSettings client:only currentDescription={null} />
</Layout>

View file

@ -1,8 +0,0 @@
---
import SubmitForm from "../components/submit-form";
import Layout from "../layout.astro";
---
<Layout>
<SubmitForm client:load />
</Layout>

View file

@ -1,133 +0,0 @@
---
import Layout from "../layout.astro";
---
<Layout>
<div class="bg-amber-50 border-2 border-amber-500 rounded-2xl p-6">
<h1 class="text-2xl font-bold">Terms of Service</h1>
<h2 class="font-light">
<strong class="font-medium">Effective Date:</strong> March 26, 2026
</h2>
<hr class="border-black/20 mt-1 mb-4" />
<p>
By registering for, or using this service, you confirm that you understand and agree to the terms below. If you do not agree to these terms, you should
not use the service.
</p>
<p class="mt-1">
If you have any questions or concerns, please contact me at:{" "}
<a href="mailto:hello@trafficlunar.net" class="text-blue-700"> hello@trafficlunar.net </a>
.
</p>
<ul class="list-decimal ml-5 marker:text-xl marker:font-semibold">
<li>
<h3 class="text-xl font-semibold mt-6 mb-2">Usage Policy</h3>
<section>
<p class="mb-2">As a user of this site, you must abide by these guidelines:</p>
<ul class="list-disc list-inside indent-4">
<li>Nothing that would interfere with or gain unauthorized access to the website or its systems.</li>
<li>Nothing that is against the law in the United Kingdom.</li>
<li>No NSFW, violent, gory, or inappropriate Miis or images.</li>
<li>No spam.</li>
<li>No impersonation of others.</li>
<li>No malware, malicious links, or phishing content.</li>
<li>No harassment, hate speech, threats, or bullying towards others.</li>
<li>Miis must be high quality: for example, not following all instructions on the submit form correctly.</li>
<li>Avoid using inappropriate language. Profanity may be automatically censored.</li>
<li>No use of automated scripts, bots, or scrapers to access or interact with the site.</li>
</ul>
<p class="mt-2">
If you find anybody or a Mii breaking these rules, please report it by going to their page and clicking the &quot;Report&quot; button.
</p>
</section>
</li>
<li>
<h3 class="text-xl font-semibold mt-6 mb-2">Termination</h3>
<section>
<p class="mb-2">
We reserve the right to suspend or terminate your access to the site at any time if you violate these Terms of Service or engage in any activities
that disrupt the functionality of the site.
</p>
<p>
To request deletion of your account and personal data, please refer to the{" "}
<a href="/privacy" class="text-blue-700"> Privacy Policy </a>{" "}
(see &quot;Data Deletion&quot;) or email me at{" "}
<a href="mailto:hello@trafficlunar.net" class="text-blue-700"> hello@trafficlunar.net </a>
</p>
</section>
</li>
<li>
<h3 class="text-xl font-semibold mt-6 mb-2">Eligibility</h3>
<section>
<p class="mb-2">By using this service, you confirm that you are at least 13 years old or have the consent of a parent or guardian.</p>
</section>
</li>
<li>
<h3 class="text-xl font-semibold mt-6 mb-2">Liability</h3>
<section>
<p class="mb-2">
This service is provided &quot;as is&quot; and without any warranties. We are not responsible for any user-generated content or the actions of users
on the site. You use the site at your own risk.
</p>
<p>
We do not guarantee continuous or secure access to the service and are not liable for any damages resulting from interruptions, loss of data, or
unauthorized access.
</p>
</section>
</li>
<li>
<h3 class="text-xl font-semibold mt-6 mb-2">DMCA & Copyright</h3>
<section>
<p class="mb-2">
If you believe that content uploaded to this site infringes on your copyright, you may submit a DMCA takedown request by emailing{" "}
<a href="mailto:hello@trafficlunar.net" class="text-blue-700"> hello@trafficlunar.net </a>{" "}
or by reporting the Mii on its page.
</p>
<p class="mb-2">Please include:</p>
<ul class="list-disc list-inside indent-4">
<li>Your name and contact information</li>
<li>A description of the copyrighted work</li>
<li>A link to the allegedly infringing material</li>
<li>A statement that you have a good faith belief that the use is not authorized</li>
<li>
A statement that the information in the notice is accurate and, under penalty of perjury, that you are authorized to act on behalf of the
copyright owner
</li>
<li>Your electronic or physical signature</li>
</ul>
</section>
</li>
<li>
<h3 class="text-xl font-semibold mt-6 mb-2">Nintendo Disclaimer</h3>
<section>
<p class="mb-2">
This site is not affiliated with, endorsed by, or associated with Nintendo in any way. &quot;Mii&quot; and all related character designs are
trademarks of Nintendo Co., Ltd.
</p>
<p>
All Mii-related content is shared by users under the assumption that it does not violate any third-party rights. If you believe your rights have
been infringed, please see the DMCA section above.
</p>
</section>
</li>
<li>
<h3 class="text-xl font-semibold mt-6 mb-2">Changes to this Terms of Service</h3>
<section>
<p class="mb-2">
This Terms of Service may be updated from time to time. We encourage you to review the terms periodically to stay informed about the use of the
site. We may notify users via a site banner or other means if changes are made to the Terms of Service.
</p>
</section>
</li>
</ul>
</div>
</Layout>

View file

@ -1,11 +0,0 @@
import { atom } from "nanostores";
interface SessionData {
user?: {
id: string;
image: string;
name: string;
};
}
export const session = atom<SessionData | null>(null);

View file

@ -1,14 +0,0 @@
{
"extends": "astro/tsconfigs/strict",
"include": [
".astro/types.d.ts",
"**/*"
],
"exclude": [
"dist"
],
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "react"
}
}

21
next.config.ts Normal file
View file

@ -0,0 +1,21 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
images: {
unoptimized: true,
},
async headers() {
return [
{
// Prevent Cloudflare from serving cached HTML for RSC navigation requests
source: "/:path*",
headers: [
{ key: "Vary", value: "RSC, Next-Router-State-Tree, Next-Router-Prefetch" },
],
},
];
},
};
export default nextConfig;

View file

@ -1,13 +1,56 @@
{
"name": "tomodachi-share",
"version": "1.0.0",
"description": "",
"main": "index.js",
"version": "0.1.0",
"private": true,
"packageManager": "pnpm@10.33.0",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"postinstall": "prisma generate"
},
"keywords": [],
"author": "",
"license": "ISC",
"packageManager": "pnpm@10.30.3"
"dependencies": {
"@2toad/profanity": "^3.3.0",
"@auth/prisma-adapter": "2.11.1",
"@bprogress/next": "^3.2.12",
"@hello-pangea/dnd": "^18.0.1",
"@prisma/client": "^6.19.2",
"bit-buffer": "^0.3.0",
"canvas-confetti": "^1.9.4",
"dayjs": "^1.11.20",
"downshift": "^9.3.2",
"embla-carousel-react": "^8.6.0",
"file-type": "^22.0.1",
"jsqr": "^1.4.0",
"next": "16.2.3",
"next-auth": "5.0.0-beta.30",
"qrcode-generator": "^2.0.4",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-dropzone": "^15.0.0",
"react-image-crop": "^11.0.10",
"redis": "^5.11.0",
"satori": "^0.26.0",
"sharp": "^0.34.5",
"sjcl-with-all": "1.0.8",
"swr": "^2.4.1",
"zod": "^4.3.6"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.5",
"@iconify/react": "^6.0.2",
"@tailwindcss/postcss": "^4.2.2",
"@types/canvas-confetti": "^1.9.0",
"@types/node": "^25.6.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/sjcl": "^1.0.34",
"eslint": "^10.2.0",
"eslint-config-next": "16.2.3",
"prisma": "^6.19.2",
"schema-dts": "^2.0.0",
"tailwindcss": "^4.2.2",
"typescript": "^6.0.2"
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,4 +0,0 @@
packages:
- "backend"
- "frontend"
- "shared"

View file

@ -0,0 +1,2 @@
-- CreateIndex
CREATE INDEX "miis_in_queue_quarantined_createdAt_idx" ON "miis"("in_queue", "quarantined", "createdAt" DESC);

View file

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

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

View file

Before

Width:  |  Height:  |  Size: 1.3 KiB

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

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

View file

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 100 KiB

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