feat: vite test

This commit is contained in:
trafficlunar 2026-04-17 14:24:40 +01:00
parent d208565a61
commit 1d11cf3f99
122 changed files with 6922 additions and 16846 deletions

View file

@ -10,7 +10,7 @@ const nextConfig: NextConfig = {
{ {
source: "/api/:path*", source: "/api/:path*",
headers: [ headers: [
{ key: "Access-Control-Allow-Origin", value: process.env.FRONTEND_URL || "http://localhost:4321" }, { 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-Credentials", value: "true" },
{ key: "Access-Control-Allow-Methods", value: "GET,POST,PATCH,DELETE,OPTIONS" }, { key: "Access-Control-Allow-Methods", value: "GET,POST,PATCH,DELETE,OPTIONS" },
], ],

34
frontend/.gitignore vendored
View file

@ -1,24 +1,24 @@
# build output # Logs
dist/ logs
# generated types *.log
.astro/
# dependencies
node_modules/
# logs
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
pnpm-debug.log* pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# environment variables # Editor directories and files
.env .vscode/*
.env.production !.vscode/extensions.json
.idea
# macOS-specific files
.DS_Store .DS_Store
*.suo
# jetbrains setting folder *.ntvs*
.idea/ *.njsproj
*.sln
*.sw?

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 +1,73 @@
# Astro Starter Kit: Minimal # React + TypeScript + Vite
```sh This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
pnpm create astro@latest -- --template minimal
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...
},
},
])
``` ```
> 🧑‍🚀 **Seasoned astronaut?** Delete this file. Have fun! 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:
## 🚀 Project Structure ```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
Inside of your Astro project, you'll see the following folders and files: export default defineConfig([
globalIgnores(['dist']),
```text {
/ files: ['**/*.{ts,tsx}'],
├── public/ extends: [
├── src/ // Other configs...
│ └── pages/ // Enable lint rules for React
│ └── index.astro reactX.configs['recommended-typescript'],
└── package.json // Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
``` ```
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",
},
],
});

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,
},
},
])

64
frontend/index.html Normal file
View file

@ -0,0 +1,64 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<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>

View file

@ -1,31 +1,23 @@
{ {
"name": "", "name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module", "type": "module",
"version": "0.0.1",
"engines": {
"node": ">=22.12.0"
},
"scripts": { "scripts": {
"dev": "astro dev", "dev": "vite",
"build": "astro build", "build": "tsc -b && vite build",
"preview": "astro preview", "lint": "eslint .",
"astro": "astro" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@astrojs/react": "^5.0.3",
"@bprogress/react": "^1.2.7", "@bprogress/react": "^1.2.7",
"@fontsource-variable/lexend": "^5.2.11",
"@hello-pangea/dnd": "^18.0.1", "@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", "@nanostores/react": "^1.1.0",
"@swup/astro": "^1.8.0",
"@tailwindcss/vite": "^4.2.2", "@tailwindcss/vite": "^4.2.2",
"@tomodachi-share/shared": "workspace:*", "@tomodachi-share/shared": "workspace:*",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"astro": "^6.1.7",
"canvas-confetti": "^1.9.4", "canvas-confetti": "^1.9.4",
"dayjs": "^1.11.20", "dayjs": "^1.11.20",
"downshift": "^9.3.2", "downshift": "^9.3.2",
@ -33,18 +25,30 @@
"jsqr": "^1.4.0", "jsqr": "^1.4.0",
"nanostores": "^1.2.0", "nanostores": "^1.2.0",
"qrcode-generator": "^2.0.4", "qrcode-generator": "^2.0.4",
"react": "^19.2.5", "react": "^19.2.4",
"react-dom": "^19.2.5", "react-dom": "^19.2.4",
"react-dropzone": "^15.0.0", "react-dropzone": "^15.0.0",
"react-image-crop": "^11.0.10", "react-image-crop": "^11.0.10",
"react-router": "^7.14.1",
"seedrandom": "^3.0.5", "seedrandom": "^3.0.5",
"tailwindcss": "^4.2.2", "tailwindcss": "^4.2.2",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.4",
"@iconify/react": "^6.0.2", "@iconify/react": "^6.0.2",
"@types/canvas-confetti": "^1.9.0", "@types/canvas-confetti": "^1.9.0",
"@types/node": "^24.12.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/seedrandom": "^3.0.8", "@types/seedrandom": "^3.0.8",
"astro-icon": "^1.1.5" "@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"
} }
} }

4479
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

0
frontend/public/tutorial/switch/adding-mii/step1.jpg Executable file → Normal file
View file

Before

Width:  |  Height:  |  Size: 233 KiB

After

Width:  |  Height:  |  Size: 233 KiB

Before After
Before After

0
frontend/public/tutorial/switch/adding-mii/step2.jpg Executable file → Normal file
View file

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Before After
Before After

0
frontend/public/tutorial/switch/adding-mii/step4.jpg Executable file → Normal file
View file

Before

Width:  |  Height:  |  Size: 151 KiB

After

Width:  |  Height:  |  Size: 151 KiB

Before After
Before After

0
frontend/public/tutorial/switch/step4.jpg Executable file → Normal file
View file

Before

Width:  |  Height:  |  Size: 148 KiB

After

Width:  |  Height:  |  Size: 148 KiB

Before After
Before After

0
frontend/public/tutorial/switch/submitting/step1.jpg Executable file → Normal file
View file

Before

Width:  |  Height:  |  Size: 247 KiB

After

Width:  |  Height:  |  Size: 247 KiB

Before After
Before After

0
frontend/public/tutorial/switch/submitting/step2.jpg Executable file → Normal file
View file

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Before After
Before After

0
frontend/public/tutorial/switch/submitting/step3.jpg Executable file → Normal file
View file

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 142 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View file

@ -1,92 +1,92 @@
import { useRouter } from "next/navigation"; // import { useRouter } from "next/navigation";
import { useEffect, useState } from "react"; // import { useEffect, useState } from "react";
import { createPortal } from "react-dom"; // import { createPortal } from "react-dom";
import { Icon } from "@iconify/react"; // import { Icon } from "@iconify/react";
import SubmitButton from "../submit-button"; // import SubmitButton from "../submit-button";
interface Props { // interface Props {
punishmentId: number; // punishmentId: number;
} // }
export default function PunishmentDeletionDialog({ punishmentId }: Props) { // export default function PunishmentDeletionDialog({ punishmentId }: Props) {
const router = useRouter(); // const router = useRouter();
const [isOpen, setIsOpen] = useState(false); // const [isOpen, setIsOpen] = useState(false);
const [isVisible, setIsVisible] = useState(false); // const [isVisible, setIsVisible] = useState(false);
const [error, setError] = useState<string | undefined>(undefined); // const [error, setError] = useState<string | undefined>(undefined);
const handleSubmit = async () => { // const handleSubmit = async () => {
const response = await fetch(`/api/admin/punish?id=${punishmentId}`, { method: "DELETE" }); // const response = await fetch(`/api/admin/punish?id=${punishmentId}`, { method: "DELETE" });
if (!response.ok) { // if (!response.ok) {
const data = await response.json(); // const data = await response.json();
setError(data.error); // setError(data.error);
return; // return;
} // }
router.refresh(); // router.refresh();
}; // };
const close = () => { // const close = () => {
setIsVisible(false); // setIsVisible(false);
setTimeout(() => { // setTimeout(() => {
setIsOpen(false); // setIsOpen(false);
}, 300); // }, 300);
}; // };
useEffect(() => { // useEffect(() => {
if (isOpen) { // if (isOpen) {
// slight delay to trigger animation // // slight delay to trigger animation
setTimeout(() => setIsVisible(true), 10); // setTimeout(() => setIsVisible(true), 10);
} // }
}, [isOpen]); // }, [isOpen]);
return ( // return (
<> // <>
<button onClick={() => setIsOpen(true)} aria-label="Delete Punishment" className="text-red-500 cursor-pointer hover:text-red-600 text-lg"> // <button onClick={() => setIsOpen(true)} aria-label="Delete Punishment" className="text-red-500 cursor-pointer hover:text-red-600 text-lg">
<Icon icon="material-symbols:close-rounded" /> // <Icon icon="material-symbols:close-rounded" />
</button> // </button>
{isOpen && // {isOpen &&
createPortal( // createPortal(
<div className="fixed inset-0 w-full h-[calc(100%-var(--header-height))] top-(--header-height) flex items-center justify-center z-40"> // <div className="fixed inset-0 w-full h-[calc(100%-var(--header-height))] top-(--header-height) flex items-center justify-center z-40">
<div // <div
onClick={close} // onClick={close}
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${ // className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${
isVisible ? "opacity-100" : "opacity-0" // isVisible ? "opacity-100" : "opacity-0"
}`} // }`}
/> // />
<div // <div
className={`z-50 bg-orange-50 border-2 border-amber-500 rounded-2xl shadow-lg p-6 w-full max-w-md transition-discrete duration-300 flex flex-col ${ // className={`z-50 bg-orange-50 border-2 border-amber-500 rounded-2xl shadow-lg p-6 w-full max-w-md transition-discrete duration-300 flex flex-col ${
isVisible ? "scale-100 opacity-100" : "scale-75 opacity-0" // isVisible ? "scale-100 opacity-100" : "scale-75 opacity-0"
}`} // }`}
> // >
<div className="flex justify-between items-center mb-2"> // <div className="flex justify-between items-center mb-2">
<h2 className="text-xl font-bold">Punishment Deletion</h2> // <h2 className="text-xl font-bold">Punishment Deletion</h2>
<button onClick={close} aria-label="Close" className="text-red-400 hover:text-red-500 text-2xl cursor-pointer"> // <button onClick={close} aria-label="Close" className="text-red-400 hover:text-red-500 text-2xl cursor-pointer">
<Icon icon="material-symbols:close-rounded" /> // <Icon icon="material-symbols:close-rounded" />
</button> // </button>
</div> // </div>
<p className="text-sm text-zinc-500">Are you sure? This will delete the user&lsquo;s punishment and they will be able to come back.</p> // <p className="text-sm text-zinc-500">Are you sure? This will delete the user&lsquo;s punishment and they will be able to come back.</p>
{error && <span className="text-red-400 font-bold mt-2">Error: {error}</span>} // {error && <span className="text-red-400 font-bold mt-2">Error: {error}</span>}
<div className="flex justify-end gap-2 mt-4"> // <div className="flex justify-end gap-2 mt-4">
<button onClick={close} className="pill button"> // <button onClick={close} className="pill button">
Cancel // Cancel
</button> // </button>
<SubmitButton onClick={handleSubmit} /> // <SubmitButton onClick={handleSubmit} />
</div> // </div>
</div> // </div>
</div>, // </div>,
document.body, // document.body,
)} // )}
</> // </>
); // );
} // }

