feat: vite test
|
|
@ -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
|
|
@ -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?
|
||||||
|
|
|
||||||
4
frontend/.vscode/extensions.json
vendored
|
|
@ -1,4 +0,0 @@
|
||||||
{
|
|
||||||
"recommendations": ["astro-build.astro-vscode"],
|
|
||||||
"unwantedRecommendations": []
|
|
||||||
}
|
|
||||||
11
frontend/.vscode/launch.json
vendored
|
|
@ -1,11 +0,0 @@
|
||||||
{
|
|
||||||
"version": "0.2.0",
|
|
||||||
"configurations": [
|
|
||||||
{
|
|
||||||
"command": "./node_modules/.bin/astro dev",
|
|
||||||
"name": "Development server",
|
|
||||||
"request": "launch",
|
|
||||||
"type": "node-terminal"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -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).
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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
|
|
@ -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>
|
||||||
|
|
@ -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
1
frontend/public/favicon.svg
Normal file
|
After Width: | Height: | Size: 9.3 KiB |
0
frontend/public/tutorial/switch/adding-mii/step1.jpg
Executable file → Normal file
|
Before Width: | Height: | Size: 233 KiB After Width: | Height: | Size: 233 KiB |
0
frontend/public/tutorial/switch/adding-mii/step2.jpg
Executable file → Normal file
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 97 KiB |
0
frontend/public/tutorial/switch/adding-mii/step4.jpg
Executable file → Normal file
|
Before Width: | Height: | Size: 151 KiB After Width: | Height: | Size: 151 KiB |
0
frontend/public/tutorial/switch/step4.jpg
Executable file → Normal file
|
Before Width: | Height: | Size: 148 KiB After Width: | Height: | Size: 148 KiB |
0
frontend/public/tutorial/switch/submitting/step1.jpg
Executable file → Normal file
|
Before Width: | Height: | Size: 247 KiB After Width: | Height: | Size: 247 KiB |
0
frontend/public/tutorial/switch/submitting/step2.jpg
Executable file → Normal file
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 74 KiB |
0
frontend/public/tutorial/switch/submitting/step3.jpg
Executable file → Normal file
|
Before Width: | Height: | Size: 142 KiB After Width: | Height: | Size: 142 KiB |
BIN
frontend/src/assets/hero.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
1
frontend/src/assets/react.svg
Normal 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 |
1
frontend/src/assets/vite.svg
Normal file
|
After Width: | Height: | Size: 8.5 KiB |
|
|
@ -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‘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‘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,
|
||||||
)}
|
// )}
|
||||||
</>
|
// </>
|
||||||
);
|
// );
|
||||||
}
|
// }
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
// );
|
||||||
}
|
// }
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
</>
|
// </>
|
||||||
);
|
// );
|
||||||
}
|
// }
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
// );
|
||||||
}
|
// }
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
55
frontend/src/components/footer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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(() => {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
42
frontend/src/components/header.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
// );
|
||||||
}
|
// }
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 }),
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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,\
|
||||||
|
|
@ -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
|
|
@ -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>,
|
||||||
|
);
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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();
|
||||||
|
|
@ -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>
|
|
||||||
58
frontend/src/pages/login.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
@ -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>
|
|
||||||
14
frontend/src/pages/not-found.tsx
Normal 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>
|
||||||
|
}
|
||||||
|
|
@ -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 'Delete Account' button. Upon clicking, your data will be
|
time by going to your profile page, clicking the settings icon, and clicking the 'Delete Account' 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>
|
}
|
||||||
|
|
@ -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();
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
5
frontend/src/pages/settings.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import ProfileSettings from "../components/profile-settings";
|
||||||
|
|
||||||
|
export default function ProfileSettingsPage() {
|
||||||
|
return <ProfileSettings currentDescription={null} />;
|
||||||
|
}
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
---
|
|
||||||
import SubmitForm from "../components/submit-form";
|
|
||||||
import Layout from "../layout.astro";
|
|
||||||
---
|
|
||||||
|
|
||||||
<Layout>
|
|
||||||
<SubmitForm client:load />
|
|
||||||
</Layout>
|
|
||||||
5
frontend/src/pages/submit.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import SubmitForm from "../components/submit-form";
|
||||||
|
|
||||||
|
export default function SubmitPage() {
|
||||||
|
return <SubmitForm />;
|
||||||
|
}
|
||||||
|
|
@ -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 "Report" button.
|
If you find anybody or a Mii breaking these rules, please report it by going to their page and clicking the "Report" 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 "Data Deletion") or email me at{" "}
|
(see "Data Deletion") 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 "as is" and without any warranties. We are not responsible for any user-generated content or the actions of users
|
This service is provided "as is" 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. "Mii" and all related character designs are
|
This site is not affiliated with, endorsed by, or associated with Nintendo in any way. "Mii" 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>
|
}
|
||||||
25
frontend/tsconfig.app.json
Normal 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"]
|
||||||
|
}
|
||||||
|
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
24
frontend/tsconfig.node.json
Normal 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
|
|
@ -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
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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"]);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||