View file

@ -1,30 +1,30 @@
import { useRouter } from "next/navigation"; // import { useRouter } from "next/navigation";
import { useTransition } from "react"; // import { useTransition } from "react";
import { ReportStatus } from "@prisma/client"; // import { ReportStatus } from "@prisma/client";
export default function ReportTabs({ status }: { status?: ReportStatus }) { // export default function ReportTabs({ status }: { status?: ReportStatus }) {
const router = useRouter(); // const router = useRouter();
const [isPending, startTransition] = useTransition(); // const [isPending, startTransition] = useTransition();
return ( // return (
<div className={`flex gap-2 p-3 border-b border-orange-300 transition-opacity ${isPending ? "opacity-50" : ""}`}> // <div className={`flex gap-2 p-3 border-b border-orange-300 transition-opacity ${isPending ? "opacity-50" : ""}`}>
{["ALL", "OPEN", "RESOLVED", "DISMISSED"].map((s) => ( // {["ALL", "OPEN", "RESOLVED", "DISMISSED"].map((s) => (
<button // <button
key={s} // key={s}
onClick={() => // onClick={() =>
startTransition(() => { // startTransition(() => {
router.push(s === "ALL" ? "/admin" : `/admin?status=${s}`, { scroll: false }); // router.push(s === "ALL" ? "/admin" : `/admin?status=${s}`, { scroll: false });
}) // })
} // }
className={`text-sm px-3 py-1 rounded-full font-medium cursor-pointer border transition-colors ${ // className={`text-sm px-3 py-1 rounded-full font-medium cursor-pointer border transition-colors ${
(s === "ALL" && !status) || s === status // (s === "ALL" && !status) || s === status
? "bg-orange-400 text-white border-orange-400" // ? "bg-orange-400 text-white border-orange-400"
: "bg-white text-orange-700 border-orange-300 hover:bg-orange-50" // : "bg-white text-orange-700 border-orange-300 hover:bg-orange-50"
}`} // }`}
> // >
{s} // {s}
</button> // </button>
))} // ))}
</div> // </div>
); // );
} // }

View file

@ -1,51 +1,51 @@
import { useState } from "react"; // import { useState } from "react";
import { Icon } from "@iconify/react"; // import { Icon } from "@iconify/react";
import { redirect } from "next/navigation"; // import { redirect } from "next/navigation";
interface Props { // interface Props {
hasExpired: boolean; // hasExpired: boolean;
} // }
export default function ReturnToIsland({ hasExpired }: Props) { // export default function ReturnToIsland({ hasExpired }: Props) {
const [isChecked, setIsChecked] = useState(false); // const [isChecked, setIsChecked] = useState(false);
const [error, setError] = useState<string | undefined>(undefined); // const [error, setError] = useState<string | undefined>(undefined);
const handleClick = async () => { // const handleClick = async () => {
const response = await fetch("/api/return", { method: "DELETE" }); // const response = await fetch("/api/return", { method: "DELETE" });
if (!response.ok) { // if (!response.ok) {
const data = await response.json(); // const data = await response.json();
setError(data.error); // setError(data.error);
return; // return;
} // }
redirect("/"); // redirect("/");
}; // };
return ( // return (
<> // <>
<div className="flex justify-center items-center gap-2"> // <div className="flex justify-center items-center gap-2">
<input // <input
type="checkbox" // type="checkbox"
id="agreement" // id="agreement"
disabled={hasExpired} // disabled={hasExpired}
checked={isChecked} // checked={isChecked}
onChange={(e) => setIsChecked(e.target.checked)} // onChange={(e) => setIsChecked(e.target.checked)}
className={`checkbox ${hasExpired && "text-zinc-600 bg-zinc-100! border-zinc-300!"}`} // className={`checkbox ${hasExpired && "text-zinc-600 bg-zinc-100! border-zinc-300!"}`}
/> // />
<label htmlFor="agreement" className={`${hasExpired && "text-zinc-500"}`}> // <label htmlFor="agreement" className={`${hasExpired && "text-zinc-500"}`}>
I Agree // I Agree
</label> // </label>
</div> // </div>
<hr className="border-zinc-300 mt-3 mb-4" /> // <hr className="border-zinc-300 mt-3 mb-4" />
{error && <span className="text-red-400 font-bold mb-2.5">Error: {error}</span>} // {error && <span className="text-red-400 font-bold mb-2.5">Error: {error}</span>}
<button disabled={!isChecked} aria-label="Travel Back Home" onClick={handleClick} className="pill button gap-2 w-fit self-center"> // <button disabled={!isChecked} aria-label="Travel Back Home" onClick={handleClick} className="pill button gap-2 w-fit self-center">
<Icon icon="ic:round-home" fontSize={24} /> // <Icon icon="ic:round-home" fontSize={24} />
Travel Back // Travel Back
</button> // </button>
</> // </>
); // );
} // }

View file

@ -1,316 +1,316 @@
// WARNING: this code is quite trash // // WARNING: this code is quite trash
import { useState } from "react"; // import { useState } from "react";
import { Icon } from "@iconify/react"; // import { Icon } from "@iconify/react";
import { Prisma, PunishmentType } from "@prisma/client"; // import { Prisma, PunishmentType } from "@prisma/client";
import ProfilePicture from "../profile-picture"; // import ProfilePicture from "../profile-picture";
import SubmitButton from "../submit-button"; // import SubmitButton from "../submit-button";
import PunishmentDeletionDialog from "./punishment-deletion-dialog"; // import PunishmentDeletionDialog from "./punishment-deletion-dialog";
interface ApiResponse { // interface ApiResponse {
success: boolean; // success: boolean;
name: string; // name: string;
image: string; // image: string;
createdAt: string; // createdAt: string;
punishments: Prisma.PunishmentGetPayload<{ // punishments: Prisma.PunishmentGetPayload<{
include: { // include: {
violatingMiis: true; // violatingMiis: true;
}; // };
}>[]; // }>[];
} // }
interface MiiList { // interface MiiList {
id: number; // id: number;
reason: string; // reason: string;
} // }
export default function Punishments() { // export default function Punishments() {
const [userId, setUserId] = useState(-1); // const [userId, setUserId] = useState(-1);
const [user, setUser] = useState<ApiResponse | undefined>(); // const [user, setUser] = useState<ApiResponse | undefined>();
const [type, setType] = useState<PunishmentType>("WARNING"); // const [type, setType] = useState<PunishmentType>("WARNING");
const [duration, setDuration] = useState(1); // const [duration, setDuration] = useState(1);
const [notes, setNotes] = useState(""); // const [notes, setNotes] = useState("");
const [reasons, setReasons] = useState(""); // const [reasons, setReasons] = useState("");
const [miiList, setMiiList] = useState<MiiList[]>([]); // const [miiList, setMiiList] = useState<MiiList[]>([]);
const [newMii, setNewMii] = useState<MiiList>({ // const [newMii, setNewMii] = useState<MiiList>({
id: 0, // id: 0,
reason: "", // reason: "",
}); // });
const [error, setError] = useState<string | undefined>(undefined); // const [error, setError] = useState<string | undefined>(undefined);
const addMiiToList = () => { // const addMiiToList = () => {
if (newMii.id && newMii.reason) { // if (newMii.id && newMii.reason) {
setMiiList([...miiList, { ...newMii, id: Number(newMii.id) }]); // setMiiList([...miiList, { ...newMii, id: Number(newMii.id) }]);
setNewMii({ id: 0, reason: "" }); // setNewMii({ id: 0, reason: "" });
} // }
}; // };
const removeMiiFromList = (index: number) => { // const removeMiiFromList = (index: number) => {
setMiiList(miiList.filter((_, i) => i !== index)); // setMiiList(miiList.filter((_, i) => i !== index));
}; // };
const handleLookup = async () => { // const handleLookup = async () => {
const response = await fetch(`/api/admin/lookup?id=${userId}`); // const response = await fetch(`/api/admin/lookup?id=${userId}`);
const data = await response.json(); // const data = await response.json();
setUser(data); // setUser(data);
}; // };
const handleSubmit = async () => { // const handleSubmit = async () => {
const response = await fetch(`/api/admin/punish?id=${userId}`, { // const response = await fetch(`/api/admin/punish?id=${userId}`, {
method: "POST", // method: "POST",
body: JSON.stringify({ // body: JSON.stringify({
type, // type,
duration, // duration,
notes, // notes,
reasons: reasons.split(","), // reasons: reasons.split(","),
miiReasons: miiList, // miiReasons: miiList,
}), // }),
}); // });
if (!response.ok) { // if (!response.ok) {
const { error } = await response.json(); // const { error } = await response.json();
setError(error); // setError(error);
} // }
// Set all inputs to empty/default // // Set all inputs to empty/default
setType("WARNING"); // setType("WARNING");
setDuration(1); // setDuration(1);
setNotes(""); // setNotes("");
setReasons(""); // setReasons("");
setMiiList([]); // setMiiList([]);
setNewMii({ id: 0, reason: "" }); // setNewMii({ id: 0, reason: "" });
setError(""); // setError("");
await handleLookup(); // await handleLookup();
}; // };
return ( // return (
<div className="bg-orange-100 rounded-xl border-2 border-orange-400 p-2 gap-2"> // <div className="bg-orange-100 rounded-xl border-2 border-orange-400 p-2 gap-2">
<div className="flex justify-center items-center gap-2"> // <div className="flex justify-center items-center gap-2">
<input // <input
type="number" // type="number"
placeholder="Enter user ID to lookup..." // placeholder="Enter user ID to lookup..."
name="user-id" // name="user-id"
value={userId !== -1 ? userId : ""} // value={userId !== -1 ? userId : ""}
onChange={(e) => setUserId(Number(e.target.value))} // onChange={(e) => setUserId(Number(e.target.value))}
className="pill input w-full max-w-lg" // className="pill input w-full max-w-lg"
/> // />
<button onClick={handleLookup} className="pill button"> // <button onClick={handleLookup} className="pill button">
Lookup User // Lookup User
</button> // </button>
</div> // </div>
{user && ( // {user && (
<div className="grid grid-cols-2 gap-2 mt-2 max-lg:grid-cols-1"> // <div className="grid grid-cols-2 gap-2 mt-2 max-lg:grid-cols-1">
<div className="p-4 bg-orange-50 border border-orange-300 rounded-md shadow-sm"> // <div className="p-4 bg-orange-50 border border-orange-300 rounded-md shadow-sm">
<div className="flex gap-1"> // <div className="flex gap-1">
<ProfilePicture src={user.image} width={96} height={96} className="rounded-full border-2 border-orange-400" /> // <ProfilePicture src={user.image} width={96} height={96} className="rounded-full border-2 border-orange-400" />
<div className="p-2 flex flex-col"> // <div className="p-2 flex flex-col">
<p className="text-xl font-bold">{user.name}</p> // <p className="text-xl font-bold">{user.name}</p>
<p className="text-black/60 text-sm font-medium">@{user.name}</p> // <p className="text-black/60 text-sm font-medium">@{user.name}</p>
<p className="text-sm mt-auto"> // <p className="text-sm mt-auto">
<span className="font-medium">Created:</span>{" "} // <span className="font-medium">Created:</span>{" "}
{new Date(user.createdAt).toLocaleString("en-GB", { // {new Date(user.createdAt).toLocaleString("en-GB", {
day: "2-digit", // day: "2-digit",
month: "long", // month: "long",
year: "numeric", // year: "numeric",
hour: "2-digit", // hour: "2-digit",
minute: "2-digit", // minute: "2-digit",
second: "2-digit", // second: "2-digit",
timeZone: "UTC", // timeZone: "UTC",
})}{" "} // })}{" "}
UTC // UTC
</p> // </p>
</div> // </div>
</div> // </div>
<hr className="border-zinc-300 my-3" /> // <hr className="border-zinc-300 my-3" />
<div className="flex flex-col gap-2"> // <div className="flex flex-col gap-2">
{user.punishments.length === 0 ? ( // {user.punishments.length === 0 ? (
<p className="text-center text-zinc-500 my-2">No punishments found.</p> // <p className="text-center text-zinc-500 my-2">No punishments found.</p>
) : ( // ) : (
<> // <>
{user.punishments.map((punishment) => ( // {user.punishments.map((punishment) => (
<div // <div
key={punishment.id} // key={punishment.id}
className={`border rounded-lg p-3 space-y-1 ${ // className={`border rounded-lg p-3 space-y-1 ${
punishment.type === "WARNING" // punishment.type === "WARNING"
? "bg-yellow-50 border-yellow-400" // ? "bg-yellow-50 border-yellow-400"
: punishment.type === "TEMP_EXILE" // : punishment.type === "TEMP_EXILE"
? "bg-orange-100 border-orange-200" // ? "bg-orange-100 border-orange-200"
: "bg-red-50 border-red-200" // : "bg-red-50 border-red-200"
}`} // }`}
> // >
<div className="flex items-center justify-between mb-2"> // <div className="flex items-center justify-between mb-2">
<span // <span
className={`border px-2 py-1 rounded text-xs font-semibold ${ // className={`border px-2 py-1 rounded text-xs font-semibold ${
punishment.type === "WARNING" // punishment.type === "WARNING"
? "bg-yellow-200 text-yellow-800 border-yellow-500" // ? "bg-yellow-200 text-yellow-800 border-yellow-500"
: punishment.type === "TEMP_EXILE" // : punishment.type === "TEMP_EXILE"
? "bg-orange-200 text-orange-800 border-orange-500" // ? "bg-orange-200 text-orange-800 border-orange-500"
: "bg-red-200 text-red-800 border-red-500" // : "bg-red-200 text-red-800 border-red-500"
}`} // }`}
> // >
{punishment.type} // {punishment.type}
</span> // </span>
<div className="flex items-center gap-2"> // <div className="flex items-center gap-2">
<span className="text-sm text-zinc-600"> // <span className="text-sm text-zinc-600">
{new Date(punishment.createdAt).toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" })} // {new Date(punishment.createdAt).toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" })}
</span> // </span>
<PunishmentDeletionDialog punishmentId={punishment.id} /> // <PunishmentDeletionDialog punishmentId={punishment.id} />
</div> // </div>
</div> // </div>
<p className="text-sm text-zinc-600"> // <p className="text-sm text-zinc-600">
<strong>Notes:</strong> {punishment.notes} // <strong>Notes:</strong> {punishment.notes}
</p> // </p>
{punishment.type !== "WARNING" && ( // {punishment.type !== "WARNING" && (
<p className="text-sm text-zinc-600"> // <p className="text-sm text-zinc-600">
<strong>Expires:</strong>{" "} // <strong>Expires:</strong>{" "}
{punishment.expiresAt // {punishment.expiresAt
? new Date(punishment.expiresAt).toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" }) // ? new Date(punishment.expiresAt).toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" })
: "Never"} // : "Never"}
</p> // </p>
)} // )}
{punishment.type !== "PERM_EXILE" && ( // {punishment.type !== "PERM_EXILE" && (
<p className="text-sm text-zinc-600"> // <p className="text-sm text-zinc-600">
<strong>Returned:</strong> {JSON.stringify(punishment.returned)} // <strong>Returned:</strong> {JSON.stringify(punishment.returned)}
</p> // </p>
)} // )}
<p className="text-sm text-zinc-600"> // <p className="text-sm text-zinc-600">
<strong>Reasons:</strong> // <strong>Reasons:</strong>
</p> // </p>
<ul className="ml-8 list-disc text-sm text-zinc-600"> // <ul className="ml-8 list-disc text-sm text-zinc-600">
{punishment.reasons.map((reason, index) => ( // {punishment.reasons.map((reason, index) => (
<li key={index}>{reason}</li> // <li key={index}>{reason}</li>
))} // ))}
</ul> // </ul>
<p className="text-sm text-zinc-600"> // <p className="text-sm text-zinc-600">
<strong>Mii Reasons:</strong> // <strong>Mii Reasons:</strong>
</p> // </p>
<ul className="ml-8 list-disc text-sm text-zinc-600"> // <ul className="ml-8 list-disc text-sm text-zinc-600">
{punishment.violatingMiis.map((mii) => ( // {punishment.violatingMiis.map((mii) => (
<li key={mii.miiId}> // <li key={mii.miiId}>
{mii.miiId}: {mii.reason} // {mii.miiId}: {mii.reason}
</li> // </li>
))} // ))}
</ul> // </ul>
</div> // </div>
))} // ))}
</> // </>
)} // )}
</div> // </div>
</div> // </div>
<div className="p-4 bg-orange-50 border border-orange-300 rounded-md shadow-sm flex flex-col gap-1"> // <div className="p-4 bg-orange-50 border border-orange-300 rounded-md shadow-sm flex flex-col gap-1">
{/* Punishment type */} // {/* Punishment type */}
<p className="text-sm">Punishment Type</p> // <p className="text-sm">Punishment Type</p>
<select name="punishment-type" value={type} onChange={(e) => setType(e.target.value as PunishmentType)} className="pill input"> // <select name="punishment-type" value={type} onChange={(e) => setType(e.target.value as PunishmentType)} className="pill input">
<option value="WARNING">Warning</option> // <option value="WARNING">Warning</option>
<option value="TEMP_EXILE">Temporary Exile</option> // <option value="TEMP_EXILE">Temporary Exile</option>
<option value="PERM_EXILE">Permanent Exile</option> // <option value="PERM_EXILE">Permanent Exile</option>
</select> // </select>
{/* Punishment duration */} // {/* Punishment duration */}
{type === "TEMP_EXILE" && ( // {type === "TEMP_EXILE" && (
<> // <>
<p className="text-sm">Duration</p> // <p className="text-sm">Duration</p>
<select name="punishment-duration" value={duration} onChange={(e) => setDuration(Number(e.target.value))} className="pill input"> // <select name="punishment-duration" value={duration} onChange={(e) => setDuration(Number(e.target.value))} className="pill input">
<option value="1">1 Day</option> // <option value="1">1 Day</option>
<option value="7">7 Days</option> // <option value="7">7 Days</option>
<option value="30">30 Days</option> // <option value="30">30 Days</option>
</select> // </select>
</> // </>
)} // )}
{/* Punishment notes */} // {/* Punishment notes */}
<p className="text-sm">Notes</p> // <p className="text-sm">Notes</p>
<textarea // <textarea
rows={2} // rows={2}
maxLength={256} // maxLength={256}
placeholder="Type notes here for the punishment..." // placeholder="Type notes here for the punishment..."
className="pill input rounded-xl! resize-none" // className="pill input rounded-xl! resize-none"
value={notes} // value={notes}
onChange={(e) => setNotes(e.target.value)} // onChange={(e) => setNotes(e.target.value)}
/> // />
{/* Punishment profile-related reasons */} // {/* Punishment profile-related reasons */}
<p className="text-sm">Profile-related reasons (split by comma)</p> // <p className="text-sm">Profile-related reasons (split by comma)</p>
<textarea // <textarea
rows={2} // rows={2}
maxLength={256} // maxLength={256}
placeholder="Type profile-related reasons here for the punishment..." // placeholder="Type profile-related reasons here for the punishment..."
className="pill input rounded-xl! resize-none" // className="pill input rounded-xl! resize-none"
value={reasons} // value={reasons}
onChange={(e) => setReasons(e.target.value)} // onChange={(e) => setReasons(e.target.value)}
/> // />
{/* Punishment mii-related reasons */} // {/* Punishment mii-related reasons */}
<p className="text-sm">Mii-related reasons</p> // <p className="text-sm">Mii-related reasons</p>
<div className="bg-orange-100 border border-orange-300 rounded-lg p-4"> // <div className="bg-orange-100 border border-orange-300 rounded-lg p-4">
{/* Add Mii Form */} // {/* Add Mii Form */}
<div className="flex gap-2"> // <div className="flex gap-2">
<input // <input
type="number" // type="number"
placeholder="Mii ID" // placeholder="Mii ID"
className="pill input w-24 text-sm" // className="pill input w-24 text-sm"
value={newMii.id} // value={newMii.id}
onChange={(e) => setNewMii({ ...newMii, id: Number(e.target.value) })} // onChange={(e) => setNewMii({ ...newMii, id: Number(e.target.value) })}
/> // />
<input // <input
type="text" // type="text"
placeholder="Reason for this Mii..." // placeholder="Reason for this Mii..."
className="pill input flex-1 text-sm" // className="pill input flex-1 text-sm"
value={newMii.reason} // value={newMii.reason}
onChange={(e) => setNewMii({ ...newMii, reason: e.target.value })} // onChange={(e) => setNewMii({ ...newMii, reason: e.target.value })}
/> // />
<button type="button" aria-label="Add Mii" onClick={addMiiToList} className="pill button aspect-square p-2.5!"> // <button type="button" aria-label="Add Mii" onClick={addMiiToList} className="pill button aspect-square p-2.5!">
<Icon icon="ic:baseline-plus" className="size-4" /> // <Icon icon="ic:baseline-plus" className="size-4" />
</button> // </button>
</div> // </div>
{/* Mii List */} // {/* Mii List */}
{miiList.length > 0 && ( // {miiList.length > 0 && (
<div className="mt-2 space-y-1"> // <div className="mt-2 space-y-1">
<p className="text-sm font-medium text-black/50">Violating Miis ({miiList.length})</p> // <p className="text-sm font-medium text-black/50">Violating Miis ({miiList.length})</p>
{miiList.map((mii, index) => ( // {miiList.map((mii, index) => (
<div key={index} className="bg-white border border-orange-200 rounded-md p-3 flex items-center justify-between"> // <div key={index} className="bg-white border border-orange-200 rounded-md p-3 flex items-center justify-between">
<div className="flex-1"> // <div className="flex-1">
<div className="flex items-center gap-2"> // <div className="flex items-center gap-2">
<span className="bg-orange-200 text-orange-800 border border-orange-400 px-2 py-1 rounded text-xs font-semibold">ID: {mii.id}</span> // <span className="bg-orange-200 text-orange-800 border border-orange-400 px-2 py-1 rounded text-xs font-semibold">ID: {mii.id}</span>
<span className="text-sm text-gray-500">{mii.reason}</span> // <span className="text-sm text-gray-500">{mii.reason}</span>
</div> // </div>
</div> // </div>
<button // <button
type="button" // type="button"
aria-label="Remove Mii" // aria-label="Remove Mii"
onClick={() => removeMiiFromList(index)} // onClick={() => removeMiiFromList(index)}
className="cursor-pointer text-red-500 hover:text-red-700 transition-colors" // className="cursor-pointer text-red-500 hover:text-red-700 transition-colors"
> // >
<Icon icon="iconamoon:trash" className="size-4" /> // <Icon icon="iconamoon:trash" className="size-4" />
</button> // </button>
</div> // </div>
))} // ))}
</div> // </div>
)} // )}
{miiList.length === 0 && <p className="text-center text-zinc-500 text-sm my-4">No Miis added yet</p>} // {miiList.length === 0 && <p className="text-center text-zinc-500 text-sm my-4">No Miis added yet</p>}
</div> // </div>
<div className="flex justify-between items-center mt-2"> // <div className="flex justify-between items-center mt-2">
{error && <span className="text-red-400 font-bold">Error: {error}</span>} // {error && <span className="text-red-400 font-bold">Error: {error}</span>}
<SubmitButton onClick={handleSubmit} className="ml-auto" /> // <SubmitButton onClick={handleSubmit} className="ml-auto" />
</div> // </div>
</div> // </div>
</div> // </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

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

View file

@ -4,7 +4,7 @@ import { useStore } from "@nanostores/react";
import { session } from "../session"; import { session } from "../session";
export default function HeaderProfile() { export default function HeaderProfile() {
const API_BASE_URL = import.meta.env.PUBLIC_API_URL; const API_BASE_URL = import.meta.env.VITE_API_URL;
const $session = useStore(session); const $session = useStore(session);
useEffect(() => { useEffect(() => {

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

@ -0,0 +1,42 @@
import { Icon } from "@iconify/react";
import SearchBar from "./search-bar";
import HeaderProfile from "./header-profile";
export default function Header() {
return (
<header className="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"
className="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 className="flex justify-center max-lg:justify-end max-md:justify-center">
<SearchBar />
</div>
<ul className="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.VITE_API_URL}/random`}
aria-label="Go to Random Link"
className="pill button p-0! h-full aspect-square"
data-tooltip="Go to a Random Mii"
>
<Icon icon="mdi:dice-3" fontSize={28} />
</a>
</li>
<li>
<a href={"/submit"} className="pill button h-full">
{" "}
Submit{" "}
</a>
</li>
<HeaderProfile />
</ul>
</header>
);
}

View file

@ -11,10 +11,10 @@ interface Props {
big?: boolean; big?: boolean;
} }
export default function LikeButton({ likes, isLiked, miiId, disabled, abbreviate, big }: Props) { export default function LikeButton({ likes, isLiked, disabled, abbreviate, big }: Props) {
const [isLikedState, setIsLikedState] = useState(isLiked); const [isLikedState, setIsLikedState] = useState(isLiked);
const [likesState, setLikesState] = useState(likes); const [likesState] = useState(likes);
const [isAnimating, setIsAnimating] = useState(false); const [isAnimating] = useState(false);
const onClick = async () => { const onClick = async () => {
// if (disabled) return; // if (disabled) return;

View file

@ -20,7 +20,7 @@ export default function DeleteMiiButton({ miiId, miiName, likes, inMiiPage }: Pr
const [inputMiiName, setInputMiiName] = useState(""); const [inputMiiName, setInputMiiName] = useState("");
const handleSubmit = async () => { const handleSubmit = async () => {
const response = await fetch(`${import.meta.env.PUBLIC_API_URL}/api/mii/${miiId}/delete`, { method: "DELETE", credentials: "include" }); const response = await fetch(`${import.meta.env.VITE_API_URL}/api/mii/${miiId}/delete`, { method: "DELETE", credentials: "include" });
if (!response.ok) { if (!response.ok) {
const { error } = await response.json(); const { error } = await response.json();
setError(error); setError(error);
@ -83,7 +83,7 @@ export default function DeleteMiiButton({ miiId, miiName, likes, inMiiPage }: Pr
<p className="text-sm text-zinc-500">Are you sure? This will delete your Mii permanently. This action cannot be undone.</p> <p className="text-sm text-zinc-500">Are you sure? This will delete your Mii permanently. This action cannot be undone.</p>
<div className="bg-orange-100 rounded-xl border-2 border-orange-400 mt-4 flex overflow-hidden"> <div className="bg-orange-100 rounded-xl border-2 border-orange-400 mt-4 flex overflow-hidden">
<img src={`${import.meta.env.PUBLIC_API_URL}/mii/${miiId}/image?type=mii`} alt="mii image" width={128} height={128} /> <img src={`${import.meta.env.VITE_API_URL}/mii/${miiId}/image?type=mii`} alt="mii image" width={128} height={128} />
<div className="p-4 min-w-0"> <div className="p-4 min-w-0">
<p className="text-xl font-bold line-clamp-3 wrap-anywhere" title={miiName}> <p className="text-xl font-bold line-clamp-3 wrap-anywhere" title={miiName}>
{miiName} {miiName}

View file

@ -1,198 +1,184 @@
import crypto from "crypto"; // import crypto from "crypto";
import seedrandom from "seedrandom"; // import seedrandom from "seedrandom";
import { searchSchema } from "@tomodachi-share/shared/schemas"; // import { searchSchema } from "@tomodachi-share/shared/schemas";
import SortSelect from "./sort-select"; // import SortSelect from "./sort-select";
import Pagination from "./pagination"; // import Pagination from "./pagination";
import FilterMenu from "./filter-menu"; // import FilterMenu from "./filter-menu";
import MiiGrid from "./mii-grid"; // import MiiGrid from "./mii-grid";
interface Props { // interface Props {
searchParams: URLSearchParams; // searchParams: URLSearchParams;
userId?: number; // Profiles // userId?: number; // Profiles
parentPage?: "likes" | "admin"; // parentPage?: "likes" | "admin";
} // }
export default async function MiiList({ searchParams, userId, parentPage }: Props) { // 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(searchParams);
if (!parsed.success) return <h1>{parsed.error.issues[0].message}</h1>; // if (!parsed.success) return <h1>{parsed.error.issues[0].message}</h1>;
const { q: query, sort, tags, exclude, platform, gender, makeup, allowCopying, quarantined, page = 1, limit = 24, seed } = parsed.data; // const { q: query, sort, tags, exclude, platform, gender, makeup, allowCopying, quarantined, page = 1, limit = 24, seed } = parsed.data;
// My Likes page // // My Likes page
let miiIdsLiked: number[] | undefined = undefined; // let miiIdsLiked: number[] | undefined = undefined;
if (parentPage === "likes" && session?.user?.id) { // if (parentPage === "likes" && session?.user?.id) {
const likedMiis = await prisma.like.findMany({ // const likedMiis = await prisma.like.findMany({
where: { userId: Number(session.user.id) }, // where: { userId: Number(session.user.id) },
select: { miiId: true }, // select: { miiId: true },
}); // });
miiIdsLiked = likedMiis.map((like) => like.miiId); // miiIdsLiked = likedMiis.map((like) => like.miiId);
} // }
const where: Prisma.MiiWhereInput = { // const where: Prisma.MiiWhereInput = {
// In queue logic // // In queue logic
...(parentPage === "admin" // ...(parentPage === "admin"
? { in_queue: true } // Only show queued Miis // ? { in_queue: true } // Only show queued Miis
: userId // : userId
? { // ? {
// Include queued Miis if user is on their profile // // Include queued Miis if user is on their profile
...(Number(session?.user?.id) === userId ? {} : { in_queue: false }), // ...(Number(session?.user?.id) === userId ? {} : { in_queue: false }),
userId, // userId,
} // }
: { // : {
// Don't show queued Miis on main page // // Don't show queued Miis on main page
in_queue: false, // in_queue: false,
}), // }),
// Only show liked miis on likes page // // Only show liked miis on likes page
...(parentPage === "likes" && miiIdsLiked && { id: { in: miiIdsLiked } }), // ...(parentPage === "likes" && miiIdsLiked && { id: { in: miiIdsLiked } }),
// Searching // // Searching
...(query && { // ...(query && {
OR: [{ name: { contains: query, mode: "insensitive" } }, { tags: { has: query } }, { description: { contains: query, mode: "insensitive" } }], // OR: [{ name: { contains: query, mode: "insensitive" } }, { tags: { has: query } }, { description: { contains: query, mode: "insensitive" } }],
}), // }),
// Tag filtering // // Tag filtering
...(tags && tags.length > 0 && { tags: { hasEvery: tags } }), // ...(tags && tags.length > 0 && { tags: { hasEvery: tags } }),
...(exclude && exclude.length > 0 && { NOT: { tags: { hasSome: exclude } } }), // ...(exclude && exclude.length > 0 && { NOT: { tags: { hasSome: exclude } } }),
// Platform // // Platform
...(platform && { platform: { equals: platform } }), // ...(platform && { platform: { equals: platform } }),
// Gender // // Gender
...(gender && { gender: { equals: gender } }), // ...(gender && { gender: { equals: gender } }),
// Allow Copying // // Allow Copying
...(allowCopying && { allowedCopying: true }), // ...(allowCopying && { allowedCopying: true }),
// Makeup // // Makeup
...(makeup && { makeup: { equals: makeup } }), // ...(makeup && { makeup: { equals: makeup } }),
// Quarantined // // Quarantined
...(!quarantined && !userId && { quarantined: false }), // ...(!quarantined && !userId && { quarantined: false }),
}; // };
const select: Prisma.MiiSelect = { // const select: Prisma.MiiSelect = {
id: true, // id: true,
// Don't show when userId is specified // // Don't show when userId is specified
...(!userId && { // ...(!userId && {
user: { // user: {
select: { // select: {
id: true, // id: true,
name: true, // name: true,
}, // },
}, // },
}), // }),
platform: true, // platform: true,
name: true, // name: true,
imageCount: true, // imageCount: true,
tags: true, // tags: true,
createdAt: true, // createdAt: true,
gender: true, // gender: true,
makeup: true, // makeup: true,
allowedCopying: true, // allowedCopying: true,
quarantined: true, // quarantined: true,
in_queue: true, // in_queue: true,
// Mii liked check // // Mii liked check
...(session?.user?.id && { // ...(session?.user?.id && {
likedBy: { // likedBy: {
where: { userId: Number(session.user.id) }, // where: { userId: Number(session.user.id) },
select: { userId: true }, // select: { userId: true },
}, // },
}), // }),
// Like count // // Like count
_count: { // _count: {
select: { likedBy: true }, // select: { likedBy: true },
}, // },
}; // };
const skip = (page - 1) * limit; // const skip = (page - 1) * limit;
let totalCount: number; // let totalCount: number;
let filteredCount: number; // let miis: Prisma.MiiGetPayload<{ select: typeof select }>[];
let miis: Prisma.MiiGetPayload<{ select: typeof select }>[];
if (sort === "random") { // if (sort === "random") {
// Get all IDs that match the where conditions // // Get all IDs that match the where conditions
const matchingIds = await prisma.mii.findMany({ // const matchingIds = await prisma.mii.findMany({
where, // where,
select: { id: true }, // select: { id: true },
}); // });
totalCount = matchingIds.length; // totalCount = matchingIds.length;
filteredCount = Math.max(0, Math.min(limit, totalCount - skip));
if (matchingIds.length === 0) return; // if (matchingIds.length === 0) return;
// Use seed for consistent random results // // Use seed for consistent random results
const randomSeed = seed || crypto.randomInt(0, 1_000_000_000); // const randomSeed = seed || crypto.randomInt(0, 1_000_000_000);
const rng = seedrandom(randomSeed.toString()); // const rng = seedrandom(randomSeed.toString());
// Randomize all IDs using the Durstenfeld algorithm // // Randomize all IDs using the Durstenfeld algorithm
for (let i = matchingIds.length - 1; i > 0; i--) { // for (let i = matchingIds.length - 1; i > 0; i--) {
const j = Math.floor(rng() * (i + 1)); // const j = Math.floor(rng() * (i + 1));
[matchingIds[i], matchingIds[j]] = [matchingIds[j], matchingIds[i]]; // [matchingIds[i], matchingIds[j]] = [matchingIds[j], matchingIds[i]];
} // }
// Convert to number[] array // // Convert to number[] array
const selectedIds = matchingIds.slice(skip, skip + limit).map((i) => i.id); // const selectedIds = matchingIds.slice(skip, skip + limit).map((i) => i.id);
miis = await prisma.mii.findMany({ // miis = await prisma.mii.findMany({
where: { // where: {
id: { in: selectedIds }, // id: { in: selectedIds },
}, // },
select, // select,
}); // });
} else { // } else {
// Sorting by likes, newest, or oldest // // Sorting by likes, newest, or oldest
let orderBy: Prisma.MiiOrderByWithRelationInput[]; // let orderBy: Prisma.MiiOrderByWithRelationInput[];
if (sort === "likes") { // if (sort === "likes") {
orderBy = [{ likedBy: { _count: "desc" } }, { name: "asc" }]; // orderBy = [{ likedBy: { _count: "desc" } }, { name: "asc" }];
} else if (sort === "oldest") { // } else if (sort === "oldest") {
orderBy = [{ createdAt: "asc" }, { name: "asc" }]; // orderBy = [{ createdAt: "asc" }, { name: "asc" }];
} else { // } else {
// default to newest // // default to newest
orderBy = [{ createdAt: "desc" }, { name: "asc" }]; // orderBy = [{ createdAt: "desc" }, { name: "asc" }];
} // }
[totalCount, filteredCount, miis] = await Promise.all([ // [totalCount, miis] = await Promise.all([
prisma.mii.count({ where: { ...where, userId } }), // prisma.mii.count({ where: { ...where, userId } }),
prisma.mii.count({ where, skip, take: limit }), // prisma.mii.findMany({
prisma.mii.findMany({ // 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 (
<div className="w-full"> // <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="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"> // <div className="flex items-center gap-2">
{totalCount == filteredCount ? ( // <span className="text-2xl font-bold text-amber-900">{totalCount}</span>
<> // <span className="text-lg text-amber-700">{totalCount === 1 ? "Mii" : "Miis"}</span>
<span className="text-2xl font-bold text-amber-900">{totalCount}</span> // </div>
<span className="text-lg text-amber-700">{totalCount === 1 ? "Mii" : "Miis"}</span>
</>
) : (
<>
<span className="text-2xl font-bold text-amber-900">{filteredCount}</span>
<span className="text-sm text-amber-700">of</span>
<span className="text-lg font-semibold text-amber-800">{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"> // <div className="relative flex items-center justify-end gap-2 w-full md:max-w-2/3 max-md:justify-center">
<FilterMenu /> // <FilterMenu />
<SortSelect /> // <SortSelect />
</div> // </div>
</div> // </div>
<MiiGrid miis={miis} userId={userId} parentPage={parentPage} /> // <MiiGrid miis={miis} userId={userId} parentPage={parentPage} />
<Pagination lastPage={lastPage} /> // <Pagination lastPage={lastPage} />
</div> // </div>
); // );
} // }

View file

@ -2,8 +2,6 @@ import { Icon } from "@iconify/react";
import LikeButton from "../../like-button"; import LikeButton from "../../like-button";
import DeleteMiiButton from "../delete-mii-button"; import DeleteMiiButton from "../delete-mii-button";
import Carousel from "../../carousel";
import ImageViewer from "../../image-viewer";
interface Props { interface Props {
// miis: Prisma.MiiGetPayload<{ include: { user: { select: { id: true; name: true } }; _count: { select: { likedBy: true } } } }>[]; // miis: Prisma.MiiGetPayload<{ include: { user: { select: { id: true; name: true } }; _count: { select: { likedBy: true } } } }>[];
@ -12,11 +10,7 @@ interface Props {
parentPage?: string; parentPage?: string;
} }
const fetcher = (url: string) => fetch(url).then((res) => res.json());
export default function MiiGrid({ miis, userId, parentPage }: Props) { export default function MiiGrid({ miis, userId, parentPage }: Props) {
const likedIds = new Set([]);
return ( return (
<div className="grid grid-cols-4 gap-4 max-lg:grid-cols-3 max-md:grid-cols-2 max-[30rem]:grid-cols-1"> <div className="grid grid-cols-4 gap-4 max-lg:grid-cols-3 max-md:grid-cols-2 max-[30rem]:grid-cols-1">
{miis.map((mii) => ( {miis.map((mii) => (
@ -33,7 +27,7 @@ export default function MiiGrid({ miis, userId, parentPage }: Props) {
<a href={`/mii/${mii.id}`} className="overflow-hidden rounded-xl bg-zinc-300 shrink-0"> <a href={`/mii/${mii.id}`} className="overflow-hidden rounded-xl bg-zinc-300 shrink-0">
<img <img
src={`${import.meta.env.PUBLIC_API_URL}/mii/${mii.id}/image?type=mii`} src={`${import.meta.env.VITE_API_URL}/mii/${mii.id}/image?type=mii`}
width={240} width={240}
height={160} height={160}
alt="mii image" alt="mii image"
@ -63,7 +57,7 @@ export default function MiiGrid({ miis, userId, parentPage }: Props) {
</div> </div>
<div className="mt-auto grid grid-cols-2 items-center"> <div className="mt-auto grid grid-cols-2 items-center">
<LikeButton likes={mii._count.likedBy} miiId={mii.id} isLiked={likedIds.has(mii.id)} abbreviate /> <LikeButton likes={mii._count.likedBy} miiId={mii.id} isLiked={false} abbreviate />
{!userId && ( {!userId && (
<a href={`/profile/${mii.user?.id}`} className="text-sm text-right overflow-hidden text-ellipsis whitespace-nowrap"> <a href={`/profile/${mii.user?.id}`} className="text-sm text-right overflow-hidden text-ellipsis whitespace-nowrap">

View file

@ -27,7 +27,7 @@ export default function ShareMiiButton({ miiId }: Props) {
}; };
const handleCopyImage = async () => { const handleCopyImage = async () => {
const response = await fetch(`${import.meta.env.PUBLIC_API_URL}/mii/${miiId}/image?type=metadata`); const response = await fetch(`${import.meta.env.VITE_API_URL}/mii/${miiId}/image?type=metadata`);
const blob = await response.blob(); const blob = await response.blob();
await navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })]); await navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })]);
@ -117,7 +117,7 @@ export default function ShareMiiButton({ miiId }: Props) {
<div className="flex justify-center items-center p-4 w-full bg-orange-100 border border-orange-400 rounded-lg"> <div className="flex justify-center items-center p-4 w-full bg-orange-100 border border-orange-400 rounded-lg">
<img <img
src={`${import.meta.env.PUBLIC_API_URL}/mii/${miiId}/image?type=metadata`} src={`${import.meta.env.VITE_API_URL}/mii/${miiId}/image?type=metadata`}
alt="mii 'metadata' image" alt="mii 'metadata' image"
width={248} width={248}
height={248} height={248}
@ -129,7 +129,7 @@ export default function ShareMiiButton({ miiId }: Props) {
<div className="flex gap-2 w-full"> <div className="flex gap-2 w-full">
{/* Save button */} {/* Save button */}
<a <a
href={`${import.meta.env.PUBLIC_API_URL}/mii/${miiId}/image?type=metadata`} href={`${import.meta.env.VITE_API_URL}/mii/${miiId}/image?type=metadata`}
className="pill button p-0! aspect-square size-11 cursor-pointer text-xl" className="pill button p-0! aspect-square size-11 cursor-pointer text-xl"
aria-label="Save Image" aria-label="Save Image"
data-tooltip="Save Image" data-tooltip="Save Image"

View file

@ -12,16 +12,17 @@ interface Props {
export default function ProfileInformation({ user, page }: Props) { export default function ProfileInformation({ user, page }: Props) {
const $session = useStore(session); const $session = useStore(session);
const isAdmin = (!user ? $session?.user.id : user.id) === Number(import.meta.env.PUBLIC_ADMIN_USER_ID); const currentUser = user ?? $session?.user;
const isAdmin = currentUser?.id === Number(import.meta.env.PUBLIC_ADMIN_USER_ID);
const isContributor = import.meta.env.PUBLIC_CONTRIBUTORS_USER_IDS?.split(",").includes(user.id); const isContributor = import.meta.env.PUBLIC_CONTRIBUTORS_USER_IDS?.split(",").includes(user.id);
const isOwnProfile = !user || $session?.user?.id === user.id; const isOwnProfile = currentUser?.id === user.id;
return ( return (
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex gap-4 mb-2 max-md:flex-col"> <div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex gap-4 mb-2 max-md:flex-col">
<div className="flex w-full gap-4 overflow-x-scroll"> <div className="flex w-full gap-4 overflow-x-scroll">
{/* Profile picture */} {/* Profile picture */}
<a href={`/profile/${user.id}`} className="size-28 aspect-square"> <a href={`/profile/${user.id}`} className="size-28 aspect-square">
<image src={user.image ?? "/guest.png"} className="rounded-full bg-white border-2 border-orange-400 shadow max-md:self-center" /> <img src={user.image ?? "/guest.png"} className="rounded-full bg-white border-2 border-orange-400 shadow max-md:self-center" />
</a> </a>
{/* User information */} {/* User information */}
<div className="flex flex-col w-full relative py-3"> <div className="flex flex-col w-full relative py-3">
@ -57,7 +58,7 @@ export default function ProfileInformation({ user, page }: Props) {
{/* Buttons */} {/* Buttons */}
<div className="flex gap-1 w-fit text-3xl text-orange-400 max-md:place-self-center *:size-17 *:flex *:flex-col *:items-center *:gap-1 **:transition-discrete **:duration-150 *:hover:brightness-75 *:hover:scale-[1.08] *:[&_span]:text-sm"> <div className="flex gap-1 w-fit text-3xl text-orange-400 max-md:place-self-center *:size-17 *:flex *:flex-col *:items-center *:gap-1 **:transition-discrete **:duration-150 *:hover:brightness-75 *:hover:scale-[1.08] *:[&_span]:text-sm">
{!isOwnProfile && ( {!isOwnProfile && (
<a aria-label="Report User" href={`${import.meta.env.PUBLIC_API_URL}/report/user/${user.id}`}> <a aria-label="Report User" href={`${import.meta.env.VITE_API_URL}/report/user/${user.id}`}>
<Icon icon="material-symbols:flag-rounded" /> <Icon icon="material-symbols:flag-rounded" />
<span>Report</span> <span>Report</span>
</a> </a>

View file

@ -25,7 +25,7 @@ export default function ProfileSettings({ currentDescription }: Props) {
return; return;
} }
const response = await fetch(`${import.meta.env.PUBLIC_API_URL}/api/auth/about-me`, { const response = await fetch(`${import.meta.env.VITE_API_URL}/api/auth/about-me`, {
method: "PATCH", method: "PATCH",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ description }), body: JSON.stringify({ description }),
@ -49,7 +49,7 @@ export default function ProfileSettings({ currentDescription }: Props) {
return; return;
} }
const response = await fetch(`${import.meta.env.PUBLIC_API_URL}/api/auth/name`, { const response = await fetch(`${import.meta.env.VITE_API_URL}/api/auth/name`, {
method: "PATCH", method: "PATCH",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }), body: JSON.stringify({ name }),

View file

@ -17,7 +17,7 @@ export default function ProfilePictureSettings() {
const formData = new FormData(); const formData = new FormData();
if (newPicture) formData.append("image", newPicture); if (newPicture) formData.append("image", newPicture);
const response = await fetch(`${import.meta.env.PUBLIC_API_URL}/api/auth/picture`, { const response = await fetch(`${import.meta.env.VITE_API_URL}/api/auth/picture`, {
method: "PATCH", method: "PATCH",
body: formData, body: formData,
credentials: "include", credentials: "include",

View file

@ -101,7 +101,7 @@ export default function SubmitForm() {
formData.append("instructions", JSON.stringify(instructions.current)); formData.append("instructions", JSON.stringify(instructions.current));
} }
const response = await fetch(`${import.meta.env.PUBLIC_API_URL}/api/submit`, { const response = await fetch(`${import.meta.env.VITE_API_URL}/api/submit`, {
method: "POST", method: "POST",
body: formData, body: formData,
credentials: "include", credentials: "include",

View file

@ -19,6 +19,10 @@
} }
} }
#root {
@apply antialiased flex flex-col items-center w-screen h-screen;
}
.pill { .pill {
@apply flex justify-center items-center px-5 py-2 bg-orange-300 border-2 border-orange-400 rounded-3xl shadow-md; @apply flex justify-center items-center px-5 py-2 bg-orange-300 border-2 border-orange-400 rounded-3xl shadow-md;
} }
@ -134,8 +138,8 @@ input[type="range"]:hover::-moz-range-thumb {
} }
body { body {
@apply bg-amber-50 text-slate-800; @apply bg-amber-50 text-slate-800 min-h-screen;
font-family: var(--font-lexend); font-family: "Lexend Variable", sans-serif;
/* syntax highlighting is a bit broken when it's at the top so it's at the bottom */ /* syntax highlighting is a bit broken when it's at the top so it's at the bottom */
background-image: url('data:image/svg+xml;utf8,\ background-image: url('data:image/svg+xml;utf8,\

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>

47
frontend/src/main.tsx Normal file
View file

@ -0,0 +1,47 @@
import { StrictMode, Suspense } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter, Route, Routes } from "react-router";
import "./index.css";
import "@fontsource-variable/lexend/wght.css";
import PrivacyPage from "./pages/privacy.tsx";
import TermsOfServicePage from "./pages/terms-of-service.tsx";
import NotFoundPage from "./pages/not-found.tsx";
import LoginPage from "./pages/login.tsx";
import ProfilePage from "./pages/profile.tsx";
import MiiPage from "./pages/mii.tsx";
import SubmitPage from "./pages/submit.tsx";
import IndexPage from "./pages/index.tsx";
import ProfileSettingsPage from "./pages/settings.tsx";
import Providers from "./components/provider.tsx";
import Header from "./components/header.tsx";
import Footer from "./components/footer.tsx";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<Providers>
<Suspense fallback={<div>Loading header...</div>}>
<Header />
</Suspense>
{/* <AdminBanner /> */}
<main className="px-4 py-8 max-w-7xl w-full grow flex flex-col">
<BrowserRouter>
<Routes>
<Route path="/" element={<IndexPage />} />
<Route path="/mii/:id" element={<MiiPage />} />
<Route path="/profile">
<Route path=":id" element={<ProfilePage />} />
<Route path="settings" element={<ProfileSettingsPage />} />
</Route>
<Route path="/submit" element={<SubmitPage />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/privacy" element={<PrivacyPage />} />
<Route path="/terms-of-service" element={<TermsOfServicePage />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</BrowserRouter>
</main>
<Footer />
</Providers>
</StrictMode>,
);

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,9 +1,9 @@
import { Suspense, useEffect, useState } from "react"; import { Suspense, useEffect, useState } from "react";
import FilterMenu from "../mii/list/filter-menu"; import FilterMenu from "../components/mii/list/filter-menu";
import SortSelect from "../mii/list/sort-select"; import SortSelect from "../components/mii/list/sort-select";
import MiiGrid from "../mii/list/mii-grid"; import MiiGrid from "../components/mii/list/mii-grid";
import Pagination from "../pagination"; import Pagination from "../components/pagination";
import Skeleton from "../mii/list/skeleton"; import Skeleton from "../components/mii/list/skeleton";
interface ApiResponse { interface ApiResponse {
totalCount: number; totalCount: number;
@ -13,12 +13,12 @@ interface ApiResponse {
} }
export default function IndexPage() { export default function IndexPage() {
const searchParams = new URLSearchParams(window.location.search); const searchParams = new URLSearchParams(location.search);
const [data, setData] = useState<ApiResponse>(); const [data, setData] = useState<ApiResponse | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
fetch(`${import.meta.env.PUBLIC_API_URL}/api/mii/list?${searchParams.toString()}`) fetch(`${import.meta.env.VITE_API_URL}/api/mii/list?${searchParams.toString()}`)
.then((res) => { .then((res) => {
if (!res.ok) throw new Error("Failed to fetch Miis"); if (!res.ok) throw new Error("Failed to fetch Miis");
return res.json(); return res.json();

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

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

View file

@ -1,26 +1,25 @@
import type { SwitchMiiInstructions } from "@tomodachi-share/shared"; import type { SwitchMiiInstructions } from "@tomodachi-share/shared";
import ImageViewer from "../image-viewer"; import ImageViewer from "../components/image-viewer";
import LikeButton from "../like-button"; import LikeButton from "../components/like-button";
import Description from "../description"; import Description from "../components/description";
import AuthorButtons from "../mii/author-buttons"; import ShareMiiButton from "../components/mii/share-mii-button";
import ShareMiiButton from "../mii/share-mii-button"; import ThreeDsScanTutorialButton from "../components/tutorial/3ds-scan";
import ThreeDsScanTutorialButton from "../tutorial/3ds-scan"; import SwitchAddMiiTutorialButton from "../components/tutorial/switch-add-mii";
import SwitchAddMiiTutorialButton from "../tutorial/switch-add-mii"; import MiiInstructions from "../components/mii/instructions";
import MiiInstructions from "../mii/instructions";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Link, useParams } from "react-router";
interface Props { export default function MiiPage() {
id: string; const { id } = useParams();
}
export default function MiiPage({ id }: Props) {
const [mii, setMii] = useState<any>(null); const [mii, setMii] = useState<any>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const API_URL = import.meta.env.VITE_API_URL;
useEffect(() => { useEffect(() => {
fetch(`${import.meta.env.PUBLIC_API_URL}/api/mii/${id}/info`) fetch(`${API_URL}/api/mii/${id}/info`)
.then((res) => { .then((res) => {
if (!res.ok) throw new Error("Failed to fetch Miis"); if (!res.ok) throw new Error("Failed to fetch Miis");
return res.json(); return res.json();
@ -40,8 +39,7 @@ export default function MiiPage({ id }: Props) {
return <div className="p-6 text-center">Loading...</div>; return <div className="p-6 text-center">Loading...</div>;
} }
const API_BASE_URL = import.meta.env.PUBLIC_API_URL; const images = [...Array.from({ length: mii.imageCount }, (_, index) => `${API_URL}/mii/${mii.id}/image?type=image${index}`)];
const images = [...Array.from({ length: mii.imageCount }, (_, index) => `${API_BASE_URL}/mii/${mii.id}/image?type=image${index}`)];
return ( return (
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
@ -67,7 +65,7 @@ export default function MiiPage({ id }: Props) {
{/* Mii Image */} {/* Mii Image */}
<div className="bg-linear-to-b from-amber-100 to-amber-200 overflow-hidden rounded-xl w-full mb-4 flex justify-center"> <div className="bg-linear-to-b from-amber-100 to-amber-200 overflow-hidden rounded-xl w-full mb-4 flex justify-center">
<ImageViewer <ImageViewer
src={`${API_BASE_URL}/mii/${mii.id}/image?type=mii`} src={`${API_URL}/mii/${mii.id}/image?type=mii`}
alt="mii headshot" alt="mii headshot"
width={250} width={250}
height={250} height={250}
@ -78,7 +76,7 @@ export default function MiiPage({ id }: Props) {
{mii.platform === "THREE_DS" ? ( {mii.platform === "THREE_DS" ? (
<div className="bg-amber-200 overflow-hidden rounded-xl w-full mb-4 flex justify-center p-2"> <div className="bg-amber-200 overflow-hidden rounded-xl w-full mb-4 flex justify-center p-2">
<ImageViewer <ImageViewer
src={`${API_BASE_URL}/mii/${mii.id}/image?type=qr-code`} src={`${API_URL}/mii/${mii.id}/image?type=qr-code`}
alt="mii qr code" alt="mii qr code"
width={128} width={128}
height={128} height={128}
@ -87,7 +85,7 @@ export default function MiiPage({ id }: Props) {
</div> </div>
) : ( ) : (
<ImageViewer <ImageViewer
src={`${API_BASE_URL}/mii/${mii.id}/image?type=features`} src={`${API_URL}/mii/${mii.id}/image?type=features`}
alt="mii features" alt="mii features"
width={300} width={300}
height={300} height={300}
@ -260,15 +258,15 @@ export default function MiiPage({ id }: Props) {
{/* Tags */} {/* Tags */}
<div id="tags" className="flex flex-wrap gap-1 mt-1 *:px-2 *:py-1 *:bg-orange-300 *:rounded-full *:text-xs"> <div id="tags" className="flex flex-wrap gap-1 mt-1 *:px-2 *:py-1 *:bg-orange-300 *:rounded-full *:text-xs">
{mii.tags.map((tag: string) => ( {mii.tags.map((tag: string) => (
<a href={`/tags=${tag}`}>{tag}</a> <Link to={`/tags=${tag}`}>{tag}</Link>
))} ))}
</div> </div>
{/* Author and Created date */} {/* Author and Created date */}
<div className="mt-2"> <div className="mt-2">
<a href={`/profile/${mii.userId}`} className="text-lg wrap-break-word"> <Link to={`/profile/${mii.userId}`} className="text-lg wrap-break-word">
By <span className="font-bold">{mii.user.name}</span> By <span className="font-bold">{mii.user.name}</span>
</a> </Link>
<h4 className="text-sm"> <h4 className="text-sm">
Created:{" "} Created:{" "}
{new Date(mii.createdAt).toLocaleString("en-GB", { {new Date(mii.createdAt).toLocaleString("en-GB", {
@ -293,10 +291,10 @@ export default function MiiPage({ id }: Props) {
{/* <AuthorButtons mii={mii} /> */} {/* <AuthorButtons mii={mii} /> */}
<ShareMiiButton miiId={mii.id} /> <ShareMiiButton miiId={mii.id} />
<a aria-label="Report Mii" href={`${import.meta.env.PUBLIC_API_URL}/report/mii/${mii.id}`}> <Link aria-label="Report Mii" to={`${API_URL}/report/mii/${mii.id}`}>
<Icon icon="material-symbols:flag-rounded" /> <Icon icon="material-symbols:flag-rounded" />
<span>Report</span> <span>Report</span>
</a> </Link>
{mii.platform === "THREE_DS" ? <ThreeDsScanTutorialButton /> : <SwitchAddMiiTutorialButton />} {mii.platform === "THREE_DS" ? <ThreeDsScanTutorialButton /> : <SwitchAddMiiTutorialButton />}
</div> </div>

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

@ -0,0 +1,14 @@
import { Icon } from "@iconify/react";
export default function NotFoundPage() {
return <div className="grow flex items-center justify-center">
<div className="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 className="text-7xl font-black">404</h2>
<p>Page not found - you swam off the island!</p>
<a href="/" className="pill button gap-2 mt-8 w-fit self-center">
<Icon icon="ic:round-home" fontSize={24} />
Travel Back
</a>
</div>
</div>
}

View file

@ -1,30 +1,26 @@
--- export default function PrivacyPage() {
import Layout from "../layout.astro"; return <div className="bg-amber-50 border-2 border-amber-500 rounded-2xl p-6">
--- <h1 className="text-2xl font-bold">Privacy Policy</h1>
<h2 className="font-light">
<Layout> <strong className="font-medium">Effective Date:</strong> 13 April 2026
<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> </h2>
<hr class="border-black/20 mt-1 mb-4" /> <hr className="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>By using this website, you confirm that you understand and agree to this Privacy Policy.</p>
<p class="mt-1"> <p className="mt-1">
If you have any questions or concerns, please contact me at:{" "} 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> <a href="mailto:hello@trafficlunar.net" className="text-blue-700"> hello@trafficlunar.net </a>
. .
</p> </p>
<ul class="list-decimal ml-5 marker:text-xl marker:font-semibold"> <ul className="list-decimal ml-5 marker:text-xl marker:font-semibold">
<li> <li>
<h3 class="text-xl font-semibold mt-6 mb-2">Information We Collect</h3> <h3 className="text-xl font-semibold mt-6 mb-2">Information We Collect</h3>
<section> <section>
<p class="mb-2">The following types of information are stored when you use this website:</p> <p className="mb-2">The following types of information are stored when you use this website:</p>
<ul class="list-disc list-inside"> <ul className="list-disc list-inside">
<li> <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 <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. authentication tokens may also be temporarily stored to maintain your login session.
@ -39,40 +35,40 @@ import Layout from "../layout.astro";
</section> </section>
</li> </li>
<li> <li>
<h3 class="text-xl font-semibold mt-6 mb-2">Use of Cookies</h3> <h3 className="text-xl font-semibold mt-6 mb-2">Use of Cookies</h3>
<section> <section>
<p class="mb-2">Cookies are necessary for user sessions and authentication. We do not use cookies for tracking or advertising purposes.</p> <p className="mb-2">Cookies are necessary for user sessions and authentication. We do not use cookies for tracking or advertising purposes.</p>
</section> </section>
</li> </li>
<li> <li>
<h3 class="text-xl font-semibold mt-6 mb-2">Analytics</h3> <h3 className="text-xl font-semibold mt-6 mb-2">Analytics</h3>
<section> <section>
<p class="mb-2"> <p className="mb-2">
We use{" "} We use{" "}
<a href="https://umami.is/" class="text-blue-700"> Umami </a>{" "} <a href="https://umami.is/" className="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 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. through this service.
</p> </p>
</section> </section>
</li> </li>
<li> <li>
<h3 class="text-xl font-semibold mt-6 mb-2">Data Sharing</h3> <h3 className="text-xl font-semibold mt-6 mb-2">Data Sharing</h3>
<section> <section>
<p class="mb-2"> <p className="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 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. tools (such as analytics) but these services are used solely to keep the site functional.
</p> </p>
</section> </section>
</li> </li>
<li> <li>
<h3 class="text-xl font-semibold mt-6 mb-2">Your Rights</h3> <h3 className="text-xl font-semibold mt-6 mb-2">Your Rights</h3>
<section> <section>
<p class="mb-2">As a user, you have the right to:</p> <p className="mb-2">As a user, you have the right to:</p>
<ul class="list-disc list-inside indent-4"> <ul className="list-disc list-inside indent-4">
<li>Access the personal data we hold about you.</li> <li>Access the personal data we hold about you.</li>
<li>Request corrections to any inaccurate or incomplete information.</li> <li>Request corrections to any inaccurate or incomplete information.</li>
<li>Request the deletion of your personal data.</li> <li>Request the deletion of your personal data.</li>
@ -80,10 +76,10 @@ import Layout from "../layout.astro";
</section> </section>
</li> </li>
<li> <li>
<h3 class="text-xl font-semibold mt-6 mb-2">Data Deletion</h3> <h3 className="text-xl font-semibold mt-6 mb-2">Data Deletion</h3>
<section> <section>
<p class="mb-2"> <p className="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 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 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. promptly removed from our servers.
@ -91,14 +87,14 @@ import Layout from "../layout.astro";
</section> </section>
</li> </li>
<li> <li>
<h3 class="text-xl font-semibold mt-6 mb-2">Changes to this Privacy Policy</h3> <h3 className="text-xl font-semibold mt-6 mb-2">Changes to this Privacy Policy</h3>
<section> <section>
<p class="mb-2"> <p className="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. 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> </p>
</section> </section>
</li> </li>
</ul> </ul>
</div> </div>
</Layout> }

View file

@ -1,16 +1,14 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import ProfileInformation from "../profile-information"; import ProfileInformation from "../components/profile-information";
import { useParams } from "react-router";
interface Props { export default function ProfilePage() {
id: string; const { id } = useParams();
}
export default function ProfilePage({ id }: Props) {
const [user, setUser] = useState<any>(null); const [user, setUser] = useState<any>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
fetch(`${import.meta.env.PUBLIC_API_URL}/api/profile/${id}/info`) fetch(`${import.meta.env.VITE_API_URL}/api/profile/${id}/info`)
.then((res) => { .then((res) => {
if (!res.ok) throw new Error("Failed to fetch profile"); if (!res.ok) throw new Error("Failed to fetch profile");
return res.json(); return res.json();

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

@ -0,0 +1,5 @@
import ProfileSettings from "../components/profile-settings";
export default function ProfileSettingsPage() {
return <ProfileSettings currentDescription={null} />;
}

View file

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

View file

@ -0,0 +1,5 @@
import SubmitForm from "../components/submit-form";
export default function SubmitPage() {
return <SubmitForm />;
}

View file

@ -1,33 +1,29 @@
--- export default function TermsOfServicePage() {
import Layout from "../layout.astro"; return <div className="bg-amber-50 border-2 border-amber-500 rounded-2xl p-6">
--- <h1 className="text-2xl font-bold">Terms of Service</h1>
<h2 className="font-light">
<Layout> <strong className="font-medium">Effective Date:</strong> March 26, 2026
<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> </h2>
<hr class="border-black/20 mt-1 mb-4" /> <hr className="border-black/20 mt-1 mb-4" />
<p> <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 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. not use the service.
</p> </p>
<p class="mt-1"> <p className="mt-1">
If you have any questions or concerns, please contact me at:{" "} 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> <a href="mailto:hello@trafficlunar.net" className="text-blue-700"> hello@trafficlunar.net </a>
. .
</p> </p>
<ul class="list-decimal ml-5 marker:text-xl marker:font-semibold"> <ul className="list-decimal ml-5 marker:text-xl marker:font-semibold">
<li> <li>
<h3 class="text-xl font-semibold mt-6 mb-2">Usage Policy</h3> <h3 className="text-xl font-semibold mt-6 mb-2">Usage Policy</h3>
<section> <section>
<p class="mb-2">As a user of this site, you must abide by these guidelines:</p> <p className="mb-2">As a user of this site, you must abide by these guidelines:</p>
<ul class="list-disc list-inside indent-4"> <ul className="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 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>Nothing that is against the law in the United Kingdom.</li>
<li>No NSFW, violent, gory, or inappropriate Miis or images.</li> <li>No NSFW, violent, gory, or inappropriate Miis or images.</li>
@ -39,39 +35,39 @@ import Layout from "../layout.astro";
<li>Avoid using inappropriate language. Profanity may be automatically censored.</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> <li>No use of automated scripts, bots, or scrapers to access or interact with the site.</li>
</ul> </ul>
<p class="mt-2"> <p className="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. 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> </p>
</section> </section>
</li> </li>
<li> <li>
<h3 class="text-xl font-semibold mt-6 mb-2">Termination</h3> <h3 className="text-xl font-semibold mt-6 mb-2">Termination</h3>
<section> <section>
<p class="mb-2"> <p className="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 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. that disrupt the functionality of the site.
</p> </p>
<p> <p>
To request deletion of your account and personal data, please refer to the{" "} To request deletion of your account and personal data, please refer to the{" "}
<a href="/privacy" class="text-blue-700"> Privacy Policy </a>{" "} <a href="/privacy" className="text-blue-700"> Privacy Policy </a>{" "}
(see &quot;Data Deletion&quot;) or email me at{" "} (see &quot;Data Deletion&quot;) or email me at{" "}
<a href="mailto:hello@trafficlunar.net" class="text-blue-700"> hello@trafficlunar.net </a> <a href="mailto:hello@trafficlunar.net" className="text-blue-700"> hello@trafficlunar.net </a>
</p> </p>
</section> </section>
</li> </li>
<li> <li>
<h3 class="text-xl font-semibold mt-6 mb-2">Eligibility</h3> <h3 className="text-xl font-semibold mt-6 mb-2">Eligibility</h3>
<section> <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> <p className="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> </section>
</li> </li>
<li> <li>
<h3 class="text-xl font-semibold mt-6 mb-2">Liability</h3> <h3 className="text-xl font-semibold mt-6 mb-2">Liability</h3>
<section> <section>
<p class="mb-2"> <p className="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 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. on the site. You use the site at your own risk.
</p> </p>
@ -82,16 +78,16 @@ import Layout from "../layout.astro";
</section> </section>
</li> </li>
<li> <li>
<h3 class="text-xl font-semibold mt-6 mb-2">DMCA & Copyright</h3> <h3 className="text-xl font-semibold mt-6 mb-2">DMCA & Copyright</h3>
<section> <section>
<p class="mb-2"> <p className="mb-2">
If you believe that content uploaded to this site infringes on your copyright, you may submit a DMCA takedown request by emailing{" "} 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>{" "} <a href="mailto:hello@trafficlunar.net" className="text-blue-700"> hello@trafficlunar.net </a>{" "}
or by reporting the Mii on its page. or by reporting the Mii on its page.
</p> </p>
<p class="mb-2">Please include:</p> <p className="mb-2">Please include:</p>
<ul class="list-disc list-inside indent-4"> <ul className="list-disc list-inside indent-4">
<li>Your name and contact information</li> <li>Your name and contact information</li>
<li>A description of the copyrighted work</li> <li>A description of the copyrighted work</li>
<li>A link to the allegedly infringing material</li> <li>A link to the allegedly infringing material</li>
@ -105,10 +101,10 @@ import Layout from "../layout.astro";
</section> </section>
</li> </li>
<li> <li>
<h3 class="text-xl font-semibold mt-6 mb-2">Nintendo Disclaimer</h3> <h3 className="text-xl font-semibold mt-6 mb-2">Nintendo Disclaimer</h3>
<section> <section>
<p class="mb-2"> <p className="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 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. trademarks of Nintendo Co., Ltd.
</p> </p>
@ -119,15 +115,15 @@ import Layout from "../layout.astro";
</section> </section>
</li> </li>
<li> <li>
<h3 class="text-xl font-semibold mt-6 mb-2">Changes to this Terms of Service</h3> <h3 className="text-xl font-semibold mt-6 mb-2">Changes to this Terms of Service</h3>
<section> <section>
<p class="mb-2"> <p className="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 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. site. We may notify users via a site banner or other means if changes are made to the Terms of Service.
</p> </p>
</section> </section>
</li> </li>
</ul> </ul>
</div> </div>;
</Layout> }

View file

@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "esnext",
"types": ["vite/client", "node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true
// "erasableSyntaxOnly": true
// "noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

View file

@ -1,14 +1,4 @@
{ {
"extends": "astro/tsconfigs/strict", "files": [],
"include": [ "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
".astro/types.d.ts",
"**/*"
],
"exclude": [
"dist"
],
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "react"
}
} }

View file

@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"module": "esnext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

8
frontend/vite.config.ts Normal file
View file

@ -0,0 +1,8 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
});

6418
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,5 @@
import { profanity } from "@2toad/profanity"; import { profanity } from "@2toad/profanity";
// import { AES_CCM } from "@trafficlunar/asmcrypto.js"; // @ts-ignore
import sjcl from "sjcl-with-all"; import sjcl from "sjcl-with-all";
import { MII_DECRYPTION_KEY, MII_QR_ENCRYPTED_LENGTH } from "./constants"; import { MII_DECRYPTION_KEY, MII_QR_ENCRYPTED_LENGTH } from "./constants";
@ -19,9 +19,7 @@ import { ThreeDsTomodachiLifeMii, HairDyeMode } from "./three-ds-tomodachi-life-
/** Private _ctrMode function defined here: {@link https://github.com/bitwiseshiftleft/sjcl/blob/85caa53c281eeeb502310013312c775d35fe0867/core/ccm.js#L194} */ /** Private _ctrMode function defined here: {@link https://github.com/bitwiseshiftleft/sjcl/blob/85caa53c281eeeb502310013312c775d35fe0867/core/ccm.js#L194} */
const sjclCcmCtrMode: const sjclCcmCtrMode:
| ((prf: sjcl.SjclCipher, data: sjcl.BitArray, iv: sjcl.BitArray, tag: sjcl.BitArray, tlen: number, L: number) => { data: sjcl.BitArray; tag: sjcl.BitArray }) | ((prf: sjcl.SjclCipher, data: sjcl.BitArray, iv: sjcl.BitArray, tag: sjcl.BitArray, tlen: number, L: number) => { data: sjcl.BitArray; tag: sjcl.BitArray })
| undefined = | undefined = sjcl.mode.ccm.u; // NOTE: This may need to be changed with a different sjcl build. Read above
// @ts-expect-error -- Referencing a private function that is not in the types.
sjcl.mode.ccm.u; // NOTE: This may need to be changed with a different sjcl build. Read above
export function convertQrCode(bytes: Uint8Array): { mii: Mii; tomodachiLifeMii: ThreeDsTomodachiLifeMii } | never { export function convertQrCode(bytes: Uint8Array): { mii: Mii; tomodachiLifeMii: ThreeDsTomodachiLifeMii } | never {
// Decrypt 96 byte 3DS/Wii U format Mii data from the QR code. // Decrypt 96 byte 3DS/Wii U format Mii data from the QR code.
@ -91,7 +89,7 @@ export function convertQrCode(bytes: Uint8Array): { mii: Mii; tomodachiLifeMii:
// Decrypt Tomodachi Life Mii data from encrypted QR code bytes. // Decrypt Tomodachi Life Mii data from encrypted QR code bytes.
const tomodachiLifeMii = ThreeDsTomodachiLifeMii.fromBytes(bytes); const tomodachiLifeMii = ThreeDsTomodachiLifeMii.fromBytes(bytes);
// Apply hair dye fields. // @ts-ignore Apply hair dye fields.
switch (tomodachiLifeMii.hairDyeMode) { switch (tomodachiLifeMii.hairDyeMode) {
case HairDyeMode.HairEyebrowBeard: case HairDyeMode.HairEyebrowBeard:
mii.eyebrowColor = tomodachiLifeMii.studioHairColor; mii.eyebrowColor = tomodachiLifeMii.studioHairColor;

View file

@ -1,4 +1,4 @@
import { SwitchMiiInstructions } from "./types"; import { type SwitchMiiInstructions } from "./types";
export function minifyInstructions(instructions: Partial<SwitchMiiInstructions>) { export function minifyInstructions(instructions: Partial<SwitchMiiInstructions>) {
const DEFAULT_ZERO_FIELDS = new Set(["height", "distance", "rotation", "size", "stretch"]); const DEFAULT_ZERO_FIELDS = new Set(["height", "distance", "rotation", "size", "stretch"]);

View file

@ -1,7 +1,7 @@
import { TOMODACHI_LIFE_DECRYPTION_KEY } from "./constants"; import { TOMODACHI_LIFE_DECRYPTION_KEY } from "./constants";
// @ts-ignore
import sjcl from "sjcl-with-all"; import sjcl from "sjcl-with-all";
// @ts-expect-error - This is not in the types, but it's a function needed to enable CTR mode.
sjcl.beware["CTR mode is dangerous because it doesn't protect message integrity."](); sjcl.beware["CTR mode is dangerous because it doesn't protect message integrity."]();
// Converts hair dye to studio color // Converts hair dye to studio color