feat: vite test
|
|
@ -10,7 +10,7 @@ const nextConfig: NextConfig = {
|
|||
{
|
||||
source: "/api/:path*",
|
||||
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-Methods", value: "GET,POST,PATCH,DELETE,OPTIONS" },
|
||||
],
|
||||
|
|
|
|||
48
frontend/.gitignore
vendored
|
|
@ -1,24 +1,24 @@
|
|||
# build output
|
||||
dist/
|
||||
# generated types
|
||||
.astro/
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
|
||||
# environment variables
|
||||
.env
|
||||
.env.production
|
||||
|
||||
# macOS-specific files
|
||||
.DS_Store
|
||||
|
||||
# jetbrains setting folder
|
||||
.idea/
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
|
|
|||
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
|
||||
|
||||
```sh
|
||||
pnpm create astro@latest -- --template minimal
|
||||
```
|
||||
|
||||
> 🧑🚀 **Seasoned astronaut?** Delete this file. Have fun!
|
||||
|
||||
## 🚀 Project Structure
|
||||
|
||||
Inside of your Astro project, you'll see the following folders and files:
|
||||
|
||||
```text
|
||||
/
|
||||
├── public/
|
||||
├── src/
|
||||
│ └── pages/
|
||||
│ └── index.astro
|
||||
└── package.json
|
||||
```
|
||||
|
||||
Astro looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name.
|
||||
|
||||
There's nothing special about `src/components/`, but that's where we like to put any Astro/React/Vue/Svelte/Preact components.
|
||||
|
||||
Any static assets, like images, can be placed in the `public/` directory.
|
||||
|
||||
## 🧞 Commands
|
||||
|
||||
All commands are run from the root of the project, from a terminal:
|
||||
|
||||
| Command | Action |
|
||||
| :------------------------ | :----------------------------------------------- |
|
||||
| `pnpm install` | Installs dependencies |
|
||||
| `pnpm dev` | Starts local dev server at `localhost:4321` |
|
||||
| `pnpm build` | Build your production site to `./dist/` |
|
||||
| `pnpm preview` | Preview your build locally, before deploying |
|
||||
| `pnpm astro ...` | Run CLI commands like `astro add`, `astro check` |
|
||||
| `pnpm astro -- --help` | Get help using the Astro CLI |
|
||||
|
||||
## 👀 Want to learn more?
|
||||
|
||||
Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
"version": "0.0.1",
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro"
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/react": "^5.0.3",
|
||||
"@bprogress/react": "^1.2.7",
|
||||
"@fontsource-variable/lexend": "^5.2.11",
|
||||
"@hello-pangea/dnd": "^18.0.1",
|
||||
"@iconify-json/ic": "^1.2.4",
|
||||
"@iconify-json/material-icon-theme": "^1.2.59",
|
||||
"@iconify-json/mdi": "^1.2.3",
|
||||
"@iconify-json/stash": "^1.2.4",
|
||||
"@nanostores/react": "^1.1.0",
|
||||
"@swup/astro": "^1.8.0",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@tomodachi-share/shared": "workspace:*",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"astro": "^6.1.7",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"dayjs": "^1.11.20",
|
||||
"downshift": "^9.3.2",
|
||||
|
|
@ -33,18 +25,30 @@
|
|||
"jsqr": "^1.4.0",
|
||||
"nanostores": "^1.2.0",
|
||||
"qrcode-generator": "^2.0.4",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-dropzone": "^15.0.0",
|
||||
"react-image-crop": "^11.0.10",
|
||||
"react-router": "^7.14.1",
|
||||
"seedrandom": "^3.0.5",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@iconify/react": "^6.0.2",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/node": "^24.12.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@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,25 +1,25 @@
|
|||
import { useState } from "react";
|
||||
|
||||
export default function BannerForm() {
|
||||
const [message, setMessage] = useState("");
|
||||
|
||||
const onClickClear = async () => {
|
||||
await fetch("/api/admin/banner", { method: "DELETE" });
|
||||
};
|
||||
|
||||
const onClickSet = async () => {
|
||||
await fetch("/api/admin/banner", { method: "POST", body: message });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-orange-100 rounded-xl border-2 border-orange-400 p-2 flex gap-2">
|
||||
<input type="text" className="pill input w-full" placeholder="Enter banner text" value={message} onChange={(e) => setMessage(e.target.value)} />
|
||||
<button type="button" className="pill button" onClick={onClickClear}>
|
||||
Clear
|
||||
</button>
|
||||
<button type="submit" className="pill button" onClick={onClickSet}>
|
||||
Set
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { useState } from "react";
|
||||
|
||||
export default function BannerForm() {
|
||||
const [message, setMessage] = useState("");
|
||||
|
||||
const onClickClear = async () => {
|
||||
await fetch("/api/admin/banner", { method: "DELETE" });
|
||||
};
|
||||
|
||||
const onClickSet = async () => {
|
||||
await fetch("/api/admin/banner", { method: "POST", body: message });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-orange-100 rounded-xl border-2 border-orange-400 p-2 flex gap-2">
|
||||
<input type="text" className="pill input w-full" placeholder="Enter banner text" value={message} onChange={(e) => setMessage(e.target.value)} />
|
||||
<button type="button" className="pill button" onClick={onClickClear}>
|
||||
Clear
|
||||
</button>
|
||||
<button type="submit" className="pill button" onClick={onClickSet}>
|
||||
Set
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,65 +1,65 @@
|
|||
// import { useSearchParams } from "next/navigation";
|
||||
// import { Suspense, useEffect, useState } from "react";
|
||||
|
||||
// import useSWR from "swr";
|
||||
// import { Icon } from "@iconify/react";
|
||||
|
||||
// interface ApiResponse {
|
||||
// message: string;
|
||||
// }
|
||||
|
||||
// const fetcher = (url: string) => fetch(url).then((res) => res.json());
|
||||
|
||||
// function RedirectBanner() {
|
||||
// const searchParams = useSearchParams();
|
||||
// const from = searchParams.get("from");
|
||||
// if (from !== "old-domain") return null;
|
||||
|
||||
// return (
|
||||
// <div className="w-full h-10 bg-orange-300 border-y-2 border-y-orange-400 mt-1 pl-2 shadow-md flex justify-center items-center gap-2 text-orange-900 text-nowrap overflow-x-auto font-semibold max-sm:justify-start">
|
||||
// <Icon icon="humbleicons:link" className="text-2xl min-w-6" />
|
||||
// <span>We have moved URLs, welcome to tomodachishare.com!</span>
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
|
||||
// export default function AdminBanner() {
|
||||
// const { data } = useSWR<ApiResponse>("/api/admin/banner", fetcher);
|
||||
// const [shouldShow, setShouldShow] = useState(true);
|
||||
|
||||
// useEffect(() => {
|
||||
// if (!data?.message) return;
|
||||
|
||||
// // Check if the current banner text was closed by the user
|
||||
// const closedBanner = window.localStorage.getItem("closedBanner");
|
||||
// setShouldShow(data.message !== closedBanner);
|
||||
// }, [data]);
|
||||
|
||||
// const handleClose = () => {
|
||||
// if (!data) return;
|
||||
|
||||
// // Close banner and remember it
|
||||
// window.localStorage.setItem("closedBanner", data.message);
|
||||
// setShouldShow(false);
|
||||
// };
|
||||
|
||||
// return (
|
||||
// <>
|
||||
// {data && data.message && shouldShow && (
|
||||
// <div className="relative w-full h-10 bg-orange-300 border-y-2 border-y-orange-400 mt-1 pl-2 shadow-md flex justify-center text-orange-900 text-nowrap overflow-x-auto font-semibold max-sm:justify-between">
|
||||
// <div className="flex gap-2 h-full items-center w-fit">
|
||||
// <Icon icon="humbleicons:exclamation" className="text-2xl min-w-6" />
|
||||
// <span>{data.message}</span>
|
||||
// </div>
|
||||
|
||||
// <button onClick={handleClose} className="min-sm:absolute right-2 cursor-pointer p-1.5">
|
||||
// <Icon icon="humbleicons:times" className="text-2xl min-w-6" />
|
||||
// </button>
|
||||
// </div>
|
||||
// )}
|
||||
// <Suspense>
|
||||
// <RedirectBanner />
|
||||
// </Suspense>
|
||||
// </>
|
||||
// );
|
||||
// }
|
||||
// import { useSearchParams } from "next/navigation";
|
||||
// import { Suspense, useEffect, useState } from "react";
|
||||
|
||||
// import useSWR from "swr";
|
||||
// import { Icon } from "@iconify/react";
|
||||
|
||||
// interface ApiResponse {
|
||||
// message: string;
|
||||
// }
|
||||
|
||||
// const fetcher = (url: string) => fetch(url).then((res) => res.json());
|
||||
|
||||
// function RedirectBanner() {
|
||||
// const searchParams = useSearchParams();
|
||||
// const from = searchParams.get("from");
|
||||
// if (from !== "old-domain") return null;
|
||||
|
||||
// return (
|
||||
// <div className="w-full h-10 bg-orange-300 border-y-2 border-y-orange-400 mt-1 pl-2 shadow-md flex justify-center items-center gap-2 text-orange-900 text-nowrap overflow-x-auto font-semibold max-sm:justify-start">
|
||||
// <Icon icon="humbleicons:link" className="text-2xl min-w-6" />
|
||||
// <span>We have moved URLs, welcome to tomodachishare.com!</span>
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
|
||||
// export default function AdminBanner() {
|
||||
// const { data } = useSWR<ApiResponse>("/api/admin/banner", fetcher);
|
||||
// const [shouldShow, setShouldShow] = useState(true);
|
||||
|
||||
// useEffect(() => {
|
||||
// if (!data?.message) return;
|
||||
|
||||
// // Check if the current banner text was closed by the user
|
||||
// const closedBanner = window.localStorage.getItem("closedBanner");
|
||||
// setShouldShow(data.message !== closedBanner);
|
||||
// }, [data]);
|
||||
|
||||
// const handleClose = () => {
|
||||
// if (!data) return;
|
||||
|
||||
// // Close banner and remember it
|
||||
// window.localStorage.setItem("closedBanner", data.message);
|
||||
// setShouldShow(false);
|
||||
// };
|
||||
|
||||
// return (
|
||||
// <>
|
||||
// {data && data.message && shouldShow && (
|
||||
// <div className="relative w-full h-10 bg-orange-300 border-y-2 border-y-orange-400 mt-1 pl-2 shadow-md flex justify-center text-orange-900 text-nowrap overflow-x-auto font-semibold max-sm:justify-between">
|
||||
// <div className="flex gap-2 h-full items-center w-fit">
|
||||
// <Icon icon="humbleicons:exclamation" className="text-2xl min-w-6" />
|
||||
// <span>{data.message}</span>
|
||||
// </div>
|
||||
|
||||
// <button onClick={handleClose} className="min-sm:absolute right-2 cursor-pointer p-1.5">
|
||||
// <Icon icon="humbleicons:times" className="text-2xl min-w-6" />
|
||||
// </button>
|
||||
// </div>
|
||||
// )}
|
||||
// <Suspense>
|
||||
// <RedirectBanner />
|
||||
// </Suspense>
|
||||
// </>
|
||||
// );
|
||||
// }
|
||||
|
|
|
|||
|
|
@ -1,45 +1,45 @@
|
|||
// import { settings } from "@/lib/settings";
|
||||
// import { useState } from "react";
|
||||
|
||||
// export default function ControlCenter() {
|
||||
// const [canSubmit, setCanSubmit] = useState(settings.canSubmit);
|
||||
// const [isQueueEnabled, setIsQeueueEnabled] = useState(settings.queueEnabled);
|
||||
|
||||
// const onClickSet = async () => {
|
||||
// await fetch("/api/admin/can-submit", { method: "PATCH", body: JSON.stringify(canSubmit) });
|
||||
// await fetch("/api/admin/queue", { method: "PATCH", body: JSON.stringify(isQueueEnabled) });
|
||||
// };
|
||||
|
||||
// return (
|
||||
// <div className="bg-orange-100 rounded-xl border-2 border-orange-400 p-2 flex flex-col gap-2">
|
||||
// <div className="flex items-center gap-2">
|
||||
// <input
|
||||
// id="submit"
|
||||
// type="checkbox"
|
||||
// className="checkbox size-6!"
|
||||
// placeholder="Enter banner text"
|
||||
// checked={canSubmit}
|
||||
// onChange={(e) => setCanSubmit(e.target.checked)}
|
||||
// />
|
||||
// <label htmlFor="submit">Enable Submissions</label>
|
||||
// </div>
|
||||
// <div className="flex items-center gap-2">
|
||||
// <input
|
||||
// id="queue"
|
||||
// type="checkbox"
|
||||
// className="checkbox size-6!"
|
||||
// placeholder="Enter banner text"
|
||||
// checked={isQueueEnabled}
|
||||
// onChange={(e) => setIsQeueueEnabled(e.target.checked)}
|
||||
// />
|
||||
// <label htmlFor="queue">Enable Queue</label>
|
||||
// </div>
|
||||
|
||||
// <div className="flex gap-2 self-end">
|
||||
// <button type="submit" className="pill button" onClick={onClickSet}>
|
||||
// Set
|
||||
// </button>
|
||||
// </div>
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
// import { settings } from "@/lib/settings";
|
||||
// import { useState } from "react";
|
||||
|
||||
// export default function ControlCenter() {
|
||||
// const [canSubmit, setCanSubmit] = useState(settings.canSubmit);
|
||||
// const [isQueueEnabled, setIsQeueueEnabled] = useState(settings.queueEnabled);
|
||||
|
||||
// const onClickSet = async () => {
|
||||
// await fetch("/api/admin/can-submit", { method: "PATCH", body: JSON.stringify(canSubmit) });
|
||||
// await fetch("/api/admin/queue", { method: "PATCH", body: JSON.stringify(isQueueEnabled) });
|
||||
// };
|
||||
|
||||
// return (
|
||||
// <div className="bg-orange-100 rounded-xl border-2 border-orange-400 p-2 flex flex-col gap-2">
|
||||
// <div className="flex items-center gap-2">
|
||||
// <input
|
||||
// id="submit"
|
||||
// type="checkbox"
|
||||
// className="checkbox size-6!"
|
||||
// placeholder="Enter banner text"
|
||||
// checked={canSubmit}
|
||||
// onChange={(e) => setCanSubmit(e.target.checked)}
|
||||
// />
|
||||
// <label htmlFor="submit">Enable Submissions</label>
|
||||
// </div>
|
||||
// <div className="flex items-center gap-2">
|
||||
// <input
|
||||
// id="queue"
|
||||
// type="checkbox"
|
||||
// className="checkbox size-6!"
|
||||
// placeholder="Enter banner text"
|
||||
// checked={isQueueEnabled}
|
||||
// onChange={(e) => setIsQeueueEnabled(e.target.checked)}
|
||||
// />
|
||||
// <label htmlFor="queue">Enable Queue</label>
|
||||
// </div>
|
||||
|
||||
// <div className="flex gap-2 self-end">
|
||||
// <button type="submit" className="pill button" onClick={onClickSet}>
|
||||
// Set
|
||||
// </button>
|
||||
// </div>
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
|
|
|
|||
|
|
@ -1,92 +1,92 @@
|
|||
import { useRouter } from "next/navigation";
|
||||
// import { useRouter } from "next/navigation";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
// import { useEffect, useState } from "react";
|
||||
// import { createPortal } from "react-dom";
|
||||
|
||||
import { Icon } from "@iconify/react";
|
||||
import SubmitButton from "../submit-button";
|
||||
// import { Icon } from "@iconify/react";
|
||||
// import SubmitButton from "../submit-button";
|
||||
|
||||
interface Props {
|
||||
punishmentId: number;
|
||||
}
|
||||
// interface Props {
|
||||
// punishmentId: number;
|
||||
// }
|
||||
|
||||
export default function PunishmentDeletionDialog({ punishmentId }: Props) {
|
||||
const router = useRouter();
|
||||
// export default function PunishmentDeletionDialog({ punishmentId }: Props) {
|
||||
// const router = useRouter();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
// const [isOpen, setIsOpen] = 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 response = await fetch(`/api/admin/punish?id=${punishmentId}`, { method: "DELETE" });
|
||||
// const handleSubmit = async () => {
|
||||
// const response = await fetch(`/api/admin/punish?id=${punishmentId}`, { method: "DELETE" });
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
setError(data.error);
|
||||
// if (!response.ok) {
|
||||
// const data = await response.json();
|
||||
// setError(data.error);
|
||||
|
||||
return;
|
||||
}
|
||||
// return;
|
||||
// }
|
||||
|
||||
router.refresh();
|
||||
};
|
||||
// router.refresh();
|
||||
// };
|
||||
|
||||
const close = () => {
|
||||
setIsVisible(false);
|
||||
setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
}, 300);
|
||||
};
|
||||
// const close = () => {
|
||||
// setIsVisible(false);
|
||||
// setTimeout(() => {
|
||||
// setIsOpen(false);
|
||||
// }, 300);
|
||||
// };
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
// slight delay to trigger animation
|
||||
setTimeout(() => setIsVisible(true), 10);
|
||||
}
|
||||
}, [isOpen]);
|
||||
// useEffect(() => {
|
||||
// if (isOpen) {
|
||||
// // slight delay to trigger animation
|
||||
// setTimeout(() => setIsVisible(true), 10);
|
||||
// }
|
||||
// }, [isOpen]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<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" />
|
||||
</button>
|
||||
// return (
|
||||
// <>
|
||||
// <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" />
|
||||
// </button>
|
||||
|
||||
{isOpen &&
|
||||
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
|
||||
onClick={close}
|
||||
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${
|
||||
isVisible ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
/>
|
||||
// {isOpen &&
|
||||
// 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
|
||||
// onClick={close}
|
||||
// className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${
|
||||
// isVisible ? "opacity-100" : "opacity-0"
|
||||
// }`}
|
||||
// />
|
||||
|
||||
<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 ${
|
||||
isVisible ? "scale-100 opacity-100" : "scale-75 opacity-0"
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<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">
|
||||
<Icon icon="material-symbols:close-rounded" />
|
||||
</button>
|
||||
</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 ${
|
||||
// isVisible ? "scale-100 opacity-100" : "scale-75 opacity-0"
|
||||
// }`}
|
||||
// >
|
||||
// <div className="flex justify-between items-center mb-2">
|
||||
// <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">
|
||||
// <Icon icon="material-symbols:close-rounded" />
|
||||
// </button>
|
||||
// </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">
|
||||
<button onClick={close} className="pill button">
|
||||
Cancel
|
||||
</button>
|
||||
<SubmitButton onClick={handleSubmit} />
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
// <div className="flex justify-end gap-2 mt-4">
|
||||
// <button onClick={close} className="pill button">
|
||||
// Cancel
|
||||
// </button>
|
||||
// <SubmitButton onClick={handleSubmit} />
|
||||
// </div>
|
||||
// </div>
|
||||
// </div>,
|
||||
// document.body,
|
||||
// )}
|
||||
// </>
|
||||
// );
|
||||
// }
|
||||
|
|
|
|||
|
|
@ -1,166 +1,166 @@
|
|||
// import { Prisma } from "@prisma/client";
|
||||
// import { useMemo, useRef, useState } from "react";
|
||||
// import Carousel from "../carousel";
|
||||
// import Link from "next/link";
|
||||
// import { Icon } from "@iconify/react";
|
||||
|
||||
// interface Props {
|
||||
// miis: Prisma.MiiGetPayload<{ include: { user: { select: { id: true; name: true } }; _count: { select: { likedBy: true } } } }>[];
|
||||
// }
|
||||
|
||||
// type Decision = "accept" | "reject" | null;
|
||||
|
||||
// export default function Queue({ miis }: Props) {
|
||||
// const [currentIndex, setCurrentIndex] = useState(4); // Current index in the miis array, not visible
|
||||
// const [visibleMiis, setVisibleMiis] = useState(miis.slice(0, 4));
|
||||
// const [decision, setDecision] = useState<Decision>(null);
|
||||
// const [isAnimating, setIsAnimating] = useState(false);
|
||||
|
||||
// const [dragOffset, setDragOffset] = useState(0);
|
||||
// const dragStart = useRef<number | null>(null);
|
||||
// const isDragging = useRef(false);
|
||||
|
||||
// const rotations = useMemo(() => {
|
||||
// const map: Record<string, number> = {};
|
||||
// miis.forEach((mii) => {
|
||||
// map[mii.id] = Math.random() * 15 - 5;
|
||||
// });
|
||||
// return map;
|
||||
// }, [miis]);
|
||||
|
||||
// const handleDecision = (decision: Decision) => {
|
||||
// if (isAnimating) return;
|
||||
// setDecision(decision);
|
||||
// setIsAnimating(true);
|
||||
// setDragOffset(decision === "accept" ? -300 : 300);
|
||||
|
||||
// setTimeout(() => {
|
||||
// setVisibleMiis((prev) => {
|
||||
// const newQueue = prev.slice(1); // Remove first Mii
|
||||
// if (miis[currentIndex]) newQueue.push(miis[currentIndex]); // Add a new Mii to the end of the list
|
||||
// return newQueue;
|
||||
// });
|
||||
// setCurrentIndex((prev) => prev + 1);
|
||||
// setDecision(null);
|
||||
// setIsAnimating(false);
|
||||
// setDragOffset(0);
|
||||
// }, 500);
|
||||
// };
|
||||
|
||||
// const onDragStart = (clientX: number) => {
|
||||
// if (isAnimating) return;
|
||||
// dragStart.current = clientX;
|
||||
// isDragging.current = true;
|
||||
// };
|
||||
|
||||
// const onDragMove = (clientX: number) => {
|
||||
// if (!isDragging.current || !dragStart.current) return;
|
||||
// setDragOffset(clientX - dragStart.current);
|
||||
// };
|
||||
|
||||
// const onDragEnd = () => {
|
||||
// if (!isDragging.current) return;
|
||||
// isDragging.current = false;
|
||||
|
||||
// if (dragOffset < -80) handleDecision("accept");
|
||||
// else if (dragOffset > 80) handleDecision("reject");
|
||||
// else setDragOffset(0);
|
||||
|
||||
// dragStart.current = null;
|
||||
// };
|
||||
|
||||
// return (
|
||||
// <div className="w-full flex justify-center items-center gap-8 relative h-100 mt-4 mb-8">
|
||||
// <button
|
||||
// onClick={() => handleDecision("accept")}
|
||||
// className="pointer-coarse:hidden aspect-square cursor-pointer size-12 bg-zinc-50 border-2 border-zinc-300 rounded-full flex justify-center items-center text-2xl text-zinc-500 shadow-xs"
|
||||
// >
|
||||
// <Icon icon="material-symbols:check-rounded" />
|
||||
// </button>
|
||||
|
||||
// <div className="relative w-full max-w-96 h-96 aspect-square">
|
||||
// {visibleMiis.map((mii, i) => {
|
||||
// const isTopCard = i === 0;
|
||||
|
||||
// // Calculate rotation/opacity based on drag distance
|
||||
// const dragRotation = isTopCard ? dragOffset / 10 : 0;
|
||||
// const dragOpacity = isTopCard ? 1 - Math.min(Math.abs(dragOffset) / 300, 1) : undefined;
|
||||
|
||||
// return (
|
||||
// <div
|
||||
// key={mii.id}
|
||||
// className={`absolute inset-0 flex flex-col bg-zinc-50 rounded-3xl border-2 shadow-lg p-[0.8rem] border-zinc-300 *:select-none
|
||||
// ${!isDragging.current ? "transition-all duration-500" : "transition-none"}
|
||||
// ${isTopCard ? "cursor-grab active:cursor-grabbing" : "pointer-events-none"}`}
|
||||
// style={{
|
||||
// transform: isTopCard
|
||||
// ? `translate(${dragOffset}px, ${Math.abs(dragOffset) * 0.1}px) rotate(${rotations[mii.id] + dragRotation}deg)`
|
||||
// : `translateY(${i * 10}px) rotate(${rotations[mii.id]}deg)`,
|
||||
// zIndex: (visibleMiis.length - i) * 10,
|
||||
// opacity: dragOpacity,
|
||||
// }}
|
||||
// onMouseDown={(e) => isTopCard && onDragStart(e.clientX)}
|
||||
// onMouseMove={(e) => isTopCard && onDragMove(e.clientX)}
|
||||
// onMouseUp={() => isTopCard && onDragEnd()}
|
||||
// onMouseLeave={() => isTopCard && isDragging.current && onDragEnd()}
|
||||
// onTouchStart={(e) => isTopCard && onDragStart(e.touches[0].clientX)}
|
||||
// onTouchMove={(e) => isTopCard && onDragMove(e.touches[0].clientX)}
|
||||
// onTouchEnd={() => isTopCard && onDragEnd()}
|
||||
// >
|
||||
// <Carousel
|
||||
// images={[
|
||||
// `/mii/${mii.id}/image?type=mii`,
|
||||
// ...(mii.platform === "THREE_DS" ? [`/mii/${mii.id}/image?type=qr-code`] : [`/mii/${mii.id}/image?type=features`]),
|
||||
// ...Array.from({ length: mii.imageCount }, (_, index) => `/mii/${mii.id}/image?type=image${index}`),
|
||||
// ]}
|
||||
// onlyButtons
|
||||
// />
|
||||
|
||||
// <div className="p-4 flex flex-col gap-1 h-full">
|
||||
// <div className="flex justify-between items-center">
|
||||
// <Link
|
||||
// href={`/mii/${mii.id}`}
|
||||
// draggable={false}
|
||||
// className="relative font-bold text-2xl line-clamp-1 w-full text-ellipsis wrap-break-word"
|
||||
// title={mii.name}
|
||||
// >
|
||||
// {mii.name}
|
||||
// </Link>
|
||||
// <div title={mii.platform === "SWITCH" ? "Switch" : "3DS"} className="-mr-3 text-[1.25rem] opacity-25">
|
||||
// {mii.platform === "SWITCH" ? (
|
||||
// <Icon icon="cib:nintendo-switch" className="text-red-400" />
|
||||
// ) : (
|
||||
// <Icon icon="cib:nintendo-3ds" className="text-sky-400" />
|
||||
// )}
|
||||
// </div>
|
||||
// </div>
|
||||
// <div id="tags" className="flex flex-wrap gap-1">
|
||||
// {mii.tags.map((tag) => (
|
||||
// <Link href={{ query: { tags: tag } }} draggable={false} key={tag} className="px-2 py-1 bg-orange-300 rounded-full text-xs">
|
||||
// {tag}
|
||||
// </Link>
|
||||
// ))}
|
||||
// </div>
|
||||
|
||||
// <div className="mt-auto grid grid-cols-2 gap-4 items-center">
|
||||
// <p className="text-sm">{mii.createdAt.toLocaleString("en-GB", { timeZone: "UTC" })}</p>
|
||||
|
||||
// <Link href={`/profile/${mii.user.id}`} draggable={false} className="text-sm text-right overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
// @{mii.user?.name}
|
||||
// </Link>
|
||||
// </div>
|
||||
// </div>
|
||||
// </div>
|
||||
// );
|
||||
// })}
|
||||
// </div>
|
||||
|
||||
// <button
|
||||
// onClick={() => handleDecision("reject")}
|
||||
// className="pointer-coarse:hidden aspect-square cursor-pointer size-12 bg-zinc-50 border-2 border-zinc-300 rounded-full flex justify-center items-center text-2xl text-zinc-500 shadow-xs"
|
||||
// >
|
||||
// <Icon icon="material-symbols:close-rounded" />
|
||||
// </button>
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
// import { Prisma } from "@prisma/client";
|
||||
// import { useMemo, useRef, useState } from "react";
|
||||
// import Carousel from "../carousel";
|
||||
// import Link from "next/link";
|
||||
// import { Icon } from "@iconify/react";
|
||||
|
||||
// interface Props {
|
||||
// miis: Prisma.MiiGetPayload<{ include: { user: { select: { id: true; name: true } }; _count: { select: { likedBy: true } } } }>[];
|
||||
// }
|
||||
|
||||
// type Decision = "accept" | "reject" | null;
|
||||
|
||||
// export default function Queue({ miis }: Props) {
|
||||
// const [currentIndex, setCurrentIndex] = useState(4); // Current index in the miis array, not visible
|
||||
// const [visibleMiis, setVisibleMiis] = useState(miis.slice(0, 4));
|
||||
// const [decision, setDecision] = useState<Decision>(null);
|
||||
// const [isAnimating, setIsAnimating] = useState(false);
|
||||
|
||||
// const [dragOffset, setDragOffset] = useState(0);
|
||||
// const dragStart = useRef<number | null>(null);
|
||||
// const isDragging = useRef(false);
|
||||
|
||||
// const rotations = useMemo(() => {
|
||||
// const map: Record<string, number> = {};
|
||||
// miis.forEach((mii) => {
|
||||
// map[mii.id] = Math.random() * 15 - 5;
|
||||
// });
|
||||
// return map;
|
||||
// }, [miis]);
|
||||
|
||||
// const handleDecision = (decision: Decision) => {
|
||||
// if (isAnimating) return;
|
||||
// setDecision(decision);
|
||||
// setIsAnimating(true);
|
||||
// setDragOffset(decision === "accept" ? -300 : 300);
|
||||
|
||||
// setTimeout(() => {
|
||||
// setVisibleMiis((prev) => {
|
||||
// const newQueue = prev.slice(1); // Remove first Mii
|
||||
// if (miis[currentIndex]) newQueue.push(miis[currentIndex]); // Add a new Mii to the end of the list
|
||||
// return newQueue;
|
||||
// });
|
||||
// setCurrentIndex((prev) => prev + 1);
|
||||
// setDecision(null);
|
||||
// setIsAnimating(false);
|
||||
// setDragOffset(0);
|
||||
// }, 500);
|
||||
// };
|
||||
|
||||
// const onDragStart = (clientX: number) => {
|
||||
// if (isAnimating) return;
|
||||
// dragStart.current = clientX;
|
||||
// isDragging.current = true;
|
||||
// };
|
||||
|
||||
// const onDragMove = (clientX: number) => {
|
||||
// if (!isDragging.current || !dragStart.current) return;
|
||||
// setDragOffset(clientX - dragStart.current);
|
||||
// };
|
||||
|
||||
// const onDragEnd = () => {
|
||||
// if (!isDragging.current) return;
|
||||
// isDragging.current = false;
|
||||
|
||||
// if (dragOffset < -80) handleDecision("accept");
|
||||
// else if (dragOffset > 80) handleDecision("reject");
|
||||
// else setDragOffset(0);
|
||||
|
||||
// dragStart.current = null;
|
||||
// };
|
||||
|
||||
// return (
|
||||
// <div className="w-full flex justify-center items-center gap-8 relative h-100 mt-4 mb-8">
|
||||
// <button
|
||||
// onClick={() => handleDecision("accept")}
|
||||
// className="pointer-coarse:hidden aspect-square cursor-pointer size-12 bg-zinc-50 border-2 border-zinc-300 rounded-full flex justify-center items-center text-2xl text-zinc-500 shadow-xs"
|
||||
// >
|
||||
// <Icon icon="material-symbols:check-rounded" />
|
||||
// </button>
|
||||
|
||||
// <div className="relative w-full max-w-96 h-96 aspect-square">
|
||||
// {visibleMiis.map((mii, i) => {
|
||||
// const isTopCard = i === 0;
|
||||
|
||||
// // Calculate rotation/opacity based on drag distance
|
||||
// const dragRotation = isTopCard ? dragOffset / 10 : 0;
|
||||
// const dragOpacity = isTopCard ? 1 - Math.min(Math.abs(dragOffset) / 300, 1) : undefined;
|
||||
|
||||
// return (
|
||||
// <div
|
||||
// key={mii.id}
|
||||
// className={`absolute inset-0 flex flex-col bg-zinc-50 rounded-3xl border-2 shadow-lg p-[0.8rem] border-zinc-300 *:select-none
|
||||
// ${!isDragging.current ? "transition-all duration-500" : "transition-none"}
|
||||
// ${isTopCard ? "cursor-grab active:cursor-grabbing" : "pointer-events-none"}`}
|
||||
// style={{
|
||||
// transform: isTopCard
|
||||
// ? `translate(${dragOffset}px, ${Math.abs(dragOffset) * 0.1}px) rotate(${rotations[mii.id] + dragRotation}deg)`
|
||||
// : `translateY(${i * 10}px) rotate(${rotations[mii.id]}deg)`,
|
||||
// zIndex: (visibleMiis.length - i) * 10,
|
||||
// opacity: dragOpacity,
|
||||
// }}
|
||||
// onMouseDown={(e) => isTopCard && onDragStart(e.clientX)}
|
||||
// onMouseMove={(e) => isTopCard && onDragMove(e.clientX)}
|
||||
// onMouseUp={() => isTopCard && onDragEnd()}
|
||||
// onMouseLeave={() => isTopCard && isDragging.current && onDragEnd()}
|
||||
// onTouchStart={(e) => isTopCard && onDragStart(e.touches[0].clientX)}
|
||||
// onTouchMove={(e) => isTopCard && onDragMove(e.touches[0].clientX)}
|
||||
// onTouchEnd={() => isTopCard && onDragEnd()}
|
||||
// >
|
||||
// <Carousel
|
||||
// images={[
|
||||
// `/mii/${mii.id}/image?type=mii`,
|
||||
// ...(mii.platform === "THREE_DS" ? [`/mii/${mii.id}/image?type=qr-code`] : [`/mii/${mii.id}/image?type=features`]),
|
||||
// ...Array.from({ length: mii.imageCount }, (_, index) => `/mii/${mii.id}/image?type=image${index}`),
|
||||
// ]}
|
||||
// onlyButtons
|
||||
// />
|
||||
|
||||
// <div className="p-4 flex flex-col gap-1 h-full">
|
||||
// <div className="flex justify-between items-center">
|
||||
// <Link
|
||||
// href={`/mii/${mii.id}`}
|
||||
// draggable={false}
|
||||
// className="relative font-bold text-2xl line-clamp-1 w-full text-ellipsis wrap-break-word"
|
||||
// title={mii.name}
|
||||
// >
|
||||
// {mii.name}
|
||||
// </Link>
|
||||
// <div title={mii.platform === "SWITCH" ? "Switch" : "3DS"} className="-mr-3 text-[1.25rem] opacity-25">
|
||||
// {mii.platform === "SWITCH" ? (
|
||||
// <Icon icon="cib:nintendo-switch" className="text-red-400" />
|
||||
// ) : (
|
||||
// <Icon icon="cib:nintendo-3ds" className="text-sky-400" />
|
||||
// )}
|
||||
// </div>
|
||||
// </div>
|
||||
// <div id="tags" className="flex flex-wrap gap-1">
|
||||
// {mii.tags.map((tag) => (
|
||||
// <Link href={{ query: { tags: tag } }} draggable={false} key={tag} className="px-2 py-1 bg-orange-300 rounded-full text-xs">
|
||||
// {tag}
|
||||
// </Link>
|
||||
// ))}
|
||||
// </div>
|
||||
|
||||
// <div className="mt-auto grid grid-cols-2 gap-4 items-center">
|
||||
// <p className="text-sm">{mii.createdAt.toLocaleString("en-GB", { timeZone: "UTC" })}</p>
|
||||
|
||||
// <Link href={`/profile/${mii.user.id}`} draggable={false} className="text-sm text-right overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
// @{mii.user?.name}
|
||||
// </Link>
|
||||
// </div>
|
||||
// </div>
|
||||
// </div>
|
||||
// );
|
||||
// })}
|
||||
// </div>
|
||||
|
||||
// <button
|
||||
// onClick={() => handleDecision("reject")}
|
||||
// className="pointer-coarse:hidden aspect-square cursor-pointer size-12 bg-zinc-50 border-2 border-zinc-300 rounded-full flex justify-center items-center text-2xl text-zinc-500 shadow-xs"
|
||||
// >
|
||||
// <Icon icon="material-symbols:close-rounded" />
|
||||
// </button>
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
|
|
|
|||
|
|
@ -1,84 +1,84 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import { Icon } from "@iconify/react";
|
||||
import SubmitButton from "../submit-button";
|
||||
|
||||
export default function RegenerateImagesButton() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const response = await fetch("/api/admin/regenerate-metadata-images", { method: "PATCH" });
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
setError(data.error);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
close();
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
setIsVisible(false);
|
||||
setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
// slight delay to trigger animation
|
||||
setTimeout(() => setIsVisible(true), 10);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button onClick={() => setIsOpen(true)} className="pill button w-fit">
|
||||
Regenerate all Mii metadata images
|
||||
</button>
|
||||
|
||||
{isOpen &&
|
||||
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
|
||||
onClick={close}
|
||||
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${
|
||||
isVisible ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
/>
|
||||
|
||||
<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 ${
|
||||
isVisible ? "scale-100 opacity-100" : "scale-75 opacity-0"
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<h2 className="text-xl font-bold">Regenerate Images</h2>
|
||||
<button onClick={close} aria-label="Close" className="text-red-400 hover:text-red-500 text-2xl cursor-pointer">
|
||||
<Icon icon="material-symbols:close-rounded" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-zinc-500">Are you sure? This will delete and regenerate every metadata image.</p>
|
||||
|
||||
{error && <span className="text-red-400 font-bold mt-2">Error: {error}</span>}
|
||||
|
||||
<div className="flex justify-end gap-2 mt-4">
|
||||
<button onClick={close} className="pill button">
|
||||
Cancel
|
||||
</button>
|
||||
<SubmitButton onClick={handleSubmit} />
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import { Icon } from "@iconify/react";
|
||||
import SubmitButton from "../submit-button";
|
||||
|
||||
export default function RegenerateImagesButton() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const response = await fetch("/api/admin/regenerate-metadata-images", { method: "PATCH" });
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
setError(data.error);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
close();
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
setIsVisible(false);
|
||||
setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
// slight delay to trigger animation
|
||||
setTimeout(() => setIsVisible(true), 10);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button onClick={() => setIsOpen(true)} className="pill button w-fit">
|
||||
Regenerate all Mii metadata images
|
||||
</button>
|
||||
|
||||
{isOpen &&
|
||||
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
|
||||
onClick={close}
|
||||
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${
|
||||
isVisible ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
/>
|
||||
|
||||
<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 ${
|
||||
isVisible ? "scale-100 opacity-100" : "scale-75 opacity-0"
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<h2 className="text-xl font-bold">Regenerate Images</h2>
|
||||
<button onClick={close} aria-label="Close" className="text-red-400 hover:text-red-500 text-2xl cursor-pointer">
|
||||
<Icon icon="material-symbols:close-rounded" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-zinc-500">Are you sure? This will delete and regenerate every metadata image.</p>
|
||||
|
||||
{error && <span className="text-red-400 font-bold mt-2">Error: {error}</span>}
|
||||
|
||||
<div className="flex justify-end gap-2 mt-4">
|
||||
<button onClick={close} className="pill button">
|
||||
Cancel
|
||||
</button>
|
||||
<SubmitButton onClick={handleSubmit} />
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,30 +1,30 @@
|
|||
import { useRouter } from "next/navigation";
|
||||
import { useTransition } from "react";
|
||||
import { ReportStatus } from "@prisma/client";
|
||||
// import { useRouter } from "next/navigation";
|
||||
// import { useTransition } from "react";
|
||||
// import { ReportStatus } from "@prisma/client";
|
||||
|
||||
export default function ReportTabs({ status }: { status?: ReportStatus }) {
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
// export default function ReportTabs({ status }: { status?: ReportStatus }) {
|
||||
// const router = useRouter();
|
||||
// const [isPending, startTransition] = useTransition();
|
||||
|
||||
return (
|
||||
<div className={`flex gap-2 p-3 border-b border-orange-300 transition-opacity ${isPending ? "opacity-50" : ""}`}>
|
||||
{["ALL", "OPEN", "RESOLVED", "DISMISSED"].map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() =>
|
||||
startTransition(() => {
|
||||
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 ${
|
||||
(s === "ALL" && !status) || s === status
|
||||
? "bg-orange-400 text-white border-orange-400"
|
||||
: "bg-white text-orange-700 border-orange-300 hover:bg-orange-50"
|
||||
}`}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// return (
|
||||
// <div className={`flex gap-2 p-3 border-b border-orange-300 transition-opacity ${isPending ? "opacity-50" : ""}`}>
|
||||
// {["ALL", "OPEN", "RESOLVED", "DISMISSED"].map((s) => (
|
||||
// <button
|
||||
// key={s}
|
||||
// onClick={() =>
|
||||
// startTransition(() => {
|
||||
// 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 ${
|
||||
// (s === "ALL" && !status) || s === status
|
||||
// ? "bg-orange-400 text-white border-orange-400"
|
||||
// : "bg-white text-orange-700 border-orange-300 hover:bg-orange-50"
|
||||
// }`}
|
||||
// >
|
||||
// {s}
|
||||
// </button>
|
||||
// ))}
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
|
|
|
|||
|
|
@ -1,202 +1,202 @@
|
|||
// import { revalidatePath } from "next/cache";
|
||||
|
||||
// import { Icon } from "@iconify/react";
|
||||
// import { ReportStatus } from "@prisma/client";
|
||||
|
||||
// import { prisma } from "@/lib/prisma";
|
||||
// import ReportTabs from "./report-tabs";
|
||||
|
||||
// const PAGE_SIZE = 20;
|
||||
|
||||
// export default async function Reports({ searchParams }: { searchParams: { status?: string; page?: string } }) {
|
||||
// const status = searchParams.status as ReportStatus | undefined;
|
||||
// const page = Number(searchParams.page ?? 1);
|
||||
|
||||
// const [reports, total] = await Promise.all([
|
||||
// prisma.report.findMany({
|
||||
// where: status ? { status } : undefined,
|
||||
// orderBy: { createdAt: "desc" },
|
||||
// skip: (page - 1) * PAGE_SIZE,
|
||||
// take: PAGE_SIZE,
|
||||
// }),
|
||||
// prisma.report.count({
|
||||
// where: status ? { status } : undefined,
|
||||
// }),
|
||||
// ]);
|
||||
|
||||
// const totalPages = Math.ceil(total / PAGE_SIZE);
|
||||
|
||||
// const updateStatus = async (formData: FormData) => {
|
||||
// "use server";
|
||||
// const id = Number(formData.get("id"));
|
||||
// const status = formData.get("status") as ReportStatus;
|
||||
|
||||
// await prisma.report.update({
|
||||
// where: { id },
|
||||
// data: { status },
|
||||
// });
|
||||
|
||||
// revalidatePath("/admin");
|
||||
// };
|
||||
|
||||
// return (
|
||||
// <div className="bg-orange-100 rounded-xl border-2 border-orange-400">
|
||||
// <ReportTabs status={status} />
|
||||
|
||||
// {/* Grid */}
|
||||
// <div className="grid grid-cols-2 gap-2 p-2 max-lg:grid-cols-1">
|
||||
// {reports.map((report) => (
|
||||
// <div key={report.id} className="p-4 bg-white border border-orange-300 shadow-sm rounded-md">
|
||||
// <div className="w-full overflow-x-scroll">
|
||||
// <div className="flex gap-1 w-max">
|
||||
// <span
|
||||
// className={`text-xs font-semibold px-2 py-1 rounded-full border ${
|
||||
// report.reportType == "USER" ? "bg-red-200 text-red-800 border-red-400" : "bg-cyan-200 text-cyan-800 border-cyan-400"
|
||||
// }`}
|
||||
// >
|
||||
// {report.reportType}
|
||||
// </span>
|
||||
|
||||
// <span
|
||||
// className={`text-xs font-semibold px-2 py-1 rounded-full border ${
|
||||
// report.status == "OPEN"
|
||||
// ? "bg-orange-200 text-orange-800 border-orange-400"
|
||||
// : report.status == "RESOLVED"
|
||||
// ? "bg-green-200 text-green-800 border-green-400"
|
||||
// : "bg-zinc-200 text-zinc-800 border-zinc-400"
|
||||
// }`}
|
||||
// >
|
||||
// {report.status}
|
||||
// </span>
|
||||
|
||||
// <span className="ml-2 flex items-center gap-1 text-sm text-zinc-500">
|
||||
// <Icon icon="lucide:calendar" className="text-base" />
|
||||
// {report.createdAt.toLocaleString("en-GB", {
|
||||
// day: "2-digit",
|
||||
// month: "long",
|
||||
// year: "numeric",
|
||||
// hour: "2-digit",
|
||||
// minute: "2-digit",
|
||||
// second: "2-digit",
|
||||
// timeZone: "UTC",
|
||||
// })}{" "}
|
||||
// UTC
|
||||
// </span>
|
||||
// </div>
|
||||
// </div>
|
||||
|
||||
// <div className="grid grid-cols-4 text-xs text-zinc-600 mt-4 max-sm:grid-cols-2">
|
||||
// <div>
|
||||
// <p>Target ID</p>
|
||||
// <a href={report.reportType === "MII" ? `/mii/${report.targetId}` : `/profile/${report.targetId}`} className="text-blue-600 text-sm">
|
||||
// {report.targetId}
|
||||
// </a>
|
||||
// </div>
|
||||
|
||||
// <div>
|
||||
// <p>Creator ID</p>
|
||||
// <a href={`/profile/${report.creatorId}`} className="text-blue-600 text-sm">
|
||||
// {report.creatorId}
|
||||
// </a>
|
||||
// </div>
|
||||
|
||||
// <div>
|
||||
// <p>Reporter</p>
|
||||
// <a href={`/profile/${report.authorId}`} className="text-blue-600 text-sm">
|
||||
// {report.authorId}
|
||||
// </a>
|
||||
// </div>
|
||||
|
||||
// <div>
|
||||
// <p>Reason</p>
|
||||
// <p className="font-medium text-black text-sm">{report.reason}</p>
|
||||
// </div>
|
||||
// </div>
|
||||
|
||||
// <div className="mt-4 border border-orange-200 bg-orange-100/50 rounded-md p-2">
|
||||
// <p className="text-zinc-600 text-xs">Notes</p>
|
||||
// <p>{report.reasonNotes}</p>
|
||||
// </div>
|
||||
|
||||
// <div className="mt-4 flex gap-4">
|
||||
// <form action={updateStatus}>
|
||||
// <input type="hidden" name="id" value={report.id} />
|
||||
// <input type="hidden" name="status" value={"OPEN"} />
|
||||
|
||||
// <button
|
||||
// type="submit"
|
||||
// aria-label="Open"
|
||||
// className="cursor-pointer text-orange-400 flex items-center gap-1 p-1.5 rounded-lg transition-colors hover:bg-orange-400/15"
|
||||
// >
|
||||
// <Icon icon="mdi:alert-circle" className="text-xl" />
|
||||
// <span className="text-sm">Open</span>
|
||||
// </button>
|
||||
// </form>
|
||||
// <form action={updateStatus}>
|
||||
// <input type="hidden" name="id" value={report.id} />
|
||||
// <input type="hidden" name="status" value={"RESOLVED"} />
|
||||
|
||||
// <button
|
||||
// type="submit"
|
||||
// aria-label="Resolve"
|
||||
// className="cursor-pointer text-green-500 flex items-center gap-1 p-1.5 rounded-lg transition-colors hover:bg-green-500/15"
|
||||
// >
|
||||
// <Icon icon="mdi:check-circle" className="text-xl" />
|
||||
// <span className="text-sm">Resolve</span>
|
||||
// </button>
|
||||
// </form>
|
||||
// <form action={updateStatus}>
|
||||
// <input type="hidden" name="id" value={report.id} />
|
||||
// <input type="hidden" name="status" value={"DISMISSED"} />
|
||||
|
||||
// <button
|
||||
// type="submit"
|
||||
// aria-label="Dismiss"
|
||||
// className="cursor-pointer text-zinc-400 flex items-center gap-1 p-1.5 rounded-lg transition-colors hover:bg-zinc-400/15"
|
||||
// >
|
||||
// <Icon icon="mdi:close-circle" className="text-xl" />
|
||||
// <span className="text-sm">Dismiss</span>
|
||||
// </button>
|
||||
// </form>
|
||||
// </div>
|
||||
// </div>
|
||||
// ))}
|
||||
// </div>
|
||||
|
||||
// {reports.length === 0 && (
|
||||
// <div className="text-center py-12 text-gray-500">
|
||||
// <p className="text-lg font-medium">No reports to display</p>
|
||||
// <p className="text-sm">Reports will appear here when users submit them</p>
|
||||
// </div>
|
||||
// )}
|
||||
|
||||
// {/* Pagination */}
|
||||
// {totalPages > 1 && (
|
||||
// <div className="flex justify-between items-center p-3 border-t border-orange-300">
|
||||
// <span className="text-sm text-orange-700">{total} total</span>
|
||||
// <div className="flex items-center gap-3">
|
||||
// {page > 1 && (
|
||||
// <a
|
||||
// href={`/admin?${new URLSearchParams({ ...(status && { status }), page: String(page - 1) })}`}
|
||||
// className="text-sm px-3 py-1 rounded-full font-medium border bg-white text-orange-700 border-orange-300 hover:bg-orange-50 transition-colors"
|
||||
// >
|
||||
// Previous
|
||||
// </a>
|
||||
// )}
|
||||
// <span className="text-sm text-orange-700">
|
||||
// Page {page} of {totalPages}
|
||||
// </span>
|
||||
// {page < totalPages && (
|
||||
// <a
|
||||
// href={`/admin?${new URLSearchParams({ ...(status && { status }), page: String(page + 1) })}`}
|
||||
// className="text-sm px-3 py-1 rounded-full font-medium border bg-white text-orange-700 border-orange-300 hover:bg-orange-50 transition-colors"
|
||||
// >
|
||||
// Next
|
||||
// </a>
|
||||
// )}
|
||||
// </div>
|
||||
// </div>
|
||||
// )}
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
// import { revalidatePath } from "next/cache";
|
||||
|
||||
// import { Icon } from "@iconify/react";
|
||||
// import { ReportStatus } from "@prisma/client";
|
||||
|
||||
// import { prisma } from "@/lib/prisma";
|
||||
// import ReportTabs from "./report-tabs";
|
||||
|
||||
// const PAGE_SIZE = 20;
|
||||
|
||||
// export default async function Reports({ searchParams }: { searchParams: { status?: string; page?: string } }) {
|
||||
// const status = searchParams.status as ReportStatus | undefined;
|
||||
// const page = Number(searchParams.page ?? 1);
|
||||
|
||||
// const [reports, total] = await Promise.all([
|
||||
// prisma.report.findMany({
|
||||
// where: status ? { status } : undefined,
|
||||
// orderBy: { createdAt: "desc" },
|
||||
// skip: (page - 1) * PAGE_SIZE,
|
||||
// take: PAGE_SIZE,
|
||||
// }),
|
||||
// prisma.report.count({
|
||||
// where: status ? { status } : undefined,
|
||||
// }),
|
||||
// ]);
|
||||
|
||||
// const totalPages = Math.ceil(total / PAGE_SIZE);
|
||||
|
||||
// const updateStatus = async (formData: FormData) => {
|
||||
// "use server";
|
||||
// const id = Number(formData.get("id"));
|
||||
// const status = formData.get("status") as ReportStatus;
|
||||
|
||||
// await prisma.report.update({
|
||||
// where: { id },
|
||||
// data: { status },
|
||||
// });
|
||||
|
||||
// revalidatePath("/admin");
|
||||
// };
|
||||
|
||||
// return (
|
||||
// <div className="bg-orange-100 rounded-xl border-2 border-orange-400">
|
||||
// <ReportTabs status={status} />
|
||||
|
||||
// {/* Grid */}
|
||||
// <div className="grid grid-cols-2 gap-2 p-2 max-lg:grid-cols-1">
|
||||
// {reports.map((report) => (
|
||||
// <div key={report.id} className="p-4 bg-white border border-orange-300 shadow-sm rounded-md">
|
||||
// <div className="w-full overflow-x-scroll">
|
||||
// <div className="flex gap-1 w-max">
|
||||
// <span
|
||||
// className={`text-xs font-semibold px-2 py-1 rounded-full border ${
|
||||
// report.reportType == "USER" ? "bg-red-200 text-red-800 border-red-400" : "bg-cyan-200 text-cyan-800 border-cyan-400"
|
||||
// }`}
|
||||
// >
|
||||
// {report.reportType}
|
||||
// </span>
|
||||
|
||||
// <span
|
||||
// className={`text-xs font-semibold px-2 py-1 rounded-full border ${
|
||||
// report.status == "OPEN"
|
||||
// ? "bg-orange-200 text-orange-800 border-orange-400"
|
||||
// : report.status == "RESOLVED"
|
||||
// ? "bg-green-200 text-green-800 border-green-400"
|
||||
// : "bg-zinc-200 text-zinc-800 border-zinc-400"
|
||||
// }`}
|
||||
// >
|
||||
// {report.status}
|
||||
// </span>
|
||||
|
||||
// <span className="ml-2 flex items-center gap-1 text-sm text-zinc-500">
|
||||
// <Icon icon="lucide:calendar" className="text-base" />
|
||||
// {report.createdAt.toLocaleString("en-GB", {
|
||||
// day: "2-digit",
|
||||
// month: "long",
|
||||
// year: "numeric",
|
||||
// hour: "2-digit",
|
||||
// minute: "2-digit",
|
||||
// second: "2-digit",
|
||||
// timeZone: "UTC",
|
||||
// })}{" "}
|
||||
// UTC
|
||||
// </span>
|
||||
// </div>
|
||||
// </div>
|
||||
|
||||
// <div className="grid grid-cols-4 text-xs text-zinc-600 mt-4 max-sm:grid-cols-2">
|
||||
// <div>
|
||||
// <p>Target ID</p>
|
||||
// <a href={report.reportType === "MII" ? `/mii/${report.targetId}` : `/profile/${report.targetId}`} className="text-blue-600 text-sm">
|
||||
// {report.targetId}
|
||||
// </a>
|
||||
// </div>
|
||||
|
||||
// <div>
|
||||
// <p>Creator ID</p>
|
||||
// <a href={`/profile/${report.creatorId}`} className="text-blue-600 text-sm">
|
||||
// {report.creatorId}
|
||||
// </a>
|
||||
// </div>
|
||||
|
||||
// <div>
|
||||
// <p>Reporter</p>
|
||||
// <a href={`/profile/${report.authorId}`} className="text-blue-600 text-sm">
|
||||
// {report.authorId}
|
||||
// </a>
|
||||
// </div>
|
||||
|
||||
// <div>
|
||||
// <p>Reason</p>
|
||||
// <p className="font-medium text-black text-sm">{report.reason}</p>
|
||||
// </div>
|
||||
// </div>
|
||||
|
||||
// <div className="mt-4 border border-orange-200 bg-orange-100/50 rounded-md p-2">
|
||||
// <p className="text-zinc-600 text-xs">Notes</p>
|
||||
// <p>{report.reasonNotes}</p>
|
||||
// </div>
|
||||
|
||||
// <div className="mt-4 flex gap-4">
|
||||
// <form action={updateStatus}>
|
||||
// <input type="hidden" name="id" value={report.id} />
|
||||
// <input type="hidden" name="status" value={"OPEN"} />
|
||||
|
||||
// <button
|
||||
// type="submit"
|
||||
// aria-label="Open"
|
||||
// className="cursor-pointer text-orange-400 flex items-center gap-1 p-1.5 rounded-lg transition-colors hover:bg-orange-400/15"
|
||||
// >
|
||||
// <Icon icon="mdi:alert-circle" className="text-xl" />
|
||||
// <span className="text-sm">Open</span>
|
||||
// </button>
|
||||
// </form>
|
||||
// <form action={updateStatus}>
|
||||
// <input type="hidden" name="id" value={report.id} />
|
||||
// <input type="hidden" name="status" value={"RESOLVED"} />
|
||||
|
||||
// <button
|
||||
// type="submit"
|
||||
// aria-label="Resolve"
|
||||
// className="cursor-pointer text-green-500 flex items-center gap-1 p-1.5 rounded-lg transition-colors hover:bg-green-500/15"
|
||||
// >
|
||||
// <Icon icon="mdi:check-circle" className="text-xl" />
|
||||
// <span className="text-sm">Resolve</span>
|
||||
// </button>
|
||||
// </form>
|
||||
// <form action={updateStatus}>
|
||||
// <input type="hidden" name="id" value={report.id} />
|
||||
// <input type="hidden" name="status" value={"DISMISSED"} />
|
||||
|
||||
// <button
|
||||
// type="submit"
|
||||
// aria-label="Dismiss"
|
||||
// className="cursor-pointer text-zinc-400 flex items-center gap-1 p-1.5 rounded-lg transition-colors hover:bg-zinc-400/15"
|
||||
// >
|
||||
// <Icon icon="mdi:close-circle" className="text-xl" />
|
||||
// <span className="text-sm">Dismiss</span>
|
||||
// </button>
|
||||
// </form>
|
||||
// </div>
|
||||
// </div>
|
||||
// ))}
|
||||
// </div>
|
||||
|
||||
// {reports.length === 0 && (
|
||||
// <div className="text-center py-12 text-gray-500">
|
||||
// <p className="text-lg font-medium">No reports to display</p>
|
||||
// <p className="text-sm">Reports will appear here when users submit them</p>
|
||||
// </div>
|
||||
// )}
|
||||
|
||||
// {/* Pagination */}
|
||||
// {totalPages > 1 && (
|
||||
// <div className="flex justify-between items-center p-3 border-t border-orange-300">
|
||||
// <span className="text-sm text-orange-700">{total} total</span>
|
||||
// <div className="flex items-center gap-3">
|
||||
// {page > 1 && (
|
||||
// <a
|
||||
// href={`/admin?${new URLSearchParams({ ...(status && { status }), page: String(page - 1) })}`}
|
||||
// className="text-sm px-3 py-1 rounded-full font-medium border bg-white text-orange-700 border-orange-300 hover:bg-orange-50 transition-colors"
|
||||
// >
|
||||
// Previous
|
||||
// </a>
|
||||
// )}
|
||||
// <span className="text-sm text-orange-700">
|
||||
// Page {page} of {totalPages}
|
||||
// </span>
|
||||
// {page < totalPages && (
|
||||
// <a
|
||||
// href={`/admin?${new URLSearchParams({ ...(status && { status }), page: String(page + 1) })}`}
|
||||
// className="text-sm px-3 py-1 rounded-full font-medium border bg-white text-orange-700 border-orange-300 hover:bg-orange-50 transition-colors"
|
||||
// >
|
||||
// Next
|
||||
// </a>
|
||||
// )}
|
||||
// </div>
|
||||
// </div>
|
||||
// )}
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
|
|
|
|||
|
|
@ -1,51 +1,51 @@
|
|||
import { useState } from "react";
|
||||
import { Icon } from "@iconify/react";
|
||||
import { redirect } from "next/navigation";
|
||||
// import { useState } from "react";
|
||||
// import { Icon } from "@iconify/react";
|
||||
// import { redirect } from "next/navigation";
|
||||
|
||||
interface Props {
|
||||
hasExpired: boolean;
|
||||
}
|
||||
// interface Props {
|
||||
// hasExpired: boolean;
|
||||
// }
|
||||
|
||||
export default function ReturnToIsland({ hasExpired }: Props) {
|
||||
const [isChecked, setIsChecked] = useState(false);
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
// export default function ReturnToIsland({ hasExpired }: Props) {
|
||||
// const [isChecked, setIsChecked] = useState(false);
|
||||
// const [error, setError] = useState<string | undefined>(undefined);
|
||||
|
||||
const handleClick = async () => {
|
||||
const response = await fetch("/api/return", { method: "DELETE" });
|
||||
// const handleClick = async () => {
|
||||
// const response = await fetch("/api/return", { method: "DELETE" });
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
setError(data.error);
|
||||
// if (!response.ok) {
|
||||
// const data = await response.json();
|
||||
// setError(data.error);
|
||||
|
||||
return;
|
||||
}
|
||||
// return;
|
||||
// }
|
||||
|
||||
redirect("/");
|
||||
};
|
||||
// redirect("/");
|
||||
// };
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-center items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="agreement"
|
||||
disabled={hasExpired}
|
||||
checked={isChecked}
|
||||
onChange={(e) => setIsChecked(e.target.checked)}
|
||||
className={`checkbox ${hasExpired && "text-zinc-600 bg-zinc-100! border-zinc-300!"}`}
|
||||
/>
|
||||
<label htmlFor="agreement" className={`${hasExpired && "text-zinc-500"}`}>
|
||||
I Agree
|
||||
</label>
|
||||
</div>
|
||||
// return (
|
||||
// <>
|
||||
// <div className="flex justify-center items-center gap-2">
|
||||
// <input
|
||||
// type="checkbox"
|
||||
// id="agreement"
|
||||
// disabled={hasExpired}
|
||||
// checked={isChecked}
|
||||
// onChange={(e) => setIsChecked(e.target.checked)}
|
||||
// className={`checkbox ${hasExpired && "text-zinc-600 bg-zinc-100! border-zinc-300!"}`}
|
||||
// />
|
||||
// <label htmlFor="agreement" className={`${hasExpired && "text-zinc-500"}`}>
|
||||
// I Agree
|
||||
// </label>
|
||||
// </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>}
|
||||
<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} />
|
||||
Travel Back
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
// {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">
|
||||
// <Icon icon="ic:round-home" fontSize={24} />
|
||||
// Travel Back
|
||||
// </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 { Prisma, PunishmentType } from "@prisma/client";
|
||||
// import { Icon } from "@iconify/react";
|
||||
// import { Prisma, PunishmentType } from "@prisma/client";
|
||||
|
||||
import ProfilePicture from "../profile-picture";
|
||||
import SubmitButton from "../submit-button";
|
||||
import PunishmentDeletionDialog from "./punishment-deletion-dialog";
|
||||
// import ProfilePicture from "../profile-picture";
|
||||
// import SubmitButton from "../submit-button";
|
||||
// import PunishmentDeletionDialog from "./punishment-deletion-dialog";
|
||||
|
||||
interface ApiResponse {
|
||||
success: boolean;
|
||||
name: string;
|
||||
image: string;
|
||||
createdAt: string;
|
||||
punishments: Prisma.PunishmentGetPayload<{
|
||||
include: {
|
||||
violatingMiis: true;
|
||||
};
|
||||
}>[];
|
||||
}
|
||||
// interface ApiResponse {
|
||||
// success: boolean;
|
||||
// name: string;
|
||||
// image: string;
|
||||
// createdAt: string;
|
||||
// punishments: Prisma.PunishmentGetPayload<{
|
||||
// include: {
|
||||
// violatingMiis: true;
|
||||
// };
|
||||
// }>[];
|
||||
// }
|
||||
|
||||
interface MiiList {
|
||||
id: number;
|
||||
reason: string;
|
||||
}
|
||||
// interface MiiList {
|
||||
// id: number;
|
||||
// reason: string;
|
||||
// }
|
||||
|
||||
export default function Punishments() {
|
||||
const [userId, setUserId] = useState(-1);
|
||||
const [user, setUser] = useState<ApiResponse | undefined>();
|
||||
// export default function Punishments() {
|
||||
// const [userId, setUserId] = useState(-1);
|
||||
// const [user, setUser] = useState<ApiResponse | undefined>();
|
||||
|
||||
const [type, setType] = useState<PunishmentType>("WARNING");
|
||||
const [duration, setDuration] = useState(1);
|
||||
const [notes, setNotes] = useState("");
|
||||
const [reasons, setReasons] = useState("");
|
||||
// const [type, setType] = useState<PunishmentType>("WARNING");
|
||||
// const [duration, setDuration] = useState(1);
|
||||
// const [notes, setNotes] = useState("");
|
||||
// const [reasons, setReasons] = useState("");
|
||||
|
||||
const [miiList, setMiiList] = useState<MiiList[]>([]);
|
||||
const [newMii, setNewMii] = useState<MiiList>({
|
||||
id: 0,
|
||||
reason: "",
|
||||
});
|
||||
// const [miiList, setMiiList] = useState<MiiList[]>([]);
|
||||
// const [newMii, setNewMii] = useState<MiiList>({
|
||||
// id: 0,
|
||||
// reason: "",
|
||||
// });
|
||||
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
// const [error, setError] = useState<string | undefined>(undefined);
|
||||
|
||||
const addMiiToList = () => {
|
||||
if (newMii.id && newMii.reason) {
|
||||
setMiiList([...miiList, { ...newMii, id: Number(newMii.id) }]);
|
||||
setNewMii({ id: 0, reason: "" });
|
||||
}
|
||||
};
|
||||
// const addMiiToList = () => {
|
||||
// if (newMii.id && newMii.reason) {
|
||||
// setMiiList([...miiList, { ...newMii, id: Number(newMii.id) }]);
|
||||
// setNewMii({ id: 0, reason: "" });
|
||||
// }
|
||||
// };
|
||||
|
||||
const removeMiiFromList = (index: number) => {
|
||||
setMiiList(miiList.filter((_, i) => i !== index));
|
||||
};
|
||||
// const removeMiiFromList = (index: number) => {
|
||||
// setMiiList(miiList.filter((_, i) => i !== index));
|
||||
// };
|
||||
|
||||
const handleLookup = async () => {
|
||||
const response = await fetch(`/api/admin/lookup?id=${userId}`);
|
||||
const data = await response.json();
|
||||
setUser(data);
|
||||
};
|
||||
// const handleLookup = async () => {
|
||||
// const response = await fetch(`/api/admin/lookup?id=${userId}`);
|
||||
// const data = await response.json();
|
||||
// setUser(data);
|
||||
// };
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const response = await fetch(`/api/admin/punish?id=${userId}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
type,
|
||||
duration,
|
||||
notes,
|
||||
reasons: reasons.split(","),
|
||||
miiReasons: miiList,
|
||||
}),
|
||||
});
|
||||
// const handleSubmit = async () => {
|
||||
// const response = await fetch(`/api/admin/punish?id=${userId}`, {
|
||||
// method: "POST",
|
||||
// body: JSON.stringify({
|
||||
// type,
|
||||
// duration,
|
||||
// notes,
|
||||
// reasons: reasons.split(","),
|
||||
// miiReasons: miiList,
|
||||
// }),
|
||||
// });
|
||||
|
||||
if (!response.ok) {
|
||||
const { error } = await response.json();
|
||||
setError(error);
|
||||
}
|
||||
// if (!response.ok) {
|
||||
// const { error } = await response.json();
|
||||
// setError(error);
|
||||
// }
|
||||
|
||||
// Set all inputs to empty/default
|
||||
setType("WARNING");
|
||||
setDuration(1);
|
||||
setNotes("");
|
||||
setReasons("");
|
||||
setMiiList([]);
|
||||
setNewMii({ id: 0, reason: "" });
|
||||
setError("");
|
||||
// // Set all inputs to empty/default
|
||||
// setType("WARNING");
|
||||
// setDuration(1);
|
||||
// setNotes("");
|
||||
// setReasons("");
|
||||
// setMiiList([]);
|
||||
// setNewMii({ id: 0, reason: "" });
|
||||
// setError("");
|
||||
|
||||
await handleLookup();
|
||||
};
|
||||
// await handleLookup();
|
||||
// };
|
||||
|
||||
return (
|
||||
<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">
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Enter user ID to lookup..."
|
||||
name="user-id"
|
||||
value={userId !== -1 ? userId : ""}
|
||||
onChange={(e) => setUserId(Number(e.target.value))}
|
||||
className="pill input w-full max-w-lg"
|
||||
/>
|
||||
<button onClick={handleLookup} className="pill button">
|
||||
Lookup User
|
||||
</button>
|
||||
</div>
|
||||
// return (
|
||||
// <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">
|
||||
// <input
|
||||
// type="number"
|
||||
// placeholder="Enter user ID to lookup..."
|
||||
// name="user-id"
|
||||
// value={userId !== -1 ? userId : ""}
|
||||
// onChange={(e) => setUserId(Number(e.target.value))}
|
||||
// className="pill input w-full max-w-lg"
|
||||
// />
|
||||
// <button onClick={handleLookup} className="pill button">
|
||||
// Lookup User
|
||||
// </button>
|
||||
// </div>
|
||||
|
||||
{user && (
|
||||
<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="flex gap-1">
|
||||
<ProfilePicture src={user.image} width={96} height={96} className="rounded-full border-2 border-orange-400" />
|
||||
<div className="p-2 flex flex-col">
|
||||
<p className="text-xl font-bold">{user.name}</p>
|
||||
<p className="text-black/60 text-sm font-medium">@{user.name}</p>
|
||||
<p className="text-sm mt-auto">
|
||||
<span className="font-medium">Created:</span>{" "}
|
||||
{new Date(user.createdAt).toLocaleString("en-GB", {
|
||||
day: "2-digit",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
timeZone: "UTC",
|
||||
})}{" "}
|
||||
UTC
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
// {user && (
|
||||
// <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="flex gap-1">
|
||||
// <ProfilePicture src={user.image} width={96} height={96} className="rounded-full border-2 border-orange-400" />
|
||||
// <div className="p-2 flex flex-col">
|
||||
// <p className="text-xl font-bold">{user.name}</p>
|
||||
// <p className="text-black/60 text-sm font-medium">@{user.name}</p>
|
||||
// <p className="text-sm mt-auto">
|
||||
// <span className="font-medium">Created:</span>{" "}
|
||||
// {new Date(user.createdAt).toLocaleString("en-GB", {
|
||||
// day: "2-digit",
|
||||
// month: "long",
|
||||
// year: "numeric",
|
||||
// hour: "2-digit",
|
||||
// minute: "2-digit",
|
||||
// second: "2-digit",
|
||||
// timeZone: "UTC",
|
||||
// })}{" "}
|
||||
// UTC
|
||||
// </p>
|
||||
// </div>
|
||||
// </div>
|
||||
|
||||
<hr className="border-zinc-300 my-3" />
|
||||
// <hr className="border-zinc-300 my-3" />
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{user.punishments.length === 0 ? (
|
||||
<p className="text-center text-zinc-500 my-2">No punishments found.</p>
|
||||
) : (
|
||||
<>
|
||||
{user.punishments.map((punishment) => (
|
||||
<div
|
||||
key={punishment.id}
|
||||
className={`border rounded-lg p-3 space-y-1 ${
|
||||
punishment.type === "WARNING"
|
||||
? "bg-yellow-50 border-yellow-400"
|
||||
: punishment.type === "TEMP_EXILE"
|
||||
? "bg-orange-100 border-orange-200"
|
||||
: "bg-red-50 border-red-200"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span
|
||||
className={`border px-2 py-1 rounded text-xs font-semibold ${
|
||||
punishment.type === "WARNING"
|
||||
? "bg-yellow-200 text-yellow-800 border-yellow-500"
|
||||
: punishment.type === "TEMP_EXILE"
|
||||
? "bg-orange-200 text-orange-800 border-orange-500"
|
||||
: "bg-red-200 text-red-800 border-red-500"
|
||||
}`}
|
||||
>
|
||||
{punishment.type}
|
||||
</span>
|
||||
// <div className="flex flex-col gap-2">
|
||||
// {user.punishments.length === 0 ? (
|
||||
// <p className="text-center text-zinc-500 my-2">No punishments found.</p>
|
||||
// ) : (
|
||||
// <>
|
||||
// {user.punishments.map((punishment) => (
|
||||
// <div
|
||||
// key={punishment.id}
|
||||
// className={`border rounded-lg p-3 space-y-1 ${
|
||||
// punishment.type === "WARNING"
|
||||
// ? "bg-yellow-50 border-yellow-400"
|
||||
// : punishment.type === "TEMP_EXILE"
|
||||
// ? "bg-orange-100 border-orange-200"
|
||||
// : "bg-red-50 border-red-200"
|
||||
// }`}
|
||||
// >
|
||||
// <div className="flex items-center justify-between mb-2">
|
||||
// <span
|
||||
// className={`border px-2 py-1 rounded text-xs font-semibold ${
|
||||
// punishment.type === "WARNING"
|
||||
// ? "bg-yellow-200 text-yellow-800 border-yellow-500"
|
||||
// : punishment.type === "TEMP_EXILE"
|
||||
// ? "bg-orange-200 text-orange-800 border-orange-500"
|
||||
// : "bg-red-200 text-red-800 border-red-500"
|
||||
// }`}
|
||||
// >
|
||||
// {punishment.type}
|
||||
// </span>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-zinc-600">
|
||||
{new Date(punishment.createdAt).toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" })}
|
||||
</span>
|
||||
<PunishmentDeletionDialog punishmentId={punishment.id} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-zinc-600">
|
||||
<strong>Notes:</strong> {punishment.notes}
|
||||
</p>
|
||||
{punishment.type !== "WARNING" && (
|
||||
<p className="text-sm text-zinc-600">
|
||||
<strong>Expires:</strong>{" "}
|
||||
{punishment.expiresAt
|
||||
? new Date(punishment.expiresAt).toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" })
|
||||
: "Never"}
|
||||
</p>
|
||||
)}
|
||||
{punishment.type !== "PERM_EXILE" && (
|
||||
<p className="text-sm text-zinc-600">
|
||||
<strong>Returned:</strong> {JSON.stringify(punishment.returned)}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm text-zinc-600">
|
||||
<strong>Reasons:</strong>
|
||||
</p>
|
||||
<ul className="ml-8 list-disc text-sm text-zinc-600">
|
||||
{punishment.reasons.map((reason, index) => (
|
||||
<li key={index}>{reason}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="text-sm text-zinc-600">
|
||||
<strong>Mii Reasons:</strong>
|
||||
</p>
|
||||
<ul className="ml-8 list-disc text-sm text-zinc-600">
|
||||
{punishment.violatingMiis.map((mii) => (
|
||||
<li key={mii.miiId}>
|
||||
{mii.miiId}: {mii.reason}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
// <div className="flex items-center gap-2">
|
||||
// <span className="text-sm text-zinc-600">
|
||||
// {new Date(punishment.createdAt).toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" })}
|
||||
// </span>
|
||||
// <PunishmentDeletionDialog punishmentId={punishment.id} />
|
||||
// </div>
|
||||
// </div>
|
||||
// <p className="text-sm text-zinc-600">
|
||||
// <strong>Notes:</strong> {punishment.notes}
|
||||
// </p>
|
||||
// {punishment.type !== "WARNING" && (
|
||||
// <p className="text-sm text-zinc-600">
|
||||
// <strong>Expires:</strong>{" "}
|
||||
// {punishment.expiresAt
|
||||
// ? new Date(punishment.expiresAt).toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" })
|
||||
// : "Never"}
|
||||
// </p>
|
||||
// )}
|
||||
// {punishment.type !== "PERM_EXILE" && (
|
||||
// <p className="text-sm text-zinc-600">
|
||||
// <strong>Returned:</strong> {JSON.stringify(punishment.returned)}
|
||||
// </p>
|
||||
// )}
|
||||
// <p className="text-sm text-zinc-600">
|
||||
// <strong>Reasons:</strong>
|
||||
// </p>
|
||||
// <ul className="ml-8 list-disc text-sm text-zinc-600">
|
||||
// {punishment.reasons.map((reason, index) => (
|
||||
// <li key={index}>{reason}</li>
|
||||
// ))}
|
||||
// </ul>
|
||||
// <p className="text-sm text-zinc-600">
|
||||
// <strong>Mii Reasons:</strong>
|
||||
// </p>
|
||||
// <ul className="ml-8 list-disc text-sm text-zinc-600">
|
||||
// {punishment.violatingMiis.map((mii) => (
|
||||
// <li key={mii.miiId}>
|
||||
// {mii.miiId}: {mii.reason}
|
||||
// </li>
|
||||
// ))}
|
||||
// </ul>
|
||||
// </div>
|
||||
// ))}
|
||||
// </>
|
||||
// )}
|
||||
// </div>
|
||||
// </div>
|
||||
|
||||
<div className="p-4 bg-orange-50 border border-orange-300 rounded-md shadow-sm flex flex-col gap-1">
|
||||
{/* Punishment type */}
|
||||
<p className="text-sm">Punishment Type</p>
|
||||
<select name="punishment-type" value={type} onChange={(e) => setType(e.target.value as PunishmentType)} className="pill input">
|
||||
<option value="WARNING">Warning</option>
|
||||
<option value="TEMP_EXILE">Temporary Exile</option>
|
||||
<option value="PERM_EXILE">Permanent Exile</option>
|
||||
</select>
|
||||
// <div className="p-4 bg-orange-50 border border-orange-300 rounded-md shadow-sm flex flex-col gap-1">
|
||||
// {/* Punishment type */}
|
||||
// <p className="text-sm">Punishment Type</p>
|
||||
// <select name="punishment-type" value={type} onChange={(e) => setType(e.target.value as PunishmentType)} className="pill input">
|
||||
// <option value="WARNING">Warning</option>
|
||||
// <option value="TEMP_EXILE">Temporary Exile</option>
|
||||
// <option value="PERM_EXILE">Permanent Exile</option>
|
||||
// </select>
|
||||
|
||||
{/* Punishment duration */}
|
||||
{type === "TEMP_EXILE" && (
|
||||
<>
|
||||
<p className="text-sm">Duration</p>
|
||||
<select name="punishment-duration" value={duration} onChange={(e) => setDuration(Number(e.target.value))} className="pill input">
|
||||
<option value="1">1 Day</option>
|
||||
<option value="7">7 Days</option>
|
||||
<option value="30">30 Days</option>
|
||||
</select>
|
||||
</>
|
||||
)}
|
||||
// {/* Punishment duration */}
|
||||
// {type === "TEMP_EXILE" && (
|
||||
// <>
|
||||
// <p className="text-sm">Duration</p>
|
||||
// <select name="punishment-duration" value={duration} onChange={(e) => setDuration(Number(e.target.value))} className="pill input">
|
||||
// <option value="1">1 Day</option>
|
||||
// <option value="7">7 Days</option>
|
||||
// <option value="30">30 Days</option>
|
||||
// </select>
|
||||
// </>
|
||||
// )}
|
||||
|
||||
{/* Punishment notes */}
|
||||
<p className="text-sm">Notes</p>
|
||||
<textarea
|
||||
rows={2}
|
||||
maxLength={256}
|
||||
placeholder="Type notes here for the punishment..."
|
||||
className="pill input rounded-xl! resize-none"
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
/>
|
||||
// {/* Punishment notes */}
|
||||
// <p className="text-sm">Notes</p>
|
||||
// <textarea
|
||||
// rows={2}
|
||||
// maxLength={256}
|
||||
// placeholder="Type notes here for the punishment..."
|
||||
// className="pill input rounded-xl! resize-none"
|
||||
// value={notes}
|
||||
// onChange={(e) => setNotes(e.target.value)}
|
||||
// />
|
||||
|
||||
{/* Punishment profile-related reasons */}
|
||||
<p className="text-sm">Profile-related reasons (split by comma)</p>
|
||||
<textarea
|
||||
rows={2}
|
||||
maxLength={256}
|
||||
placeholder="Type profile-related reasons here for the punishment..."
|
||||
className="pill input rounded-xl! resize-none"
|
||||
value={reasons}
|
||||
onChange={(e) => setReasons(e.target.value)}
|
||||
/>
|
||||
// {/* Punishment profile-related reasons */}
|
||||
// <p className="text-sm">Profile-related reasons (split by comma)</p>
|
||||
// <textarea
|
||||
// rows={2}
|
||||
// maxLength={256}
|
||||
// placeholder="Type profile-related reasons here for the punishment..."
|
||||
// className="pill input rounded-xl! resize-none"
|
||||
// value={reasons}
|
||||
// onChange={(e) => setReasons(e.target.value)}
|
||||
// />
|
||||
|
||||
{/* Punishment mii-related reasons */}
|
||||
<p className="text-sm">Mii-related reasons</p>
|
||||
<div className="bg-orange-100 border border-orange-300 rounded-lg p-4">
|
||||
{/* Add Mii Form */}
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Mii ID"
|
||||
className="pill input w-24 text-sm"
|
||||
value={newMii.id}
|
||||
onChange={(e) => setNewMii({ ...newMii, id: Number(e.target.value) })}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Reason for this Mii..."
|
||||
className="pill input flex-1 text-sm"
|
||||
value={newMii.reason}
|
||||
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!">
|
||||
<Icon icon="ic:baseline-plus" className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
// {/* Punishment mii-related reasons */}
|
||||
// <p className="text-sm">Mii-related reasons</p>
|
||||
// <div className="bg-orange-100 border border-orange-300 rounded-lg p-4">
|
||||
// {/* Add Mii Form */}
|
||||
// <div className="flex gap-2">
|
||||
// <input
|
||||
// type="number"
|
||||
// placeholder="Mii ID"
|
||||
// className="pill input w-24 text-sm"
|
||||
// value={newMii.id}
|
||||
// onChange={(e) => setNewMii({ ...newMii, id: Number(e.target.value) })}
|
||||
// />
|
||||
// <input
|
||||
// type="text"
|
||||
// placeholder="Reason for this Mii..."
|
||||
// className="pill input flex-1 text-sm"
|
||||
// value={newMii.reason}
|
||||
// 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!">
|
||||
// <Icon icon="ic:baseline-plus" className="size-4" />
|
||||
// </button>
|
||||
// </div>
|
||||
|
||||
{/* Mii List */}
|
||||
{miiList.length > 0 && (
|
||||
<div className="mt-2 space-y-1">
|
||||
<p className="text-sm font-medium text-black/50">Violating Miis ({miiList.length})</p>
|
||||
{miiList.map((mii, index) => (
|
||||
<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 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="text-sm text-gray-500">{mii.reason}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Remove Mii"
|
||||
onClick={() => removeMiiFromList(index)}
|
||||
className="cursor-pointer text-red-500 hover:text-red-700 transition-colors"
|
||||
>
|
||||
<Icon icon="iconamoon:trash" className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
// {/* Mii List */}
|
||||
// {miiList.length > 0 && (
|
||||
// <div className="mt-2 space-y-1">
|
||||
// <p className="text-sm font-medium text-black/50">Violating Miis ({miiList.length})</p>
|
||||
// {miiList.map((mii, index) => (
|
||||
// <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 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="text-sm text-gray-500">{mii.reason}</span>
|
||||
// </div>
|
||||
// </div>
|
||||
// <button
|
||||
// type="button"
|
||||
// aria-label="Remove Mii"
|
||||
// onClick={() => removeMiiFromList(index)}
|
||||
// className="cursor-pointer text-red-500 hover:text-red-700 transition-colors"
|
||||
// >
|
||||
// <Icon icon="iconamoon:trash" className="size-4" />
|
||||
// </button>
|
||||
// </div>
|
||||
// ))}
|
||||
// </div>
|
||||
// )}
|
||||
|
||||
{miiList.length === 0 && <p className="text-center text-zinc-500 text-sm my-4">No Miis added yet</p>}
|
||||
</div>
|
||||
// {miiList.length === 0 && <p className="text-center text-zinc-500 text-sm my-4">No Miis added yet</p>}
|
||||
// </div>
|
||||
|
||||
<div className="flex justify-between items-center mt-2">
|
||||
{error && <span className="text-red-400 font-bold">Error: {error}</span>}
|
||||
// <div className="flex justify-between items-center mt-2">
|
||||
// {error && <span className="text-red-400 font-bold">Error: {error}</span>}
|
||||
|
||||
<SubmitButton onClick={handleSubmit} className="ml-auto" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// <SubmitButton onClick={handleSubmit} className="ml-auto" />
|
||||
// </div>
|
||||
// </div>
|
||||
// </div>
|
||||
// )}
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
|
|
|
|||
|
|
@ -1,93 +1,93 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import useEmblaCarousel from "embla-carousel-react";
|
||||
import { Icon } from "@iconify/react";
|
||||
|
||||
import ImageViewer from "./image-viewer";
|
||||
|
||||
interface Props {
|
||||
images: string[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Carousel({ images, className }: Props) {
|
||||
const [emblaRef, emblaApi] = useEmblaCarousel({ duration: 15 });
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [scrollSnaps, setScrollSnaps] = useState<number[]>([]);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!emblaApi) return;
|
||||
emblaApi.reInit();
|
||||
setScrollSnaps(emblaApi.scrollSnapList());
|
||||
setSelectedIndex(0);
|
||||
emblaApi.on("select", () => setSelectedIndex(emblaApi.selectedScrollSnap()));
|
||||
}, [images, emblaApi]);
|
||||
|
||||
// Handle keyboard events
|
||||
useEffect(() => {
|
||||
if (!isFocused || !emblaApi) return;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "ArrowLeft") emblaApi.scrollPrev();
|
||||
else if (event.key === "ArrowRight") emblaApi.scrollNext();
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [isFocused, emblaApi]);
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-fit" tabIndex={0} onMouseEnter={() => setIsFocused(true)} onMouseLeave={() => setIsFocused(false)}>
|
||||
<div className={`overflow-hidden rounded-xl bg-zinc-300 ${className ?? ""}`} ref={emblaRef}>
|
||||
<div className="flex">
|
||||
{images.map((src, index) => (
|
||||
<div key={index} className="shrink-0 w-full">
|
||||
<ImageViewer src={src} alt="mii image" width={240} height={160} className="w-full h-auto aspect-3/2 object-contain" images={images} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{images.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Scroll Carousel Left"
|
||||
onClick={() => emblaApi?.scrollPrev()}
|
||||
disabled={!emblaApi?.canScrollPrev()}
|
||||
className={`absolute left-2 top-1/2 -translate-y-1/2 bg-white p-1 rounded-full shadow text-xl transition-opacity ${
|
||||
emblaApi?.canScrollPrev() ? "opacity-100 cursor-pointer" : "opacity-50"
|
||||
}`}
|
||||
>
|
||||
<Icon icon="ic:round-chevron-left" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Scroll Carousel Right"
|
||||
onClick={() => emblaApi?.scrollNext()}
|
||||
disabled={!emblaApi?.canScrollNext()}
|
||||
className={`absolute right-2 top-1/2 -translate-y-1/2 bg-white p-1 rounded-full shadow text-xl transition-opacity ${
|
||||
emblaApi?.canScrollNext() ? "opacity-100 cursor-pointer" : "opacity-50"
|
||||
}`}
|
||||
>
|
||||
<Icon icon="ic:round-chevron-right" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex justify-center p-2 gap-2 absolute right-0">
|
||||
{scrollSnaps.map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
aria-label={`Go to ${index} in Carousel`}
|
||||
onClick={() => emblaApi?.scrollTo(index)}
|
||||
className={`size-1.5 cursor-pointer rounded-full ${index === selectedIndex ? "bg-black" : "bg-black/25"}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { useEffect, useState } from "react";
|
||||
import useEmblaCarousel from "embla-carousel-react";
|
||||
import { Icon } from "@iconify/react";
|
||||
|
||||
import ImageViewer from "./image-viewer";
|
||||
|
||||
interface Props {
|
||||
images: string[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Carousel({ images, className }: Props) {
|
||||
const [emblaRef, emblaApi] = useEmblaCarousel({ duration: 15 });
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [scrollSnaps, setScrollSnaps] = useState<number[]>([]);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!emblaApi) return;
|
||||
emblaApi.reInit();
|
||||
setScrollSnaps(emblaApi.scrollSnapList());
|
||||
setSelectedIndex(0);
|
||||
emblaApi.on("select", () => setSelectedIndex(emblaApi.selectedScrollSnap()));
|
||||
}, [images, emblaApi]);
|
||||
|
||||
// Handle keyboard events
|
||||
useEffect(() => {
|
||||
if (!isFocused || !emblaApi) return;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "ArrowLeft") emblaApi.scrollPrev();
|
||||
else if (event.key === "ArrowRight") emblaApi.scrollNext();
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [isFocused, emblaApi]);
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-fit" tabIndex={0} onMouseEnter={() => setIsFocused(true)} onMouseLeave={() => setIsFocused(false)}>
|
||||
<div className={`overflow-hidden rounded-xl bg-zinc-300 ${className ?? ""}`} ref={emblaRef}>
|
||||
<div className="flex">
|
||||
{images.map((src, index) => (
|
||||
<div key={index} className="shrink-0 w-full">
|
||||
<ImageViewer src={src} alt="mii image" width={240} height={160} className="w-full h-auto aspect-3/2 object-contain" images={images} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{images.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Scroll Carousel Left"
|
||||
onClick={() => emblaApi?.scrollPrev()}
|
||||
disabled={!emblaApi?.canScrollPrev()}
|
||||
className={`absolute left-2 top-1/2 -translate-y-1/2 bg-white p-1 rounded-full shadow text-xl transition-opacity ${
|
||||
emblaApi?.canScrollPrev() ? "opacity-100 cursor-pointer" : "opacity-50"
|
||||
}`}
|
||||
>
|
||||
<Icon icon="ic:round-chevron-left" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Scroll Carousel Right"
|
||||
onClick={() => emblaApi?.scrollNext()}
|
||||
disabled={!emblaApi?.canScrollNext()}
|
||||
className={`absolute right-2 top-1/2 -translate-y-1/2 bg-white p-1 rounded-full shadow text-xl transition-opacity ${
|
||||
emblaApi?.canScrollNext() ? "opacity-100 cursor-pointer" : "opacity-50"
|
||||
}`}
|
||||
>
|
||||
<Icon icon="ic:round-chevron-right" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex justify-center p-2 gap-2 absolute right-0">
|
||||
{scrollSnaps.map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
aria-label={`Go to ${index} in Carousel`}
|
||||
onClick={() => emblaApi?.scrollTo(index)}
|
||||
className={`size-1.5 cursor-pointer rounded-full ${index === selectedIndex ? "bg-black" : "bg-black/25"}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,42 +1,42 @@
|
|||
import { Icon } from "@iconify/react";
|
||||
|
||||
interface Props {
|
||||
text: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Adds fancy formatting to links
|
||||
export default function Description({ text, className }: Props) {
|
||||
const urlRegex = /(https?:\/\/[^\s]+)/g;
|
||||
const parts = text.split(urlRegex);
|
||||
|
||||
return (
|
||||
<p className={`text-sm mt-2 bg-white/50 p-3 rounded-lg border border-orange-200 whitespace-break-spaces max-h-54 overflow-y-auto ${className}`}>
|
||||
{parts.map(async (part, index) => {
|
||||
try {
|
||||
// Check if it's a URL
|
||||
if (!urlRegex.test(part)) throw new Error("Not a URL");
|
||||
const url = new URL(part);
|
||||
|
||||
return (
|
||||
<a
|
||||
key={index}
|
||||
href={`/out?url=${encodeURIComponent(part)}`}
|
||||
target="_blank"
|
||||
className="text-blue-700 underline break-all ml-1 inline-flex items-center group"
|
||||
title={`Go to ${url.hostname}`}
|
||||
>
|
||||
{url.hostname}
|
||||
{url.pathname !== "/" ? url.pathname : ""}
|
||||
{url.search}
|
||||
<Icon icon="mi:arrow-right-up" fontSize={16} className="transition group-hover:translate-x-0.5 group-hover:-translate-y-0.5" />
|
||||
</a>
|
||||
);
|
||||
} catch {
|
||||
// Normal text/Invalid URL fallback
|
||||
return <span key={index}>{part}</span>;
|
||||
}
|
||||
})}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
import { Icon } from "@iconify/react";
|
||||
|
||||
interface Props {
|
||||
text: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Adds fancy formatting to links
|
||||
export default function Description({ text, className }: Props) {
|
||||
const urlRegex = /(https?:\/\/[^\s]+)/g;
|
||||
const parts = text.split(urlRegex);
|
||||
|
||||
return (
|
||||
<p className={`text-sm mt-2 bg-white/50 p-3 rounded-lg border border-orange-200 whitespace-break-spaces max-h-54 overflow-y-auto ${className}`}>
|
||||
{parts.map(async (part, index) => {
|
||||
try {
|
||||
// Check if it's a URL
|
||||
if (!urlRegex.test(part)) throw new Error("Not a URL");
|
||||
const url = new URL(part);
|
||||
|
||||
return (
|
||||
<a
|
||||
key={index}
|
||||
href={`/out?url=${encodeURIComponent(part)}`}
|
||||
target="_blank"
|
||||
className="text-blue-700 underline break-all ml-1 inline-flex items-center group"
|
||||
title={`Go to ${url.hostname}`}
|
||||
>
|
||||
{url.hostname}
|
||||
{url.pathname !== "/" ? url.pathname : ""}
|
||||
{url.search}
|
||||
<Icon icon="mi:arrow-right-up" fontSize={16} className="transition group-hover:translate-x-0.5 group-hover:-translate-y-0.5" />
|
||||
</a>
|
||||
);
|
||||
} catch {
|
||||
// Normal text/Invalid URL fallback
|
||||
return <span key={index}>{part}</span>;
|
||||
}
|
||||
})}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,49 +1,49 @@
|
|||
import { type ReactNode, useState } from "react";
|
||||
import { type DropzoneOptions, type FileWithPath, useDropzone } from "react-dropzone";
|
||||
import { Icon } from "@iconify/react";
|
||||
|
||||
interface Props {
|
||||
onDrop: (acceptedFiles: FileWithPath[]) => void;
|
||||
options?: DropzoneOptions;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export default function Dropzone({ onDrop, options, children }: Props) {
|
||||
const [isDraggingOver, setIsDraggingOver] = useState(false);
|
||||
|
||||
const handleDrop = (acceptedFiles: FileWithPath[]) => {
|
||||
setIsDraggingOver(false);
|
||||
onDrop(acceptedFiles);
|
||||
};
|
||||
|
||||
const { getRootProps, getInputProps } = useDropzone({
|
||||
onDrop: handleDrop,
|
||||
maxFiles: 3,
|
||||
accept: {
|
||||
"image/*": [".png", ".jpg", ".jpeg", ".bmp", ".png", ".heic"],
|
||||
},
|
||||
...options,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
{...getRootProps()}
|
||||
onDragOver={() => setIsDraggingOver(true)}
|
||||
onDragLeave={() => setIsDraggingOver(false)}
|
||||
className={`relative bg-orange-200 flex flex-col justify-center items-center gap-2 p-4 rounded-xl border-2 border-dashed border-amber-500 select-none size-full transition-all duration-200 ${
|
||||
isDraggingOver && "scale-105 brightness-90 shadow-xl"
|
||||
}`}
|
||||
>
|
||||
{/* Used to transition from border-dashed to border-solid */}
|
||||
<div
|
||||
className={`absolute inset-0 rounded-[10px] outline-2 outline-amber-500 transition-opacity duration-300 ${
|
||||
isDraggingOver ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
></div>
|
||||
|
||||
<input {...getInputProps({ multiple: options?.maxFiles ? options.maxFiles > 1 : false })} />
|
||||
<Icon icon="material-symbols:upload" fontSize={48} />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { type ReactNode, useState } from "react";
|
||||
import { type DropzoneOptions, type FileWithPath, useDropzone } from "react-dropzone";
|
||||
import { Icon } from "@iconify/react";
|
||||
|
||||
interface Props {
|
||||
onDrop: (acceptedFiles: FileWithPath[]) => void;
|
||||
options?: DropzoneOptions;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export default function Dropzone({ onDrop, options, children }: Props) {
|
||||
const [isDraggingOver, setIsDraggingOver] = useState(false);
|
||||
|
||||
const handleDrop = (acceptedFiles: FileWithPath[]) => {
|
||||
setIsDraggingOver(false);
|
||||
onDrop(acceptedFiles);
|
||||
};
|
||||
|
||||
const { getRootProps, getInputProps } = useDropzone({
|
||||
onDrop: handleDrop,
|
||||
maxFiles: 3,
|
||||
accept: {
|
||||
"image/*": [".png", ".jpg", ".jpeg", ".bmp", ".png", ".heic"],
|
||||
},
|
||||
...options,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
{...getRootProps()}
|
||||
onDragOver={() => setIsDraggingOver(true)}
|
||||
onDragLeave={() => setIsDraggingOver(false)}
|
||||
className={`relative bg-orange-200 flex flex-col justify-center items-center gap-2 p-4 rounded-xl border-2 border-dashed border-amber-500 select-none size-full transition-all duration-200 ${
|
||||
isDraggingOver && "scale-105 brightness-90 shadow-xl"
|
||||
}`}
|
||||
>
|
||||
{/* Used to transition from border-dashed to border-solid */}
|
||||
<div
|
||||
className={`absolute inset-0 rounded-[10px] outline-2 outline-amber-500 transition-opacity duration-300 ${
|
||||
isDraggingOver ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
></div>
|
||||
|
||||
<input {...getInputProps({ multiple: options?.maxFiles ? options.maxFiles > 1 : false })} />
|
||||
<Icon icon="material-symbols:upload" fontSize={48} />
|
||||
{children}
|
||||
</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";
|
||||
|
||||
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);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,165 +1,165 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import useEmblaCarousel from "embla-carousel-react";
|
||||
import { Icon } from "@iconify/react";
|
||||
|
||||
interface Props {
|
||||
src: string;
|
||||
alt: string;
|
||||
width: number;
|
||||
height: number;
|
||||
className?: string;
|
||||
images?: string[];
|
||||
}
|
||||
|
||||
export default function ImageViewer({ src, alt, width, height, className, images = [] }: Props) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true, duration: 15 });
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [scrollSnaps, setScrollSnaps] = useState<number[]>([]);
|
||||
|
||||
const close = () => {
|
||||
setIsVisible(false);
|
||||
setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
// slight delay to trigger animation
|
||||
setTimeout(() => setIsVisible(true), 10);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!emblaApi) return;
|
||||
|
||||
// Keep order of images whilst opening at src prop
|
||||
const index = images.indexOf(src);
|
||||
if (index !== -1) {
|
||||
emblaApi.scrollTo(index, true);
|
||||
setSelectedIndex(index);
|
||||
}
|
||||
|
||||
setScrollSnaps(emblaApi.scrollSnapList());
|
||||
emblaApi.on("select", () => setSelectedIndex(emblaApi.selectedScrollSnap()));
|
||||
}, [emblaApi, images, src]);
|
||||
|
||||
// Handle keyboard events
|
||||
useEffect(() => {
|
||||
if (!isOpen || !emblaApi) return;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "ArrowLeft") emblaApi.scrollPrev();
|
||||
else if (event.key === "ArrowRight") emblaApi.scrollNext();
|
||||
else if (event.key === "Escape") close();
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [isOpen, emblaApi]);
|
||||
|
||||
const imagesMap = images.length === 0 ? [src] : images;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* not inserting pixelated image-rendering here because i thought it looked a bit weird */}
|
||||
<img src={src} alt={alt} width={width} height={height} loading="lazy" onClick={() => setIsOpen(true)} className={`cursor-pointer ${className}`} />
|
||||
|
||||
{isOpen &&
|
||||
createPortal(
|
||||
<div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-(--header-height) flex items-center justify-center z-40">
|
||||
<div
|
||||
onClick={close}
|
||||
className={`absolute inset-0 backdrop-brightness-40 backdrop-contrast-125 backdrop-blur-sm transition-opacity duration-300 ${isVisible ? "opacity-100" : "opacity-0"}`}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
onClick={close}
|
||||
className={`pill button p-2! size-11 aspect-square text-2xl absolute top-4 right-4 shrink-0 ${isVisible ? "opacity-100" : "opacity-0"}`}
|
||||
>
|
||||
<Icon icon="material-symbols:close-rounded" />
|
||||
</button>
|
||||
|
||||
<div
|
||||
className={`overflow-hidden max-w-4xl h-[75vh] max-md:h-[55vh] transition-discrete duration-300 ${isVisible ? "scale-100 opacity-100" : "scale-90 opacity-0"}`}
|
||||
ref={emblaRef}
|
||||
>
|
||||
<div className="flex h-full">
|
||||
{imagesMap.map((image, index) => (
|
||||
<div key={index} className="flex-[0_0_100%] h-full flex items-center px-4">
|
||||
<img
|
||||
src={image}
|
||||
alt={alt}
|
||||
width={896}
|
||||
height={896}
|
||||
loading={Math.abs(index - selectedIndex) <= 1 ? "eager" : "lazy"}
|
||||
className="max-w-full max-h-full object-contain drop-shadow-lg"
|
||||
style={{ imageRendering: image.includes("qr-code") ? "pixelated" : "auto" }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{images.length > 1 && (
|
||||
<>
|
||||
{/* Carousel counter */}
|
||||
<div
|
||||
className={`flex justify-center gap-2 bg-orange-300 w-15 font-semibold text-sm py-1 rounded-full border-2 border-orange-400 absolute top-4 left-4 transition-opacity duration-300 ${
|
||||
isVisible ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
>
|
||||
{selectedIndex + 1} / {images.length}
|
||||
</div>
|
||||
|
||||
{/* Carousel buttons */}
|
||||
{/* Prev button */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Scroll Carousel Left"
|
||||
onClick={() => emblaApi?.scrollPrev()}
|
||||
className={`absolute left-2 top-1/2 -translate-y-1/2 pill button p-0.5! aspect-square text-4xl transition-opacity duration-300 ${isVisible ? "opacity-100" : "opacity-0"}`}
|
||||
>
|
||||
<Icon icon="ic:round-chevron-left" />
|
||||
</button>
|
||||
{/* Next button */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Scroll Carousel Right"
|
||||
onClick={() => emblaApi?.scrollNext()}
|
||||
className={`absolute right-2 top-1/2 -translate-y-1/2 pill button p-0.5! aspect-square text-4xl transition-opacity duration-300 ${isVisible ? "opacity-100" : "opacity-0"}`}
|
||||
>
|
||||
<Icon icon="ic:round-chevron-right" />
|
||||
</button>
|
||||
|
||||
{/* Carousel snaps */}
|
||||
<div
|
||||
className={`flex justify-center gap-2 bg-orange-300 p-2.5 rounded-full border-2 border-orange-400 absolute left-1/2 -translate-x-1/2 bottom-4 transition-opacity duration-300 ${
|
||||
isVisible ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
>
|
||||
{scrollSnaps.map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
aria-label={`Go to ${index} in Carousel`}
|
||||
onClick={() => emblaApi?.scrollTo(index)}
|
||||
className={`size-2 cursor-pointer rounded-full transition-all duration-300 ${index === selectedIndex ? "bg-slate-800 w-8" : "bg-slate-800/30"}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import useEmblaCarousel from "embla-carousel-react";
|
||||
import { Icon } from "@iconify/react";
|
||||
|
||||
interface Props {
|
||||
src: string;
|
||||
alt: string;
|
||||
width: number;
|
||||
height: number;
|
||||
className?: string;
|
||||
images?: string[];
|
||||
}
|
||||
|
||||
export default function ImageViewer({ src, alt, width, height, className, images = [] }: Props) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true, duration: 15 });
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [scrollSnaps, setScrollSnaps] = useState<number[]>([]);
|
||||
|
||||
const close = () => {
|
||||
setIsVisible(false);
|
||||
setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
// slight delay to trigger animation
|
||||
setTimeout(() => setIsVisible(true), 10);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!emblaApi) return;
|
||||
|
||||
// Keep order of images whilst opening at src prop
|
||||
const index = images.indexOf(src);
|
||||
if (index !== -1) {
|
||||
emblaApi.scrollTo(index, true);
|
||||
setSelectedIndex(index);
|
||||
}
|
||||
|
||||
setScrollSnaps(emblaApi.scrollSnapList());
|
||||
emblaApi.on("select", () => setSelectedIndex(emblaApi.selectedScrollSnap()));
|
||||
}, [emblaApi, images, src]);
|
||||
|
||||
// Handle keyboard events
|
||||
useEffect(() => {
|
||||
if (!isOpen || !emblaApi) return;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "ArrowLeft") emblaApi.scrollPrev();
|
||||
else if (event.key === "ArrowRight") emblaApi.scrollNext();
|
||||
else if (event.key === "Escape") close();
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [isOpen, emblaApi]);
|
||||
|
||||
const imagesMap = images.length === 0 ? [src] : images;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* not inserting pixelated image-rendering here because i thought it looked a bit weird */}
|
||||
<img src={src} alt={alt} width={width} height={height} loading="lazy" onClick={() => setIsOpen(true)} className={`cursor-pointer ${className}`} />
|
||||
|
||||
{isOpen &&
|
||||
createPortal(
|
||||
<div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-(--header-height) flex items-center justify-center z-40">
|
||||
<div
|
||||
onClick={close}
|
||||
className={`absolute inset-0 backdrop-brightness-40 backdrop-contrast-125 backdrop-blur-sm transition-opacity duration-300 ${isVisible ? "opacity-100" : "opacity-0"}`}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
onClick={close}
|
||||
className={`pill button p-2! size-11 aspect-square text-2xl absolute top-4 right-4 shrink-0 ${isVisible ? "opacity-100" : "opacity-0"}`}
|
||||
>
|
||||
<Icon icon="material-symbols:close-rounded" />
|
||||
</button>
|
||||
|
||||
<div
|
||||
className={`overflow-hidden max-w-4xl h-[75vh] max-md:h-[55vh] transition-discrete duration-300 ${isVisible ? "scale-100 opacity-100" : "scale-90 opacity-0"}`}
|
||||
ref={emblaRef}
|
||||
>
|
||||
<div className="flex h-full">
|
||||
{imagesMap.map((image, index) => (
|
||||
<div key={index} className="flex-[0_0_100%] h-full flex items-center px-4">
|
||||
<img
|
||||
src={image}
|
||||
alt={alt}
|
||||
width={896}
|
||||
height={896}
|
||||
loading={Math.abs(index - selectedIndex) <= 1 ? "eager" : "lazy"}
|
||||
className="max-w-full max-h-full object-contain drop-shadow-lg"
|
||||
style={{ imageRendering: image.includes("qr-code") ? "pixelated" : "auto" }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{images.length > 1 && (
|
||||
<>
|
||||
{/* Carousel counter */}
|
||||
<div
|
||||
className={`flex justify-center gap-2 bg-orange-300 w-15 font-semibold text-sm py-1 rounded-full border-2 border-orange-400 absolute top-4 left-4 transition-opacity duration-300 ${
|
||||
isVisible ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
>
|
||||
{selectedIndex + 1} / {images.length}
|
||||
</div>
|
||||
|
||||
{/* Carousel buttons */}
|
||||
{/* Prev button */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Scroll Carousel Left"
|
||||
onClick={() => emblaApi?.scrollPrev()}
|
||||
className={`absolute left-2 top-1/2 -translate-y-1/2 pill button p-0.5! aspect-square text-4xl transition-opacity duration-300 ${isVisible ? "opacity-100" : "opacity-0"}`}
|
||||
>
|
||||
<Icon icon="ic:round-chevron-left" />
|
||||
</button>
|
||||
{/* Next button */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Scroll Carousel Right"
|
||||
onClick={() => emblaApi?.scrollNext()}
|
||||
className={`absolute right-2 top-1/2 -translate-y-1/2 pill button p-0.5! aspect-square text-4xl transition-opacity duration-300 ${isVisible ? "opacity-100" : "opacity-0"}`}
|
||||
>
|
||||
<Icon icon="ic:round-chevron-right" />
|
||||
</button>
|
||||
|
||||
{/* Carousel snaps */}
|
||||
<div
|
||||
className={`flex justify-center gap-2 bg-orange-300 p-2.5 rounded-full border-2 border-orange-400 absolute left-1/2 -translate-x-1/2 bottom-4 transition-opacity duration-300 ${
|
||||
isVisible ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
>
|
||||
{scrollSnaps.map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
aria-label={`Go to ${index} in Carousel`}
|
||||
onClick={() => emblaApi?.scrollTo(index)}
|
||||
className={`size-2 cursor-pointer rounded-full transition-all duration-300 ${index === selectedIndex ? "bg-slate-800 w-8" : "bg-slate-800/30"}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,10 +11,10 @@ interface Props {
|
|||
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 [likesState, setLikesState] = useState(likes);
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
const [likesState] = useState(likes);
|
||||
const [isAnimating] = useState(false);
|
||||
|
||||
const onClick = async () => {
|
||||
// if (disabled) return;
|
||||
|
|
|
|||
|
|
@ -1,23 +1,23 @@
|
|||
import { Icon } from "@iconify/react";
|
||||
import DeleteMiiButton from "./delete-mii-button";
|
||||
|
||||
interface Props {
|
||||
mii: any;
|
||||
}
|
||||
|
||||
export default function AuthorButtons({ mii }: Props) {
|
||||
// const session = useSession();
|
||||
|
||||
// if (!session.data || (Number(session.data.user?.id) !== mii.userId && Number(session.data.user?.id) !== Number(import.meta.env.NEXT_PUBLIC_ADMIN_USER_ID)))
|
||||
// return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<a aria-label="Edit Mii" href={`/edit/${mii.id}`}>
|
||||
<Icon icon="mdi:pencil" />
|
||||
<span>Edit</span>
|
||||
</a>
|
||||
<DeleteMiiButton miiId={mii.id} miiName={mii.name} likes={mii._count.likedBy ?? 0} inMiiPage />
|
||||
</>
|
||||
);
|
||||
}
|
||||
import { Icon } from "@iconify/react";
|
||||
import DeleteMiiButton from "./delete-mii-button";
|
||||
|
||||
interface Props {
|
||||
mii: any;
|
||||
}
|
||||
|
||||
export default function AuthorButtons({ mii }: Props) {
|
||||
// const session = useSession();
|
||||
|
||||
// if (!session.data || (Number(session.data.user?.id) !== mii.userId && Number(session.data.user?.id) !== Number(import.meta.env.NEXT_PUBLIC_ADMIN_USER_ID)))
|
||||
// return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<a aria-label="Edit Mii" href={`/edit/${mii.id}`}>
|
||||
<Icon icon="mdi:pencil" />
|
||||
<span>Edit</span>
|
||||
</a>
|
||||
<DeleteMiiButton miiId={mii.id} miiName={mii.name} likes={mii._count.likedBy ?? 0} inMiiPage />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,34 +1,34 @@
|
|||
import { type ChangeEvent } from "react";
|
||||
import { type MiiGender, type SwitchMiiInstructions } from "@tomodachi-share/shared";
|
||||
|
||||
interface Props {
|
||||
data: SwitchMiiInstructions["datingPreferences"];
|
||||
onChecked?: (e: ChangeEvent<HTMLInputElement, HTMLInputElement>, gender: MiiGender) => void;
|
||||
}
|
||||
|
||||
const DATING_PREFERENCES = ["Male", "Female", "Nonbinary"];
|
||||
|
||||
export default function DatingPreferencesViewer({ data, onChecked }: Props) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{DATING_PREFERENCES.map((gender) => {
|
||||
const genderEnum = gender.toUpperCase() as MiiGender;
|
||||
|
||||
return (
|
||||
<div key={gender} className="flex gap-1.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={gender}
|
||||
className="checkbox"
|
||||
checked={data.includes(genderEnum)}
|
||||
{...(onChecked ? { onChange: (e: ChangeEvent<HTMLInputElement>) => onChecked(e, genderEnum) } : { readOnly: true })}
|
||||
/>
|
||||
<label htmlFor={gender} className="text-sm select-none">
|
||||
{gender}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { type ChangeEvent } from "react";
|
||||
import { type MiiGender, type SwitchMiiInstructions } from "@tomodachi-share/shared";
|
||||
|
||||
interface Props {
|
||||
data: SwitchMiiInstructions["datingPreferences"];
|
||||
onChecked?: (e: ChangeEvent<HTMLInputElement, HTMLInputElement>, gender: MiiGender) => void;
|
||||
}
|
||||
|
||||
const DATING_PREFERENCES = ["Male", "Female", "Nonbinary"];
|
||||
|
||||
export default function DatingPreferencesViewer({ data, onChecked }: Props) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{DATING_PREFERENCES.map((gender) => {
|
||||
const genderEnum = gender.toUpperCase() as MiiGender;
|
||||
|
||||
return (
|
||||
<div key={gender} className="flex gap-1.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={gender}
|
||||
className="checkbox"
|
||||
checked={data.includes(genderEnum)}
|
||||
{...(onChecked ? { onChange: (e: ChangeEvent<HTMLInputElement>) => onChecked(e, genderEnum) } : { readOnly: true })}
|
||||
/>
|
||||
<label htmlFor={gender} className="text-sm select-none">
|
||||
{gender}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ export default function DeleteMiiButton({ miiId, miiName, likes, inMiiPage }: Pr
|
|||
const [inputMiiName, setInputMiiName] = useState("");
|
||||
|
||||
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) {
|
||||
const { error } = await response.json();
|
||||
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>
|
||||
|
||||
<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">
|
||||
<p className="text-xl font-bold line-clamp-3 wrap-anywhere" title={miiName}>
|
||||
{miiName}
|
||||
|
|
|
|||
|
|
@ -1,254 +1,254 @@
|
|||
import type { ReactNode } from "react";
|
||||
import DatingPreferencesViewer from "./dating-preferences";
|
||||
import PersonalityViewer from "./personality-viewer";
|
||||
|
||||
import { type SwitchMiiInstructions, COLORS } from "@tomodachi-share/shared";
|
||||
|
||||
interface Props {
|
||||
instructions: Partial<SwitchMiiInstructions>;
|
||||
}
|
||||
|
||||
interface SectionProps {
|
||||
name: string;
|
||||
instructions: Partial<SwitchMiiInstructions[keyof SwitchMiiInstructions]>;
|
||||
children?: ReactNode;
|
||||
isSubSection?: boolean;
|
||||
}
|
||||
|
||||
const ORDINAL_SUFFIXES: Record<string, string> = {
|
||||
one: "st",
|
||||
two: "nd",
|
||||
few: "rd",
|
||||
other: "th",
|
||||
};
|
||||
const ordinalRules = new Intl.PluralRules("en-US", { type: "ordinal" });
|
||||
|
||||
function not(value: any) {
|
||||
return value !== undefined && value !== null;
|
||||
}
|
||||
|
||||
function numberValue(value: number, cutoff: number = 25) {
|
||||
return value === cutoff ? "0" : value > cutoff ? `+${value - cutoff}` : `${value - cutoff}`;
|
||||
}
|
||||
|
||||
function GridPosition({ index, cols = 5 }: { index: number; cols?: number }) {
|
||||
const row = Math.floor(index / cols) + 1;
|
||||
const col = (index % cols) + 1;
|
||||
const rowSuffix = ORDINAL_SUFFIXES[ordinalRules.select(row)];
|
||||
const colSuffix = ORDINAL_SUFFIXES[ordinalRules.select(col)];
|
||||
|
||||
return `${row}${rowSuffix} row, ${col}${colSuffix} column`;
|
||||
}
|
||||
|
||||
function ColorPosition({ color }: { color: number | undefined | null }) {
|
||||
if (color === undefined || color === null) return null;
|
||||
if (color <= 7) {
|
||||
return (
|
||||
<span className="flex items-center">
|
||||
<div className="size-5 rounded mr-1.5 shrink-0" style={{ backgroundColor: `#${COLORS[color]}` }}></div>
|
||||
Color menu on left, <GridPosition index={color} cols={1} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (color >= 108) {
|
||||
return (
|
||||
<span className="flex items-center">
|
||||
<div className="size-5 rounded mr-1.5 shrink-0" style={{ backgroundColor: `#${COLORS[color]}` }}></div>
|
||||
Outside color menu, <GridPosition index={color - 108} cols={2} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="flex items-center">
|
||||
<div className="size-5 rounded mr-1.5 shrink-0" style={{ backgroundColor: `#${COLORS[color]}` }}></div>
|
||||
Color menu on right, <GridPosition index={color - 8} cols={10} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface TableCellProps {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function TableCell({ label, children }: TableCellProps) {
|
||||
return (
|
||||
<tr className={"border-b border-orange-300/50 last:border-0"}>
|
||||
<td className={"py-0.5 pr-6 text-amber-700 font-semibold w-30 text-sm"}>{label}</td>
|
||||
<td className={"py-0.5 text-amber-950"}>{children}</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ name, instructions, children, isSubSection }: SectionProps) {
|
||||
if (typeof instructions !== "object" || !instructions) return null;
|
||||
|
||||
const color = "color" in instructions ? instructions.color : undefined;
|
||||
const height = "height" in instructions ? instructions.height : undefined;
|
||||
const distance = "distance" in instructions ? instructions.distance : undefined;
|
||||
const rotation = "rotation" in instructions ? instructions.rotation : undefined;
|
||||
const size = "size" in instructions ? instructions.size : undefined;
|
||||
const stretch = "stretch" in instructions ? instructions.stretch : undefined;
|
||||
|
||||
return (
|
||||
<div className={`p-3 w-max ${isSubSection ? "not-first:mt-2 pt-0!" : "border-l-4 border-amber-400 bg-amber-100/50 rounded-r-lg py-2.5"}`}>
|
||||
<h3 className="font-semibold text-xl text-amber-800 mb-1">{name}</h3>
|
||||
|
||||
<table className="w-full">
|
||||
<tbody>
|
||||
{not(color) && (
|
||||
<TableCell label="Color">
|
||||
<ColorPosition color={color} />
|
||||
</TableCell>
|
||||
)}
|
||||
{not(height) && <TableCell label="Height">{numberValue(height!, 0)}</TableCell>}
|
||||
{not(distance) && <TableCell label="Distance">{numberValue(distance!, 0)}</TableCell>}
|
||||
{not(rotation) && <TableCell label="Rotation">{numberValue(rotation!, 0)}</TableCell>}
|
||||
{not(size) && <TableCell label="Size">{numberValue(size!, 0)}</TableCell>}
|
||||
{not(stretch) && <TableCell label="Stretch">{numberValue(stretch!, 0)}</TableCell>}
|
||||
|
||||
{children}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MiiInstructions({ instructions }: Props) {
|
||||
if (Object.keys(instructions).length === 0) return null;
|
||||
const { head, hair, eyebrows, eyes, nose, lips, ears, glasses, other, height, weight, birthday, datingPreferences, voice, personality } = instructions;
|
||||
|
||||
return (
|
||||
<>
|
||||
{head && (
|
||||
<Section name="Head" instructions={head}>
|
||||
{not(head.skinColor) && (
|
||||
<TableCell label="Skin Color">
|
||||
<ColorPosition color={head.skinColor} />
|
||||
</TableCell>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
{hair && (
|
||||
<Section name="Hair" instructions={hair}>
|
||||
{not(hair.subColor) && (
|
||||
<TableCell label="Sub Color">
|
||||
<ColorPosition color={hair.subColor} />
|
||||
</TableCell>
|
||||
)}
|
||||
{not(hair.subColor2) && (
|
||||
<TableCell label="Sub Color (Back)">
|
||||
<ColorPosition color={hair.subColor2} />
|
||||
</TableCell>
|
||||
)}
|
||||
{not(hair.style) && <TableCell label="Tying Style">{hair.style}</TableCell>}
|
||||
{not(hair.isFlipped) && <TableCell label="Flipped">{hair.isFlipped ? "Yes" : "No"}</TableCell>}
|
||||
</Section>
|
||||
)}
|
||||
{eyebrows && <Section name="Eyebrows" instructions={eyebrows}></Section>}
|
||||
{eyes && (
|
||||
<Section name="Eyes" instructions={eyes}>
|
||||
<Section isSubSection name="Tab 1" instructions={eyes.main} />
|
||||
<Section isSubSection name="Tab 2" instructions={eyes.eyelashesTop} />
|
||||
<Section isSubSection name="Tab 3" instructions={eyes.eyelashesBottom} />
|
||||
<Section isSubSection name="Tab 4" instructions={eyes.eyelidTop} />
|
||||
<Section isSubSection name="Tab 5" instructions={eyes.eyelidBottom} />
|
||||
<Section isSubSection name="Tab 6" instructions={eyes.eyeliner} />
|
||||
<Section isSubSection name="Tab 7" instructions={eyes.pupil} />
|
||||
</Section>
|
||||
)}
|
||||
{nose && <Section name="Nose" instructions={nose}></Section>}
|
||||
{lips && (
|
||||
<Section name="Lips" instructions={lips}>
|
||||
{not(lips.hasLipstick) && <TableCell label="Lipstick">{lips.hasLipstick ? "Yes" : "No"}</TableCell>}
|
||||
</Section>
|
||||
)}
|
||||
{ears && <Section name="Ears" instructions={ears}></Section>}
|
||||
{glasses && (
|
||||
<Section name="Glasses" instructions={glasses}>
|
||||
{not(glasses.ringColor) && (
|
||||
<TableCell label="Ring Color">
|
||||
<ColorPosition color={glasses.ringColor} />
|
||||
</TableCell>
|
||||
)}
|
||||
{not(glasses.shadesColor) && (
|
||||
<TableCell label="Shades Color">
|
||||
<ColorPosition color={glasses.shadesColor} />
|
||||
</TableCell>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
{other && (
|
||||
<Section name="Other" instructions={other}>
|
||||
<Section isSubSection name="Tab 1" instructions={other.wrinkles1} />
|
||||
<Section isSubSection name="Tab 2" instructions={other.wrinkles2} />
|
||||
<Section isSubSection name="Tab 3" instructions={other.beard} />
|
||||
<Section isSubSection name="Tab 4" instructions={other.moustache}>
|
||||
{other.moustache && other.moustache.isFlipped && <TableCell label="Flipped">{other.moustache.isFlipped ? "Yes" : "No"}</TableCell>}
|
||||
</Section>
|
||||
<Section isSubSection name="Tab 5" instructions={other.goatee} />
|
||||
<Section isSubSection name="Tab 6" instructions={other.mole} />
|
||||
<Section isSubSection name="Tab 7" instructions={other.eyeShadow} />
|
||||
<Section isSubSection name="Tab 8" instructions={other.blush} />
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{(height || weight || datingPreferences || voice || personality) && (
|
||||
<div className="p-3 border-l-4 border-amber-400 bg-amber-100/50 rounded-r-lg py-2.5 text-amber-950 w-max">
|
||||
<h3 className="font-semibold text-xl text-amber-800 mb-1">Misc</h3>
|
||||
|
||||
<table className="w-full">
|
||||
<tbody>
|
||||
{not(height) && <TableCell label="Height">{numberValue(height!, 64)}</TableCell>}
|
||||
{not(weight) && <TableCell label="Weight">{numberValue(weight!, 64)}</TableCell>}
|
||||
</tbody>
|
||||
</table>
|
||||
{birthday && (
|
||||
<div className="pl-2 not-nth-2:mt-4">
|
||||
<h4 className="font-semibold text-xl text-amber-800 mb-1">Birthday</h4>
|
||||
<table className="w-full">
|
||||
<tbody>
|
||||
{not(birthday.day) && <TableCell label="Day">{birthday.day}</TableCell>}
|
||||
{not(birthday.month) && <TableCell label="Month">{birthday.month}</TableCell>}
|
||||
{not(birthday.age) && <TableCell label="Age">{birthday.age}</TableCell>}
|
||||
{not(birthday.dontAge) && <TableCell label="Don't Age">{birthday.dontAge ? "Yes" : "No"}</TableCell>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{voice && (
|
||||
<div className="pl-2 not-nth-2:mt-4">
|
||||
<h4 className="font-semibold text-xl text-amber-800 mb-1">Voice</h4>
|
||||
<table className="w-full">
|
||||
<tbody>
|
||||
{not(voice.speed) && <TableCell label="Speed">{numberValue(voice.speed!, 25)}</TableCell>}
|
||||
{not(voice.pitch) && <TableCell label="Pitch">{numberValue(voice.pitch!, 25)}</TableCell>}
|
||||
{not(voice.depth) && <TableCell label="Depth">{numberValue(voice.depth!, 25)}</TableCell>}
|
||||
{not(voice.delivery) && <TableCell label="Delivery">{numberValue(voice.delivery!, 25)}</TableCell>}
|
||||
{not(voice.tone) && <TableCell label="Tone">{voice.tone}</TableCell>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{datingPreferences && (
|
||||
<div className="pl-2 not-nth-2:mt-4">
|
||||
<h4 className="font-semibold text-xl text-amber-800 mb-1">Dating Preferences</h4>
|
||||
<div className="w-min">
|
||||
<DatingPreferencesViewer data={datingPreferences} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{personality && (
|
||||
<div className="pl-2 not-nth-2:mt-4">
|
||||
<h4 className="font-semibold text-xl text-amber-800 mb-1">Personality</h4>
|
||||
<div className="w-min">
|
||||
<PersonalityViewer data={personality} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
import type { ReactNode } from "react";
|
||||
import DatingPreferencesViewer from "./dating-preferences";
|
||||
import PersonalityViewer from "./personality-viewer";
|
||||
|
||||
import { type SwitchMiiInstructions, COLORS } from "@tomodachi-share/shared";
|
||||
|
||||
interface Props {
|
||||
instructions: Partial<SwitchMiiInstructions>;
|
||||
}
|
||||
|
||||
interface SectionProps {
|
||||
name: string;
|
||||
instructions: Partial<SwitchMiiInstructions[keyof SwitchMiiInstructions]>;
|
||||
children?: ReactNode;
|
||||
isSubSection?: boolean;
|
||||
}
|
||||
|
||||
const ORDINAL_SUFFIXES: Record<string, string> = {
|
||||
one: "st",
|
||||
two: "nd",
|
||||
few: "rd",
|
||||
other: "th",
|
||||
};
|
||||
const ordinalRules = new Intl.PluralRules("en-US", { type: "ordinal" });
|
||||
|
||||
function not(value: any) {
|
||||
return value !== undefined && value !== null;
|
||||
}
|
||||
|
||||
function numberValue(value: number, cutoff: number = 25) {
|
||||
return value === cutoff ? "0" : value > cutoff ? `+${value - cutoff}` : `${value - cutoff}`;
|
||||
}
|
||||
|
||||
function GridPosition({ index, cols = 5 }: { index: number; cols?: number }) {
|
||||
const row = Math.floor(index / cols) + 1;
|
||||
const col = (index % cols) + 1;
|
||||
const rowSuffix = ORDINAL_SUFFIXES[ordinalRules.select(row)];
|
||||
const colSuffix = ORDINAL_SUFFIXES[ordinalRules.select(col)];
|
||||
|
||||
return `${row}${rowSuffix} row, ${col}${colSuffix} column`;
|
||||
}
|
||||
|
||||
function ColorPosition({ color }: { color: number | undefined | null }) {
|
||||
if (color === undefined || color === null) return null;
|
||||
if (color <= 7) {
|
||||
return (
|
||||
<span className="flex items-center">
|
||||
<div className="size-5 rounded mr-1.5 shrink-0" style={{ backgroundColor: `#${COLORS[color]}` }}></div>
|
||||
Color menu on left, <GridPosition index={color} cols={1} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (color >= 108) {
|
||||
return (
|
||||
<span className="flex items-center">
|
||||
<div className="size-5 rounded mr-1.5 shrink-0" style={{ backgroundColor: `#${COLORS[color]}` }}></div>
|
||||
Outside color menu, <GridPosition index={color - 108} cols={2} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="flex items-center">
|
||||
<div className="size-5 rounded mr-1.5 shrink-0" style={{ backgroundColor: `#${COLORS[color]}` }}></div>
|
||||
Color menu on right, <GridPosition index={color - 8} cols={10} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface TableCellProps {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function TableCell({ label, children }: TableCellProps) {
|
||||
return (
|
||||
<tr className={"border-b border-orange-300/50 last:border-0"}>
|
||||
<td className={"py-0.5 pr-6 text-amber-700 font-semibold w-30 text-sm"}>{label}</td>
|
||||
<td className={"py-0.5 text-amber-950"}>{children}</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ name, instructions, children, isSubSection }: SectionProps) {
|
||||
if (typeof instructions !== "object" || !instructions) return null;
|
||||
|
||||
const color = "color" in instructions ? instructions.color : undefined;
|
||||
const height = "height" in instructions ? instructions.height : undefined;
|
||||
const distance = "distance" in instructions ? instructions.distance : undefined;
|
||||
const rotation = "rotation" in instructions ? instructions.rotation : undefined;
|
||||
const size = "size" in instructions ? instructions.size : undefined;
|
||||
const stretch = "stretch" in instructions ? instructions.stretch : undefined;
|
||||
|
||||
return (
|
||||
<div className={`p-3 w-max ${isSubSection ? "not-first:mt-2 pt-0!" : "border-l-4 border-amber-400 bg-amber-100/50 rounded-r-lg py-2.5"}`}>
|
||||
<h3 className="font-semibold text-xl text-amber-800 mb-1">{name}</h3>
|
||||
|
||||
<table className="w-full">
|
||||
<tbody>
|
||||
{not(color) && (
|
||||
<TableCell label="Color">
|
||||
<ColorPosition color={color} />
|
||||
</TableCell>
|
||||
)}
|
||||
{not(height) && <TableCell label="Height">{numberValue(height!, 0)}</TableCell>}
|
||||
{not(distance) && <TableCell label="Distance">{numberValue(distance!, 0)}</TableCell>}
|
||||
{not(rotation) && <TableCell label="Rotation">{numberValue(rotation!, 0)}</TableCell>}
|
||||
{not(size) && <TableCell label="Size">{numberValue(size!, 0)}</TableCell>}
|
||||
{not(stretch) && <TableCell label="Stretch">{numberValue(stretch!, 0)}</TableCell>}
|
||||
|
||||
{children}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MiiInstructions({ instructions }: Props) {
|
||||
if (Object.keys(instructions).length === 0) return null;
|
||||
const { head, hair, eyebrows, eyes, nose, lips, ears, glasses, other, height, weight, birthday, datingPreferences, voice, personality } = instructions;
|
||||
|
||||
return (
|
||||
<>
|
||||
{head && (
|
||||
<Section name="Head" instructions={head}>
|
||||
{not(head.skinColor) && (
|
||||
<TableCell label="Skin Color">
|
||||
<ColorPosition color={head.skinColor} />
|
||||
</TableCell>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
{hair && (
|
||||
<Section name="Hair" instructions={hair}>
|
||||
{not(hair.subColor) && (
|
||||
<TableCell label="Sub Color">
|
||||
<ColorPosition color={hair.subColor} />
|
||||
</TableCell>
|
||||
)}
|
||||
{not(hair.subColor2) && (
|
||||
<TableCell label="Sub Color (Back)">
|
||||
<ColorPosition color={hair.subColor2} />
|
||||
</TableCell>
|
||||
)}
|
||||
{not(hair.style) && <TableCell label="Tying Style">{hair.style}</TableCell>}
|
||||
{not(hair.isFlipped) && <TableCell label="Flipped">{hair.isFlipped ? "Yes" : "No"}</TableCell>}
|
||||
</Section>
|
||||
)}
|
||||
{eyebrows && <Section name="Eyebrows" instructions={eyebrows}></Section>}
|
||||
{eyes && (
|
||||
<Section name="Eyes" instructions={eyes}>
|
||||
<Section isSubSection name="Tab 1" instructions={eyes.main} />
|
||||
<Section isSubSection name="Tab 2" instructions={eyes.eyelashesTop} />
|
||||
<Section isSubSection name="Tab 3" instructions={eyes.eyelashesBottom} />
|
||||
<Section isSubSection name="Tab 4" instructions={eyes.eyelidTop} />
|
||||
<Section isSubSection name="Tab 5" instructions={eyes.eyelidBottom} />
|
||||
<Section isSubSection name="Tab 6" instructions={eyes.eyeliner} />
|
||||
<Section isSubSection name="Tab 7" instructions={eyes.pupil} />
|
||||
</Section>
|
||||
)}
|
||||
{nose && <Section name="Nose" instructions={nose}></Section>}
|
||||
{lips && (
|
||||
<Section name="Lips" instructions={lips}>
|
||||
{not(lips.hasLipstick) && <TableCell label="Lipstick">{lips.hasLipstick ? "Yes" : "No"}</TableCell>}
|
||||
</Section>
|
||||
)}
|
||||
{ears && <Section name="Ears" instructions={ears}></Section>}
|
||||
{glasses && (
|
||||
<Section name="Glasses" instructions={glasses}>
|
||||
{not(glasses.ringColor) && (
|
||||
<TableCell label="Ring Color">
|
||||
<ColorPosition color={glasses.ringColor} />
|
||||
</TableCell>
|
||||
)}
|
||||
{not(glasses.shadesColor) && (
|
||||
<TableCell label="Shades Color">
|
||||
<ColorPosition color={glasses.shadesColor} />
|
||||
</TableCell>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
{other && (
|
||||
<Section name="Other" instructions={other}>
|
||||
<Section isSubSection name="Tab 1" instructions={other.wrinkles1} />
|
||||
<Section isSubSection name="Tab 2" instructions={other.wrinkles2} />
|
||||
<Section isSubSection name="Tab 3" instructions={other.beard} />
|
||||
<Section isSubSection name="Tab 4" instructions={other.moustache}>
|
||||
{other.moustache && other.moustache.isFlipped && <TableCell label="Flipped">{other.moustache.isFlipped ? "Yes" : "No"}</TableCell>}
|
||||
</Section>
|
||||
<Section isSubSection name="Tab 5" instructions={other.goatee} />
|
||||
<Section isSubSection name="Tab 6" instructions={other.mole} />
|
||||
<Section isSubSection name="Tab 7" instructions={other.eyeShadow} />
|
||||
<Section isSubSection name="Tab 8" instructions={other.blush} />
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{(height || weight || datingPreferences || voice || personality) && (
|
||||
<div className="p-3 border-l-4 border-amber-400 bg-amber-100/50 rounded-r-lg py-2.5 text-amber-950 w-max">
|
||||
<h3 className="font-semibold text-xl text-amber-800 mb-1">Misc</h3>
|
||||
|
||||
<table className="w-full">
|
||||
<tbody>
|
||||
{not(height) && <TableCell label="Height">{numberValue(height!, 64)}</TableCell>}
|
||||
{not(weight) && <TableCell label="Weight">{numberValue(weight!, 64)}</TableCell>}
|
||||
</tbody>
|
||||
</table>
|
||||
{birthday && (
|
||||
<div className="pl-2 not-nth-2:mt-4">
|
||||
<h4 className="font-semibold text-xl text-amber-800 mb-1">Birthday</h4>
|
||||
<table className="w-full">
|
||||
<tbody>
|
||||
{not(birthday.day) && <TableCell label="Day">{birthday.day}</TableCell>}
|
||||
{not(birthday.month) && <TableCell label="Month">{birthday.month}</TableCell>}
|
||||
{not(birthday.age) && <TableCell label="Age">{birthday.age}</TableCell>}
|
||||
{not(birthday.dontAge) && <TableCell label="Don't Age">{birthday.dontAge ? "Yes" : "No"}</TableCell>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{voice && (
|
||||
<div className="pl-2 not-nth-2:mt-4">
|
||||
<h4 className="font-semibold text-xl text-amber-800 mb-1">Voice</h4>
|
||||
<table className="w-full">
|
||||
<tbody>
|
||||
{not(voice.speed) && <TableCell label="Speed">{numberValue(voice.speed!, 25)}</TableCell>}
|
||||
{not(voice.pitch) && <TableCell label="Pitch">{numberValue(voice.pitch!, 25)}</TableCell>}
|
||||
{not(voice.depth) && <TableCell label="Depth">{numberValue(voice.depth!, 25)}</TableCell>}
|
||||
{not(voice.delivery) && <TableCell label="Delivery">{numberValue(voice.delivery!, 25)}</TableCell>}
|
||||
{not(voice.tone) && <TableCell label="Tone">{voice.tone}</TableCell>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{datingPreferences && (
|
||||
<div className="pl-2 not-nth-2:mt-4">
|
||||
<h4 className="font-semibold text-xl text-amber-800 mb-1">Dating Preferences</h4>
|
||||
<div className="w-min">
|
||||
<DatingPreferencesViewer data={datingPreferences} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{personality && (
|
||||
<div className="pl-2 not-nth-2:mt-4">
|
||||
<h4 className="font-semibold text-xl text-amber-800 mb-1">Personality</h4>
|
||||
<div className="w-min">
|
||||
<PersonalityViewer data={personality} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,132 +1,132 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Icon } from "@iconify/react";
|
||||
|
||||
import PlatformSelect from "./platform-select";
|
||||
import TagFilter from "./tag-filter";
|
||||
import GenderSelect from "./gender-select";
|
||||
import OtherFilters from "./other-filters";
|
||||
import MakeupSelect from "./makeup-select";
|
||||
import type { MiiGender, MiiMakeup, MiiPlatform } from "@tomodachi-share/shared";
|
||||
|
||||
export default function FilterMenu() {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
const platform = (searchParams.get("platform") as MiiPlatform) || undefined;
|
||||
const gender = (searchParams.get("gender") as MiiGender) || undefined;
|
||||
const makeup = (searchParams.get("makeup") as MiiMakeup) || undefined;
|
||||
const rawTags = searchParams.get("tags") || "";
|
||||
const rawExclude = searchParams.get("exclude") || "";
|
||||
const allowCopying = (searchParams.get("allowCopying") as unknown as boolean) || false;
|
||||
|
||||
const tags = useMemo(
|
||||
() =>
|
||||
rawTags
|
||||
? rawTags
|
||||
.split(",")
|
||||
.map((tag) => tag.trim())
|
||||
.filter((tag) => tag.length > 0)
|
||||
: [],
|
||||
[rawTags],
|
||||
);
|
||||
const exclude = useMemo(
|
||||
() =>
|
||||
rawExclude
|
||||
? rawExclude
|
||||
.split(",")
|
||||
.map((tag) => tag.trim())
|
||||
.filter((tag) => tag.length > 0)
|
||||
: [],
|
||||
[rawExclude],
|
||||
);
|
||||
|
||||
const [filterCount, setFilterCount] = useState(tags.length);
|
||||
|
||||
// Filter menu button handler
|
||||
const handleClick = () => {
|
||||
if (!isOpen) {
|
||||
setIsOpen(true);
|
||||
// slight delay to trigger animation
|
||||
setTimeout(() => setIsVisible(true), 10);
|
||||
} else {
|
||||
setIsVisible(false);
|
||||
setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
}, 200);
|
||||
}
|
||||
};
|
||||
|
||||
// Count all active filters
|
||||
useEffect(() => {
|
||||
let count = tags.length + exclude.length;
|
||||
if (platform) count++;
|
||||
if (gender) count++;
|
||||
if (allowCopying) count++;
|
||||
if (makeup) count++;
|
||||
|
||||
setFilterCount(count);
|
||||
}, [tags, exclude, platform, gender, allowCopying, makeup]);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button className="pill button gap-2" onClick={handleClick}>
|
||||
<Icon icon="mdi:filter" className="text-xl" />
|
||||
Filter
|
||||
<span className="w-5">({filterCount})</span>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div
|
||||
className={`absolute w-80 left-0 top-full mt-8 z-40 flex flex-col items-center bg-orange-50
|
||||
border-2 border-amber-500 rounded-2xl shadow-lg p-4 transition-discrete duration-200 ${isVisible ? "translate-y-0 opacity-100" : "-translate-y-2 opacity-0"}`}
|
||||
>
|
||||
{/* Arrow */}
|
||||
<div className="absolute bottom-full left-1/6 -translate-x-1/2 size-0 border-8 border-transparent border-b-amber-500"></div>
|
||||
|
||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium w-full mb-2">
|
||||
<hr className="grow border-zinc-300" />
|
||||
<span>Platform</span>
|
||||
<hr className="grow border-zinc-300" />
|
||||
</div>
|
||||
<PlatformSelect />
|
||||
|
||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium w-full mt-2 mb-1">
|
||||
<hr className="grow border-zinc-300" />
|
||||
<span>Gender</span>
|
||||
<hr className="grow border-zinc-300" />
|
||||
</div>
|
||||
<GenderSelect />
|
||||
|
||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium w-full mt-2 mb-2">
|
||||
<hr className="grow border-zinc-300" />
|
||||
<span>Tags Include</span>
|
||||
<hr className="grow border-zinc-300" />
|
||||
</div>
|
||||
<TagFilter />
|
||||
|
||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium w-full mt-2 mb-2">
|
||||
<hr className="grow border-zinc-300" />
|
||||
<span>Tags Exclude</span>
|
||||
<hr className="grow border-zinc-300" />
|
||||
</div>
|
||||
<TagFilter isExclude />
|
||||
|
||||
{platform !== "THREE_DS" && (
|
||||
<>
|
||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium w-full mt-2 mb-1">
|
||||
<hr className="grow border-zinc-300" />
|
||||
<span>Face Paint</span>
|
||||
<hr className="grow border-zinc-300" />
|
||||
</div>
|
||||
<MakeupSelect />
|
||||
</>
|
||||
)}
|
||||
|
||||
<OtherFilters />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Icon } from "@iconify/react";
|
||||
|
||||
import PlatformSelect from "./platform-select";
|
||||
import TagFilter from "./tag-filter";
|
||||
import GenderSelect from "./gender-select";
|
||||
import OtherFilters from "./other-filters";
|
||||
import MakeupSelect from "./makeup-select";
|
||||
import type { MiiGender, MiiMakeup, MiiPlatform } from "@tomodachi-share/shared";
|
||||
|
||||
export default function FilterMenu() {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
const platform = (searchParams.get("platform") as MiiPlatform) || undefined;
|
||||
const gender = (searchParams.get("gender") as MiiGender) || undefined;
|
||||
const makeup = (searchParams.get("makeup") as MiiMakeup) || undefined;
|
||||
const rawTags = searchParams.get("tags") || "";
|
||||
const rawExclude = searchParams.get("exclude") || "";
|
||||
const allowCopying = (searchParams.get("allowCopying") as unknown as boolean) || false;
|
||||
|
||||
const tags = useMemo(
|
||||
() =>
|
||||
rawTags
|
||||
? rawTags
|
||||
.split(",")
|
||||
.map((tag) => tag.trim())
|
||||
.filter((tag) => tag.length > 0)
|
||||
: [],
|
||||
[rawTags],
|
||||
);
|
||||
const exclude = useMemo(
|
||||
() =>
|
||||
rawExclude
|
||||
? rawExclude
|
||||
.split(",")
|
||||
.map((tag) => tag.trim())
|
||||
.filter((tag) => tag.length > 0)
|
||||
: [],
|
||||
[rawExclude],
|
||||
);
|
||||
|
||||
const [filterCount, setFilterCount] = useState(tags.length);
|
||||
|
||||
// Filter menu button handler
|
||||
const handleClick = () => {
|
||||
if (!isOpen) {
|
||||
setIsOpen(true);
|
||||
// slight delay to trigger animation
|
||||
setTimeout(() => setIsVisible(true), 10);
|
||||
} else {
|
||||
setIsVisible(false);
|
||||
setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
}, 200);
|
||||
}
|
||||
};
|
||||
|
||||
// Count all active filters
|
||||
useEffect(() => {
|
||||
let count = tags.length + exclude.length;
|
||||
if (platform) count++;
|
||||
if (gender) count++;
|
||||
if (allowCopying) count++;
|
||||
if (makeup) count++;
|
||||
|
||||
setFilterCount(count);
|
||||
}, [tags, exclude, platform, gender, allowCopying, makeup]);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button className="pill button gap-2" onClick={handleClick}>
|
||||
<Icon icon="mdi:filter" className="text-xl" />
|
||||
Filter
|
||||
<span className="w-5">({filterCount})</span>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div
|
||||
className={`absolute w-80 left-0 top-full mt-8 z-40 flex flex-col items-center bg-orange-50
|
||||
border-2 border-amber-500 rounded-2xl shadow-lg p-4 transition-discrete duration-200 ${isVisible ? "translate-y-0 opacity-100" : "-translate-y-2 opacity-0"}`}
|
||||
>
|
||||
{/* Arrow */}
|
||||
<div className="absolute bottom-full left-1/6 -translate-x-1/2 size-0 border-8 border-transparent border-b-amber-500"></div>
|
||||
|
||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium w-full mb-2">
|
||||
<hr className="grow border-zinc-300" />
|
||||
<span>Platform</span>
|
||||
<hr className="grow border-zinc-300" />
|
||||
</div>
|
||||
<PlatformSelect />
|
||||
|
||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium w-full mt-2 mb-1">
|
||||
<hr className="grow border-zinc-300" />
|
||||
<span>Gender</span>
|
||||
<hr className="grow border-zinc-300" />
|
||||
</div>
|
||||
<GenderSelect />
|
||||
|
||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium w-full mt-2 mb-2">
|
||||
<hr className="grow border-zinc-300" />
|
||||
<span>Tags Include</span>
|
||||
<hr className="grow border-zinc-300" />
|
||||
</div>
|
||||
<TagFilter />
|
||||
|
||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium w-full mt-2 mb-2">
|
||||
<hr className="grow border-zinc-300" />
|
||||
<span>Tags Exclude</span>
|
||||
<hr className="grow border-zinc-300" />
|
||||
</div>
|
||||
<TagFilter isExclude />
|
||||
|
||||
{platform !== "THREE_DS" && (
|
||||
<>
|
||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium w-full mt-2 mb-1">
|
||||
<hr className="grow border-zinc-300" />
|
||||
<span>Face Paint</span>
|
||||
<hr className="grow border-zinc-300" />
|
||||
</div>
|
||||
<MakeupSelect />
|
||||
</>
|
||||
)}
|
||||
|
||||
<OtherFilters />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,72 +1,72 @@
|
|||
import { useState, useTransition } from "react";
|
||||
import { Icon } from "@iconify/react";
|
||||
import type { MiiGender, MiiPlatform } from "@tomodachi-share/shared";
|
||||
|
||||
export default function GenderSelect() {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const [selected, setSelected] = useState<MiiGender | null>((searchParams.get("gender") as MiiGender) ?? null);
|
||||
const platform = (searchParams.get("platform") as MiiPlatform) || undefined;
|
||||
|
||||
const handleClick = (gender: MiiGender) => {
|
||||
const filter = selected === gender ? null : gender;
|
||||
setSelected(filter);
|
||||
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set("page", "1");
|
||||
|
||||
if (filter) {
|
||||
params.set("gender", filter);
|
||||
} else {
|
||||
params.delete("gender");
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
// router.push(`?${params.toString()}`, { scroll: false });
|
||||
window.location.href = `?${params.toString()}`;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex gap-0.5 w-fit">
|
||||
<button
|
||||
onClick={() => handleClick("MALE")}
|
||||
aria-label="Filter for Male Miis"
|
||||
data-tooltip-span
|
||||
className={`cursor-pointer rounded-xl flex justify-center items-center size-13 text-5xl border-2 transition-all ${
|
||||
selected === "MALE" ? "bg-blue-100 border-blue-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
|
||||
}`}
|
||||
>
|
||||
<div className="tooltip bg-blue-400! border-blue-400! before:border-b-blue-400!">Male</div>
|
||||
<Icon icon="foundation:male" className="text-blue-400" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleClick("FEMALE")}
|
||||
aria-label="Filter for Female Miis"
|
||||
data-tooltip-span
|
||||
className={`cursor-pointer rounded-xl flex justify-center items-center size-13 text-5xl border-2 transition-all ${
|
||||
selected === "FEMALE" ? "bg-pink-100 border-pink-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
|
||||
}`}
|
||||
>
|
||||
<div className="tooltip bg-pink-400! border-pink-400! before:border-b-pink-400!">Female</div>
|
||||
<Icon icon="foundation:female" className="text-pink-400" />
|
||||
</button>
|
||||
|
||||
{platform !== "THREE_DS" && (
|
||||
<button
|
||||
onClick={() => handleClick("NONBINARY")}
|
||||
aria-label="Filter for Nonbinary Miis"
|
||||
data-tooltip-span
|
||||
className={`cursor-pointer rounded-xl flex justify-center items-center size-13 text-5xl border-2 transition-all ${
|
||||
selected === "NONBINARY" ? "bg-purple-100 border-purple-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
|
||||
}`}
|
||||
>
|
||||
<div className="tooltip bg-purple-400! border-purple-400! before:border-b-purple-400!">Nonbinary</div>
|
||||
<Icon icon="mdi:gender-non-binary" className="text-purple-400" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { useState, useTransition } from "react";
|
||||
import { Icon } from "@iconify/react";
|
||||
import type { MiiGender, MiiPlatform } from "@tomodachi-share/shared";
|
||||
|
||||
export default function GenderSelect() {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const [selected, setSelected] = useState<MiiGender | null>((searchParams.get("gender") as MiiGender) ?? null);
|
||||
const platform = (searchParams.get("platform") as MiiPlatform) || undefined;
|
||||
|
||||
const handleClick = (gender: MiiGender) => {
|
||||
const filter = selected === gender ? null : gender;
|
||||
setSelected(filter);
|
||||
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set("page", "1");
|
||||
|
||||
if (filter) {
|
||||
params.set("gender", filter);
|
||||
} else {
|
||||
params.delete("gender");
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
// router.push(`?${params.toString()}`, { scroll: false });
|
||||
window.location.href = `?${params.toString()}`;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex gap-0.5 w-fit">
|
||||
<button
|
||||
onClick={() => handleClick("MALE")}
|
||||
aria-label="Filter for Male Miis"
|
||||
data-tooltip-span
|
||||
className={`cursor-pointer rounded-xl flex justify-center items-center size-13 text-5xl border-2 transition-all ${
|
||||
selected === "MALE" ? "bg-blue-100 border-blue-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
|
||||
}`}
|
||||
>
|
||||
<div className="tooltip bg-blue-400! border-blue-400! before:border-b-blue-400!">Male</div>
|
||||
<Icon icon="foundation:male" className="text-blue-400" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleClick("FEMALE")}
|
||||
aria-label="Filter for Female Miis"
|
||||
data-tooltip-span
|
||||
className={`cursor-pointer rounded-xl flex justify-center items-center size-13 text-5xl border-2 transition-all ${
|
||||
selected === "FEMALE" ? "bg-pink-100 border-pink-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
|
||||
}`}
|
||||
>
|
||||
<div className="tooltip bg-pink-400! border-pink-400! before:border-b-pink-400!">Female</div>
|
||||
<Icon icon="foundation:female" className="text-pink-400" />
|
||||
</button>
|
||||
|
||||
{platform !== "THREE_DS" && (
|
||||
<button
|
||||
onClick={() => handleClick("NONBINARY")}
|
||||
aria-label="Filter for Nonbinary Miis"
|
||||
data-tooltip-span
|
||||
className={`cursor-pointer rounded-xl flex justify-center items-center size-13 text-5xl border-2 transition-all ${
|
||||
selected === "NONBINARY" ? "bg-purple-100 border-purple-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
|
||||
}`}
|
||||
>
|
||||
<div className="tooltip bg-purple-400! border-purple-400! before:border-b-purple-400!">Nonbinary</div>
|
||||
<Icon icon="mdi:gender-non-binary" className="text-purple-400" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,198 +1,184 @@
|
|||
import crypto from "crypto";
|
||||
import seedrandom from "seedrandom";
|
||||
// import crypto from "crypto";
|
||||
// import seedrandom from "seedrandom";
|
||||
|
||||
import { searchSchema } from "@tomodachi-share/shared/schemas";
|
||||
// import { searchSchema } from "@tomodachi-share/shared/schemas";
|
||||
|
||||
import SortSelect from "./sort-select";
|
||||
import Pagination from "./pagination";
|
||||
import FilterMenu from "./filter-menu";
|
||||
import MiiGrid from "./mii-grid";
|
||||
// import SortSelect from "./sort-select";
|
||||
// import Pagination from "./pagination";
|
||||
// import FilterMenu from "./filter-menu";
|
||||
// import MiiGrid from "./mii-grid";
|
||||
|
||||
interface Props {
|
||||
searchParams: URLSearchParams;
|
||||
userId?: number; // Profiles
|
||||
parentPage?: "likes" | "admin";
|
||||
}
|
||||
// interface Props {
|
||||
// searchParams: URLSearchParams;
|
||||
// userId?: number; // Profiles
|
||||
// parentPage?: "likes" | "admin";
|
||||
// }
|
||||
|
||||
export default async function MiiList({ searchParams, userId, parentPage }: Props) {
|
||||
const session = await auth();
|
||||
const parsed = searchSchema.safeParse(searchParams);
|
||||
if (!parsed.success) return <h1>{parsed.error.issues[0].message}</h1>;
|
||||
// export default async function MiiList({ searchParams, userId, parentPage }: Props) {
|
||||
// const session = await auth();
|
||||
// const parsed = searchSchema.safeParse(searchParams);
|
||||
// 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
|
||||
let miiIdsLiked: number[] | undefined = undefined;
|
||||
// // My Likes page
|
||||
// let miiIdsLiked: number[] | undefined = undefined;
|
||||
|
||||
if (parentPage === "likes" && session?.user?.id) {
|
||||
const likedMiis = await prisma.like.findMany({
|
||||
where: { userId: Number(session.user.id) },
|
||||
select: { miiId: true },
|
||||
});
|
||||
miiIdsLiked = likedMiis.map((like) => like.miiId);
|
||||
}
|
||||
// if (parentPage === "likes" && session?.user?.id) {
|
||||
// const likedMiis = await prisma.like.findMany({
|
||||
// where: { userId: Number(session.user.id) },
|
||||
// select: { miiId: true },
|
||||
// });
|
||||
// miiIdsLiked = likedMiis.map((like) => like.miiId);
|
||||
// }
|
||||
|
||||
const where: Prisma.MiiWhereInput = {
|
||||
// In queue logic
|
||||
...(parentPage === "admin"
|
||||
? { in_queue: true } // Only show queued Miis
|
||||
: userId
|
||||
? {
|
||||
// Include queued Miis if user is on their profile
|
||||
...(Number(session?.user?.id) === userId ? {} : { in_queue: false }),
|
||||
userId,
|
||||
}
|
||||
: {
|
||||
// Don't show queued Miis on main page
|
||||
in_queue: false,
|
||||
}),
|
||||
// Only show liked miis on likes page
|
||||
...(parentPage === "likes" && miiIdsLiked && { id: { in: miiIdsLiked } }),
|
||||
// Searching
|
||||
...(query && {
|
||||
OR: [{ name: { contains: query, mode: "insensitive" } }, { tags: { has: query } }, { description: { contains: query, mode: "insensitive" } }],
|
||||
}),
|
||||
// Tag filtering
|
||||
...(tags && tags.length > 0 && { tags: { hasEvery: tags } }),
|
||||
...(exclude && exclude.length > 0 && { NOT: { tags: { hasSome: exclude } } }),
|
||||
// Platform
|
||||
...(platform && { platform: { equals: platform } }),
|
||||
// Gender
|
||||
...(gender && { gender: { equals: gender } }),
|
||||
// Allow Copying
|
||||
...(allowCopying && { allowedCopying: true }),
|
||||
// Makeup
|
||||
...(makeup && { makeup: { equals: makeup } }),
|
||||
// Quarantined
|
||||
...(!quarantined && !userId && { quarantined: false }),
|
||||
};
|
||||
// const where: Prisma.MiiWhereInput = {
|
||||
// // In queue logic
|
||||
// ...(parentPage === "admin"
|
||||
// ? { in_queue: true } // Only show queued Miis
|
||||
// : userId
|
||||
// ? {
|
||||
// // Include queued Miis if user is on their profile
|
||||
// ...(Number(session?.user?.id) === userId ? {} : { in_queue: false }),
|
||||
// userId,
|
||||
// }
|
||||
// : {
|
||||
// // Don't show queued Miis on main page
|
||||
// in_queue: false,
|
||||
// }),
|
||||
// // Only show liked miis on likes page
|
||||
// ...(parentPage === "likes" && miiIdsLiked && { id: { in: miiIdsLiked } }),
|
||||
// // Searching
|
||||
// ...(query && {
|
||||
// OR: [{ name: { contains: query, mode: "insensitive" } }, { tags: { has: query } }, { description: { contains: query, mode: "insensitive" } }],
|
||||
// }),
|
||||
// // Tag filtering
|
||||
// ...(tags && tags.length > 0 && { tags: { hasEvery: tags } }),
|
||||
// ...(exclude && exclude.length > 0 && { NOT: { tags: { hasSome: exclude } } }),
|
||||
// // Platform
|
||||
// ...(platform && { platform: { equals: platform } }),
|
||||
// // Gender
|
||||
// ...(gender && { gender: { equals: gender } }),
|
||||
// // Allow Copying
|
||||
// ...(allowCopying && { allowedCopying: true }),
|
||||
// // Makeup
|
||||
// ...(makeup && { makeup: { equals: makeup } }),
|
||||
// // Quarantined
|
||||
// ...(!quarantined && !userId && { quarantined: false }),
|
||||
// };
|
||||
|
||||
const select: Prisma.MiiSelect = {
|
||||
id: true,
|
||||
// Don't show when userId is specified
|
||||
...(!userId && {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
platform: true,
|
||||
name: true,
|
||||
imageCount: true,
|
||||
tags: true,
|
||||
createdAt: true,
|
||||
gender: true,
|
||||
makeup: true,
|
||||
allowedCopying: true,
|
||||
quarantined: true,
|
||||
in_queue: true,
|
||||
// Mii liked check
|
||||
...(session?.user?.id && {
|
||||
likedBy: {
|
||||
where: { userId: Number(session.user.id) },
|
||||
select: { userId: true },
|
||||
},
|
||||
}),
|
||||
// Like count
|
||||
_count: {
|
||||
select: { likedBy: true },
|
||||
},
|
||||
};
|
||||
// const select: Prisma.MiiSelect = {
|
||||
// id: true,
|
||||
// // Don't show when userId is specified
|
||||
// ...(!userId && {
|
||||
// user: {
|
||||
// select: {
|
||||
// id: true,
|
||||
// name: true,
|
||||
// },
|
||||
// },
|
||||
// }),
|
||||
// platform: true,
|
||||
// name: true,
|
||||
// imageCount: true,
|
||||
// tags: true,
|
||||
// createdAt: true,
|
||||
// gender: true,
|
||||
// makeup: true,
|
||||
// allowedCopying: true,
|
||||
// quarantined: true,
|
||||
// in_queue: true,
|
||||
// // Mii liked check
|
||||
// ...(session?.user?.id && {
|
||||
// likedBy: {
|
||||
// where: { userId: Number(session.user.id) },
|
||||
// select: { userId: true },
|
||||
// },
|
||||
// }),
|
||||
// // Like count
|
||||
// _count: {
|
||||
// select: { likedBy: true },
|
||||
// },
|
||||
// };
|
||||
|
||||
const skip = (page - 1) * limit;
|
||||
// const skip = (page - 1) * limit;
|
||||
|
||||
let totalCount: number;
|
||||
let filteredCount: number;
|
||||
let miis: Prisma.MiiGetPayload<{ select: typeof select }>[];
|
||||
// let totalCount: number;
|
||||
// let miis: Prisma.MiiGetPayload<{ select: typeof select }>[];
|
||||
|
||||
if (sort === "random") {
|
||||
// Get all IDs that match the where conditions
|
||||
const matchingIds = await prisma.mii.findMany({
|
||||
where,
|
||||
select: { id: true },
|
||||
});
|
||||
// if (sort === "random") {
|
||||
// // Get all IDs that match the where conditions
|
||||
// const matchingIds = await prisma.mii.findMany({
|
||||
// where,
|
||||
// select: { id: true },
|
||||
// });
|
||||
|
||||
totalCount = matchingIds.length;
|
||||
filteredCount = Math.max(0, Math.min(limit, totalCount - skip));
|
||||
// totalCount = matchingIds.length;
|
||||
|
||||
if (matchingIds.length === 0) return;
|
||||
// if (matchingIds.length === 0) return;
|
||||
|
||||
// Use seed for consistent random results
|
||||
const randomSeed = seed || crypto.randomInt(0, 1_000_000_000);
|
||||
const rng = seedrandom(randomSeed.toString());
|
||||
// // Use seed for consistent random results
|
||||
// const randomSeed = seed || crypto.randomInt(0, 1_000_000_000);
|
||||
// const rng = seedrandom(randomSeed.toString());
|
||||
|
||||
// Randomize all IDs using the Durstenfeld algorithm
|
||||
for (let i = matchingIds.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(rng() * (i + 1));
|
||||
[matchingIds[i], matchingIds[j]] = [matchingIds[j], matchingIds[i]];
|
||||
}
|
||||
// // Randomize all IDs using the Durstenfeld algorithm
|
||||
// for (let i = matchingIds.length - 1; i > 0; i--) {
|
||||
// const j = Math.floor(rng() * (i + 1));
|
||||
// [matchingIds[i], matchingIds[j]] = [matchingIds[j], matchingIds[i]];
|
||||
// }
|
||||
|
||||
// Convert to number[] array
|
||||
const selectedIds = matchingIds.slice(skip, skip + limit).map((i) => i.id);
|
||||
// // Convert to number[] array
|
||||
// const selectedIds = matchingIds.slice(skip, skip + limit).map((i) => i.id);
|
||||
|
||||
miis = await prisma.mii.findMany({
|
||||
where: {
|
||||
id: { in: selectedIds },
|
||||
},
|
||||
select,
|
||||
});
|
||||
} else {
|
||||
// Sorting by likes, newest, or oldest
|
||||
let orderBy: Prisma.MiiOrderByWithRelationInput[];
|
||||
// miis = await prisma.mii.findMany({
|
||||
// where: {
|
||||
// id: { in: selectedIds },
|
||||
// },
|
||||
// select,
|
||||
// });
|
||||
// } else {
|
||||
// // Sorting by likes, newest, or oldest
|
||||
// let orderBy: Prisma.MiiOrderByWithRelationInput[];
|
||||
|
||||
if (sort === "likes") {
|
||||
orderBy = [{ likedBy: { _count: "desc" } }, { name: "asc" }];
|
||||
} else if (sort === "oldest") {
|
||||
orderBy = [{ createdAt: "asc" }, { name: "asc" }];
|
||||
} else {
|
||||
// default to newest
|
||||
orderBy = [{ createdAt: "desc" }, { name: "asc" }];
|
||||
}
|
||||
// if (sort === "likes") {
|
||||
// orderBy = [{ likedBy: { _count: "desc" } }, { name: "asc" }];
|
||||
// } else if (sort === "oldest") {
|
||||
// orderBy = [{ createdAt: "asc" }, { name: "asc" }];
|
||||
// } else {
|
||||
// // default to newest
|
||||
// orderBy = [{ createdAt: "desc" }, { name: "asc" }];
|
||||
// }
|
||||
|
||||
[totalCount, filteredCount, miis] = await Promise.all([
|
||||
prisma.mii.count({ where: { ...where, userId } }),
|
||||
prisma.mii.count({ where, skip, take: limit }),
|
||||
prisma.mii.findMany({
|
||||
where,
|
||||
orderBy,
|
||||
select,
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
}),
|
||||
]);
|
||||
}
|
||||
// [totalCount, miis] = await Promise.all([
|
||||
// prisma.mii.count({ where: { ...where, userId } }),
|
||||
// prisma.mii.findMany({
|
||||
// where,
|
||||
// orderBy,
|
||||
// select,
|
||||
// skip,
|
||||
// take: limit,
|
||||
// }),
|
||||
// ]);
|
||||
// }
|
||||
|
||||
const lastPage = Math.ceil(totalCount / limit);
|
||||
// const lastPage = Math.ceil(totalCount / limit);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex justify-between items-center gap-2 mb-2 max-md:flex-col">
|
||||
<div className="flex items-center gap-2">
|
||||
{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">{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>
|
||||
// return (
|
||||
// <div className="w-full">
|
||||
// <div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex justify-between items-center gap-2 mb-2 max-md:flex-col">
|
||||
// <div className="flex items-center gap-2">
|
||||
// <span className="text-2xl font-bold text-amber-900">{totalCount}</span>
|
||||
// <span className="text-lg text-amber-700">{totalCount === 1 ? "Mii" : "Miis"}</span>
|
||||
// </div>
|
||||
|
||||
<div className="relative flex items-center justify-end gap-2 w-full md:max-w-2/3 max-md:justify-center">
|
||||
<FilterMenu />
|
||||
<SortSelect />
|
||||
</div>
|
||||
</div>
|
||||
// <div className="relative flex items-center justify-end gap-2 w-full md:max-w-2/3 max-md:justify-center">
|
||||
// <FilterMenu />
|
||||
// <SortSelect />
|
||||
// </div>
|
||||
// </div>
|
||||
|
||||
<MiiGrid miis={miis} userId={userId} parentPage={parentPage} />
|
||||
<Pagination lastPage={lastPage} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// <MiiGrid miis={miis} userId={userId} parentPage={parentPage} />
|
||||
// <Pagination lastPage={lastPage} />
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
|
|
|
|||
|
|
@ -1,72 +1,72 @@
|
|||
import { useState, useTransition } from "react";
|
||||
import { Icon } from "@iconify/react";
|
||||
import type { MiiMakeup } from "@tomodachi-share/shared";
|
||||
|
||||
export default function MakeupSelect() {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const [selected, setSelected] = useState<MiiMakeup | null>((searchParams.get("makeup") as MiiMakeup) ?? null);
|
||||
|
||||
const handleClick = (makeup: MiiMakeup) => {
|
||||
const filter = selected === makeup ? null : makeup;
|
||||
setSelected(filter);
|
||||
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set("page", "1");
|
||||
|
||||
if (filter) {
|
||||
params.set("makeup", filter);
|
||||
} else {
|
||||
params.delete("makeup");
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
// router.push(`?${params.toString()}`, { scroll: false });
|
||||
window.location.href = `?${params.toString()}`;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex gap-0.5 w-fit">
|
||||
{/* Full Makeup */}
|
||||
<button
|
||||
onClick={() => handleClick("FULL")}
|
||||
aria-label="Filter for Full Face Paint"
|
||||
data-tooltip-span
|
||||
className={`cursor-pointer rounded-xl flex justify-center items-center size-13 text-5xl border-2 transition-all ${
|
||||
selected === "FULL" ? "bg-pink-100 border-pink-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
|
||||
}`}
|
||||
>
|
||||
<div className="tooltip bg-pink-400! border-pink-400! before:border-b-pink-400!">Full Face Paint</div>
|
||||
<Icon icon="mdi:palette" className="text-pink-400" />
|
||||
</button>
|
||||
|
||||
{/* Partial Makeup */}
|
||||
<button
|
||||
onClick={() => handleClick("PARTIAL")}
|
||||
aria-label="Filter for Partial Face Paint"
|
||||
data-tooltip-span
|
||||
className={`cursor-pointer rounded-xl flex justify-center items-center size-13 text-5xl border-2 transition-all ${
|
||||
selected === "PARTIAL" ? "bg-purple-100 border-purple-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
|
||||
}`}
|
||||
>
|
||||
<div className="tooltip bg-purple-400! border-purple-400! before:border-b-purple-400!">Partial Face Paint</div>
|
||||
<Icon icon="mdi:lipstick" className="text-purple-400" />
|
||||
</button>
|
||||
|
||||
{/* No Makeup */}
|
||||
<button
|
||||
onClick={() => handleClick("NONE")}
|
||||
aria-label="Filter for No Face Paint"
|
||||
data-tooltip-span
|
||||
className={`cursor-pointer rounded-xl flex justify-center items-center size-13 text-5xl border-2 transition-all ${
|
||||
selected === "NONE" ? "bg-gray-200 border-gray-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
|
||||
}`}
|
||||
>
|
||||
<div className="tooltip bg-gray-400! border-gray-400! before:border-b-gray-400!">No Face Paint</div>
|
||||
<Icon icon="codex:cross" className="text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { useState, useTransition } from "react";
|
||||
import { Icon } from "@iconify/react";
|
||||
import type { MiiMakeup } from "@tomodachi-share/shared";
|
||||
|
||||
export default function MakeupSelect() {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const [selected, setSelected] = useState<MiiMakeup | null>((searchParams.get("makeup") as MiiMakeup) ?? null);
|
||||
|
||||
const handleClick = (makeup: MiiMakeup) => {
|
||||
const filter = selected === makeup ? null : makeup;
|
||||
setSelected(filter);
|
||||
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set("page", "1");
|
||||
|
||||
if (filter) {
|
||||
params.set("makeup", filter);
|
||||
} else {
|
||||
params.delete("makeup");
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
// router.push(`?${params.toString()}`, { scroll: false });
|
||||
window.location.href = `?${params.toString()}`;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex gap-0.5 w-fit">
|
||||
{/* Full Makeup */}
|
||||
<button
|
||||
onClick={() => handleClick("FULL")}
|
||||
aria-label="Filter for Full Face Paint"
|
||||
data-tooltip-span
|
||||
className={`cursor-pointer rounded-xl flex justify-center items-center size-13 text-5xl border-2 transition-all ${
|
||||
selected === "FULL" ? "bg-pink-100 border-pink-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
|
||||
}`}
|
||||
>
|
||||
<div className="tooltip bg-pink-400! border-pink-400! before:border-b-pink-400!">Full Face Paint</div>
|
||||
<Icon icon="mdi:palette" className="text-pink-400" />
|
||||
</button>
|
||||
|
||||
{/* Partial Makeup */}
|
||||
<button
|
||||
onClick={() => handleClick("PARTIAL")}
|
||||
aria-label="Filter for Partial Face Paint"
|
||||
data-tooltip-span
|
||||
className={`cursor-pointer rounded-xl flex justify-center items-center size-13 text-5xl border-2 transition-all ${
|
||||
selected === "PARTIAL" ? "bg-purple-100 border-purple-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
|
||||
}`}
|
||||
>
|
||||
<div className="tooltip bg-purple-400! border-purple-400! before:border-b-purple-400!">Partial Face Paint</div>
|
||||
<Icon icon="mdi:lipstick" className="text-purple-400" />
|
||||
</button>
|
||||
|
||||
{/* No Makeup */}
|
||||
<button
|
||||
onClick={() => handleClick("NONE")}
|
||||
aria-label="Filter for No Face Paint"
|
||||
data-tooltip-span
|
||||
className={`cursor-pointer rounded-xl flex justify-center items-center size-13 text-5xl border-2 transition-all ${
|
||||
selected === "NONE" ? "bg-gray-200 border-gray-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
|
||||
}`}
|
||||
>
|
||||
<div className="tooltip bg-gray-400! border-gray-400! before:border-b-gray-400!">No Face Paint</div>
|
||||
<Icon icon="codex:cross" className="text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,6 @@ import { Icon } from "@iconify/react";
|
|||
|
||||
import LikeButton from "../../like-button";
|
||||
import DeleteMiiButton from "../delete-mii-button";
|
||||
import Carousel from "../../carousel";
|
||||
import ImageViewer from "../../image-viewer";
|
||||
|
||||
interface Props {
|
||||
// miis: Prisma.MiiGetPayload<{ include: { user: { select: { id: true; name: true } }; _count: { select: { likedBy: true } } } }>[];
|
||||
|
|
@ -12,11 +10,7 @@ interface Props {
|
|||
parentPage?: string;
|
||||
}
|
||||
|
||||
const fetcher = (url: string) => fetch(url).then((res) => res.json());
|
||||
|
||||
export default function MiiGrid({ miis, userId, parentPage }: Props) {
|
||||
const likedIds = new Set([]);
|
||||
|
||||
return (
|
||||
<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) => (
|
||||
|
|
@ -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">
|
||||
<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}
|
||||
height={160}
|
||||
alt="mii image"
|
||||
|
|
@ -63,7 +57,7 @@ export default function MiiGrid({ miis, userId, parentPage }: Props) {
|
|||
</div>
|
||||
|
||||
<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 && (
|
||||
<a href={`/profile/${mii.user?.id}`} className="text-sm text-right overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
|
|
|
|||
|
|
@ -1,79 +1,79 @@
|
|||
import type { MiiPlatform } from "@tomodachi-share/shared";
|
||||
import { type ChangeEvent, useState, useTransition } from "react";
|
||||
|
||||
export default function OtherFilters() {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const platform = (searchParams.get("platform") as MiiPlatform) || undefined;
|
||||
const [allowCopying, setAllowCopying] = useState<boolean>((searchParams.get("allowCopying") as unknown as boolean) ?? false);
|
||||
const [quarantined, setQuarantined] = useState<boolean>((searchParams.get("quarantined") as unknown as boolean) ?? false);
|
||||
|
||||
const handleChangeAllowCopying = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setAllowCopying(e.target.checked);
|
||||
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set("page", "1");
|
||||
|
||||
if (!allowCopying) {
|
||||
params.set("allowCopying", "true");
|
||||
} else {
|
||||
params.delete("allowCopying");
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
// router.push(`?${params.toString()}`, { scroll: false });
|
||||
window.location.href = `?${params.toString()}`;
|
||||
});
|
||||
};
|
||||
|
||||
const handleChangeQuarantined = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setQuarantined(e.target.checked);
|
||||
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set("page", "1");
|
||||
|
||||
if (!quarantined) {
|
||||
params.set("quarantined", "true");
|
||||
} else {
|
||||
params.delete("quarantined");
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
// router.push(`?${params.toString()}`, { scroll: false });
|
||||
window.location.href = `?${params.toString()}`;
|
||||
});
|
||||
};
|
||||
|
||||
const showAllowCopying = platform !== "SWITCH";
|
||||
const showQuarantined = !location.pathname.startsWith("/profile");
|
||||
|
||||
if (!showAllowCopying && !showQuarantined) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium w-full mt-2 mb-1">
|
||||
<hr className="grow border-zinc-300" />
|
||||
<span>Other</span>
|
||||
<hr className="grow border-zinc-300" />
|
||||
</div>
|
||||
|
||||
{showAllowCopying && (
|
||||
<div className="flex justify-between items-center w-full mb-1">
|
||||
<label htmlFor="allowCopying" className="text-sm">
|
||||
Allow Copying
|
||||
</label>
|
||||
<input type="checkbox" id="allowCopying" className="checkbox-alt" checked={allowCopying} onChange={handleChangeAllowCopying} />
|
||||
</div>
|
||||
)}
|
||||
{showQuarantined && (
|
||||
<div className="flex justify-between items-center w-full">
|
||||
<label htmlFor="quarantined" className="text-sm">
|
||||
Show Controversial Miis
|
||||
</label>
|
||||
<input type="checkbox" id="quarantined" className="checkbox-alt" checked={quarantined} onChange={handleChangeQuarantined} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
import type { MiiPlatform } from "@tomodachi-share/shared";
|
||||
import { type ChangeEvent, useState, useTransition } from "react";
|
||||
|
||||
export default function OtherFilters() {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const platform = (searchParams.get("platform") as MiiPlatform) || undefined;
|
||||
const [allowCopying, setAllowCopying] = useState<boolean>((searchParams.get("allowCopying") as unknown as boolean) ?? false);
|
||||
const [quarantined, setQuarantined] = useState<boolean>((searchParams.get("quarantined") as unknown as boolean) ?? false);
|
||||
|
||||
const handleChangeAllowCopying = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setAllowCopying(e.target.checked);
|
||||
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set("page", "1");
|
||||
|
||||
if (!allowCopying) {
|
||||
params.set("allowCopying", "true");
|
||||
} else {
|
||||
params.delete("allowCopying");
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
// router.push(`?${params.toString()}`, { scroll: false });
|
||||
window.location.href = `?${params.toString()}`;
|
||||
});
|
||||
};
|
||||
|
||||
const handleChangeQuarantined = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setQuarantined(e.target.checked);
|
||||
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set("page", "1");
|
||||
|
||||
if (!quarantined) {
|
||||
params.set("quarantined", "true");
|
||||
} else {
|
||||
params.delete("quarantined");
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
// router.push(`?${params.toString()}`, { scroll: false });
|
||||
window.location.href = `?${params.toString()}`;
|
||||
});
|
||||
};
|
||||
|
||||
const showAllowCopying = platform !== "SWITCH";
|
||||
const showQuarantined = !location.pathname.startsWith("/profile");
|
||||
|
||||
if (!showAllowCopying && !showQuarantined) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium w-full mt-2 mb-1">
|
||||
<hr className="grow border-zinc-300" />
|
||||
<span>Other</span>
|
||||
<hr className="grow border-zinc-300" />
|
||||
</div>
|
||||
|
||||
{showAllowCopying && (
|
||||
<div className="flex justify-between items-center w-full mb-1">
|
||||
<label htmlFor="allowCopying" className="text-sm">
|
||||
Allow Copying
|
||||
</label>
|
||||
<input type="checkbox" id="allowCopying" className="checkbox-alt" checked={allowCopying} onChange={handleChangeAllowCopying} />
|
||||
</div>
|
||||
)}
|
||||
{showQuarantined && (
|
||||
<div className="flex justify-between items-center w-full">
|
||||
<label htmlFor="quarantined" className="text-sm">
|
||||
Show Controversial Miis
|
||||
</label>
|
||||
<input type="checkbox" id="quarantined" className="checkbox-alt" checked={quarantined} onChange={handleChangeQuarantined} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,55 +1,55 @@
|
|||
import { useState, useTransition } from "react";
|
||||
import { Icon } from "@iconify/react";
|
||||
import type { MiiPlatform } from "@tomodachi-share/shared";
|
||||
|
||||
export default function PlatformSelect() {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const [selected, setSelected] = useState<MiiPlatform | null>((searchParams.get("platform") as MiiPlatform) ?? null);
|
||||
|
||||
const handleClick = (platform: MiiPlatform) => {
|
||||
const filter = selected === platform ? null : platform;
|
||||
setSelected(filter);
|
||||
|
||||
const params = new URLSearchParams(searchParams);
|
||||
if (filter) {
|
||||
params.set("platform", filter);
|
||||
} else {
|
||||
params.delete("platform");
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
// router.push(`?${params.toString()}`);
|
||||
window.location.href = `?${params.toString()}`;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-0.5 w-fit">
|
||||
<button
|
||||
onClick={() => handleClick("THREE_DS")}
|
||||
aria-label="Filter for 3DS Miis"
|
||||
data-tooltip-span
|
||||
className={`cursor-pointer rounded-xl flex justify-center items-center size-13 text-3xl border-2 transition-all ${
|
||||
selected === "THREE_DS" ? "bg-sky-100 border-sky-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
|
||||
}`}
|
||||
>
|
||||
<div className="tooltip bg-sky-400! border-sky-400! before:border-b-sky-400!">3DS</div>
|
||||
<Icon icon="cib:nintendo-3ds" className="text-sky-400" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleClick("SWITCH")}
|
||||
aria-label="Filter for Switch Miis"
|
||||
data-tooltip-span
|
||||
className={`cursor-pointer rounded-xl flex justify-center items-center size-13 text-3xl border-2 transition-all ${
|
||||
selected === "SWITCH" ? "bg-red-100 border-red-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
|
||||
}`}
|
||||
>
|
||||
<div className="tooltip bg-red-400! border-red-400! before:border-b-red-400!">Switch</div>
|
||||
<Icon icon="cib:nintendo-switch" className="text-red-400" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { useState, useTransition } from "react";
|
||||
import { Icon } from "@iconify/react";
|
||||
import type { MiiPlatform } from "@tomodachi-share/shared";
|
||||
|
||||
export default function PlatformSelect() {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const [selected, setSelected] = useState<MiiPlatform | null>((searchParams.get("platform") as MiiPlatform) ?? null);
|
||||
|
||||
const handleClick = (platform: MiiPlatform) => {
|
||||
const filter = selected === platform ? null : platform;
|
||||
setSelected(filter);
|
||||
|
||||
const params = new URLSearchParams(searchParams);
|
||||
if (filter) {
|
||||
params.set("platform", filter);
|
||||
} else {
|
||||
params.delete("platform");
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
// router.push(`?${params.toString()}`);
|
||||
window.location.href = `?${params.toString()}`;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-0.5 w-fit">
|
||||
<button
|
||||
onClick={() => handleClick("THREE_DS")}
|
||||
aria-label="Filter for 3DS Miis"
|
||||
data-tooltip-span
|
||||
className={`cursor-pointer rounded-xl flex justify-center items-center size-13 text-3xl border-2 transition-all ${
|
||||
selected === "THREE_DS" ? "bg-sky-100 border-sky-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
|
||||
}`}
|
||||
>
|
||||
<div className="tooltip bg-sky-400! border-sky-400! before:border-b-sky-400!">3DS</div>
|
||||
<Icon icon="cib:nintendo-3ds" className="text-sky-400" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleClick("SWITCH")}
|
||||
aria-label="Filter for Switch Miis"
|
||||
data-tooltip-span
|
||||
className={`cursor-pointer rounded-xl flex justify-center items-center size-13 text-3xl border-2 transition-all ${
|
||||
selected === "SWITCH" ? "bg-red-100 border-red-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
|
||||
}`}
|
||||
>
|
||||
<div className="tooltip bg-red-400! border-red-400! before:border-b-red-400!">Switch</div>
|
||||
<Icon icon="cib:nintendo-switch" className="text-red-400" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,53 +1,53 @@
|
|||
import FilterSelect from "./tag-filter";
|
||||
import SortSelect from "./sort-select";
|
||||
import Pagination from "../../pagination";
|
||||
|
||||
export default function Skeleton() {
|
||||
return (
|
||||
<div className="w-full animate-pulse">
|
||||
<div className="flex justify-between items-end mb-2 max-[32rem]:flex-col max-[32rem]:items-center">
|
||||
<p className="text-lg">
|
||||
<span className="font-extrabold">???</span> Miis
|
||||
</p>
|
||||
|
||||
<div className="flex gap-2 pointer-events-none">
|
||||
<FilterSelect />
|
||||
<SortSelect />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-4 max-lg:grid-cols-3 max-md:grid-cols-2 max-[30rem]:grid-cols-1">
|
||||
{[...Array(24)].map((_, index) => (
|
||||
<div key={index} className="flex flex-col bg-zinc-50 rounded-3xl border-2 border-zinc-300 shadow-lg p-3">
|
||||
{/* Carousel Skeleton */}
|
||||
<div className="relative rounded-xl bg-zinc-300 border-2 border-zinc-300 mb-1">
|
||||
<div className="aspect-3/2"></div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4 flex flex-col gap-1 h-full">
|
||||
{/* Name */}
|
||||
<div className="h-7 bg-zinc-300 rounded w-2/3 mb-0.5" />
|
||||
|
||||
{/* Tags */}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<div className="px-4 py-2 bg-orange-200 rounded-full w-14 h-6" />
|
||||
<div className="px-4 py-2 bg-orange-200 rounded-full w-10 h-6" />
|
||||
</div>
|
||||
|
||||
{/* Bottom row */}
|
||||
<div className="mt-0.5 grid grid-cols-2 items-center">
|
||||
<div className="h-6 w-12 bg-red-200 rounded" />
|
||||
<div className="h-4 w-24 bg-zinc-200 rounded justify-self-end" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="pointer-events-none">
|
||||
<Pagination lastPage={10} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import FilterSelect from "./tag-filter";
|
||||
import SortSelect from "./sort-select";
|
||||
import Pagination from "../../pagination";
|
||||
|
||||
export default function Skeleton() {
|
||||
return (
|
||||
<div className="w-full animate-pulse">
|
||||
<div className="flex justify-between items-end mb-2 max-[32rem]:flex-col max-[32rem]:items-center">
|
||||
<p className="text-lg">
|
||||
<span className="font-extrabold">???</span> Miis
|
||||
</p>
|
||||
|
||||
<div className="flex gap-2 pointer-events-none">
|
||||
<FilterSelect />
|
||||
<SortSelect />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-4 max-lg:grid-cols-3 max-md:grid-cols-2 max-[30rem]:grid-cols-1">
|
||||
{[...Array(24)].map((_, index) => (
|
||||
<div key={index} className="flex flex-col bg-zinc-50 rounded-3xl border-2 border-zinc-300 shadow-lg p-3">
|
||||
{/* Carousel Skeleton */}
|
||||
<div className="relative rounded-xl bg-zinc-300 border-2 border-zinc-300 mb-1">
|
||||
<div className="aspect-3/2"></div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4 flex flex-col gap-1 h-full">
|
||||
{/* Name */}
|
||||
<div className="h-7 bg-zinc-300 rounded w-2/3 mb-0.5" />
|
||||
|
||||
{/* Tags */}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<div className="px-4 py-2 bg-orange-200 rounded-full w-14 h-6" />
|
||||
<div className="px-4 py-2 bg-orange-200 rounded-full w-10 h-6" />
|
||||
</div>
|
||||
|
||||
{/* Bottom row */}
|
||||
<div className="mt-0.5 grid grid-cols-2 items-center">
|
||||
<div className="h-6 w-12 bg-red-200 rounded" />
|
||||
<div className="h-4 w-24 bg-zinc-200 rounded justify-self-end" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="pointer-events-none">
|
||||
<Pagination lastPage={10} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,61 +1,61 @@
|
|||
import { useTransition } from "react";
|
||||
import { useSelect } from "downshift";
|
||||
import { Icon } from "@iconify/react";
|
||||
|
||||
type Sort = "likes" | "newest" | "oldest" | "random";
|
||||
|
||||
const items = ["likes", "newest", "oldest", "random"];
|
||||
|
||||
export default function SortSelect() {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const currentSort = (searchParams.get("sort") as Sort) || "newest";
|
||||
|
||||
const { isOpen, getToggleButtonProps, getMenuProps, getItemProps, highlightedIndex, selectedItem } = useSelect({
|
||||
items,
|
||||
selectedItem: currentSort,
|
||||
onSelectedItemChange: ({ selectedItem }) => {
|
||||
if (!selectedItem) return;
|
||||
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set("page", "1");
|
||||
params.set("sort", selectedItem);
|
||||
|
||||
if (selectedItem == "random") {
|
||||
params.set("seed", Math.floor(Math.random() * 1_000_000_000).toString());
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
// router.push(`?${params.toString()}`, { scroll: false });
|
||||
window.location.href = `?${params.toString()}`;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="relative w-fit">
|
||||
{/* Toggle button to open the dropdown */}
|
||||
<button type="button" {...getToggleButtonProps()} aria-label="Sort dropdown" className="pill input w-full gap-1 justify-between! text-nowrap">
|
||||
<span>Sort by </span>
|
||||
{selectedItem || "Select a way to sort"}
|
||||
<Icon icon="tabler:chevron-down" className="ml-2 size-5" />
|
||||
</button>
|
||||
|
||||
{/* Dropdown menu */}
|
||||
<ul
|
||||
{...getMenuProps()}
|
||||
className={`absolute z-50 w-full bg-orange-200 border-2 border-orange-400 rounded-lg mt-1 shadow-lg max-h-60 overflow-y-auto ${
|
||||
isOpen ? "block" : "hidden"
|
||||
}`}
|
||||
>
|
||||
{isOpen &&
|
||||
items.map((item, index) => (
|
||||
<li key={item} {...getItemProps({ item, index })} className={`px-4 py-1 cursor-pointer text-sm ${highlightedIndex === index ? "bg-black/15" : ""}`}>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { useTransition } from "react";
|
||||
import { useSelect } from "downshift";
|
||||
import { Icon } from "@iconify/react";
|
||||
|
||||
type Sort = "likes" | "newest" | "oldest" | "random";
|
||||
|
||||
const items = ["likes", "newest", "oldest", "random"];
|
||||
|
||||
export default function SortSelect() {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const currentSort = (searchParams.get("sort") as Sort) || "newest";
|
||||
|
||||
const { isOpen, getToggleButtonProps, getMenuProps, getItemProps, highlightedIndex, selectedItem } = useSelect({
|
||||
items,
|
||||
selectedItem: currentSort,
|
||||
onSelectedItemChange: ({ selectedItem }) => {
|
||||
if (!selectedItem) return;
|
||||
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set("page", "1");
|
||||
params.set("sort", selectedItem);
|
||||
|
||||
if (selectedItem == "random") {
|
||||
params.set("seed", Math.floor(Math.random() * 1_000_000_000).toString());
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
// router.push(`?${params.toString()}`, { scroll: false });
|
||||
window.location.href = `?${params.toString()}`;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="relative w-fit">
|
||||
{/* Toggle button to open the dropdown */}
|
||||
<button type="button" {...getToggleButtonProps()} aria-label="Sort dropdown" className="pill input w-full gap-1 justify-between! text-nowrap">
|
||||
<span>Sort by </span>
|
||||
{selectedItem || "Select a way to sort"}
|
||||
<Icon icon="tabler:chevron-down" className="ml-2 size-5" />
|
||||
</button>
|
||||
|
||||
{/* Dropdown menu */}
|
||||
<ul
|
||||
{...getMenuProps()}
|
||||
className={`absolute z-50 w-full bg-orange-200 border-2 border-orange-400 rounded-lg mt-1 shadow-lg max-h-60 overflow-y-auto ${
|
||||
isOpen ? "block" : "hidden"
|
||||
}`}
|
||||
>
|
||||
{isOpen &&
|
||||
items.map((item, index) => (
|
||||
<li key={item} {...getItemProps({ item, index })} className={`px-4 py-1 cursor-pointer text-sm ${highlightedIndex === index ? "bg-black/15" : ""}`}>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,59 +1,59 @@
|
|||
import { useEffect, useMemo, useState, useTransition } from "react";
|
||||
import TagSelector from "../../tag-selector";
|
||||
|
||||
interface Props {
|
||||
isExclude?: boolean;
|
||||
}
|
||||
|
||||
export default function TagFilter({ isExclude }: Props) {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const rawTags = searchParams.get(isExclude ? "exclude" : "tags") || "";
|
||||
const preexistingTags = useMemo(
|
||||
() =>
|
||||
rawTags
|
||||
? rawTags
|
||||
.split(",")
|
||||
.map((tag) => tag.trim())
|
||||
.filter((tag) => tag.length > 0)
|
||||
: [],
|
||||
[rawTags],
|
||||
);
|
||||
|
||||
const [tags, setTags] = useState<string[]>(preexistingTags);
|
||||
|
||||
// Sync state if the URL tags change (e.g. via navigation)
|
||||
useEffect(() => {
|
||||
setTags(preexistingTags);
|
||||
}, [preexistingTags]);
|
||||
|
||||
// Redirect automatically on tags change
|
||||
useEffect(() => {
|
||||
const urlTags = preexistingTags.join(",");
|
||||
const stateTags = tags.join(",");
|
||||
|
||||
if (urlTags === stateTags) return;
|
||||
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set("page", "1");
|
||||
|
||||
if (tags.length > 0) {
|
||||
params.set(isExclude ? "exclude" : "tags", stateTags);
|
||||
} else {
|
||||
params.delete(isExclude ? "exclude" : "tags");
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
// router.push(`?${params.toString()}`, { scroll: false });
|
||||
window.location.href = `?${params.toString()}`;
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tags]);
|
||||
|
||||
return (
|
||||
<div className="w-72">
|
||||
<TagSelector tags={tags} setTags={setTags} isExclude={isExclude} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { useEffect, useMemo, useState, useTransition } from "react";
|
||||
import TagSelector from "../../tag-selector";
|
||||
|
||||
interface Props {
|
||||
isExclude?: boolean;
|
||||
}
|
||||
|
||||
export default function TagFilter({ isExclude }: Props) {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const rawTags = searchParams.get(isExclude ? "exclude" : "tags") || "";
|
||||
const preexistingTags = useMemo(
|
||||
() =>
|
||||
rawTags
|
||||
? rawTags
|
||||
.split(",")
|
||||
.map((tag) => tag.trim())
|
||||
.filter((tag) => tag.length > 0)
|
||||
: [],
|
||||
[rawTags],
|
||||
);
|
||||
|
||||
const [tags, setTags] = useState<string[]>(preexistingTags);
|
||||
|
||||
// Sync state if the URL tags change (e.g. via navigation)
|
||||
useEffect(() => {
|
||||
setTags(preexistingTags);
|
||||
}, [preexistingTags]);
|
||||
|
||||
// Redirect automatically on tags change
|
||||
useEffect(() => {
|
||||
const urlTags = preexistingTags.join(",");
|
||||
const stateTags = tags.join(",");
|
||||
|
||||
if (urlTags === stateTags) return;
|
||||
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set("page", "1");
|
||||
|
||||
if (tags.length > 0) {
|
||||
params.set(isExclude ? "exclude" : "tags", stateTags);
|
||||
} else {
|
||||
params.delete(isExclude ? "exclude" : "tags");
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
// router.push(`?${params.toString()}`, { scroll: false });
|
||||
window.location.href = `?${params.toString()}`;
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tags]);
|
||||
|
||||
return (
|
||||
<div className="w-72">
|
||||
<TagSelector tags={tags} setTags={setTags} isExclude={isExclude} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,56 +1,56 @@
|
|||
import { type SwitchMiiInstructions } from "@tomodachi-share/shared";
|
||||
|
||||
interface Props {
|
||||
data: SwitchMiiInstructions["personality"];
|
||||
onClick?: (key: string, i: number) => void;
|
||||
}
|
||||
|
||||
const PERSONALITY_SETTINGS: { label: string; left: string; right: string }[] = [
|
||||
{ label: "Movement", left: "Slow", right: "Quick" },
|
||||
{ label: "Speech", left: "Polite", right: "Honest" },
|
||||
{ label: "Energy", left: "Flat", right: "Varied" },
|
||||
{ label: "Thinking", left: "Serious", right: "Chill" },
|
||||
{ label: "Overall", left: "Normal", right: "Quirky" },
|
||||
];
|
||||
|
||||
export default function PersonalityViewer({ data, onClick }: Props) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 mb-3">
|
||||
{PERSONALITY_SETTINGS.map(({ label, left, right }) => {
|
||||
const key = label.toLowerCase() as keyof typeof data;
|
||||
return (
|
||||
<div key={label} className="flex justify-center items-center gap-2">
|
||||
<span className="text-sm w-24 shrink-0">{label}</span>
|
||||
<span className="text-sm text-zinc-500 w-14 text-right">{left}</span>
|
||||
<div className="flex gap-0.5">
|
||||
{Array.from({ length: 8 }).map((_, i) => {
|
||||
const colors = [
|
||||
"bg-green-400",
|
||||
"bg-green-300",
|
||||
"bg-emerald-200",
|
||||
"bg-teal-200",
|
||||
"bg-orange-200",
|
||||
"bg-orange-300",
|
||||
"bg-orange-400",
|
||||
"bg-orange-500",
|
||||
];
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (onClick) onClick(key, i);
|
||||
}}
|
||||
className={`size-7 rounded-lg transition-opacity duration-100 border-black/40
|
||||
${colors[i]} ${data[key] === i ? "border-2 opacity-100" : "opacity-70"} ${onClick ? "cursor-pointer" : ""}`}
|
||||
></button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<span className="text-sm text-zinc-500 w-12 shrink-0">{right}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { type SwitchMiiInstructions } from "@tomodachi-share/shared";
|
||||
|
||||
interface Props {
|
||||
data: SwitchMiiInstructions["personality"];
|
||||
onClick?: (key: string, i: number) => void;
|
||||
}
|
||||
|
||||
const PERSONALITY_SETTINGS: { label: string; left: string; right: string }[] = [
|
||||
{ label: "Movement", left: "Slow", right: "Quick" },
|
||||
{ label: "Speech", left: "Polite", right: "Honest" },
|
||||
{ label: "Energy", left: "Flat", right: "Varied" },
|
||||
{ label: "Thinking", left: "Serious", right: "Chill" },
|
||||
{ label: "Overall", left: "Normal", right: "Quirky" },
|
||||
];
|
||||
|
||||
export default function PersonalityViewer({ data, onClick }: Props) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 mb-3">
|
||||
{PERSONALITY_SETTINGS.map(({ label, left, right }) => {
|
||||
const key = label.toLowerCase() as keyof typeof data;
|
||||
return (
|
||||
<div key={label} className="flex justify-center items-center gap-2">
|
||||
<span className="text-sm w-24 shrink-0">{label}</span>
|
||||
<span className="text-sm text-zinc-500 w-14 text-right">{left}</span>
|
||||
<div className="flex gap-0.5">
|
||||
{Array.from({ length: 8 }).map((_, i) => {
|
||||
const colors = [
|
||||
"bg-green-400",
|
||||
"bg-green-300",
|
||||
"bg-emerald-200",
|
||||
"bg-teal-200",
|
||||
"bg-orange-200",
|
||||
"bg-orange-300",
|
||||
"bg-orange-400",
|
||||
"bg-orange-500",
|
||||
];
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (onClick) onClick(key, i);
|
||||
}}
|
||||
className={`size-7 rounded-lg transition-opacity duration-100 border-black/40
|
||||
${colors[i]} ${data[key] === i ? "border-2 opacity-100" : "opacity-70"} ${onClick ? "cursor-pointer" : ""}`}
|
||||
></button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<span className="text-sm text-zinc-500 w-12 shrink-0">{right}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ export default function ShareMiiButton({ miiId }: Props) {
|
|||
};
|
||||
|
||||
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();
|
||||
|
||||
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">
|
||||
<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"
|
||||
width={248}
|
||||
height={248}
|
||||
|
|
@ -129,7 +129,7 @@ export default function ShareMiiButton({ miiId }: Props) {
|
|||
<div className="flex gap-2 w-full">
|
||||
{/* Save button */}
|
||||
<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"
|
||||
aria-label="Save Image"
|
||||
data-tooltip="Save Image"
|
||||
|
|
|
|||
|
|
@ -1,41 +1,41 @@
|
|||
import type { SwitchMiiInstructions } from "@tomodachi-share/shared";
|
||||
import EnhancedSlider from "../submit-form/mii-editor/enhanced-slider";
|
||||
|
||||
interface Props {
|
||||
data: SwitchMiiInstructions["voice"];
|
||||
onChange: (value: number, label: string) => void;
|
||||
onClickTone: (i: number) => void;
|
||||
}
|
||||
|
||||
const VOICE_SETTINGS = ["Speed", "Pitch", "Depth", "Delivery"];
|
||||
|
||||
export default function VoiceViewer({ data, onChange, onClickTone }: Props) {
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{VOICE_SETTINGS.map((label) => {
|
||||
const value = data[label.toLowerCase() as keyof typeof data] ?? 25;
|
||||
return <EnhancedSlider key={label} label={label} value={value} onChange={(v) => onChange?.(v, label.toLowerCase())} min={0} max={50} mid={25} />;
|
||||
})}
|
||||
|
||||
<div className="flex gap-3 mt-2">
|
||||
<label htmlFor="delivery" className="text-sm w-14">
|
||||
Tone
|
||||
</label>
|
||||
<div className="grid grid-cols-6 gap-1 min-w-50">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<button
|
||||
type="button"
|
||||
key={i}
|
||||
onClick={() => {
|
||||
if (onClickTone) onClickTone(i + 1);
|
||||
}}
|
||||
className={`transition-colors duration-100 rounded-xl hover:bg-orange-300 cursor-pointer ${data.tone === i + 1 ? "bg-orange-400!" : ""}`}
|
||||
>
|
||||
{i + 1}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import type { SwitchMiiInstructions } from "@tomodachi-share/shared";
|
||||
import EnhancedSlider from "../submit-form/mii-editor/enhanced-slider";
|
||||
|
||||
interface Props {
|
||||
data: SwitchMiiInstructions["voice"];
|
||||
onChange: (value: number, label: string) => void;
|
||||
onClickTone: (i: number) => void;
|
||||
}
|
||||
|
||||
const VOICE_SETTINGS = ["Speed", "Pitch", "Depth", "Delivery"];
|
||||
|
||||
export default function VoiceViewer({ data, onChange, onClickTone }: Props) {
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{VOICE_SETTINGS.map((label) => {
|
||||
const value = data[label.toLowerCase() as keyof typeof data] ?? 25;
|
||||
return <EnhancedSlider key={label} label={label} value={value} onChange={(v) => onChange?.(v, label.toLowerCase())} min={0} max={50} mid={25} />;
|
||||
})}
|
||||
|
||||
<div className="flex gap-3 mt-2">
|
||||
<label htmlFor="delivery" className="text-sm w-14">
|
||||
Tone
|
||||
</label>
|
||||
<div className="grid grid-cols-6 gap-1 min-w-50">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<button
|
||||
type="button"
|
||||
key={i}
|
||||
onClick={() => {
|
||||
if (onClickTone) onClickTone(i + 1);
|
||||
}}
|
||||
className={`transition-colors duration-100 rounded-xl hover:bg-orange-300 cursor-pointer ${data.tone === i + 1 ? "bg-orange-400!" : ""}`}
|
||||
>
|
||||
{i + 1}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,95 +1,95 @@
|
|||
import { useCallback, useMemo } from "react";
|
||||
import { Icon } from "@iconify/react";
|
||||
|
||||
interface Props {
|
||||
lastPage: number;
|
||||
}
|
||||
|
||||
export default function Pagination({ lastPage }: Props) {
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
const page = Number(searchParams.get("page") ?? 1);
|
||||
|
||||
const createPageUrl = useCallback(
|
||||
(pageNumber: number) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set("page", pageNumber.toString());
|
||||
return `${location.pathname}?${params.toString()}`;
|
||||
},
|
||||
[searchParams, location.pathname],
|
||||
);
|
||||
|
||||
const numbers = useMemo(() => {
|
||||
const result = [];
|
||||
|
||||
// Always show 5 pages, centering around the current page when possible
|
||||
const start = Math.max(1, Math.min(page - 2, lastPage - 4));
|
||||
const end = Math.min(lastPage, start + 4);
|
||||
|
||||
for (let i = start; i <= end; i++) result.push(i);
|
||||
|
||||
return result;
|
||||
}, [page, lastPage]);
|
||||
|
||||
return (
|
||||
<div className="flex justify-center items-center w-full mt-8">
|
||||
{/* Go to first page */}
|
||||
<a
|
||||
href={page === 1 ? "#" : createPageUrl(1)}
|
||||
aria-label="Go to First Page"
|
||||
aria-disabled={page === 1}
|
||||
tabIndex={page === 1 ? -1 : undefined}
|
||||
className={`pill button bg-orange-100! p-0.5! aspect-square text-2xl ${page === 1 ? "pointer-events-none opacity-50" : "hover:bg-orange-400!"}`}
|
||||
>
|
||||
<Icon icon="stash:chevron-double-left" />
|
||||
</a>
|
||||
|
||||
{/* Previous page */}
|
||||
<a
|
||||
href={page === 1 ? "#" : createPageUrl(page - 1)}
|
||||
aria-label="Go to Previous Page"
|
||||
aria-disabled={page === 1}
|
||||
tabIndex={page === 1 ? -1 : undefined}
|
||||
className={`pill bg-orange-100! p-0.5! aspect-square text-2xl ${page === 1 ? "pointer-events-none opacity-50" : "hover:bg-orange-400!"}`}
|
||||
>
|
||||
<Icon icon="stash:chevron-left" />
|
||||
</a>
|
||||
|
||||
{/* Page numbers */}
|
||||
<div className="flex mx-2">
|
||||
{numbers.map((number) => (
|
||||
<a
|
||||
key={number}
|
||||
href={createPageUrl(number)}
|
||||
aria-label={`Go to Page ${number}`}
|
||||
aria-current={number === page ? "page" : undefined}
|
||||
className={`pill p-0! w-8 h-8 text-center rounded-md! ${number == page ? "bg-orange-400!" : "bg-orange-100! hover:bg-orange-400!"}`}
|
||||
>
|
||||
{number}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Next page */}
|
||||
<a
|
||||
href={page >= lastPage ? "#" : createPageUrl(page + 1)}
|
||||
aria-label="Go to Next Page"
|
||||
aria-disabled={page >= lastPage}
|
||||
tabIndex={page >= lastPage ? -1 : undefined}
|
||||
className={`pill button bg-orange-100! p-0.5! aspect-square text-2xl ${page >= lastPage ? "pointer-events-none opacity-50" : "hover:bg-orange-400!"}`}
|
||||
>
|
||||
<Icon icon="stash:chevron-right" />
|
||||
</a>
|
||||
|
||||
{/* Go to last page */}
|
||||
<a
|
||||
href={page >= lastPage ? "#" : createPageUrl(lastPage)}
|
||||
aria-label="Go to Last Page"
|
||||
aria-disabled={page >= lastPage}
|
||||
tabIndex={page >= lastPage ? -1 : undefined}
|
||||
className={`pill button bg-orange-100! p-0.5! aspect-square text-2xl ${page >= lastPage ? "pointer-events-none opacity-50" : "hover:bg-orange-400!"}`}
|
||||
>
|
||||
<Icon icon="stash:chevron-double-right" />
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Icon } from "@iconify/react";
|
||||
|
||||
interface Props {
|
||||
lastPage: number;
|
||||
}
|
||||
|
||||
export default function Pagination({ lastPage }: Props) {
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
const page = Number(searchParams.get("page") ?? 1);
|
||||
|
||||
const createPageUrl = useCallback(
|
||||
(pageNumber: number) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set("page", pageNumber.toString());
|
||||
return `${location.pathname}?${params.toString()}`;
|
||||
},
|
||||
[searchParams, location.pathname],
|
||||
);
|
||||
|
||||
const numbers = useMemo(() => {
|
||||
const result = [];
|
||||
|
||||
// Always show 5 pages, centering around the current page when possible
|
||||
const start = Math.max(1, Math.min(page - 2, lastPage - 4));
|
||||
const end = Math.min(lastPage, start + 4);
|
||||
|
||||
for (let i = start; i <= end; i++) result.push(i);
|
||||
|
||||
return result;
|
||||
}, [page, lastPage]);
|
||||
|
||||
return (
|
||||
<div className="flex justify-center items-center w-full mt-8">
|
||||
{/* Go to first page */}
|
||||
<a
|
||||
href={page === 1 ? "#" : createPageUrl(1)}
|
||||
aria-label="Go to First Page"
|
||||
aria-disabled={page === 1}
|
||||
tabIndex={page === 1 ? -1 : undefined}
|
||||
className={`pill button bg-orange-100! p-0.5! aspect-square text-2xl ${page === 1 ? "pointer-events-none opacity-50" : "hover:bg-orange-400!"}`}
|
||||
>
|
||||
<Icon icon="stash:chevron-double-left" />
|
||||
</a>
|
||||
|
||||
{/* Previous page */}
|
||||
<a
|
||||
href={page === 1 ? "#" : createPageUrl(page - 1)}
|
||||
aria-label="Go to Previous Page"
|
||||
aria-disabled={page === 1}
|
||||
tabIndex={page === 1 ? -1 : undefined}
|
||||
className={`pill bg-orange-100! p-0.5! aspect-square text-2xl ${page === 1 ? "pointer-events-none opacity-50" : "hover:bg-orange-400!"}`}
|
||||
>
|
||||
<Icon icon="stash:chevron-left" />
|
||||
</a>
|
||||
|
||||
{/* Page numbers */}
|
||||
<div className="flex mx-2">
|
||||
{numbers.map((number) => (
|
||||
<a
|
||||
key={number}
|
||||
href={createPageUrl(number)}
|
||||
aria-label={`Go to Page ${number}`}
|
||||
aria-current={number === page ? "page" : undefined}
|
||||
className={`pill p-0! w-8 h-8 text-center rounded-md! ${number == page ? "bg-orange-400!" : "bg-orange-100! hover:bg-orange-400!"}`}
|
||||
>
|
||||
{number}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Next page */}
|
||||
<a
|
||||
href={page >= lastPage ? "#" : createPageUrl(page + 1)}
|
||||
aria-label="Go to Next Page"
|
||||
aria-disabled={page >= lastPage}
|
||||
tabIndex={page >= lastPage ? -1 : undefined}
|
||||
className={`pill button bg-orange-100! p-0.5! aspect-square text-2xl ${page >= lastPage ? "pointer-events-none opacity-50" : "hover:bg-orange-400!"}`}
|
||||
>
|
||||
<Icon icon="stash:chevron-right" />
|
||||
</a>
|
||||
|
||||
{/* Go to last page */}
|
||||
<a
|
||||
href={page >= lastPage ? "#" : createPageUrl(lastPage)}
|
||||
aria-label="Go to Last Page"
|
||||
aria-disabled={page >= lastPage}
|
||||
tabIndex={page >= lastPage ? -1 : undefined}
|
||||
className={`pill button bg-orange-100! p-0.5! aspect-square text-2xl ${page >= lastPage ? "pointer-events-none opacity-50" : "hover:bg-orange-400!"}`}
|
||||
>
|
||||
<Icon icon="stash:chevron-double-right" />
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,16 +12,17 @@ interface Props {
|
|||
export default function ProfileInformation({ user, page }: Props) {
|
||||
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 isOwnProfile = !user || $session?.user?.id === user.id;
|
||||
const isOwnProfile = currentUser?.id === user.id;
|
||||
|
||||
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="flex w-full gap-4 overflow-x-scroll">
|
||||
{/* Profile picture */}
|
||||
<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>
|
||||
{/* User information */}
|
||||
<div className="flex flex-col w-full relative py-3">
|
||||
|
|
@ -57,7 +58,7 @@ export default function ProfileInformation({ user, page }: Props) {
|
|||
{/* 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">
|
||||
{!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" />
|
||||
<span>Report</span>
|
||||
</a>
|
||||
|
|
|
|||
|
|
@ -1,81 +1,81 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Icon } from "@iconify/react";
|
||||
import SubmitButton from "../submit-button";
|
||||
|
||||
export default function DeleteAccount() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const response = await fetch("/api/auth/delete", { method: "DELETE" });
|
||||
if (!response.ok) {
|
||||
const { error } = await response.json();
|
||||
setError(error);
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.href = "/404";
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
setIsVisible(false);
|
||||
setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
// slight delay to trigger animation
|
||||
setTimeout(() => setIsVisible(true), 10);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button onClick={() => setIsOpen(true)} className="pill button w-fit h-min ml-auto bg-red-400! border-red-500! hover:bg-red-500!">
|
||||
Delete Account
|
||||
</button>
|
||||
|
||||
{isOpen &&
|
||||
createPortal(
|
||||
<div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-(--header-height) flex items-center justify-center z-40">
|
||||
<div
|
||||
onClick={close}
|
||||
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${
|
||||
isVisible ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
/>
|
||||
|
||||
<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 ${
|
||||
isVisible ? "scale-100 opacity-100" : "scale-75 opacity-0"
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<h2 className="text-xl font-bold">Delete Account</h2>
|
||||
<button onClick={close} aria-label="Close" className="text-red-400 hover:text-red-500 text-2xl cursor-pointer">
|
||||
<Icon icon="material-symbols:close-rounded" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-zinc-500">Are you sure? This is permanent and will remove all uploaded Miis. This action cannot be undone.</p>
|
||||
|
||||
{error && <span className="text-red-400 font-bold mt-2">Error: {error}</span>}
|
||||
|
||||
<div className="flex justify-end gap-2 mt-4">
|
||||
<button onClick={close} className="pill button">
|
||||
Cancel
|
||||
</button>
|
||||
<SubmitButton onClick={handleSubmit} text="Delete" className="bg-red-400! border-red-500! hover:bg-red-500!" />
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Icon } from "@iconify/react";
|
||||
import SubmitButton from "../submit-button";
|
||||
|
||||
export default function DeleteAccount() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const response = await fetch("/api/auth/delete", { method: "DELETE" });
|
||||
if (!response.ok) {
|
||||
const { error } = await response.json();
|
||||
setError(error);
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.href = "/404";
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
setIsVisible(false);
|
||||
setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
// slight delay to trigger animation
|
||||
setTimeout(() => setIsVisible(true), 10);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button onClick={() => setIsOpen(true)} className="pill button w-fit h-min ml-auto bg-red-400! border-red-500! hover:bg-red-500!">
|
||||
Delete Account
|
||||
</button>
|
||||
|
||||
{isOpen &&
|
||||
createPortal(
|
||||
<div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-(--header-height) flex items-center justify-center z-40">
|
||||
<div
|
||||
onClick={close}
|
||||
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${
|
||||
isVisible ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
/>
|
||||
|
||||
<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 ${
|
||||
isVisible ? "scale-100 opacity-100" : "scale-75 opacity-0"
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<h2 className="text-xl font-bold">Delete Account</h2>
|
||||
<button onClick={close} aria-label="Close" className="text-red-400 hover:text-red-500 text-2xl cursor-pointer">
|
||||
<Icon icon="material-symbols:close-rounded" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-zinc-500">Are you sure? This is permanent and will remove all uploaded Miis. This action cannot be undone.</p>
|
||||
|
||||
{error && <span className="text-red-400 font-bold mt-2">Error: {error}</span>}
|
||||
|
||||
<div className="flex justify-end gap-2 mt-4">
|
||||
<button onClick={close} className="pill button">
|
||||
Cancel
|
||||
</button>
|
||||
<SubmitButton onClick={handleSubmit} text="Delete" className="bg-red-400! border-red-500! hover:bg-red-500!" />
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ export default function ProfileSettings({ currentDescription }: Props) {
|
|||
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",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ description }),
|
||||
|
|
@ -49,7 +49,7 @@ export default function ProfileSettings({ currentDescription }: Props) {
|
|||
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",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name }),
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ export default function ProfilePictureSettings() {
|
|||
const formData = new FormData();
|
||||
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",
|
||||
body: formData,
|
||||
credentials: "include",
|
||||
|
|
|
|||
|
|
@ -1,81 +1,81 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Icon } from "@iconify/react";
|
||||
import SubmitButton from "../submit-button";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
description: string;
|
||||
onSubmit: (close: () => void) => void;
|
||||
error?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function SubmitDialogButton({ title, description, onSubmit, error, children }: Props) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
const submit = () => {
|
||||
onSubmit(close);
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
setIsVisible(false);
|
||||
setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
// slight delay to trigger animation
|
||||
setTimeout(() => setIsVisible(true), 10);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button onClick={() => setIsOpen(true)} aria-label="Open Submit Dialog" className="pill button size-11 p-1! text-2xl">
|
||||
<Icon icon="material-symbols:check-rounded" />
|
||||
</button>
|
||||
|
||||
{isOpen &&
|
||||
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
|
||||
onClick={close}
|
||||
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${
|
||||
isVisible ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
/>
|
||||
|
||||
<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 ${
|
||||
isVisible ? "scale-100 opacity-100" : "scale-75 opacity-0"
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<h2 className="text-xl font-bold">{title}</h2>
|
||||
<button onClick={close} aria-label="Close" className="text-red-400 hover:text-red-500 text-2xl cursor-pointer">
|
||||
<Icon icon="material-symbols:close-rounded" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-zinc-500">{description}</p>
|
||||
|
||||
{children}
|
||||
{error && <span className="text-red-400 font-bold mt-2">Error: {error}</span>}
|
||||
|
||||
<div className="flex justify-end gap-2 mt-4">
|
||||
<button onClick={close} className="pill button">
|
||||
Cancel
|
||||
</button>
|
||||
<SubmitButton onClick={submit} />
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Icon } from "@iconify/react";
|
||||
import SubmitButton from "../submit-button";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
description: string;
|
||||
onSubmit: (close: () => void) => void;
|
||||
error?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function SubmitDialogButton({ title, description, onSubmit, error, children }: Props) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
const submit = () => {
|
||||
onSubmit(close);
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
setIsVisible(false);
|
||||
setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
// slight delay to trigger animation
|
||||
setTimeout(() => setIsVisible(true), 10);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button onClick={() => setIsOpen(true)} aria-label="Open Submit Dialog" className="pill button size-11 p-1! text-2xl">
|
||||
<Icon icon="material-symbols:check-rounded" />
|
||||
</button>
|
||||
|
||||
{isOpen &&
|
||||
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
|
||||
onClick={close}
|
||||
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${
|
||||
isVisible ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
/>
|
||||
|
||||
<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 ${
|
||||
isVisible ? "scale-100 opacity-100" : "scale-75 opacity-0"
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<h2 className="text-xl font-bold">{title}</h2>
|
||||
<button onClick={close} aria-label="Close" className="text-red-400 hover:text-red-500 text-2xl cursor-pointer">
|
||||
<Icon icon="material-symbols:close-rounded" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-zinc-500">{description}</p>
|
||||
|
||||
{children}
|
||||
{error && <span className="text-red-400 font-bold mt-2">Error: {error}</span>}
|
||||
|
||||
<div className="flex justify-end gap-2 mt-4">
|
||||
<button onClick={close} className="pill button">
|
||||
Cancel
|
||||
</button>
|
||||
<SubmitButton onClick={submit} />
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,31 +1,31 @@
|
|||
import { useEffect } from "react";
|
||||
import { ProgressProvider } from "@bprogress/react";
|
||||
|
||||
export default function Providers({ children }: { children: React.ReactNode }) {
|
||||
// Calculate header height
|
||||
useEffect(() => {
|
||||
const header = document.querySelector("header");
|
||||
if (!header) return;
|
||||
|
||||
const updateHeaderHeight = () => {
|
||||
document.documentElement.style.setProperty("--header-height", `${header.offsetHeight}px`);
|
||||
};
|
||||
|
||||
const resizeObserver = new ResizeObserver(updateHeaderHeight);
|
||||
resizeObserver.observe(header);
|
||||
window.addEventListener("resize", updateHeaderHeight);
|
||||
|
||||
updateHeaderHeight();
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
window.removeEventListener("resize", updateHeaderHeight);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ProgressProvider height="4px" color="var(--color-amber-500)" options={{ showSpinner: false }} shallowRouting>
|
||||
{children}
|
||||
</ProgressProvider>
|
||||
);
|
||||
}
|
||||
import { useEffect } from "react";
|
||||
import { ProgressProvider } from "@bprogress/react";
|
||||
|
||||
export default function Providers({ children }: { children: React.ReactNode }) {
|
||||
// Calculate header height
|
||||
useEffect(() => {
|
||||
const header = document.querySelector("header");
|
||||
if (!header) return;
|
||||
|
||||
const updateHeaderHeight = () => {
|
||||
document.documentElement.style.setProperty("--header-height", `${header.offsetHeight}px`);
|
||||
};
|
||||
|
||||
const resizeObserver = new ResizeObserver(updateHeaderHeight);
|
||||
resizeObserver.observe(header);
|
||||
window.addEventListener("resize", updateHeaderHeight);
|
||||
|
||||
updateHeaderHeight();
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
window.removeEventListener("resize", updateHeaderHeight);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ProgressProvider height="4px" color="var(--color-amber-500)" options={{ showSpinner: false }} shallowRouting>
|
||||
{children}
|
||||
</ProgressProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,50 +1,50 @@
|
|||
import { useState } from "react";
|
||||
import { Icon } from "@iconify/react";
|
||||
import { querySchema } from "@tomodachi-share/shared/schemas";
|
||||
|
||||
export default function SearchBar() {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const [query, setQuery] = useState(searchParams.get("q") || "");
|
||||
|
||||
const handleSearch = () => {
|
||||
const result = querySchema.safeParse(query);
|
||||
if (!result.success) {
|
||||
// router.push("/", { scroll: false });
|
||||
window.location.href = "/";
|
||||
return;
|
||||
}
|
||||
|
||||
// Clone current search params and add query param
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set("q", query);
|
||||
params.set("page", "1");
|
||||
|
||||
// router.push(`/?${params.toString()}`, { scroll: false });
|
||||
window.location.href = `/?${params.toString()}`;
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||
if (event.key === "Enter") handleSearch();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-md w-full flex rounded-xl focus-within:ring-[3px] ring-orange-400/50 transition shadow-md">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="bg-orange-200 border-2 border-orange-400 py-2 px-3 rounded-l-xl outline-0 w-full placeholder:text-black/40"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
aria-label="Search"
|
||||
data-tooltip="Search"
|
||||
className="bg-orange-400 p-2 w-12 rounded-r-xl flex justify-center items-center cursor-pointer text-2xl"
|
||||
>
|
||||
<Icon icon="ic:baseline-search" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { useState } from "react";
|
||||
import { Icon } from "@iconify/react";
|
||||
import { querySchema } from "@tomodachi-share/shared/schemas";
|
||||
|
||||
export default function SearchBar() {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const [query, setQuery] = useState(searchParams.get("q") || "");
|
||||
|
||||
const handleSearch = () => {
|
||||
const result = querySchema.safeParse(query);
|
||||
if (!result.success) {
|
||||
// router.push("/", { scroll: false });
|
||||
window.location.href = "/";
|
||||
return;
|
||||
}
|
||||
|
||||
// Clone current search params and add query param
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set("q", query);
|
||||
params.set("page", "1");
|
||||
|
||||
// router.push(`/?${params.toString()}`, { scroll: false });
|
||||
window.location.href = `/?${params.toString()}`;
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||
if (event.key === "Enter") handleSearch();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-md w-full flex rounded-xl focus-within:ring-[3px] ring-orange-400/50 transition shadow-md">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="bg-orange-200 border-2 border-orange-400 py-2 px-3 rounded-l-xl outline-0 w-full placeholder:text-black/40"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
aria-label="Search"
|
||||
data-tooltip="Search"
|
||||
className="bg-orange-400 p-2 w-12 rounded-r-xl flex justify-center items-center cursor-pointer text-2xl"
|
||||
>
|
||||
<Icon icon="ic:baseline-search" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,31 +1,31 @@
|
|||
import { useState } from "react";
|
||||
import { Icon } from "@iconify/react";
|
||||
|
||||
interface Props {
|
||||
onClick: () => void | Promise<void>;
|
||||
disabled?: boolean;
|
||||
text?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function SubmitButton({ onClick, disabled = false, text = "Submit", className }: Props) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleClick = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await onClick();
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button type="submit" aria-label={text} onClick={handleClick} disabled={disabled} className={`pill button w-min ${className}`}>
|
||||
{text}
|
||||
{isLoading && <Icon icon="svg-spinners:180-ring-with-bg" className="ml-2" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
import { useState } from "react";
|
||||
import { Icon } from "@iconify/react";
|
||||
|
||||
interface Props {
|
||||
onClick: () => void | Promise<void>;
|
||||
disabled?: boolean;
|
||||
text?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function SubmitButton({ onClick, disabled = false, text = "Submit", className }: Props) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleClick = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await onClick();
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button type="submit" aria-label={text} onClick={handleClick} disabled={disabled} className={`pill button w-min ${className}`}>
|
||||
{text}
|
||||
{isLoading && <Icon icon="svg-spinners:180-ring-with-bg" className="ml-2" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,255 +1,255 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import jsQR from "jsqr";
|
||||
import { Icon } from "@iconify/react";
|
||||
|
||||
import QrFinder from "./qr-finder";
|
||||
import { useSelect } from "downshift";
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
onCapture?: () => void;
|
||||
setImage?: (value: string | undefined) => void;
|
||||
setQrBytesRaw?: React.Dispatch<React.SetStateAction<number[]>>;
|
||||
}
|
||||
|
||||
export default function Camera({ isOpen, setIsOpen, onCapture, setImage, setQrBytesRaw }: Props) {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [permissionGranted, setPermissionGranted] = useState<boolean | null>(null);
|
||||
const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);
|
||||
const [selectedDeviceId, setSelectedDeviceId] = useState<string | null>(null);
|
||||
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const requestRef = useRef<number>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
const cameraItems = devices.map((device) => ({
|
||||
value: device.deviceId,
|
||||
label: device.label || `Camera ${devices.indexOf(device) + 1}`,
|
||||
}));
|
||||
|
||||
const {
|
||||
isOpen: isDropdownOpen,
|
||||
getToggleButtonProps,
|
||||
getMenuProps,
|
||||
getItemProps,
|
||||
highlightedIndex,
|
||||
selectedItem,
|
||||
} = useSelect({
|
||||
items: cameraItems,
|
||||
selectedItem: cameraItems.find((item) => item.value === selectedDeviceId) ?? null,
|
||||
onSelectedItemChange: ({ selectedItem }) => {
|
||||
setSelectedDeviceId(selectedItem?.value ?? null);
|
||||
},
|
||||
});
|
||||
|
||||
const takePicture = useCallback(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
// Continue scanning in a loop
|
||||
if (setQrBytesRaw) requestRef.current = requestAnimationFrame(takePicture);
|
||||
|
||||
const video = videoRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
if (!video || video.videoWidth === 0 || video.videoHeight === 0 || !canvas) return;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
ctx.drawImage(video, 0, 0, video.videoWidth, video.videoHeight);
|
||||
|
||||
if (setImage) {
|
||||
setImage(canvas.toDataURL());
|
||||
if (onCapture) onCapture();
|
||||
close();
|
||||
return;
|
||||
}
|
||||
if (!setQrBytesRaw) return;
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, video.videoWidth, video.videoHeight);
|
||||
const code = jsQR(imageData.data, imageData.width, imageData.height);
|
||||
if (!code || !code.binaryData) return;
|
||||
|
||||
// Cancel animation frame to stop scanning
|
||||
if (requestRef.current) {
|
||||
cancelAnimationFrame(requestRef.current);
|
||||
requestRef.current = null;
|
||||
}
|
||||
|
||||
setQrBytesRaw(code.binaryData);
|
||||
close();
|
||||
}, [isOpen, setIsOpen, setQrBytesRaw]);
|
||||
|
||||
const requestPermission = () => {
|
||||
if (!navigator.mediaDevices) return;
|
||||
|
||||
navigator.mediaDevices
|
||||
.getUserMedia({ video: true, audio: false })
|
||||
.then((stream) => {
|
||||
// immediately stop this temp stream
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
setPermissionGranted(true);
|
||||
})
|
||||
.catch((err) => {
|
||||
setPermissionGranted(false);
|
||||
console.error("An error occurred trying to access the camera", err);
|
||||
});
|
||||
};
|
||||
|
||||
const stopCamera = () => {
|
||||
if (requestRef.current) {
|
||||
cancelAnimationFrame(requestRef.current);
|
||||
requestRef.current = null;
|
||||
}
|
||||
if (videoRef.current) {
|
||||
videoRef.current.pause();
|
||||
videoRef.current.srcObject = null;
|
||||
}
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach((track) => track.stop());
|
||||
streamRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
stopCamera();
|
||||
setIsVisible(false);
|
||||
setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
// slight delay to trigger animation
|
||||
setTimeout(() => setIsVisible(true), 10);
|
||||
requestPermission();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !permissionGranted) return;
|
||||
|
||||
navigator.mediaDevices
|
||||
.enumerateDevices()
|
||||
.then((devices) => {
|
||||
const videoDevices = devices.filter((d) => d.kind === "videoinput");
|
||||
setDevices(videoDevices);
|
||||
|
||||
const targetDeviceId = selectedDeviceId || videoDevices[0]?.deviceId;
|
||||
if (!targetDeviceId) return;
|
||||
setSelectedDeviceId(targetDeviceId);
|
||||
|
||||
// start camera stream
|
||||
return navigator.mediaDevices.getUserMedia({
|
||||
video: { deviceId: targetDeviceId },
|
||||
audio: false,
|
||||
});
|
||||
})
|
||||
.then((stream) => {
|
||||
if (!stream || !videoRef.current) return;
|
||||
streamRef.current = stream;
|
||||
videoRef.current.srcObject = stream;
|
||||
videoRef.current.play();
|
||||
})
|
||||
.catch((err) => console.error("Camera error", err));
|
||||
|
||||
if (setQrBytesRaw) requestRef.current = requestAnimationFrame(takePicture);
|
||||
|
||||
// cleanup
|
||||
return () => {
|
||||
stopCamera();
|
||||
};
|
||||
}, [isOpen, permissionGranted, selectedDeviceId]);
|
||||
|
||||
return (
|
||||
<div className={`fixed inset-0 h-[calc(100%-var(--header-height))] top-(--header-height) flex items-center justify-center z-40 ${!isOpen ? "hidden" : ""}`}>
|
||||
<div
|
||||
onClick={close}
|
||||
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${isVisible ? "opacity-100" : "opacity-0"}`}
|
||||
/>
|
||||
|
||||
<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 ${
|
||||
isVisible ? "scale-100 opacity-100" : "scale-75 opacity-0"
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<h2 className="text-xl font-bold">{setQrBytesRaw ? "Scan QR Code" : "Take Picture"}</h2>
|
||||
<button type="button" aria-label="Close" onClick={close} className="text-red-400 hover:text-red-500 text-2xl cursor-pointer">
|
||||
<Icon icon="material-symbols:close-rounded" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={`mb-4 flex flex-col gap-1 ${devices.length <= 1 ? "hidden" : ""}`}>
|
||||
<label className="text-sm font-semibold">Camera:</label>
|
||||
<div className="relative w-full">
|
||||
{/* Toggle button to open the dropdown */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Select camera dropdown"
|
||||
{...getToggleButtonProps({}, { suppressRefError: true })}
|
||||
className="pill input w-full px-2! py-0.5! justify-between! text-sm"
|
||||
>
|
||||
{selectedItem?.label || "Select a camera"}
|
||||
|
||||
<Icon icon="tabler:chevron-down" className="ml-2 size-5" />
|
||||
</button>
|
||||
|
||||
{/* Dropdown menu */}
|
||||
<ul
|
||||
{...getMenuProps({}, { suppressRefError: true })}
|
||||
className={`absolute z-50 w-full bg-orange-200 border-2 border-orange-400 rounded-lg mt-1 shadow-lg max-h-60 overflow-y-auto ${
|
||||
isDropdownOpen ? "block" : "hidden"
|
||||
}`}
|
||||
>
|
||||
{isDropdownOpen &&
|
||||
cameraItems.map((item, index) => (
|
||||
<li
|
||||
key={item.value}
|
||||
{...getItemProps({ item, index })}
|
||||
className={`px-4 py-1 cursor-pointer text-sm ${highlightedIndex === index ? "bg-black/15" : ""}`}
|
||||
>
|
||||
{item.label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`relative w-full ${setQrBytesRaw ? "aspect-square" : ""}`}>
|
||||
{!permissionGranted && (
|
||||
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center rounded-2xl bg-amber-50 border-2 border-amber-500 text-center p-8">
|
||||
<p className="text-red-400 font-bold text-lg mb-2">Camera access denied</p>
|
||||
<p className="text-gray-600">Please allow camera access in your browser settings to {setQrBytesRaw ? "scan QR codes" : "take pictures"}</p>
|
||||
<button type="button" onClick={requestPermission} className="pill button text-xs mt-2 py-0.5! px-2!">
|
||||
Request Permission
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-2xl border-2 border-amber-500 max-h-96 flex justify-center items-center overflow-hidden">
|
||||
<img src="/loading.svg" alt="loading indicator" width={256} height={256} className="absolute" />
|
||||
<video ref={videoRef} className={`size-full z-10 ${setQrBytesRaw ? "object-cover aspect-square" : ""}`} />
|
||||
</div>
|
||||
{setQrBytesRaw && <QrFinder />}
|
||||
<canvas ref={canvasRef} className="hidden" />
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-center gap-2">
|
||||
<button type="button" onClick={close} className="pill button">
|
||||
Cancel
|
||||
</button>
|
||||
{setImage && (
|
||||
<button type="button" onClick={takePicture} className="pill button">
|
||||
Take Picture
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import jsQR from "jsqr";
|
||||
import { Icon } from "@iconify/react";
|
||||
|
||||
import QrFinder from "./qr-finder";
|
||||
import { useSelect } from "downshift";
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
onCapture?: () => void;
|
||||
setImage?: (value: string | undefined) => void;
|
||||
setQrBytesRaw?: React.Dispatch<React.SetStateAction<number[]>>;
|
||||
}
|
||||
|
||||
export default function Camera({ isOpen, setIsOpen, onCapture, setImage, setQrBytesRaw }: Props) {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [permissionGranted, setPermissionGranted] = useState<boolean | null>(null);
|
||||
const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);
|
||||
const [selectedDeviceId, setSelectedDeviceId] = useState<string | null>(null);
|
||||
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const requestRef = useRef<number>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
const cameraItems = devices.map((device) => ({
|
||||
value: device.deviceId,
|
||||
label: device.label || `Camera ${devices.indexOf(device) + 1}`,
|
||||
}));
|
||||
|
||||
const {
|
||||
isOpen: isDropdownOpen,
|
||||
getToggleButtonProps,
|
||||
getMenuProps,
|
||||
getItemProps,
|
||||
highlightedIndex,
|
||||
selectedItem,
|
||||
} = useSelect({
|
||||
items: cameraItems,
|
||||
selectedItem: cameraItems.find((item) => item.value === selectedDeviceId) ?? null,
|
||||
onSelectedItemChange: ({ selectedItem }) => {
|
||||
setSelectedDeviceId(selectedItem?.value ?? null);
|
||||
},
|
||||
});
|
||||
|
||||
const takePicture = useCallback(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
// Continue scanning in a loop
|
||||
if (setQrBytesRaw) requestRef.current = requestAnimationFrame(takePicture);
|
||||
|
||||
const video = videoRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
if (!video || video.videoWidth === 0 || video.videoHeight === 0 || !canvas) return;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
ctx.drawImage(video, 0, 0, video.videoWidth, video.videoHeight);
|
||||
|
||||
if (setImage) {
|
||||
setImage(canvas.toDataURL());
|
||||
if (onCapture) onCapture();
|
||||
close();
|
||||
return;
|
||||
}
|
||||
if (!setQrBytesRaw) return;
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, video.videoWidth, video.videoHeight);
|
||||
const code = jsQR(imageData.data, imageData.width, imageData.height);
|
||||
if (!code || !code.binaryData) return;
|
||||
|
||||
// Cancel animation frame to stop scanning
|
||||
if (requestRef.current) {
|
||||
cancelAnimationFrame(requestRef.current);
|
||||
requestRef.current = null;
|
||||
}
|
||||
|
||||
setQrBytesRaw(code.binaryData);
|
||||
close();
|
||||
}, [isOpen, setIsOpen, setQrBytesRaw]);
|
||||
|
||||
const requestPermission = () => {
|
||||
if (!navigator.mediaDevices) return;
|
||||
|
||||
navigator.mediaDevices
|
||||
.getUserMedia({ video: true, audio: false })
|
||||
.then((stream) => {
|
||||
// immediately stop this temp stream
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
setPermissionGranted(true);
|
||||
})
|
||||
.catch((err) => {
|
||||
setPermissionGranted(false);
|
||||
console.error("An error occurred trying to access the camera", err);
|
||||
});
|
||||
};
|
||||
|
||||
const stopCamera = () => {
|
||||
if (requestRef.current) {
|
||||
cancelAnimationFrame(requestRef.current);
|
||||
requestRef.current = null;
|
||||
}
|
||||
if (videoRef.current) {
|
||||
videoRef.current.pause();
|
||||
videoRef.current.srcObject = null;
|
||||
}
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach((track) => track.stop());
|
||||
streamRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
stopCamera();
|
||||
setIsVisible(false);
|
||||
setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
// slight delay to trigger animation
|
||||
setTimeout(() => setIsVisible(true), 10);
|
||||
requestPermission();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !permissionGranted) return;
|
||||
|
||||
navigator.mediaDevices
|
||||
.enumerateDevices()
|
||||
.then((devices) => {
|
||||
const videoDevices = devices.filter((d) => d.kind === "videoinput");
|
||||
setDevices(videoDevices);
|
||||
|
||||
const targetDeviceId = selectedDeviceId || videoDevices[0]?.deviceId;
|
||||
if (!targetDeviceId) return;
|
||||
setSelectedDeviceId(targetDeviceId);
|
||||
|
||||
// start camera stream
|
||||
return navigator.mediaDevices.getUserMedia({
|
||||
video: { deviceId: targetDeviceId },
|
||||
audio: false,
|
||||
});
|
||||
})
|
||||
.then((stream) => {
|
||||
if (!stream || !videoRef.current) return;
|
||||
streamRef.current = stream;
|
||||
videoRef.current.srcObject = stream;
|
||||
videoRef.current.play();
|
||||
})
|
||||
.catch((err) => console.error("Camera error", err));
|
||||
|
||||
if (setQrBytesRaw) requestRef.current = requestAnimationFrame(takePicture);
|
||||
|
||||
// cleanup
|
||||
return () => {
|
||||
stopCamera();
|
||||
};
|
||||
}, [isOpen, permissionGranted, selectedDeviceId]);
|
||||
|
||||
return (
|
||||
<div className={`fixed inset-0 h-[calc(100%-var(--header-height))] top-(--header-height) flex items-center justify-center z-40 ${!isOpen ? "hidden" : ""}`}>
|
||||
<div
|
||||
onClick={close}
|
||||
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${isVisible ? "opacity-100" : "opacity-0"}`}
|
||||
/>
|
||||
|
||||
<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 ${
|
||||
isVisible ? "scale-100 opacity-100" : "scale-75 opacity-0"
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<h2 className="text-xl font-bold">{setQrBytesRaw ? "Scan QR Code" : "Take Picture"}</h2>
|
||||
<button type="button" aria-label="Close" onClick={close} className="text-red-400 hover:text-red-500 text-2xl cursor-pointer">
|
||||
<Icon icon="material-symbols:close-rounded" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={`mb-4 flex flex-col gap-1 ${devices.length <= 1 ? "hidden" : ""}`}>
|
||||
<label className="text-sm font-semibold">Camera:</label>
|
||||
<div className="relative w-full">
|
||||
{/* Toggle button to open the dropdown */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Select camera dropdown"
|
||||
{...getToggleButtonProps({}, { suppressRefError: true })}
|
||||
className="pill input w-full px-2! py-0.5! justify-between! text-sm"
|
||||
>
|
||||
{selectedItem?.label || "Select a camera"}
|
||||
|
||||
<Icon icon="tabler:chevron-down" className="ml-2 size-5" />
|
||||
</button>
|
||||
|
||||
{/* Dropdown menu */}
|
||||
<ul
|
||||
{...getMenuProps({}, { suppressRefError: true })}
|
||||
className={`absolute z-50 w-full bg-orange-200 border-2 border-orange-400 rounded-lg mt-1 shadow-lg max-h-60 overflow-y-auto ${
|
||||
isDropdownOpen ? "block" : "hidden"
|
||||
}`}
|
||||
>
|
||||
{isDropdownOpen &&
|
||||
cameraItems.map((item, index) => (
|
||||
<li
|
||||
key={item.value}
|
||||
{...getItemProps({ item, index })}
|
||||
className={`px-4 py-1 cursor-pointer text-sm ${highlightedIndex === index ? "bg-black/15" : ""}`}
|
||||
>
|
||||
{item.label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`relative w-full ${setQrBytesRaw ? "aspect-square" : ""}`}>
|
||||
{!permissionGranted && (
|
||||
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center rounded-2xl bg-amber-50 border-2 border-amber-500 text-center p-8">
|
||||
<p className="text-red-400 font-bold text-lg mb-2">Camera access denied</p>
|
||||
<p className="text-gray-600">Please allow camera access in your browser settings to {setQrBytesRaw ? "scan QR codes" : "take pictures"}</p>
|
||||
<button type="button" onClick={requestPermission} className="pill button text-xs mt-2 py-0.5! px-2!">
|
||||
Request Permission
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-2xl border-2 border-amber-500 max-h-96 flex justify-center items-center overflow-hidden">
|
||||
<img src="/loading.svg" alt="loading indicator" width={256} height={256} className="absolute" />
|
||||
<video ref={videoRef} className={`size-full z-10 ${setQrBytesRaw ? "object-cover aspect-square" : ""}`} />
|
||||
</div>
|
||||
{setQrBytesRaw && <QrFinder />}
|
||||
<canvas ref={canvasRef} className="hidden" />
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-center gap-2">
|
||||
<button type="button" onClick={close} className="pill button">
|
||||
Cancel
|
||||
</button>
|
||||
{setImage && (
|
||||
<button type="button" onClick={takePicture} className="pill button">
|
||||
Take Picture
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,456 +1,456 @@
|
|||
// import { redirect } from "next/navigation";
|
||||
|
||||
// import { useCallback, useEffect, useRef, useState } from "react";
|
||||
// import { FileWithPath } from "react-dropzone";
|
||||
// import { Mii, MiiGender, MiiMakeup } from "@prisma/client";
|
||||
// import { useSession } from "next-auth/react";
|
||||
|
||||
// import { nameSchema, tagsSchema } from "@tomodachi-share/shared/schemas";
|
||||
// import { defaultInstructions, minifyInstructions } from "@/lib/switch";
|
||||
// import { SwitchMiiInstructions } from "@tomodachi-share/shared";
|
||||
|
||||
// import TagSelector from "../tag-selector";
|
||||
// import ImageList from "./image-list";
|
||||
// import LikeButton from "../like-button";
|
||||
// import Carousel from "../carousel";
|
||||
// import SubmitButton from "../submit-button";
|
||||
// import Dropzone from "../dropzone";
|
||||
// import MiiEditor from "./mii-editor";
|
||||
// import SwitchSubmitTutorialButton from "../tutorial/switch-submit";
|
||||
// import { Icon } from "@iconify/react";
|
||||
// import SwitchFileUpload from "./switch-file-upload";
|
||||
|
||||
// interface Props {
|
||||
// mii: Mii;
|
||||
// likes: number;
|
||||
// }
|
||||
|
||||
// function deepMerge<T>(target: T, source: Partial<T>): T {
|
||||
// const output = structuredClone(target);
|
||||
|
||||
// if (typeof source !== "object" || source === null) return output;
|
||||
|
||||
// for (const key in source) {
|
||||
// const sourceValue = source[key];
|
||||
// const targetValue = (output as any)[key];
|
||||
|
||||
// if (typeof sourceValue === "object" && sourceValue !== null && !Array.isArray(sourceValue)) {
|
||||
// (output as any)[key] = deepMerge(targetValue, sourceValue);
|
||||
// } else {
|
||||
// (output as any)[key] = sourceValue;
|
||||
// }
|
||||
// }
|
||||
|
||||
// return output;
|
||||
// }
|
||||
|
||||
// export default function EditForm({ mii, likes }: Props) {
|
||||
// const session = useSession();
|
||||
// const [files, setFiles] = useState<FileWithPath[]>([]);
|
||||
|
||||
// const handleFilesChange: React.Dispatch<React.SetStateAction<FileWithPath[]>> = (updater) => {
|
||||
// hasCustomImagesChanged.current = true;
|
||||
// setFiles(updater);
|
||||
// };
|
||||
|
||||
// const handleDrop = useCallback(
|
||||
// (acceptedFiles: FileWithPath[]) => {
|
||||
// if (files.length >= 3) return;
|
||||
// hasCustomImagesChanged.current = true;
|
||||
|
||||
// setFiles((prev) => [...prev, ...acceptedFiles]);
|
||||
// },
|
||||
// [files.length],
|
||||
// );
|
||||
|
||||
// const [error, setError] = useState<string | undefined>(undefined);
|
||||
|
||||
// const [name, setName] = useState(mii.name);
|
||||
// const [tags, setTags] = useState(mii.tags);
|
||||
// const [description, setDescription] = useState(mii.description);
|
||||
// const [gender, setGender] = useState<MiiGender>(mii.gender ?? "MALE");
|
||||
// const [makeup, setMakeup] = useState<MiiMakeup>(mii.makeup ?? "PARTIAL");
|
||||
// const [miiPortraitUri, setMiiPortraitUri] = useState<string | undefined>(`/mii/${mii.id}/image?type=mii`);
|
||||
// const [miiFeaturesUri, setMiiFeaturesUri] = useState<string | undefined>(`/mii/${mii.id}/image?type=features`);
|
||||
// const [youtubeId, setYouTubeId] = useState(mii.youtubeId ?? "");
|
||||
// const instructions = useRef<SwitchMiiInstructions>(deepMerge(defaultInstructions, (mii.instructions as object) ?? {}));
|
||||
|
||||
// const [quarantined, setQuarantined] = useState(mii.quarantined);
|
||||
// const hasCustomImagesChanged = useRef(false);
|
||||
// const hasMiiPortraitChanged = useRef(false);
|
||||
// const hasMiiFeaturesChanged = useRef(false);
|
||||
|
||||
// const handleSubmit = async () => {
|
||||
// // Validate before sending request
|
||||
// const nameValidation = nameSchema.safeParse(name);
|
||||
// if (!nameValidation.success) {
|
||||
// setError(nameValidation.error.issues[0].message);
|
||||
// return;
|
||||
// }
|
||||
// const tagsValidation = tagsSchema.safeParse(tags);
|
||||
// if (!tagsValidation.success) {
|
||||
// setError(tagsValidation.error.issues[0].message);
|
||||
// return;
|
||||
// }
|
||||
|
||||
// // Send request to server
|
||||
// const formData = new FormData();
|
||||
// if (name != mii.name) formData.append("name", name);
|
||||
// if (tags != mii.tags) formData.append("tags", JSON.stringify(tags));
|
||||
// if (description && description != mii.description) formData.append("description", description);
|
||||
// if (gender != mii.gender) formData.append("gender", gender);
|
||||
// if (makeup != mii.makeup) formData.append("makeup", makeup);
|
||||
// if (miiPortraitUri) formData.append("miiPortraitUri", miiPortraitUri);
|
||||
// if (quarantined != mii.quarantined) formData.append("quarantined", JSON.stringify(quarantined));
|
||||
// if (youtubeId != mii.youtubeId) formData.append("youtubeId", youtubeId);
|
||||
// if (minifyInstructions(structuredClone(instructions.current)) !== (mii.instructions as object))
|
||||
// formData.append("instructions", JSON.stringify(instructions.current));
|
||||
|
||||
// if (hasCustomImagesChanged.current) {
|
||||
// files.forEach((file, index) => {
|
||||
// // image1, image2, etc.
|
||||
// formData.append(`image${index + 1}`, file);
|
||||
// });
|
||||
// }
|
||||
|
||||
// // Switch pictures
|
||||
// async function getBlob(uri: string): Promise<Blob | null> {
|
||||
// const response = await fetch(uri);
|
||||
// if (!response.ok) {
|
||||
// setError("Failed to get Mii portrait/features screenshot. Did you upload one?");
|
||||
// return null;
|
||||
// }
|
||||
|
||||
// const blob = await response.blob();
|
||||
// if (!blob.type.startsWith("image/")) {
|
||||
// setError("Invalid image file found");
|
||||
// return null;
|
||||
// }
|
||||
|
||||
// return blob;
|
||||
// }
|
||||
|
||||
// if (miiPortraitUri && hasMiiPortraitChanged.current) {
|
||||
// const blob = await getBlob(miiPortraitUri);
|
||||
// if (blob) formData.append("miiPortraitImage", blob);
|
||||
// }
|
||||
// if (miiFeaturesUri && hasMiiFeaturesChanged.current) {
|
||||
// const blob = await getBlob(miiFeaturesUri);
|
||||
// if (blob) formData.append("miiFeaturesImage", blob);
|
||||
// }
|
||||
|
||||
// const response = await fetch(`/api/mii/${mii.id}/edit`, {
|
||||
// method: "PATCH",
|
||||
// body: formData,
|
||||
// });
|
||||
// const { error } = await response.json();
|
||||
|
||||
// if (!response.ok) {
|
||||
// setError(error);
|
||||
// return;
|
||||
// }
|
||||
|
||||
// redirect(`/mii/${mii.id}`);
|
||||
// };
|
||||
|
||||
// const handleMiiPortraitChange = (uri: string | undefined) => {
|
||||
// hasMiiPortraitChanged.current = true;
|
||||
// setMiiPortraitUri(uri);
|
||||
// };
|
||||
|
||||
// const handleMiiFeaturesChange = (uri: string | undefined) => {
|
||||
// hasMiiFeaturesChanged.current = true;
|
||||
// setMiiFeaturesUri(uri);
|
||||
// };
|
||||
|
||||
// // Load existing images - converts image URLs to File objects
|
||||
// useEffect(() => {
|
||||
// const loadExistingImages = async () => {
|
||||
// try {
|
||||
// const existing = await Promise.all(
|
||||
// Array.from({ length: mii.imageCount }, async (_, index) => {
|
||||
// const path = `/mii/${mii.id}/image?type=image${index}`;
|
||||
// const response = await fetch(path);
|
||||
// const blob = await response.blob();
|
||||
|
||||
// return Object.assign(new File([blob], `image${index}.png`, { type: "image/png" }), { path });
|
||||
// }),
|
||||
// );
|
||||
|
||||
// setFiles(existing);
|
||||
// } catch (error) {
|
||||
// console.error("Error loading existing images:", error);
|
||||
// }
|
||||
// };
|
||||
|
||||
// loadExistingImages();
|
||||
// }, [mii.id, mii.imageCount]);
|
||||
|
||||
// return (
|
||||
// <div className="flex justify-center gap-4 w-full max-lg:flex-col max-lg:items-center">
|
||||
// <div className="flex justify-center">
|
||||
// <div className="w-75 h-min flex flex-col bg-zinc-50 rounded-3xl border-2 border-zinc-300 shadow-lg p-3">
|
||||
// <Carousel
|
||||
// images={[
|
||||
// miiPortraitUri ?? `/mii/${mii.id}/image?type=mii`,
|
||||
// ...(mii.platform === "THREE_DS" ? [`/mii/${mii.id}/image?type=qr-code`] : [miiFeaturesUri ?? `/mii/${mii.id}/image?type=features`]),
|
||||
// ...files.map((file) => URL.createObjectURL(file)),
|
||||
// ]}
|
||||
// />
|
||||
|
||||
// <div className="p-4 flex flex-col gap-1 h-full">
|
||||
// <h1 className="font-bold text-2xl line-clamp-1" title={name}>
|
||||
// {name || "Mii name"}
|
||||
// </h1>
|
||||
// <div id="tags" className="flex flex-wrap gap-1">
|
||||
// {tags.length == 0 && <span className="px-2 py-1 bg-orange-300 rounded-full text-xs">tag</span>}
|
||||
// {tags.map((tag) => (
|
||||
// <span key={tag} className="px-2 py-1 bg-orange-300 rounded-full text-xs">
|
||||
// {tag}
|
||||
// </span>
|
||||
// ))}
|
||||
// </div>
|
||||
|
||||
// <div className="mt-auto">
|
||||
// <LikeButton likes={likes} isLiked={false} abbreviate disabled />
|
||||
// </div>
|
||||
// </div>
|
||||
// </div>
|
||||
// </div>
|
||||
|
||||
// <div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex flex-col gap-2 max-w-2xl w-full">
|
||||
// <div>
|
||||
// <h2 className="text-2xl font-bold">Edit your Mii</h2>
|
||||
// <p className="text-sm text-zinc-500">Make changes to your existing Mii.</p>
|
||||
// </div>
|
||||
|
||||
// {/* Separator */}
|
||||
// <div className="flex items-center gap-4 text-zinc-500 text-sm font-medium my-1">
|
||||
// <hr className="grow border-zinc-300" />
|
||||
// <span>Info</span>
|
||||
// <hr className="grow border-zinc-300" />
|
||||
// </div>
|
||||
|
||||
// <div className="w-full grid grid-cols-3 items-center">
|
||||
// <label htmlFor="name" className="font-semibold">
|
||||
// Name
|
||||
// </label>
|
||||
// <input
|
||||
// id="name"
|
||||
// type="text"
|
||||
// className="pill input w-full col-span-2"
|
||||
// minLength={2}
|
||||
// maxLength={64}
|
||||
// placeholder="Type your mii's name here..."
|
||||
// value={name}
|
||||
// onChange={(e) => setName(e.target.value)}
|
||||
// />
|
||||
// </div>
|
||||
|
||||
// <div className="w-full grid grid-cols-3 items-center">
|
||||
// <label htmlFor="tags" className="font-semibold">
|
||||
// Tags
|
||||
// </label>
|
||||
// <TagSelector tags={tags} setTags={setTags} showTagLimit />
|
||||
// </div>
|
||||
|
||||
// <div className="w-full grid grid-cols-3 items-start">
|
||||
// <label htmlFor="reason-note" className="font-semibold py-2">
|
||||
// Description
|
||||
// </label>
|
||||
// <textarea
|
||||
// rows={5}
|
||||
// maxLength={512}
|
||||
// placeholder="(optional) Type a description..."
|
||||
// className="pill input rounded-xl! resize-none col-span-2 text-sm"
|
||||
// value={description ?? ""}
|
||||
// onChange={(e) => setDescription(e.target.value)}
|
||||
// />
|
||||
// </div>
|
||||
|
||||
// {session.data?.user?.id == import.meta.env.NEXT_PUBLIC_ADMIN_USER_ID && (
|
||||
// <>
|
||||
// <div className="w-full grid grid-cols-3 items-center">
|
||||
// <label htmlFor="quarantined" className="font-semibold py-2">
|
||||
// Quarantined
|
||||
// </label>
|
||||
|
||||
// <div className="col-span-2 flex gap-1">
|
||||
// <input type="checkbox" id="quarantined" className="checkbox-alt" checked={quarantined} onChange={(e) => setQuarantined(e.target.checked)} />
|
||||
// </div>
|
||||
// </div>
|
||||
// </>
|
||||
// )}
|
||||
|
||||
// {/* Makeup/Images/Instructions (Switch only) */}
|
||||
// {mii.platform === "SWITCH" && (
|
||||
// <>
|
||||
// <div className="w-full grid grid-cols-3 items-start z-20">
|
||||
// <label htmlFor="gender" className="font-semibold py-2">
|
||||
// Gender
|
||||
// </label>
|
||||
// <div className="col-span-2 flex gap-1">
|
||||
// <button
|
||||
// type="button"
|
||||
// onClick={() => setGender("MALE")}
|
||||
// aria-label="Filter for Male Miis"
|
||||
// data-tooltip="Male"
|
||||
// className={`cursor-pointer rounded-xl flex justify-center items-center size-11 text-4xl border-2 transition-all after:bg-blue-400! after:border-blue-400! before:border-b-blue-400! ${
|
||||
// gender === "MALE" ? "bg-blue-100 border-blue-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
|
||||
// }`}
|
||||
// >
|
||||
// <Icon icon="foundation:male" className="text-blue-400" />
|
||||
// </button>
|
||||
|
||||
// <button
|
||||
// type="button"
|
||||
// onClick={() => setGender("FEMALE")}
|
||||
// aria-label="Filter for Female Miis"
|
||||
// data-tooltip="Female"
|
||||
// className={`cursor-pointer rounded-xl flex justify-center items-center size-11 text-4xl border-2 transition-all after:bg-pink-400! after:border-pink-400! before:border-b-pink-400! ${
|
||||
// gender === "FEMALE" ? "bg-pink-100 border-pink-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
|
||||
// }`}
|
||||
// >
|
||||
// <Icon icon="foundation:female" className="text-pink-400" />
|
||||
// </button>
|
||||
|
||||
// <button
|
||||
// type="button"
|
||||
// onClick={() => setGender("NONBINARY")}
|
||||
// aria-label="Filter for Nonbinary Miis"
|
||||
// data-tooltip="Nonbinary"
|
||||
// className={`cursor-pointer rounded-xl flex justify-center items-center size-11 text-4xl border-2 transition-all after:bg-purple-400! after:border-purple-400! before:border-b-purple-400! ${
|
||||
// gender === "NONBINARY" ? "bg-purple-100 border-purple-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
|
||||
// }`}
|
||||
// >
|
||||
// <Icon icon="mdi:gender-non-binary" className="text-purple-400" />
|
||||
// </button>
|
||||
// </div>
|
||||
// </div>
|
||||
|
||||
// <div className="w-full grid grid-cols-3 items-start">
|
||||
// <label htmlFor="makeup" className="font-semibold py-2">
|
||||
// Face Paint
|
||||
// </label>
|
||||
|
||||
// <div className="col-span-2 flex gap-1">
|
||||
// {/* Full Makeup */}
|
||||
// <button
|
||||
// type="button"
|
||||
// onClick={() => setMakeup("FULL")}
|
||||
// aria-label="Full Face Paint"
|
||||
// data-tooltip="Full Face Paint"
|
||||
// className={`cursor-pointer rounded-xl flex justify-center items-center size-11 text-4xl border-2 transition-all after:bg-pink-400! after:border-pink-400! before:border-b-pink-400! ${
|
||||
// makeup === "FULL" ? "bg-pink-100 border-pink-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
|
||||
// }`}
|
||||
// >
|
||||
// <Icon icon="mdi:palette" className="text-pink-400" />
|
||||
// </button>
|
||||
|
||||
// {/* Partial Makeup */}
|
||||
// <button
|
||||
// type="button"
|
||||
// onClick={() => setMakeup("PARTIAL")}
|
||||
// aria-label="Partial Face Paint"
|
||||
// data-tooltip="Partial Face Paint"
|
||||
// className={`cursor-pointer rounded-xl flex justify-center items-center size-11 text-4xl border-2 transition-all after:bg-purple-400! after:border-purple-400! before:border-b-purple-400! ${
|
||||
// makeup === "PARTIAL" ? "bg-purple-100 border-purple-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
|
||||
// }`}
|
||||
// >
|
||||
// <Icon icon="mdi:lipstick" className="text-purple-400" />
|
||||
// </button>
|
||||
|
||||
// {/* No Makeup */}
|
||||
// <button
|
||||
// type="button"
|
||||
// onClick={() => setMakeup("NONE")}
|
||||
// aria-label="No Face Paint"
|
||||
// data-tooltip="No Face Paint"
|
||||
// className={`cursor-pointer rounded-xl flex justify-center items-center size-11 text-4xl border-2 transition-all after:bg-gray-400! after:border-gray-400! before:border-b-gray-400! ${
|
||||
// makeup === "NONE" ? "bg-gray-200 border-gray-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
|
||||
// }`}
|
||||
// >
|
||||
// <Icon icon="codex:cross" className="text-gray-400" />
|
||||
// </button>
|
||||
// </div>
|
||||
// </div>
|
||||
|
||||
// {/* (Switch Only) Mii Portrait */}
|
||||
// <div>
|
||||
// {/* Separator */}
|
||||
// <div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-8 mb-2">
|
||||
// <hr className="grow border-zinc-300" />
|
||||
// <span>Mii Portrait</span>
|
||||
// <hr className="grow border-zinc-300" />
|
||||
// </div>
|
||||
|
||||
// <div className="flex flex-col items-center gap-2">
|
||||
// <SwitchFileUpload text="a screenshot of your Mii here" image={miiPortraitUri} setImage={handleMiiPortraitChange} forceCrop />
|
||||
// <SwitchFileUpload text="a screenshot of your Mii's features here" image={miiFeaturesUri} setImage={handleMiiFeaturesChange} />
|
||||
// <SwitchSubmitTutorialButton />
|
||||
// </div>
|
||||
|
||||
// <p className="text-xs text-zinc-400 text-center mt-2">You must upload a screenshot of the features, check tutorial on how.</p>
|
||||
// </div>
|
||||
|
||||
// <div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-8">
|
||||
// <hr className="grow border-zinc-300" />
|
||||
// <span>Instructions</span>
|
||||
// <hr className="grow border-zinc-300" />
|
||||
// </div>
|
||||
|
||||
// {/* YouTube */}
|
||||
// <div className="w-full grid grid-cols-3 items-center">
|
||||
// <label htmlFor="youtube" className="font-semibold">
|
||||
// YouTube Video
|
||||
// </label>
|
||||
// <input
|
||||
// id="youtube"
|
||||
// type="text"
|
||||
// className="pill input w-full col-span-2"
|
||||
// minLength={2}
|
||||
// maxLength={64}
|
||||
// placeholder="Paste a URL or video ID..."
|
||||
// value={youtubeId}
|
||||
// onChange={(e) => {
|
||||
// const val = e.target.value;
|
||||
// const match = val.match(/(?:youtube\.com\/(?:watch\?v=|shorts\/|embed\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/);
|
||||
// setYouTubeId(match ? match[1] : val);
|
||||
// }}
|
||||
// />
|
||||
// </div>
|
||||
|
||||
// <MiiEditor instructions={instructions} />
|
||||
// <SwitchSubmitTutorialButton />
|
||||
// </>
|
||||
// )}
|
||||
|
||||
// {/* Separator */}
|
||||
// <div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-8">
|
||||
// <hr className="grow border-zinc-300" />
|
||||
// <span>Custom images</span>
|
||||
// <hr className="grow border-zinc-300" />
|
||||
// </div>
|
||||
|
||||
// <div className="max-w-md w-full self-center">
|
||||
// <Dropzone onDrop={handleDrop}>
|
||||
// <p className="text-center text-sm">
|
||||
// Drag and drop your images here
|
||||
// <br />
|
||||
// or click to open
|
||||
// </p>
|
||||
// </Dropzone>
|
||||
// </div>
|
||||
|
||||
// <ImageList files={files} setFiles={handleFilesChange} />
|
||||
|
||||
// <hr className="border-zinc-300 my-2" />
|
||||
// <div className="flex justify-between items-center">
|
||||
// {error && <span className="text-red-400 font-bold">Error: {error}</span>}
|
||||
|
||||
// <SubmitButton onClick={handleSubmit} text="Edit" className="ml-auto" />
|
||||
// </div>
|
||||
// </div>
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
// import { redirect } from "next/navigation";
|
||||
|
||||
// import { useCallback, useEffect, useRef, useState } from "react";
|
||||
// import { FileWithPath } from "react-dropzone";
|
||||
// import { Mii, MiiGender, MiiMakeup } from "@prisma/client";
|
||||
// import { useSession } from "next-auth/react";
|
||||
|
||||
// import { nameSchema, tagsSchema } from "@tomodachi-share/shared/schemas";
|
||||
// import { defaultInstructions, minifyInstructions } from "@/lib/switch";
|
||||
// import { SwitchMiiInstructions } from "@tomodachi-share/shared";
|
||||
|
||||
// import TagSelector from "../tag-selector";
|
||||
// import ImageList from "./image-list";
|
||||
// import LikeButton from "../like-button";
|
||||
// import Carousel from "../carousel";
|
||||
// import SubmitButton from "../submit-button";
|
||||
// import Dropzone from "../dropzone";
|
||||
// import MiiEditor from "./mii-editor";
|
||||
// import SwitchSubmitTutorialButton from "../tutorial/switch-submit";
|
||||
// import { Icon } from "@iconify/react";
|
||||
// import SwitchFileUpload from "./switch-file-upload";
|
||||
|
||||
// interface Props {
|
||||
// mii: Mii;
|
||||
// likes: number;
|
||||
// }
|
||||
|
||||
// function deepMerge<T>(target: T, source: Partial<T>): T {
|
||||
// const output = structuredClone(target);
|
||||
|
||||
// if (typeof source !== "object" || source === null) return output;
|
||||
|
||||
// for (const key in source) {
|
||||
// const sourceValue = source[key];
|
||||
// const targetValue = (output as any)[key];
|
||||
|
||||
// if (typeof sourceValue === "object" && sourceValue !== null && !Array.isArray(sourceValue)) {
|
||||
// (output as any)[key] = deepMerge(targetValue, sourceValue);
|
||||
// } else {
|
||||
// (output as any)[key] = sourceValue;
|
||||
// }
|
||||
// }
|
||||
|
||||
// return output;
|
||||
// }
|
||||
|
||||
// export default function EditForm({ mii, likes }: Props) {
|
||||
// const session = useSession();
|
||||
// const [files, setFiles] = useState<FileWithPath[]>([]);
|
||||
|
||||
// const handleFilesChange: React.Dispatch<React.SetStateAction<FileWithPath[]>> = (updater) => {
|
||||
// hasCustomImagesChanged.current = true;
|
||||
// setFiles(updater);
|
||||
// };
|
||||
|
||||
// const handleDrop = useCallback(
|
||||
// (acceptedFiles: FileWithPath[]) => {
|
||||
// if (files.length >= 3) return;
|
||||
// hasCustomImagesChanged.current = true;
|
||||
|
||||
// setFiles((prev) => [...prev, ...acceptedFiles]);
|
||||
// },
|
||||
// [files.length],
|
||||
// );
|
||||
|
||||
// const [error, setError] = useState<string | undefined>(undefined);
|
||||
|
||||
// const [name, setName] = useState(mii.name);
|
||||
// const [tags, setTags] = useState(mii.tags);
|
||||
// const [description, setDescription] = useState(mii.description);
|
||||
// const [gender, setGender] = useState<MiiGender>(mii.gender ?? "MALE");
|
||||
// const [makeup, setMakeup] = useState<MiiMakeup>(mii.makeup ?? "PARTIAL");
|
||||
// const [miiPortraitUri, setMiiPortraitUri] = useState<string | undefined>(`/mii/${mii.id}/image?type=mii`);
|
||||
// const [miiFeaturesUri, setMiiFeaturesUri] = useState<string | undefined>(`/mii/${mii.id}/image?type=features`);
|
||||
// const [youtubeId, setYouTubeId] = useState(mii.youtubeId ?? "");
|
||||
// const instructions = useRef<SwitchMiiInstructions>(deepMerge(defaultInstructions, (mii.instructions as object) ?? {}));
|
||||
|
||||
// const [quarantined, setQuarantined] = useState(mii.quarantined);
|
||||
// const hasCustomImagesChanged = useRef(false);
|
||||
// const hasMiiPortraitChanged = useRef(false);
|
||||
// const hasMiiFeaturesChanged = useRef(false);
|
||||
|
||||
// const handleSubmit = async () => {
|
||||
// // Validate before sending request
|
||||
// const nameValidation = nameSchema.safeParse(name);
|
||||
// if (!nameValidation.success) {
|
||||
// setError(nameValidation.error.issues[0].message);
|
||||
// return;
|
||||
// }
|
||||
// const tagsValidation = tagsSchema.safeParse(tags);
|
||||
// if (!tagsValidation.success) {
|
||||
// setError(tagsValidation.error.issues[0].message);
|
||||
// return;
|
||||
// }
|
||||
|
||||
// // Send request to server
|
||||
// const formData = new FormData();
|
||||
// if (name != mii.name) formData.append("name", name);
|
||||
// if (tags != mii.tags) formData.append("tags", JSON.stringify(tags));
|
||||
// if (description && description != mii.description) formData.append("description", description);
|
||||
// if (gender != mii.gender) formData.append("gender", gender);
|
||||
// if (makeup != mii.makeup) formData.append("makeup", makeup);
|
||||
// if (miiPortraitUri) formData.append("miiPortraitUri", miiPortraitUri);
|
||||
// if (quarantined != mii.quarantined) formData.append("quarantined", JSON.stringify(quarantined));
|
||||
// if (youtubeId != mii.youtubeId) formData.append("youtubeId", youtubeId);
|
||||
// if (minifyInstructions(structuredClone(instructions.current)) !== (mii.instructions as object))
|
||||
// formData.append("instructions", JSON.stringify(instructions.current));
|
||||
|
||||
// if (hasCustomImagesChanged.current) {
|
||||
// files.forEach((file, index) => {
|
||||
// // image1, image2, etc.
|
||||
// formData.append(`image${index + 1}`, file);
|
||||
// });
|
||||
// }
|
||||
|
||||
// // Switch pictures
|
||||
// async function getBlob(uri: string): Promise<Blob | null> {
|
||||
// const response = await fetch(uri);
|
||||
// if (!response.ok) {
|
||||
// setError("Failed to get Mii portrait/features screenshot. Did you upload one?");
|
||||
// return null;
|
||||
// }
|
||||
|
||||
// const blob = await response.blob();
|
||||
// if (!blob.type.startsWith("image/")) {
|
||||
// setError("Invalid image file found");
|
||||
// return null;
|
||||
// }
|
||||
|
||||
// return blob;
|
||||
// }
|
||||
|
||||
// if (miiPortraitUri && hasMiiPortraitChanged.current) {
|
||||
// const blob = await getBlob(miiPortraitUri);
|
||||
// if (blob) formData.append("miiPortraitImage", blob);
|
||||
// }
|
||||
// if (miiFeaturesUri && hasMiiFeaturesChanged.current) {
|
||||
// const blob = await getBlob(miiFeaturesUri);
|
||||
// if (blob) formData.append("miiFeaturesImage", blob);
|
||||
// }
|
||||
|
||||
// const response = await fetch(`/api/mii/${mii.id}/edit`, {
|
||||
// method: "PATCH",
|
||||
// body: formData,
|
||||
// });
|
||||
// const { error } = await response.json();
|
||||
|
||||
// if (!response.ok) {
|
||||
// setError(error);
|
||||
// return;
|
||||
// }
|
||||
|
||||
// redirect(`/mii/${mii.id}`);
|
||||
// };
|
||||
|
||||
// const handleMiiPortraitChange = (uri: string | undefined) => {
|
||||
// hasMiiPortraitChanged.current = true;
|
||||
// setMiiPortraitUri(uri);
|
||||
// };
|
||||
|
||||
// const handleMiiFeaturesChange = (uri: string | undefined) => {
|
||||
// hasMiiFeaturesChanged.current = true;
|
||||
// setMiiFeaturesUri(uri);
|
||||
// };
|
||||
|
||||
// // Load existing images - converts image URLs to File objects
|
||||
// useEffect(() => {
|
||||
// const loadExistingImages = async () => {
|
||||
// try {
|
||||
// const existing = await Promise.all(
|
||||
// Array.from({ length: mii.imageCount }, async (_, index) => {
|
||||
// const path = `/mii/${mii.id}/image?type=image${index}`;
|
||||
// const response = await fetch(path);
|
||||
// const blob = await response.blob();
|
||||
|
||||
// return Object.assign(new File([blob], `image${index}.png`, { type: "image/png" }), { path });
|
||||
// }),
|
||||
// );
|
||||
|
||||
// setFiles(existing);
|
||||
// } catch (error) {
|
||||
// console.error("Error loading existing images:", error);
|
||||
// }
|
||||
// };
|
||||
|
||||
// loadExistingImages();
|
||||
// }, [mii.id, mii.imageCount]);
|
||||
|
||||
// return (
|
||||
// <div className="flex justify-center gap-4 w-full max-lg:flex-col max-lg:items-center">
|
||||
// <div className="flex justify-center">
|
||||
// <div className="w-75 h-min flex flex-col bg-zinc-50 rounded-3xl border-2 border-zinc-300 shadow-lg p-3">
|
||||
// <Carousel
|
||||
// images={[
|
||||
// miiPortraitUri ?? `/mii/${mii.id}/image?type=mii`,
|
||||
// ...(mii.platform === "THREE_DS" ? [`/mii/${mii.id}/image?type=qr-code`] : [miiFeaturesUri ?? `/mii/${mii.id}/image?type=features`]),
|
||||
// ...files.map((file) => URL.createObjectURL(file)),
|
||||
// ]}
|
||||
// />
|
||||
|
||||
// <div className="p-4 flex flex-col gap-1 h-full">
|
||||
// <h1 className="font-bold text-2xl line-clamp-1" title={name}>
|
||||
// {name || "Mii name"}
|
||||
// </h1>
|
||||
// <div id="tags" className="flex flex-wrap gap-1">
|
||||
// {tags.length == 0 && <span className="px-2 py-1 bg-orange-300 rounded-full text-xs">tag</span>}
|
||||
// {tags.map((tag) => (
|
||||
// <span key={tag} className="px-2 py-1 bg-orange-300 rounded-full text-xs">
|
||||
// {tag}
|
||||
// </span>
|
||||
// ))}
|
||||
// </div>
|
||||
|
||||
// <div className="mt-auto">
|
||||
// <LikeButton likes={likes} isLiked={false} abbreviate disabled />
|
||||
// </div>
|
||||
// </div>
|
||||
// </div>
|
||||
// </div>
|
||||
|
||||
// <div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 flex flex-col gap-2 max-w-2xl w-full">
|
||||
// <div>
|
||||
// <h2 className="text-2xl font-bold">Edit your Mii</h2>
|
||||
// <p className="text-sm text-zinc-500">Make changes to your existing Mii.</p>
|
||||
// </div>
|
||||
|
||||
// {/* Separator */}
|
||||
// <div className="flex items-center gap-4 text-zinc-500 text-sm font-medium my-1">
|
||||
// <hr className="grow border-zinc-300" />
|
||||
// <span>Info</span>
|
||||
// <hr className="grow border-zinc-300" />
|
||||
// </div>
|
||||
|
||||
// <div className="w-full grid grid-cols-3 items-center">
|
||||
// <label htmlFor="name" className="font-semibold">
|
||||
// Name
|
||||
// </label>
|
||||
// <input
|
||||
// id="name"
|
||||
// type="text"
|
||||
// className="pill input w-full col-span-2"
|
||||
// minLength={2}
|
||||
// maxLength={64}
|
||||
// placeholder="Type your mii's name here..."
|
||||
// value={name}
|
||||
// onChange={(e) => setName(e.target.value)}
|
||||
// />
|
||||
// </div>
|
||||
|
||||
// <div className="w-full grid grid-cols-3 items-center">
|
||||
// <label htmlFor="tags" className="font-semibold">
|
||||
// Tags
|
||||
// </label>
|
||||
// <TagSelector tags={tags} setTags={setTags} showTagLimit />
|
||||
// </div>
|
||||
|
||||
// <div className="w-full grid grid-cols-3 items-start">
|
||||
// <label htmlFor="reason-note" className="font-semibold py-2">
|
||||
// Description
|
||||
// </label>
|
||||
// <textarea
|
||||
// rows={5}
|
||||
// maxLength={512}
|
||||
// placeholder="(optional) Type a description..."
|
||||
// className="pill input rounded-xl! resize-none col-span-2 text-sm"
|
||||
// value={description ?? ""}
|
||||
// onChange={(e) => setDescription(e.target.value)}
|
||||
// />
|
||||
// </div>
|
||||
|
||||
// {session.data?.user?.id == import.meta.env.NEXT_PUBLIC_ADMIN_USER_ID && (
|
||||
// <>
|
||||
// <div className="w-full grid grid-cols-3 items-center">
|
||||
// <label htmlFor="quarantined" className="font-semibold py-2">
|
||||
// Quarantined
|
||||
// </label>
|
||||
|
||||
// <div className="col-span-2 flex gap-1">
|
||||
// <input type="checkbox" id="quarantined" className="checkbox-alt" checked={quarantined} onChange={(e) => setQuarantined(e.target.checked)} />
|
||||
// </div>
|
||||
// </div>
|
||||
// </>
|
||||
// )}
|
||||
|
||||
// {/* Makeup/Images/Instructions (Switch only) */}
|
||||
// {mii.platform === "SWITCH" && (
|
||||
// <>
|
||||
// <div className="w-full grid grid-cols-3 items-start z-20">
|
||||
// <label htmlFor="gender" className="font-semibold py-2">
|
||||
// Gender
|
||||
// </label>
|
||||
// <div className="col-span-2 flex gap-1">
|
||||
// <button
|
||||
// type="button"
|
||||
// onClick={() => setGender("MALE")}
|
||||
// aria-label="Filter for Male Miis"
|
||||
// data-tooltip="Male"
|
||||
// className={`cursor-pointer rounded-xl flex justify-center items-center size-11 text-4xl border-2 transition-all after:bg-blue-400! after:border-blue-400! before:border-b-blue-400! ${
|
||||
// gender === "MALE" ? "bg-blue-100 border-blue-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
|
||||
// }`}
|
||||
// >
|
||||
// <Icon icon="foundation:male" className="text-blue-400" />
|
||||
// </button>
|
||||
|
||||
// <button
|
||||
// type="button"
|
||||
// onClick={() => setGender("FEMALE")}
|
||||
// aria-label="Filter for Female Miis"
|
||||
// data-tooltip="Female"
|
||||
// className={`cursor-pointer rounded-xl flex justify-center items-center size-11 text-4xl border-2 transition-all after:bg-pink-400! after:border-pink-400! before:border-b-pink-400! ${
|
||||
// gender === "FEMALE" ? "bg-pink-100 border-pink-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
|
||||
// }`}
|
||||
// >
|
||||
// <Icon icon="foundation:female" className="text-pink-400" />
|
||||
// </button>
|
||||
|
||||
// <button
|
||||
// type="button"
|
||||
// onClick={() => setGender("NONBINARY")}
|
||||
// aria-label="Filter for Nonbinary Miis"
|
||||
// data-tooltip="Nonbinary"
|
||||
// className={`cursor-pointer rounded-xl flex justify-center items-center size-11 text-4xl border-2 transition-all after:bg-purple-400! after:border-purple-400! before:border-b-purple-400! ${
|
||||
// gender === "NONBINARY" ? "bg-purple-100 border-purple-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
|
||||
// }`}
|
||||
// >
|
||||
// <Icon icon="mdi:gender-non-binary" className="text-purple-400" />
|
||||
// </button>
|
||||
// </div>
|
||||
// </div>
|
||||
|
||||
// <div className="w-full grid grid-cols-3 items-start">
|
||||
// <label htmlFor="makeup" className="font-semibold py-2">
|
||||
// Face Paint
|
||||
// </label>
|
||||
|
||||
// <div className="col-span-2 flex gap-1">
|
||||
// {/* Full Makeup */}
|
||||
// <button
|
||||
// type="button"
|
||||
// onClick={() => setMakeup("FULL")}
|
||||
// aria-label="Full Face Paint"
|
||||
// data-tooltip="Full Face Paint"
|
||||
// className={`cursor-pointer rounded-xl flex justify-center items-center size-11 text-4xl border-2 transition-all after:bg-pink-400! after:border-pink-400! before:border-b-pink-400! ${
|
||||
// makeup === "FULL" ? "bg-pink-100 border-pink-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
|
||||
// }`}
|
||||
// >
|
||||
// <Icon icon="mdi:palette" className="text-pink-400" />
|
||||
// </button>
|
||||
|
||||
// {/* Partial Makeup */}
|
||||
// <button
|
||||
// type="button"
|
||||
// onClick={() => setMakeup("PARTIAL")}
|
||||
// aria-label="Partial Face Paint"
|
||||
// data-tooltip="Partial Face Paint"
|
||||
// className={`cursor-pointer rounded-xl flex justify-center items-center size-11 text-4xl border-2 transition-all after:bg-purple-400! after:border-purple-400! before:border-b-purple-400! ${
|
||||
// makeup === "PARTIAL" ? "bg-purple-100 border-purple-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
|
||||
// }`}
|
||||
// >
|
||||
// <Icon icon="mdi:lipstick" className="text-purple-400" />
|
||||
// </button>
|
||||
|
||||
// {/* No Makeup */}
|
||||
// <button
|
||||
// type="button"
|
||||
// onClick={() => setMakeup("NONE")}
|
||||
// aria-label="No Face Paint"
|
||||
// data-tooltip="No Face Paint"
|
||||
// className={`cursor-pointer rounded-xl flex justify-center items-center size-11 text-4xl border-2 transition-all after:bg-gray-400! after:border-gray-400! before:border-b-gray-400! ${
|
||||
// makeup === "NONE" ? "bg-gray-200 border-gray-400 shadow-md" : "bg-white border-gray-300 hover:border-gray-400"
|
||||
// }`}
|
||||
// >
|
||||
// <Icon icon="codex:cross" className="text-gray-400" />
|
||||
// </button>
|
||||
// </div>
|
||||
// </div>
|
||||
|
||||
// {/* (Switch Only) Mii Portrait */}
|
||||
// <div>
|
||||
// {/* Separator */}
|
||||
// <div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-8 mb-2">
|
||||
// <hr className="grow border-zinc-300" />
|
||||
// <span>Mii Portrait</span>
|
||||
// <hr className="grow border-zinc-300" />
|
||||
// </div>
|
||||
|
||||
// <div className="flex flex-col items-center gap-2">
|
||||
// <SwitchFileUpload text="a screenshot of your Mii here" image={miiPortraitUri} setImage={handleMiiPortraitChange} forceCrop />
|
||||
// <SwitchFileUpload text="a screenshot of your Mii's features here" image={miiFeaturesUri} setImage={handleMiiFeaturesChange} />
|
||||
// <SwitchSubmitTutorialButton />
|
||||
// </div>
|
||||
|
||||
// <p className="text-xs text-zinc-400 text-center mt-2">You must upload a screenshot of the features, check tutorial on how.</p>
|
||||
// </div>
|
||||
|
||||
// <div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-8">
|
||||
// <hr className="grow border-zinc-300" />
|
||||
// <span>Instructions</span>
|
||||
// <hr className="grow border-zinc-300" />
|
||||
// </div>
|
||||
|
||||
// {/* YouTube */}
|
||||
// <div className="w-full grid grid-cols-3 items-center">
|
||||
// <label htmlFor="youtube" className="font-semibold">
|
||||
// YouTube Video
|
||||
// </label>
|
||||
// <input
|
||||
// id="youtube"
|
||||
// type="text"
|
||||
// className="pill input w-full col-span-2"
|
||||
// minLength={2}
|
||||
// maxLength={64}
|
||||
// placeholder="Paste a URL or video ID..."
|
||||
// value={youtubeId}
|
||||
// onChange={(e) => {
|
||||
// const val = e.target.value;
|
||||
// const match = val.match(/(?:youtube\.com\/(?:watch\?v=|shorts\/|embed\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/);
|
||||
// setYouTubeId(match ? match[1] : val);
|
||||
// }}
|
||||
// />
|
||||
// </div>
|
||||
|
||||
// <MiiEditor instructions={instructions} />
|
||||
// <SwitchSubmitTutorialButton />
|
||||
// </>
|
||||
// )}
|
||||
|
||||
// {/* Separator */}
|
||||
// <div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-8">
|
||||
// <hr className="grow border-zinc-300" />
|
||||
// <span>Custom images</span>
|
||||
// <hr className="grow border-zinc-300" />
|
||||
// </div>
|
||||
|
||||
// <div className="max-w-md w-full self-center">
|
||||
// <Dropzone onDrop={handleDrop}>
|
||||
// <p className="text-center text-sm">
|
||||
// Drag and drop your images here
|
||||
// <br />
|
||||
// or click to open
|
||||
// </p>
|
||||
// </Dropzone>
|
||||
// </div>
|
||||
|
||||
// <ImageList files={files} setFiles={handleFilesChange} />
|
||||
|
||||
// <hr className="border-zinc-300 my-2" />
|
||||
// <div className="flex justify-between items-center">
|
||||
// {error && <span className="text-red-400 font-bold">Error: {error}</span>}
|
||||
|
||||
// <SubmitButton onClick={handleSubmit} text="Edit" className="ml-auto" />
|
||||
// </div>
|
||||
// </div>
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
|
|
|
|||
|
|
@ -1,114 +1,114 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import ReactCrop, { type Crop } from "react-image-crop";
|
||||
import { Icon } from "@iconify/react";
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
image: string | undefined;
|
||||
setImage: (value: string | undefined) => void;
|
||||
}
|
||||
|
||||
export default function ImageEditorPortrait({ isOpen, setIsOpen, image, setImage }: Props) {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [crop, setCrop] = useState<Crop>();
|
||||
|
||||
const imageRef = useRef<HTMLImageElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
const applyCrop = useCallback(() => {
|
||||
if (!imageRef.current || !canvasRef.current || !crop) return;
|
||||
|
||||
const image = imageRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
|
||||
if (!crop.width || !crop.height || image.naturalWidth === 0 || image.naturalHeight === 0) return;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const scaleX = image.naturalWidth / image.width;
|
||||
const scaleY = image.naturalHeight / image.height;
|
||||
|
||||
canvas.width = crop.width * scaleX;
|
||||
canvas.height = crop.height * scaleY;
|
||||
|
||||
ctx.drawImage(image, crop.x * scaleX, crop.y * scaleY, crop.width * scaleX, crop.height * scaleY, 0, 0, crop.width * scaleX, crop.height * scaleY);
|
||||
|
||||
setImage(canvas.toDataURL());
|
||||
setCrop(undefined);
|
||||
}, [crop, setImage]);
|
||||
|
||||
const rotate = () => {
|
||||
if (!imageRef.current || !canvasRef.current) return;
|
||||
|
||||
const image = imageRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
canvas.width = image.naturalHeight;
|
||||
canvas.height = image.naturalWidth;
|
||||
|
||||
ctx.translate(canvas.width / 2, canvas.height / 2);
|
||||
ctx.rotate(Math.PI / 2);
|
||||
ctx.drawImage(image, -image.naturalWidth / 2, -image.naturalHeight / 2);
|
||||
|
||||
setImage(canvas.toDataURL());
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
setIsVisible(false);
|
||||
setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
// slight delay to trigger animation
|
||||
setTimeout(() => setIsVisible(true), 10);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<div className={`fixed inset-0 h-[calc(100%-var(--header-height))] top-(--header-height) flex items-center justify-center z-40 ${!isOpen ? "hidden" : ""}`}>
|
||||
<div
|
||||
onClick={close}
|
||||
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${isVisible ? "opacity-100" : "opacity-0"}`}
|
||||
/>
|
||||
|
||||
<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 ${
|
||||
isVisible ? "scale-100 opacity-100" : "scale-75 opacity-0"
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<h2 className="text-xl font-bold">Edit Image</h2>
|
||||
<button type="button" aria-label="Close" onClick={close} className="text-red-400 hover:text-red-500 text-2xl cursor-pointer">
|
||||
<Icon icon="material-symbols:close-rounded" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="relative w-full flex justify-center">
|
||||
<ReactCrop crop={crop} onChange={(c) => setCrop(c)} className="rounded-2xl border-2 border-amber-500 overflow-hidden max-h-96">
|
||||
<img ref={imageRef} src={image} />
|
||||
</ReactCrop>
|
||||
<canvas ref={canvasRef} className="hidden" />
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-center gap-2">
|
||||
<button type="button" onClick={close} className="pill button">
|
||||
Done
|
||||
</button>
|
||||
<button type="button" onClick={applyCrop} className="pill button">
|
||||
Crop
|
||||
</button>
|
||||
<button type="button" onClick={rotate} className="pill button">
|
||||
Rotate
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import ReactCrop, { type Crop } from "react-image-crop";
|
||||
import { Icon } from "@iconify/react";
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
image: string | undefined;
|
||||
setImage: (value: string | undefined) => void;
|
||||
}
|
||||
|
||||
export default function ImageEditorPortrait({ isOpen, setIsOpen, image, setImage }: Props) {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [crop, setCrop] = useState<Crop>();
|
||||
|
||||
const imageRef = useRef<HTMLImageElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
const applyCrop = useCallback(() => {
|
||||
if (!imageRef.current || !canvasRef.current || !crop) return;
|
||||
|
||||
const image = imageRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
|
||||
if (!crop.width || !crop.height || image.naturalWidth === 0 || image.naturalHeight === 0) return;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const scaleX = image.naturalWidth / image.width;
|
||||
const scaleY = image.naturalHeight / image.height;
|
||||
|
||||
canvas.width = crop.width * scaleX;
|
||||
canvas.height = crop.height * scaleY;
|
||||
|
||||
ctx.drawImage(image, crop.x * scaleX, crop.y * scaleY, crop.width * scaleX, crop.height * scaleY, 0, 0, crop.width * scaleX, crop.height * scaleY);
|
||||
|
||||
setImage(canvas.toDataURL());
|
||||
setCrop(undefined);
|
||||
}, [crop, setImage]);
|
||||
|
||||
const rotate = () => {
|
||||
if (!imageRef.current || !canvasRef.current) return;
|
||||
|
||||
const image = imageRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
canvas.width = image.naturalHeight;
|
||||
canvas.height = image.naturalWidth;
|
||||
|
||||
ctx.translate(canvas.width / 2, canvas.height / 2);
|
||||
ctx.rotate(Math.PI / 2);
|
||||
ctx.drawImage(image, -image.naturalWidth / 2, -image.naturalHeight / 2);
|
||||
|
||||
setImage(canvas.toDataURL());
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
setIsVisible(false);
|
||||
setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
// slight delay to trigger animation
|
||||
setTimeout(() => setIsVisible(true), 10);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<div className={`fixed inset-0 h-[calc(100%-var(--header-height))] top-(--header-height) flex items-center justify-center z-40 ${!isOpen ? "hidden" : ""}`}>
|
||||
<div
|
||||
onClick={close}
|
||||
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${isVisible ? "opacity-100" : "opacity-0"}`}
|
||||
/>
|
||||
|
||||
<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 ${
|
||||
isVisible ? "scale-100 opacity-100" : "scale-75 opacity-0"
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<h2 className="text-xl font-bold">Edit Image</h2>
|
||||
<button type="button" aria-label="Close" onClick={close} className="text-red-400 hover:text-red-500 text-2xl cursor-pointer">
|
||||
<Icon icon="material-symbols:close-rounded" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="relative w-full flex justify-center">
|
||||
<ReactCrop crop={crop} onChange={(c) => setCrop(c)} className="rounded-2xl border-2 border-amber-500 overflow-hidden max-h-96">
|
||||
<img ref={imageRef} src={image} />
|
||||
</ReactCrop>
|
||||
<canvas ref={canvasRef} className="hidden" />
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-center gap-2">
|
||||
<button type="button" onClick={close} className="pill button">
|
||||
Done
|
||||
</button>
|
||||
<button type="button" onClick={applyCrop} className="pill button">
|
||||
Crop
|
||||
</button>
|
||||
<button type="button" onClick={rotate} className="pill button">
|
||||
Rotate
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,73 +1,73 @@
|
|||
import { type FileWithPath } from "react-dropzone";
|
||||
import { DragDropContext, Draggable, Droppable, type DropResult } from "@hello-pangea/dnd";
|
||||
import { Icon } from "@iconify/react";
|
||||
|
||||
interface Props {
|
||||
files: readonly FileWithPath[];
|
||||
setFiles: React.Dispatch<React.SetStateAction<FileWithPath[]>>;
|
||||
}
|
||||
|
||||
export default function ImageList({ files, setFiles }: Props) {
|
||||
const handleDelete = (index: number) => {
|
||||
const newFiles = [...files];
|
||||
newFiles.splice(index, 1);
|
||||
setFiles(newFiles);
|
||||
};
|
||||
|
||||
const handleDragEnd = (result: DropResult) => {
|
||||
if (!result.destination) return;
|
||||
|
||||
const items = Array.from(files);
|
||||
const [reorderedItem] = items.splice(result.source.index, 1);
|
||||
items.splice(result.destination.index, 0, reorderedItem);
|
||||
|
||||
setFiles(items);
|
||||
};
|
||||
|
||||
return (
|
||||
<DragDropContext onDragEnd={handleDragEnd}>
|
||||
<Droppable droppableId="imageDroppable">
|
||||
{(provided) => (
|
||||
<div ref={provided.innerRef} {...provided.droppableProps} className="flex flex-col px-12 max-lg:px-0 max-md:px-12 max-[32rem]:px-0">
|
||||
{files.map((file, index) => (
|
||||
<Draggable key={file.name} draggableId={file.name} index={index}>
|
||||
{(provided) => (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
className="w-full p-4 rounded-xl bg-orange-100 border-2 border-amber-500 flex gap-2 shadow-md my-1"
|
||||
>
|
||||
<img
|
||||
src={URL.createObjectURL(file)}
|
||||
alt={file.name}
|
||||
width={96}
|
||||
height={96}
|
||||
className="aspect-3/2 object-contain w-24 rounded-md bg-orange-300 border-2 border-orange-400"
|
||||
/>
|
||||
<div className="flex flex-col justify-center w-full min-w-0">
|
||||
<span className="font-semibold overflow-hidden text-ellipsis">{file.name}</span>
|
||||
<button
|
||||
onClick={() => handleDelete(index)}
|
||||
className="pill button text-xs w-min px-3! py-1! bg-red-300! border-red-400! hover:bg-red-400!"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
{...provided.dragHandleProps}
|
||||
className="h-full w-11 px-1 cursor-grab flex items-center justify-center rounded transition-colors hover:bg-black/10"
|
||||
>
|
||||
<Icon icon="tabler:grip-horizontal" className="size-6 text-black/50" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
);
|
||||
}
|
||||
import { type FileWithPath } from "react-dropzone";
|
||||
import { DragDropContext, Draggable, Droppable, type DropResult } from "@hello-pangea/dnd";
|
||||
import { Icon } from "@iconify/react";
|
||||
|
||||
interface Props {
|
||||
files: readonly FileWithPath[];
|
||||
setFiles: React.Dispatch<React.SetStateAction<FileWithPath[]>>;
|
||||
}
|
||||
|
||||
export default function ImageList({ files, setFiles }: Props) {
|
||||
const handleDelete = (index: number) => {
|
||||
const newFiles = [...files];
|
||||
newFiles.splice(index, 1);
|
||||
setFiles(newFiles);
|
||||
};
|
||||
|
||||
const handleDragEnd = (result: DropResult) => {
|
||||
if (!result.destination) return;
|
||||
|
||||
const items = Array.from(files);
|
||||
const [reorderedItem] = items.splice(result.source.index, 1);
|
||||
items.splice(result.destination.index, 0, reorderedItem);
|
||||
|
||||
setFiles(items);
|
||||
};
|
||||
|
||||
return (
|
||||
<DragDropContext onDragEnd={handleDragEnd}>
|
||||
<Droppable droppableId="imageDroppable">
|
||||
{(provided) => (
|
||||
<div ref={provided.innerRef} {...provided.droppableProps} className="flex flex-col px-12 max-lg:px-0 max-md:px-12 max-[32rem]:px-0">
|
||||
{files.map((file, index) => (
|
||||
<Draggable key={file.name} draggableId={file.name} index={index}>
|
||||
{(provided) => (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
className="w-full p-4 rounded-xl bg-orange-100 border-2 border-amber-500 flex gap-2 shadow-md my-1"
|
||||
>
|
||||
<img
|
||||
src={URL.createObjectURL(file)}
|
||||
alt={file.name}
|
||||
width={96}
|
||||
height={96}
|
||||
className="aspect-3/2 object-contain w-24 rounded-md bg-orange-300 border-2 border-orange-400"
|
||||
/>
|
||||
<div className="flex flex-col justify-center w-full min-w-0">
|
||||
<span className="font-semibold overflow-hidden text-ellipsis">{file.name}</span>
|
||||
<button
|
||||
onClick={() => handleDelete(index)}
|
||||
className="pill button text-xs w-min px-3! py-1! bg-red-300! border-red-400! hover:bg-red-400!"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
{...provided.dragHandleProps}
|
||||
className="h-full w-11 px-1 cursor-grab flex items-center justify-center rounded transition-colors hover:bg-black/10"
|
||||
>
|
||||
<Icon icon="tabler:grip-horizontal" className="size-6 text-black/50" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@ export default function SubmitForm() {
|
|||
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",
|
||||
body: formData,
|
||||
credentials: "include",
|
||||
|
|
|
|||
|
|
@ -1,124 +1,124 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { Icon } from "@iconify/react";
|
||||
import { COLORS } from "@tomodachi-share/shared";
|
||||
|
||||
interface Props {
|
||||
disabled?: boolean;
|
||||
color: number;
|
||||
setColor: (color: number) => void;
|
||||
tab?: "hair" | "eyes" | "lips" | "glasses" | "eyeliner";
|
||||
}
|
||||
|
||||
export default function ColorPicker({ disabled, color, setColor, tab = "hair" }: Props) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
const getExtraSlice = () => {
|
||||
switch (tab) {
|
||||
case "hair":
|
||||
return { start: 0, end: 8 };
|
||||
case "eyes":
|
||||
return { start: 122, end: 128 };
|
||||
case "lips":
|
||||
return { start: 128, end: 133 };
|
||||
case "glasses":
|
||||
return { start: 133, end: 139 };
|
||||
case "eyeliner":
|
||||
return { start: 139, end: 152 };
|
||||
default:
|
||||
return { start: 108, end: 122 };
|
||||
}
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
setIsVisible(false);
|
||||
setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
// slight delay to trigger animation
|
||||
setTimeout(() => setIsVisible(true), 10);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (isOpen) {
|
||||
close();
|
||||
} else {
|
||||
setIsOpen(true);
|
||||
}
|
||||
}}
|
||||
disabled={disabled}
|
||||
className={`w-20 flex gap-1.5 mb-2 p-2 rounded-xl shadow ${disabled ? "bg-zinc-300 opacity-50 cursor-not-allowed" : "bg-zinc-100 cursor-pointer"}`}
|
||||
>
|
||||
<Icon icon={"material-symbols:palette"} className="text-xl" />
|
||||
<div className="grow rounded" style={{ backgroundColor: `#${COLORS[color]}` }}></div>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div
|
||||
className={`absolute inset-0 z-10 w-full p-0.5 bg-orange-100 rounded-lg transition-transform duration-500 overflow-x-auto flex
|
||||
${isVisible ? "opacity-100" : "opacity-0"}`}
|
||||
style={{
|
||||
transition: isVisible
|
||||
? "transform 500ms cubic-bezier(0.34, 1.28, 0.64, 1), opacity 300ms"
|
||||
: "transform 1000ms cubic-bezier(0.55, 0, 0.45, 1), opacity 300ms",
|
||||
}}
|
||||
>
|
||||
<div className="w-max flex items-center justify-center grow shrink-0">
|
||||
<div className="mr-8 flex flex-col gap-0.5">
|
||||
{COLORS.slice(getExtraSlice().start, getExtraSlice().end).map((c, i) => {
|
||||
const actualIndex = i + getExtraSlice().start;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={actualIndex}
|
||||
onClick={() => setColor(actualIndex)}
|
||||
className={`size-7.5 cursor-pointer rounded-md ring-orange-500 ring-offset-2 ${color === actualIndex ? "ring-2 z-10" : ""}`}
|
||||
style={{
|
||||
backgroundColor: `#${c}`,
|
||||
opacity: isVisible ? 1 : 0,
|
||||
transform: isVisible ? "scale(1)" : "scale(0.7)",
|
||||
transition: `opacity 250ms ease, transform 320ms cubic-bezier(0.34, 1.4, 0.64, 1)`,
|
||||
// stagger by column then row for a wave effect
|
||||
transitionDelay: isVisible ? `${120 + (i % 10) * 18 + Math.floor(i / 10) * 10}ms` : "0ms",
|
||||
}}
|
||||
></button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-10 gap-0.5 overflow-x-auto">
|
||||
{COLORS.slice(8, 108).map((c, i) => (
|
||||
<button
|
||||
type="button"
|
||||
key={i + 8}
|
||||
onClick={() => setColor(i + 8)}
|
||||
className={`size-7.5 cursor-pointer rounded-md ring-orange-500 ring-offset-2 ${color === i + 8 ? "ring-2 z-10" : ""}`}
|
||||
style={{
|
||||
backgroundColor: `#${c}`,
|
||||
opacity: isVisible ? 1 : 0,
|
||||
transform: isVisible ? "scale(1)" : "scale(0.7)",
|
||||
transition: `opacity 250ms ease, transform 320ms cubic-bezier(0.34, 1.4, 0.64, 1)`,
|
||||
transitionDelay: isVisible ? `${120 + (i % 10) * 18 + Math.floor(i / 10) * 10}ms` : "0ms",
|
||||
}}
|
||||
></button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button type="button" onClick={close} className="h-4/5 w-16 ml-4 cursor-pointer transition-transform hover:scale-115 active:scale-90">
|
||||
<Icon icon={"tabler:chevron-right"} className="text-4xl" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
import { useEffect, useState } from "react";
|
||||
import { Icon } from "@iconify/react";
|
||||
import { COLORS } from "@tomodachi-share/shared";
|
||||
|
||||
interface Props {
|
||||
disabled?: boolean;
|
||||
color: number;
|
||||
setColor: (color: number) => void;
|
||||
tab?: "hair" | "eyes" | "lips" | "glasses" | "eyeliner";
|
||||
}
|
||||
|
||||
export default function ColorPicker({ disabled, color, setColor, tab = "hair" }: Props) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
const getExtraSlice = () => {
|
||||
switch (tab) {
|
||||
case "hair":
|
||||
return { start: 0, end: 8 };
|
||||
case "eyes":
|
||||
return { start: 122, end: 128 };
|
||||
case "lips":
|
||||
return { start: 128, end: 133 };
|
||||
case "glasses":
|
||||
return { start: 133, end: 139 };
|
||||
case "eyeliner":
|
||||
return { start: 139, end: 152 };
|
||||
default:
|
||||
return { start: 108, end: 122 };
|
||||
}
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
setIsVisible(false);
|
||||
setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
// slight delay to trigger animation
|
||||
setTimeout(() => setIsVisible(true), 10);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (isOpen) {
|
||||
close();
|
||||
} else {
|
||||
setIsOpen(true);
|
||||
}
|
||||
}}
|
||||
disabled={disabled}
|
||||
className={`w-20 flex gap-1.5 mb-2 p-2 rounded-xl shadow ${disabled ? "bg-zinc-300 opacity-50 cursor-not-allowed" : "bg-zinc-100 cursor-pointer"}`}
|
||||
>
|
||||
<Icon icon={"material-symbols:palette"} className="text-xl" />
|
||||
<div className="grow rounded" style={{ backgroundColor: `#${COLORS[color]}` }}></div>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div
|
||||
className={`absolute inset-0 z-10 w-full p-0.5 bg-orange-100 rounded-lg transition-transform duration-500 overflow-x-auto flex
|
||||
${isVisible ? "opacity-100" : "opacity-0"}`}
|
||||
style={{
|
||||
transition: isVisible
|
||||
? "transform 500ms cubic-bezier(0.34, 1.28, 0.64, 1), opacity 300ms"
|
||||
: "transform 1000ms cubic-bezier(0.55, 0, 0.45, 1), opacity 300ms",
|
||||
}}
|
||||
>
|
||||
<div className="w-max flex items-center justify-center grow shrink-0">
|
||||
<div className="mr-8 flex flex-col gap-0.5">
|
||||
{COLORS.slice(getExtraSlice().start, getExtraSlice().end).map((c, i) => {
|
||||
const actualIndex = i + getExtraSlice().start;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={actualIndex}
|
||||
onClick={() => setColor(actualIndex)}
|
||||
className={`size-7.5 cursor-pointer rounded-md ring-orange-500 ring-offset-2 ${color === actualIndex ? "ring-2 z-10" : ""}`}
|
||||
style={{
|
||||
backgroundColor: `#${c}`,
|
||||
opacity: isVisible ? 1 : 0,
|
||||
transform: isVisible ? "scale(1)" : "scale(0.7)",
|
||||
transition: `opacity 250ms ease, transform 320ms cubic-bezier(0.34, 1.4, 0.64, 1)`,
|
||||
// stagger by column then row for a wave effect
|
||||
transitionDelay: isVisible ? `${120 + (i % 10) * 18 + Math.floor(i / 10) * 10}ms` : "0ms",
|
||||
}}
|
||||
></button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-10 gap-0.5 overflow-x-auto">
|
||||
{COLORS.slice(8, 108).map((c, i) => (
|
||||
<button
|
||||
type="button"
|
||||
key={i + 8}
|
||||
onClick={() => setColor(i + 8)}
|
||||
className={`size-7.5 cursor-pointer rounded-md ring-orange-500 ring-offset-2 ${color === i + 8 ? "ring-2 z-10" : ""}`}
|
||||
style={{
|
||||
backgroundColor: `#${c}`,
|
||||
opacity: isVisible ? 1 : 0,
|
||||
transform: isVisible ? "scale(1)" : "scale(0.7)",
|
||||
transition: `opacity 250ms ease, transform 320ms cubic-bezier(0.34, 1.4, 0.64, 1)`,
|
||||
transitionDelay: isVisible ? `${120 + (i % 10) * 18 + Math.floor(i / 10) * 10}ms` : "0ms",
|
||||
}}
|
||||
></button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button type="button" onClick={close} className="h-4/5 w-16 ml-4 cursor-pointer transition-transform hover:scale-115 active:scale-90">
|
||||
<Icon icon={"tabler:chevron-right"} className="text-4xl" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,78 +1,78 @@
|
|||
import { Icon } from "@iconify/react";
|
||||
|
||||
interface SliderProps {
|
||||
label: string;
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
min?: number;
|
||||
max?: number;
|
||||
mid?: number;
|
||||
step?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function EnhancedSlider({ label, value, onChange, min = 0, max = 128, mid = 64, step = 1, className = "" }: SliderProps) {
|
||||
const handleChange = (newValue: number) => {
|
||||
const clampedValue = Math.min(max, Math.max(min, newValue));
|
||||
onChange(clampedValue);
|
||||
};
|
||||
|
||||
const nudge = (direction: number) => {
|
||||
const newValue = value + direction * step;
|
||||
handleChange(newValue);
|
||||
};
|
||||
|
||||
const displayValue = value - mid;
|
||||
const displayText = displayValue > 0 ? `+${displayValue}` : displayValue.toString();
|
||||
const percentage = ((value - min) / (max - min)) * 100;
|
||||
|
||||
return (
|
||||
<div className={`w-full ${className}`}>
|
||||
<div className="flex justify-between items-center my-1 relative">
|
||||
<h3 className="text-sm font-semibold">{label}</h3>
|
||||
<span className="absolute left-1/2 transform -translate-x-1/2 text-xs font-bold text-orange-600 bg-orange-50 border-2 border-orange-400 px-2 py-1 rounded-full shadow-sm">
|
||||
{displayText}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => nudge(-1)}
|
||||
disabled={value <= min}
|
||||
className="bg-orange-50 border-2 border-orange-400 text-orange-400 font-bold size-7 rounded-lg cursor-pointer flex items-center justify-center shrink-0 transition-transform not-disabled:active:scale-95 disabled:opacity-30 disabled:cursor-not-allowed hover:bg-orange-50"
|
||||
aria-label={`Decrease ${label}`}
|
||||
>
|
||||
<Icon icon="mdi:chevron-left" width="16" height="16" />
|
||||
</button>
|
||||
|
||||
<div className="relative flex-1 h-8 flex items-center">
|
||||
{/* Tick mark at center */}
|
||||
<div className="absolute left-1/2 top-1/2 transform -translate-x-1/2 -translate-y-1/2 w-0.5 h-3 bg-orange-400 rounded z-10 opacity-60"></div>
|
||||
|
||||
<input
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={value}
|
||||
onChange={(e) => handleChange(e.target.valueAsNumber)}
|
||||
className="w-full px-0.5 h-2 bg-orange-200 rounded-lg appearance-none cursor-pointer focus:outline-0"
|
||||
style={{
|
||||
background: `linear-gradient(to right, #fb923c 0%, #fb923c ${percentage}%, #fed7aa ${percentage}%, #fed7aa 100%)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => nudge(1)}
|
||||
disabled={value >= max}
|
||||
className="bg-orange-50 border-2 border-orange-400 text-orange-400 font-bold size-7 rounded-lg cursor-pointer flex items-center justify-center shrink-0 transition-transform not-disabled:active:scale-95 disabled:opacity-30 disabled:cursor-not-allowed hover:bg-orange-50"
|
||||
aria-label={`Increase ${label}`}
|
||||
>
|
||||
<Icon icon="mdi:chevron-right" width="16" height="16" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { Icon } from "@iconify/react";
|
||||
|
||||
interface SliderProps {
|
||||
label: string;
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
min?: number;
|
||||
max?: number;
|
||||
mid?: number;
|
||||
step?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function EnhancedSlider({ label, value, onChange, min = 0, max = 128, mid = 64, step = 1, className = "" }: SliderProps) {
|
||||
const handleChange = (newValue: number) => {
|
||||
const clampedValue = Math.min(max, Math.max(min, newValue));
|
||||
onChange(clampedValue);
|
||||
};
|
||||
|
||||
const nudge = (direction: number) => {
|
||||
const newValue = value + direction * step;
|
||||
handleChange(newValue);
|
||||
};
|
||||
|
||||
const displayValue = value - mid;
|
||||
const displayText = displayValue > 0 ? `+${displayValue}` : displayValue.toString();
|
||||
const percentage = ((value - min) / (max - min)) * 100;
|
||||
|
||||
return (
|
||||
<div className={`w-full ${className}`}>
|
||||
<div className="flex justify-between items-center my-1 relative">
|
||||
<h3 className="text-sm font-semibold">{label}</h3>
|
||||
<span className="absolute left-1/2 transform -translate-x-1/2 text-xs font-bold text-orange-600 bg-orange-50 border-2 border-orange-400 px-2 py-1 rounded-full shadow-sm">
|
||||
{displayText}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => nudge(-1)}
|
||||
disabled={value <= min}
|
||||
className="bg-orange-50 border-2 border-orange-400 text-orange-400 font-bold size-7 rounded-lg cursor-pointer flex items-center justify-center shrink-0 transition-transform not-disabled:active:scale-95 disabled:opacity-30 disabled:cursor-not-allowed hover:bg-orange-50"
|
||||
aria-label={`Decrease ${label}`}
|
||||
>
|
||||
<Icon icon="mdi:chevron-left" width="16" height="16" />
|
||||
</button>
|
||||
|
||||
<div className="relative flex-1 h-8 flex items-center">
|
||||
{/* Tick mark at center */}
|
||||
<div className="absolute left-1/2 top-1/2 transform -translate-x-1/2 -translate-y-1/2 w-0.5 h-3 bg-orange-400 rounded z-10 opacity-60"></div>
|
||||
|
||||
<input
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={value}
|
||||
onChange={(e) => handleChange(e.target.valueAsNumber)}
|
||||
className="w-full px-0.5 h-2 bg-orange-200 rounded-lg appearance-none cursor-pointer focus:outline-0"
|
||||
style={{
|
||||
background: `linear-gradient(to right, #fb923c 0%, #fb923c ${percentage}%, #fed7aa ${percentage}%, #fed7aa 100%)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => nudge(1)}
|
||||
disabled={value >= max}
|
||||
className="bg-orange-50 border-2 border-orange-400 text-orange-400 font-bold size-7 rounded-lg cursor-pointer flex items-center justify-center shrink-0 transition-transform not-disabled:active:scale-95 disabled:opacity-30 disabled:cursor-not-allowed hover:bg-orange-50"
|
||||
aria-label={`Increase ${label}`}
|
||||
>
|
||||
<Icon icon="mdi:chevron-right" width="16" height="16" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,80 +1,80 @@
|
|||
import { type SwitchMiiInstructions } from "@tomodachi-share/shared";
|
||||
import React, { useState } from "react";
|
||||
import { Icon } from "@iconify/react";
|
||||
|
||||
import HeadTab from "./tabs/head";
|
||||
import HairTab from "./tabs/hair";
|
||||
import EyebrowsTab from "./tabs/eyebrows";
|
||||
import EyesTab from "./tabs/eyes";
|
||||
import NoseTab from "./tabs/nose";
|
||||
import LipsTab from "./tabs/lips";
|
||||
import EarsTab from "./tabs/ears";
|
||||
import GlassesTab from "./tabs/glasses";
|
||||
import OtherTab from "./tabs/other";
|
||||
import MiscTab from "./tabs/misc";
|
||||
|
||||
interface Props {
|
||||
instructions: React.RefObject<SwitchMiiInstructions>;
|
||||
}
|
||||
|
||||
type Tab = "head" | "hair" | "eyebrows" | "eyes" | "nose" | "lips" | "ears" | "glasses" | "other" | "misc";
|
||||
|
||||
export const TAB_ICONS: Record<Tab, string> = {
|
||||
head: "mingcute:head-fill",
|
||||
hair: "mingcute:hair-fill",
|
||||
eyebrows: "material-symbols:eyebrow",
|
||||
eyes: "mdi:eye",
|
||||
nose: "mingcute:nose-fill",
|
||||
lips: "material-symbols-light:lips",
|
||||
ears: "ion:ear",
|
||||
glasses: "solar:glasses-bold",
|
||||
other: "mdi:sparkles",
|
||||
misc: "material-symbols:settings",
|
||||
};
|
||||
|
||||
export const TAB_COMPONENTS: Record<Tab, React.ComponentType<any>> = {
|
||||
head: HeadTab,
|
||||
hair: HairTab,
|
||||
eyebrows: EyebrowsTab,
|
||||
eyes: EyesTab,
|
||||
nose: NoseTab,
|
||||
lips: LipsTab,
|
||||
ears: EarsTab,
|
||||
glasses: GlassesTab,
|
||||
other: OtherTab,
|
||||
misc: MiscTab,
|
||||
};
|
||||
|
||||
export default function MiiEditor({ instructions }: Props) {
|
||||
const [tab, setTab] = useState<Tab>("head");
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full h-91 flex flex-col sm:flex-row bg-orange-100 border-2 border-orange-200 rounded-xl overflow-hidden">
|
||||
<div className="w-full flex flex-row sm:flex-col max-sm:max-h-9 sm:max-w-9">
|
||||
{(Object.keys(TAB_COMPONENTS) as Tab[]).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => setTab(t)}
|
||||
className={`size-full aspect-square flex justify-center items-center text-[1.35rem] cursor-pointer bg-orange-200 hover:bg-orange-300 transition-colors duration-75 ${tab === t ? "bg-orange-100!" : ""}`}
|
||||
>
|
||||
{/* ml because of border on left causing icons to look miscentered */}
|
||||
<Icon icon={TAB_ICONS[t]} className="-ml-0.5" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Keep all tabs loaded to avoid flickering */}
|
||||
{(Object.keys(TAB_COMPONENTS) as Tab[]).map((t) => {
|
||||
const TabComponent = TAB_COMPONENTS[t];
|
||||
return (
|
||||
<div key={t} className={t === tab ? "grow relative p-3" : "hidden"}>
|
||||
<TabComponent instructions={instructions} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
import { type SwitchMiiInstructions } from "@tomodachi-share/shared";
|
||||
import React, { useState } from "react";
|
||||
import { Icon } from "@iconify/react";
|
||||
|
||||
import HeadTab from "./tabs/head";
|
||||
import HairTab from "./tabs/hair";
|
||||
import EyebrowsTab from "./tabs/eyebrows";
|
||||
import EyesTab from "./tabs/eyes";
|
||||
import NoseTab from "./tabs/nose";
|
||||
import LipsTab from "./tabs/lips";
|
||||
import EarsTab from "./tabs/ears";
|
||||
import GlassesTab from "./tabs/glasses";
|
||||
import OtherTab from "./tabs/other";
|
||||
import MiscTab from "./tabs/misc";
|
||||
|
||||
interface Props {
|
||||
instructions: React.RefObject<SwitchMiiInstructions>;
|
||||
}
|
||||
|
||||
type Tab = "head" | "hair" | "eyebrows" | "eyes" | "nose" | "lips" | "ears" | "glasses" | "other" | "misc";
|
||||
|
||||
export const TAB_ICONS: Record<Tab, string> = {
|
||||
head: "mingcute:head-fill",
|
||||
hair: "mingcute:hair-fill",
|
||||
eyebrows: "material-symbols:eyebrow",
|
||||
eyes: "mdi:eye",
|
||||
nose: "mingcute:nose-fill",
|
||||
lips: "material-symbols-light:lips",
|
||||
ears: "ion:ear",
|
||||
glasses: "solar:glasses-bold",
|
||||
other: "mdi:sparkles",
|
||||
misc: "material-symbols:settings",
|
||||
};
|
||||
|
||||
export const TAB_COMPONENTS: Record<Tab, React.ComponentType<any>> = {
|
||||
head: HeadTab,
|
||||
hair: HairTab,
|
||||
eyebrows: EyebrowsTab,
|
||||
eyes: EyesTab,
|
||||
nose: NoseTab,
|
||||
lips: LipsTab,
|
||||
ears: EarsTab,
|
||||
glasses: GlassesTab,
|
||||
other: OtherTab,
|
||||
misc: MiscTab,
|
||||
};
|
||||
|
||||
export default function MiiEditor({ instructions }: Props) {
|
||||
const [tab, setTab] = useState<Tab>("head");
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full h-91 flex flex-col sm:flex-row bg-orange-100 border-2 border-orange-200 rounded-xl overflow-hidden">
|
||||
<div className="w-full flex flex-row sm:flex-col max-sm:max-h-9 sm:max-w-9">
|
||||
{(Object.keys(TAB_COMPONENTS) as Tab[]).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => setTab(t)}
|
||||
className={`size-full aspect-square flex justify-center items-center text-[1.35rem] cursor-pointer bg-orange-200 hover:bg-orange-300 transition-colors duration-75 ${tab === t ? "bg-orange-100!" : ""}`}
|
||||
>
|
||||
{/* ml because of border on left causing icons to look miscentered */}
|
||||
<Icon icon={TAB_ICONS[t]} className="-ml-0.5" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Keep all tabs loaded to avoid flickering */}
|
||||
{(Object.keys(TAB_COMPONENTS) as Tab[]).map((t) => {
|
||||
const TabComponent = TAB_COMPONENTS[t];
|
||||
return (
|
||||
<div key={t} className={t === tab ? "grow relative p-3" : "hidden"}>
|
||||
<TabComponent instructions={instructions} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,92 +1,92 @@
|
|||
import { Icon } from "@iconify/react";
|
||||
import { useState } from "react";
|
||||
|
||||
interface Props {
|
||||
target: { height?: number; distance?: number; rotation?: number; size?: number; stretch?: number } | any;
|
||||
}
|
||||
|
||||
export default function NumberInputs({ target }: Props) {
|
||||
const [values, setValues] = useState<Record<string, number>>({
|
||||
height: target?.height ?? 0,
|
||||
distance: target?.distance ?? 0,
|
||||
rotation: target?.rotation ?? 0,
|
||||
size: target?.size ?? 0,
|
||||
stretch: target?.stretch ?? 0,
|
||||
});
|
||||
|
||||
if (!target) return null;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-x-4 h-min w-fit">
|
||||
{["Height", "Distance", "Rotation", "Size", "Stretch"].map(
|
||||
(label) =>
|
||||
target[label.toLowerCase()] !== undefined && (
|
||||
<NumberField
|
||||
key={label}
|
||||
label={label}
|
||||
value={values[label.toLowerCase()]}
|
||||
onChange={(value) => {
|
||||
const field = label.toLowerCase();
|
||||
setValues((prev) => ({ ...prev, [field]: value }));
|
||||
target[field] = value;
|
||||
}}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface NumberFieldProps {
|
||||
label: string;
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
}
|
||||
|
||||
function NumberField({ label, value, onChange }: NumberFieldProps) {
|
||||
const MIN = -100;
|
||||
const MAX = 100;
|
||||
|
||||
const decrement = () => onChange(Math.max(MIN, value - 1));
|
||||
const increment = () => onChange(Math.min(MAX, value + 1));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor={label} className="text-xs">
|
||||
{label}
|
||||
</label>
|
||||
<div className="pill input text-sm py-1! px-2! w-full flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={decrement}
|
||||
disabled={value <= MIN}
|
||||
className="cursor-pointer flex items-center justify-center shrink-0 disabled:opacity-30"
|
||||
aria-label={`Decrease ${label}`}
|
||||
>
|
||||
<Icon icon="mdi:minus" width="16" height="16" />
|
||||
</button>
|
||||
<input
|
||||
type="number"
|
||||
id={label}
|
||||
min={MIN}
|
||||
max={MAX}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
const val = Math.min(MAX, Math.max(MIN, Number(e.target.value)));
|
||||
onChange(val);
|
||||
}}
|
||||
className="w-full text-center bg-transparent outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={increment}
|
||||
disabled={value >= MAX}
|
||||
className="cursor-pointer flex items-center justify-center shrink-0 disabled:opacity-30"
|
||||
aria-label={`Increase ${label}`}
|
||||
>
|
||||
<Icon icon="mdi:plus" width="16" height="16" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { Icon } from "@iconify/react";
|
||||
import { useState } from "react";
|
||||
|
||||
interface Props {
|
||||
target: { height?: number; distance?: number; rotation?: number; size?: number; stretch?: number } | any;
|
||||
}
|
||||
|
||||
export default function NumberInputs({ target }: Props) {
|
||||
const [values, setValues] = useState<Record<string, number>>({
|
||||
height: target?.height ?? 0,
|
||||
distance: target?.distance ?? 0,
|
||||
rotation: target?.rotation ?? 0,
|
||||
size: target?.size ?? 0,
|
||||
stretch: target?.stretch ?? 0,
|
||||
});
|
||||
|
||||
if (!target) return null;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-x-4 h-min w-fit">
|
||||
{["Height", "Distance", "Rotation", "Size", "Stretch"].map(
|
||||
(label) =>
|
||||
target[label.toLowerCase()] !== undefined && (
|
||||
<NumberField
|
||||
key={label}
|
||||
label={label}
|
||||
value={values[label.toLowerCase()]}
|
||||
onChange={(value) => {
|
||||
const field = label.toLowerCase();
|
||||
setValues((prev) => ({ ...prev, [field]: value }));
|
||||
target[field] = value;
|
||||
}}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface NumberFieldProps {
|
||||
label: string;
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
}
|
||||
|
||||
function NumberField({ label, value, onChange }: NumberFieldProps) {
|
||||
const MIN = -100;
|
||||
const MAX = 100;
|
||||
|
||||
const decrement = () => onChange(Math.max(MIN, value - 1));
|
||||
const increment = () => onChange(Math.min(MAX, value + 1));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor={label} className="text-xs">
|
||||
{label}
|
||||
</label>
|
||||
<div className="pill input text-sm py-1! px-2! w-full flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={decrement}
|
||||
disabled={value <= MIN}
|
||||
className="cursor-pointer flex items-center justify-center shrink-0 disabled:opacity-30"
|
||||
aria-label={`Decrease ${label}`}
|
||||
>
|
||||
<Icon icon="mdi:minus" width="16" height="16" />
|
||||
</button>
|
||||
<input
|
||||
type="number"
|
||||
id={label}
|
||||
min={MIN}
|
||||
max={MAX}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
const val = Math.min(MAX, Math.max(MIN, Number(e.target.value)));
|
||||
onChange(val);
|
||||
}}
|
||||
className="w-full text-center bg-transparent outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={increment}
|
||||
disabled={value >= MAX}
|
||||
className="cursor-pointer flex items-center justify-center shrink-0 disabled:opacity-30"
|
||||
aria-label={`Increase ${label}`}
|
||||
>
|
||||
<Icon icon="mdi:plus" width="16" height="16" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,18 @@
|
|||
import { type SwitchMiiInstructions } from "@tomodachi-share/shared";
|
||||
import NumberInputs from "../number-inputs";
|
||||
|
||||
interface EarsProps {
|
||||
instructions: React.RefObject<SwitchMiiInstructions>;
|
||||
}
|
||||
|
||||
export default function EarsTab({ instructions }: EarsProps) {
|
||||
return (
|
||||
<>
|
||||
<h1 className="absolute font-bold text-xl">Ears</h1>
|
||||
|
||||
<div className="size-full flex flex-col justify-center items-center">
|
||||
<NumberInputs target={instructions.current.ears} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
import { type SwitchMiiInstructions } from "@tomodachi-share/shared";
|
||||
import NumberInputs from "../number-inputs";
|
||||
|
||||
interface EarsProps {
|
||||
instructions: React.RefObject<SwitchMiiInstructions>;
|
||||
}
|
||||
|
||||
export default function EarsTab({ instructions }: EarsProps) {
|
||||
return (
|
||||
<>
|
||||
<h1 className="absolute font-bold text-xl">Ears</h1>
|
||||
|
||||
<div className="size-full flex flex-col justify-center items-center">
|
||||
<NumberInputs target={instructions.current.ears} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,29 +1,29 @@
|
|||
import { useState } from "react";
|
||||
import { type SwitchMiiInstructions } from "@tomodachi-share/shared";
|
||||
import ColorPicker from "../color-picker";
|
||||
import NumberInputs from "../number-inputs";
|
||||
|
||||
interface Props {
|
||||
instructions: React.RefObject<SwitchMiiInstructions>;
|
||||
}
|
||||
|
||||
export default function EyebrowsTab({ instructions }: Props) {
|
||||
const [color, setColor] = useState(instructions.current.eyebrows.color ?? 3);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="absolute font-bold text-xl">Eyebrows</h1>
|
||||
|
||||
<div className="size-full flex flex-col justify-center items-center">
|
||||
<ColorPicker
|
||||
color={color}
|
||||
setColor={(i) => {
|
||||
setColor(i);
|
||||
instructions.current.eyebrows.color = i;
|
||||
}}
|
||||
/>
|
||||
<NumberInputs target={instructions.current.eyebrows} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
import { useState } from "react";
|
||||
import { type SwitchMiiInstructions } from "@tomodachi-share/shared";
|
||||
import ColorPicker from "../color-picker";
|
||||
import NumberInputs from "../number-inputs";
|
||||
|
||||
interface Props {
|
||||
instructions: React.RefObject<SwitchMiiInstructions>;
|
||||
}
|
||||
|
||||
export default function EyebrowsTab({ instructions }: Props) {
|
||||
const [color, setColor] = useState(instructions.current.eyebrows.color ?? 3);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="absolute font-bold text-xl">Eyebrows</h1>
|
||||
|
||||
<div className="size-full flex flex-col justify-center items-center">
|
||||
<ColorPicker
|
||||
color={color}
|
||||
setColor={(i) => {
|
||||
setColor(i);
|
||||
instructions.current.eyebrows.color = i;
|
||||
}}
|
||||
/>
|
||||
<NumberInputs target={instructions.current.eyebrows} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,67 +1,67 @@
|
|||
import { type SwitchMiiInstructions } from "@tomodachi-share/shared";
|
||||
import { useState } from "react";
|
||||
import ColorPicker from "../color-picker";
|
||||
import NumberInputs from "../number-inputs";
|
||||
|
||||
interface Props {
|
||||
instructions: React.RefObject<SwitchMiiInstructions>;
|
||||
}
|
||||
|
||||
const TABS: { name: keyof SwitchMiiInstructions["eyes"]; colorsDisabled?: boolean }[] = [
|
||||
{ name: "main" },
|
||||
{ name: "eyelashesTop", colorsDisabled: true },
|
||||
{ name: "eyelashesBottom", colorsDisabled: true },
|
||||
{ name: "eyelidTop", colorsDisabled: true },
|
||||
{ name: "eyelidBottom", colorsDisabled: true },
|
||||
{ name: "eyeliner" },
|
||||
{ name: "pupil", colorsDisabled: true },
|
||||
];
|
||||
|
||||
export default function EyesTab({ instructions }: Props) {
|
||||
const [tab, setTab] = useState(0);
|
||||
const [colors, setColors] = useState<number[]>(() =>
|
||||
TABS.map((t) => {
|
||||
const entry = instructions.current.eyes[t.name] ?? {};
|
||||
const color = entry && "color" in entry ? entry.color : null;
|
||||
return color ?? 122;
|
||||
}),
|
||||
);
|
||||
|
||||
const currentTab = TABS[tab];
|
||||
|
||||
const setColor = (value: number) => {
|
||||
setColors((prev) => {
|
||||
const copy = [...prev];
|
||||
copy[tab] = value;
|
||||
return copy;
|
||||
});
|
||||
|
||||
if (!currentTab.colorsDisabled) (instructions.current.eyes[currentTab.name] as { color: number }).color = value;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="absolute font-bold text-xl">Eyes</h1>
|
||||
|
||||
<div className="absolute right-3 z-10 flex justify-end">
|
||||
<div className="rounded-2xl bg-orange-200">
|
||||
{TABS.map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => setTab(i)}
|
||||
className={`px-3 py-1 rounded-2xl cursor-pointer hover:bg-orange-300/50 transition-colors duration-75 ${tab === i ? "bg-orange-300!" : "orange-200"}`}
|
||||
>
|
||||
{i + 1}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-0 flex flex-col justify-center items-center">
|
||||
<ColorPicker disabled={currentTab.colorsDisabled} color={colors[tab]} setColor={setColor} tab={tab === 5 ? "eyeliner" : "eyes"} />
|
||||
<NumberInputs key={tab} target={instructions.current.eyes[currentTab.name]} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
import { type SwitchMiiInstructions } from "@tomodachi-share/shared";
|
||||
import { useState } from "react";
|
||||
import ColorPicker from "../color-picker";
|
||||
import NumberInputs from "../number-inputs";
|
||||
|
||||
interface Props {
|
||||
instructions: React.RefObject<SwitchMiiInstructions>;
|
||||
}
|
||||
|
||||
const TABS: { name: keyof SwitchMiiInstructions["eyes"]; colorsDisabled?: boolean }[] = [
|
||||
{ name: "main" },
|
||||
{ name: "eyelashesTop", colorsDisabled: true },
|
||||
{ name: "eyelashesBottom", colorsDisabled: true },
|
||||
{ name: "eyelidTop", colorsDisabled: true },
|
||||
{ name: "eyelidBottom", colorsDisabled: true },
|
||||
{ name: "eyeliner" },
|
||||
{ name: "pupil", colorsDisabled: true },
|
||||
];
|
||||
|
||||
export default function EyesTab({ instructions }: Props) {
|
||||
const [tab, setTab] = useState(0);
|
||||
const [colors, setColors] = useState<number[]>(() =>
|
||||
TABS.map((t) => {
|
||||
const entry = instructions.current.eyes[t.name] ?? {};
|
||||
const color = entry && "color" in entry ? entry.color : null;
|
||||
return color ?? 122;
|
||||
}),
|
||||
);
|
||||
|
||||
const currentTab = TABS[tab];
|
||||
|
||||
const setColor = (value: number) => {
|
||||
setColors((prev) => {
|
||||
const copy = [...prev];
|
||||
copy[tab] = value;
|
||||
return copy;
|
||||
});
|
||||
|
||||
if (!currentTab.colorsDisabled) (instructions.current.eyes[currentTab.name] as { color: number }).color = value;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="absolute font-bold text-xl">Eyes</h1>
|
||||
|
||||
<div className="absolute right-3 z-10 flex justify-end">
|
||||
<div className="rounded-2xl bg-orange-200">
|
||||
{TABS.map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => setTab(i)}
|
||||
className={`px-3 py-1 rounded-2xl cursor-pointer hover:bg-orange-300/50 transition-colors duration-75 ${tab === i ? "bg-orange-300!" : "orange-200"}`}
|
||||
>
|
||||
{i + 1}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-0 flex flex-col justify-center items-center">
|
||||
<ColorPicker disabled={currentTab.colorsDisabled} color={colors[tab]} setColor={setColor} tab={tab === 5 ? "eyeliner" : "eyes"} />
|
||||
<NumberInputs key={tab} target={instructions.current.eyes[currentTab.name]} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,39 +1,39 @@
|
|||
import { useState } from "react";
|
||||
import { type SwitchMiiInstructions } from "@tomodachi-share/shared";
|
||||
import ColorPicker from "../color-picker";
|
||||
import NumberInputs from "../number-inputs";
|
||||
|
||||
interface Props {
|
||||
instructions: React.RefObject<SwitchMiiInstructions>;
|
||||
}
|
||||
|
||||
export default function GlassesTab({ instructions }: Props) {
|
||||
const [ringColor, setRingColor] = useState(instructions.current.glasses.ringColor ?? 133);
|
||||
const [shadesColor, setShadesColor] = useState(instructions.current.glasses.shadesColor ?? 133);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="absolute font-bold text-xl">Glasses</h1>
|
||||
|
||||
<div className="size-full flex flex-col justify-center items-center">
|
||||
<ColorPicker
|
||||
color={ringColor}
|
||||
setColor={(i) => {
|
||||
setRingColor(i);
|
||||
instructions.current.glasses.ringColor = i;
|
||||
}}
|
||||
tab="glasses"
|
||||
/>
|
||||
<ColorPicker
|
||||
color={shadesColor}
|
||||
setColor={(i) => {
|
||||
setShadesColor(i);
|
||||
instructions.current.glasses.shadesColor = i;
|
||||
}}
|
||||
tab="glasses"
|
||||
/>
|
||||
<NumberInputs target={instructions.current.glasses} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
import { useState } from "react";
|
||||
import { type SwitchMiiInstructions } from "@tomodachi-share/shared";
|
||||
import ColorPicker from "../color-picker";
|
||||
import NumberInputs from "../number-inputs";
|
||||
|
||||
interface Props {
|
||||
instructions: React.RefObject<SwitchMiiInstructions>;
|
||||
}
|
||||
|
||||
export default function GlassesTab({ instructions }: Props) {
|
||||
const [ringColor, setRingColor] = useState(instructions.current.glasses.ringColor ?? 133);
|
||||
const [shadesColor, setShadesColor] = useState(instructions.current.glasses.shadesColor ?? 133);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="absolute font-bold text-xl">Glasses</h1>
|
||||
|
||||
<div className="size-full flex flex-col justify-center items-center">
|
||||
<ColorPicker
|
||||
color={ringColor}
|
||||
setColor={(i) => {
|
||||
setRingColor(i);
|
||||
instructions.current.glasses.ringColor = i;
|
||||
}}
|
||||
tab="glasses"
|
||||
/>
|
||||
<ColorPicker
|
||||
color={shadesColor}
|
||||
setColor={(i) => {
|
||||
setShadesColor(i);
|
||||
instructions.current.glasses.shadesColor = i;
|
||||
}}
|
||||
tab="glasses"
|
||||
/>
|
||||
<NumberInputs target={instructions.current.glasses} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,129 +1,129 @@
|
|||
import { type SwitchMiiInstructions } from "@tomodachi-share/shared";
|
||||
import { useState } from "react";
|
||||
import ColorPicker from "../color-picker";
|
||||
|
||||
interface Props {
|
||||
instructions: React.RefObject<SwitchMiiInstructions>;
|
||||
}
|
||||
|
||||
type Tab = "sets" | "bangs" | "back";
|
||||
|
||||
export default function HairTab({ instructions }: Props) {
|
||||
const [tab, setTab] = useState<Tab>("sets");
|
||||
const [color, setColor] = useState(instructions.current.hair.color ?? 3);
|
||||
const [subColor, setSubColor] = useState<number | null>(instructions.current.hair.subColor);
|
||||
const [subColor2, setSubColor2] = useState<number | null>(instructions.current.hair.subColor2);
|
||||
const [style, setStyle] = useState<number | null>(instructions.current.hair.style);
|
||||
const [isFlipped, setIsFlipped] = useState(instructions.current.hair.isFlipped);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="absolute font-bold text-xl">Hair</h1>
|
||||
|
||||
<div className="absolute right-3 z-10 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab("sets")}
|
||||
className={`px-3 py-1 rounded-2xl bg-orange-200 mr-1 cursor-pointer hover:bg-orange-300/50 transition-colors duration-75 ${tab === "sets" ? "bg-orange-300!" : "orange-200"}`}
|
||||
>
|
||||
Sets
|
||||
</button>
|
||||
|
||||
<div className="rounded-2xl bg-orange-200 flex">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab("bangs")}
|
||||
className={`px-3 py-1 rounded-2xl cursor-pointer hover:bg-orange-300/50 transition-colors duration-75 ${tab === "bangs" ? "bg-orange-300!" : "orange-200"}`}
|
||||
>
|
||||
Bangs
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab("back")}
|
||||
className={`px-3 py-1 rounded-2xl cursor-pointer hover:bg-orange-300/50 transition-colors duration-75 ${tab === "back" ? "bg-orange-300!" : "orange-200"}`}
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-0 flex flex-col justify-center items-center">
|
||||
<ColorPicker
|
||||
color={color}
|
||||
setColor={(i) => {
|
||||
setColor(i);
|
||||
instructions.current.hair.color = i;
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex gap-1.5 items-center mb-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="subcolor"
|
||||
className="checkbox"
|
||||
checked={tab === "back" ? subColor2 !== null : subColor !== null}
|
||||
onChange={(e) => {
|
||||
if (tab === "back") {
|
||||
setSubColor2(e.target.checked ? 0 : null);
|
||||
instructions.current.hair.subColor2 = e.target.checked ? 0 : null;
|
||||
} else {
|
||||
setSubColor(e.target.checked ? 0 : null);
|
||||
instructions.current.hair.subColor = e.target.checked ? 0 : null;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<label htmlFor="subcolor" className="text-xs">
|
||||
Sub color
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<ColorPicker
|
||||
disabled={tab === "back" ? subColor2 === null : subColor === null}
|
||||
color={tab === "back" ? (subColor2 ?? 0) : (subColor ?? 0)}
|
||||
setColor={(i) => {
|
||||
if (tab === "back") {
|
||||
setSubColor2(i);
|
||||
instructions.current.hair.subColor2 = i;
|
||||
} else {
|
||||
setSubColor(i);
|
||||
instructions.current.hair.subColor = i;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<p className="text-sm mb-1">Tying style</p>
|
||||
<div className="grid grid-cols-3 gap-0.5">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<button
|
||||
type="button"
|
||||
key={i}
|
||||
onClick={() => {
|
||||
setStyle(i + 1);
|
||||
instructions.current.hair.style = i + 1;
|
||||
}}
|
||||
className={`size-full aspect-square cursor-pointer hover:bg-orange-300 transition-colors duration-100 rounded-lg ${style === i + 1 ? "bg-orange-400!" : ""}`}
|
||||
>
|
||||
{i + 1}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1.5 items-center mt-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="subcolor"
|
||||
className="checkbox"
|
||||
checked={isFlipped}
|
||||
onChange={(e) => {
|
||||
setIsFlipped(e.target.checked);
|
||||
instructions.current.hair.isFlipped = e.target.checked;
|
||||
}}
|
||||
/>
|
||||
<label htmlFor="subcolor" className="text-xs">
|
||||
Flip
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
import { type SwitchMiiInstructions } from "@tomodachi-share/shared";
|
||||
import { useState } from "react";
|
||||
import ColorPicker from "../color-picker";
|
||||
|
||||
interface Props {
|
||||
instructions: React.RefObject<SwitchMiiInstructions>;
|
||||
}
|
||||
|
||||
type Tab = "sets" | "bangs" | "back";
|
||||
|
||||
export default function HairTab({ instructions }: Props) {
|
||||
const [tab, setTab] = useState<Tab>("sets");
|
||||
const [color, setColor] = useState(instructions.current.hair.color ?? 3);
|
||||
const [subColor, setSubColor] = useState<number | null>(instructions.current.hair.subColor);
|
||||
const [subColor2, setSubColor2] = useState<number | null>(instructions.current.hair.subColor2);
|
||||
const [style, setStyle] = useState<number | null>(instructions.current.hair.style);
|
||||
const [isFlipped, setIsFlipped] = useState(instructions.current.hair.isFlipped);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="absolute font-bold text-xl">Hair</h1>
|
||||
|
||||
<div className="absolute right-3 z-10 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab("sets")}
|
||||
className={`px-3 py-1 rounded-2xl bg-orange-200 mr-1 cursor-pointer hover:bg-orange-300/50 transition-colors duration-75 ${tab === "sets" ? "bg-orange-300!" : "orange-200"}`}
|
||||
>
|
||||
Sets
|
||||
</button>
|
||||
|
||||
<div className="rounded-2xl bg-orange-200 flex">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab("bangs")}
|
||||
className={`px-3 py-1 rounded-2xl cursor-pointer hover:bg-orange-300/50 transition-colors duration-75 ${tab === "bangs" ? "bg-orange-300!" : "orange-200"}`}
|
||||
>
|
||||
Bangs
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab("back")}
|
||||
className={`px-3 py-1 rounded-2xl cursor-pointer hover:bg-orange-300/50 transition-colors duration-75 ${tab === "back" ? "bg-orange-300!" : "orange-200"}`}
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-0 flex flex-col justify-center items-center">
|
||||
<ColorPicker
|
||||
color={color}
|
||||
setColor={(i) => {
|
||||
setColor(i);
|
||||
instructions.current.hair.color = i;
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex gap-1.5 items-center mb-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="subcolor"
|
||||
className="checkbox"
|
||||
checked={tab === "back" ? subColor2 !== null : subColor !== null}
|
||||
onChange={(e) => {
|
||||
if (tab === "back") {
|
||||
setSubColor2(e.target.checked ? 0 : null);
|
||||
instructions.current.hair.subColor2 = e.target.checked ? 0 : null;
|
||||
} else {
|
||||
setSubColor(e.target.checked ? 0 : null);
|
||||
instructions.current.hair.subColor = e.target.checked ? 0 : null;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<label htmlFor="subcolor" className="text-xs">
|
||||
Sub color
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<ColorPicker
|
||||
disabled={tab === "back" ? subColor2 === null : subColor === null}
|
||||
color={tab === "back" ? (subColor2 ?? 0) : (subColor ?? 0)}
|
||||
setColor={(i) => {
|
||||
if (tab === "back") {
|
||||
setSubColor2(i);
|
||||
instructions.current.hair.subColor2 = i;
|
||||
} else {
|
||||
setSubColor(i);
|
||||
instructions.current.hair.subColor = i;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<p className="text-sm mb-1">Tying style</p>
|
||||
<div className="grid grid-cols-3 gap-0.5">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<button
|
||||
type="button"
|
||||
key={i}
|
||||
onClick={() => {
|
||||
setStyle(i + 1);
|
||||
instructions.current.hair.style = i + 1;
|
||||
}}
|
||||
className={`size-full aspect-square cursor-pointer hover:bg-orange-300 transition-colors duration-100 rounded-lg ${style === i + 1 ? "bg-orange-400!" : ""}`}
|
||||
>
|
||||
{i + 1}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1.5 items-center mt-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="subcolor"
|
||||
className="checkbox"
|
||||
checked={isFlipped}
|
||||
onChange={(e) => {
|
||||
setIsFlipped(e.target.checked);
|
||||
instructions.current.hair.isFlipped = e.target.checked;
|
||||
}}
|
||||
/>
|
||||
<label htmlFor="subcolor" className="text-xs">
|
||||
Flip
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,44 +1,44 @@
|
|||
import { useState } from "react";
|
||||
import ColorPicker from "../color-picker";
|
||||
import { type SwitchMiiInstructions } from "@tomodachi-share/shared";
|
||||
|
||||
interface Props {
|
||||
instructions: React.RefObject<SwitchMiiInstructions>;
|
||||
}
|
||||
|
||||
const COLORS = ["FFD8BA", "FFD5AC", "FEC1A4", "FEC68F", "FEB089", "FEBA6B", "F39866", "E89854", "E37E3F", "B45627", "914220", "59371F", "662D16", "392D1E"];
|
||||
|
||||
export default function HeadTab({ instructions }: Props) {
|
||||
const [color, setColor] = useState(instructions.current.head.skinColor ?? 109);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="absolute font-bold text-xl">Head</h1>
|
||||
|
||||
<div className="size-full flex flex-col justify-center items-center">
|
||||
<ColorPicker
|
||||
color={color}
|
||||
setColor={(i) => {
|
||||
setColor(i);
|
||||
instructions.current.head.skinColor = i;
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{COLORS.map((hex, i) => (
|
||||
<button
|
||||
type="button"
|
||||
key={i + 108}
|
||||
onClick={() => {
|
||||
setColor(i + 108);
|
||||
instructions.current.head.skinColor = i + 108;
|
||||
}}
|
||||
className={`size-9 rounded-lg cursor-pointer ring-offset-2 ring-orange-500 ${color === i + 108 ? "ring-2" : ""}`}
|
||||
style={{ backgroundColor: `#${hex}` }}
|
||||
></button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
import { useState } from "react";
|
||||
import ColorPicker from "../color-picker";
|
||||
import { type SwitchMiiInstructions } from "@tomodachi-share/shared";
|
||||
|
||||
interface Props {
|
||||
instructions: React.RefObject<SwitchMiiInstructions>;
|
||||
}
|
||||
|
||||
const COLORS = ["FFD8BA", "FFD5AC", "FEC1A4", "FEC68F", "FEB089", "FEBA6B", "F39866", "E89854", "E37E3F", "B45627", "914220", "59371F", "662D16", "392D1E"];
|
||||
|
||||
export default function HeadTab({ instructions }: Props) {
|
||||
const [color, setColor] = useState(instructions.current.head.skinColor ?? 109);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="absolute font-bold text-xl">Head</h1>
|
||||
|
||||
<div className="size-full flex flex-col justify-center items-center">
|
||||
<ColorPicker
|
||||
color={color}
|
||||
setColor={(i) => {
|
||||
setColor(i);
|
||||
instructions.current.head.skinColor = i;
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{COLORS.map((hex, i) => (
|
||||
<button
|
||||
type="button"
|
||||
key={i + 108}
|
||||
onClick={() => {
|
||||
setColor(i + 108);
|
||||
instructions.current.head.skinColor = i + 108;
|
||||
}}
|
||||
className={`size-9 rounded-lg cursor-pointer ring-offset-2 ring-orange-500 ${color === i + 108 ? "ring-2" : ""}`}
|
||||
style={{ backgroundColor: `#${hex}` }}
|
||||
></button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,47 +1,47 @@
|
|||
import { type SwitchMiiInstructions } from "@tomodachi-share/shared";
|
||||
import ColorPicker from "../color-picker";
|
||||
import NumberInputs from "../number-inputs";
|
||||
import { useState } from "react";
|
||||
|
||||
interface Props {
|
||||
instructions: React.RefObject<SwitchMiiInstructions>;
|
||||
}
|
||||
|
||||
export default function LipsTab({ instructions }: Props) {
|
||||
const [color, setColor] = useState(instructions.current.lips.color ?? 128);
|
||||
const [hasLipstick, setHasLipstick] = useState(instructions.current.lips.hasLipstick);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="absolute font-bold text-xl">Lips</h1>
|
||||
|
||||
<div className="size-full flex flex-col justify-center items-center">
|
||||
<ColorPicker
|
||||
color={color}
|
||||
setColor={(i) => {
|
||||
setColor(i);
|
||||
instructions.current.lips.color = i;
|
||||
}}
|
||||
tab="lips"
|
||||
/>
|
||||
<NumberInputs target={instructions.current.lips} />
|
||||
|
||||
<div className="flex gap-1.5 items-center mt-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="subcolor"
|
||||
className="checkbox"
|
||||
checked={hasLipstick}
|
||||
onChange={(e) => {
|
||||
setHasLipstick(e.target.checked);
|
||||
instructions.current.lips.hasLipstick = e.target.checked;
|
||||
}}
|
||||
/>
|
||||
<label htmlFor="subcolor" className="text-xs">
|
||||
Lipstick
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
import { type SwitchMiiInstructions } from "@tomodachi-share/shared";
|
||||
import ColorPicker from "../color-picker";
|
||||
import NumberInputs from "../number-inputs";
|
||||
import { useState } from "react";
|
||||
|
||||
interface Props {
|
||||
instructions: React.RefObject<SwitchMiiInstructions>;
|
||||
}
|
||||
|
||||
export default function LipsTab({ instructions }: Props) {
|
||||
const [color, setColor] = useState(instructions.current.lips.color ?? 128);
|
||||
const [hasLipstick, setHasLipstick] = useState(instructions.current.lips.hasLipstick);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="absolute font-bold text-xl">Lips</h1>
|
||||
|
||||
<div className="size-full flex flex-col justify-center items-center">
|
||||
<ColorPicker
|
||||
color={color}
|
||||
setColor={(i) => {
|
||||
setColor(i);
|
||||
instructions.current.lips.color = i;
|
||||
}}
|
||||
tab="lips"
|
||||
/>
|
||||
<NumberInputs target={instructions.current.lips} />
|
||||
|
||||
<div className="flex gap-1.5 items-center mt-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="subcolor"
|
||||
className="checkbox"
|
||||
checked={hasLipstick}
|
||||
onChange={(e) => {
|
||||
setHasLipstick(e.target.checked);
|
||||
instructions.current.lips.hasLipstick = e.target.checked;
|
||||
}}
|
||||
/>
|
||||
<label htmlFor="subcolor" className="text-xs">
|
||||
Lipstick
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,213 +1,213 @@
|
|||
import { useState } from "react";
|
||||
import type { MiiGender, SwitchMiiInstructions } from "@tomodachi-share/shared";
|
||||
import EnhancedSlider from "../enhanced-slider";
|
||||
import DatingPreferencesViewer from "../../../mii/dating-preferences";
|
||||
import VoiceViewer from "../../../mii/voice-viewer";
|
||||
import PersonalityViewer from "../../../mii/personality-viewer";
|
||||
|
||||
interface Props {
|
||||
instructions: React.RefObject<SwitchMiiInstructions>;
|
||||
}
|
||||
|
||||
export default function MiscTab({ instructions }: Props) {
|
||||
const [height, setHeight] = useState(instructions.current.height ?? 64);
|
||||
const [weight, setWeight] = useState(instructions.current.weight ?? 64);
|
||||
const [datingPreferences, setDatingPreferences] = useState<MiiGender[]>(instructions.current.datingPreferences ?? []);
|
||||
const [voice, setVoice] = useState({
|
||||
speed: instructions.current.voice.speed ?? 25,
|
||||
pitch: instructions.current.voice.pitch ?? 25,
|
||||
depth: instructions.current.voice.depth ?? 25,
|
||||
delivery: instructions.current.voice.delivery ?? 25,
|
||||
tone: instructions.current.voice.tone ?? 0,
|
||||
});
|
||||
const [birthday, setBirthday] = useState({
|
||||
day: instructions.current.birthday.day ?? (null as number | null),
|
||||
month: instructions.current.birthday.month ?? (null as number | null),
|
||||
age: instructions.current.birthday.age ?? (null as number | null),
|
||||
dontAge: instructions.current.birthday.dontAge,
|
||||
});
|
||||
const [personality, setPersonality] = useState({
|
||||
movement: instructions.current.personality.movement ?? -1,
|
||||
speech: instructions.current.personality.speech ?? -1,
|
||||
energy: instructions.current.personality.energy ?? -1,
|
||||
thinking: instructions.current.personality.thinking ?? -1,
|
||||
overall: instructions.current.personality.overall ?? -1,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="font-bold text-xl">Misc</h1>
|
||||
|
||||
<div className="grow h-full overflow-y-auto pb-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium">
|
||||
<hr className="grow border-zinc-300" />
|
||||
<span>Body</span>
|
||||
<hr className="grow border-zinc-300" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<EnhancedSlider
|
||||
label="Height"
|
||||
value={height}
|
||||
onChange={(v) => {
|
||||
setHeight(v);
|
||||
instructions.current.height = v;
|
||||
}}
|
||||
min={0}
|
||||
max={128}
|
||||
mid={64}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<EnhancedSlider
|
||||
label="Weight"
|
||||
value={weight}
|
||||
onChange={(v) => {
|
||||
setWeight(v);
|
||||
instructions.current.weight = v;
|
||||
}}
|
||||
min={0}
|
||||
max={128}
|
||||
mid={64}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-1.5 mb-2">
|
||||
<hr className="grow border-zinc-300" />
|
||||
<span>Dating Preferences</span>
|
||||
<hr className="grow border-zinc-300" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<DatingPreferencesViewer
|
||||
data={datingPreferences}
|
||||
onChecked={(e, gender) => {
|
||||
setDatingPreferences((prev) => {
|
||||
const updated = e.target.checked ? (prev.includes(gender) ? prev : [...prev, gender]) : prev.filter((p) => p !== gender);
|
||||
instructions.current.datingPreferences = updated;
|
||||
return updated;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium">
|
||||
<hr className="grow border-zinc-300" />
|
||||
<span>Voice</span>
|
||||
<hr className="grow border-zinc-300" />
|
||||
</div>
|
||||
|
||||
<VoiceViewer
|
||||
data={voice}
|
||||
onChange={(v, label) => {
|
||||
setVoice((p) => ({ ...p, [label]: v }));
|
||||
instructions.current.voice[label as keyof typeof voice] = v;
|
||||
}}
|
||||
onClickTone={(i) => {
|
||||
setVoice((p) => ({ ...p, tone: i }));
|
||||
instructions.current.voice.tone = i;
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-1.5 mb-2">
|
||||
<hr className="grow border-zinc-300" />
|
||||
<span>Birthday</span>
|
||||
<hr className="grow border-zinc-300" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div>
|
||||
<label htmlFor="day" className="text-xs">
|
||||
Day
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="day"
|
||||
min={1}
|
||||
max={31}
|
||||
className="pill input text-sm py-1! px-3! w-full"
|
||||
value={birthday.day ?? undefined}
|
||||
onChange={(e) => {
|
||||
setBirthday((p) => ({ ...p, day: e.target.valueAsNumber }));
|
||||
instructions.current.birthday.day = e.target.valueAsNumber;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="month" className="text-xs">
|
||||
Month
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="month"
|
||||
min={1}
|
||||
max={12}
|
||||
className="pill input text-sm py-1! px-3! w-full"
|
||||
value={birthday.month ?? undefined}
|
||||
onChange={(e) => {
|
||||
setBirthday((p) => ({ ...p, month: e.target.valueAsNumber }));
|
||||
instructions.current.birthday.month = e.target.valueAsNumber;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="age" className="text-xs">
|
||||
Age
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="age"
|
||||
min={1}
|
||||
max={1000}
|
||||
className="pill input text-sm py-1! px-3! w-full"
|
||||
value={birthday.age ?? undefined}
|
||||
onChange={(e) => {
|
||||
setBirthday((p) => ({ ...p, age: e.target.valueAsNumber }));
|
||||
instructions.current.birthday.age = e.target.valueAsNumber;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-1.5 col-span-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="dontAge"
|
||||
className="checkbox"
|
||||
checked={birthday.dontAge}
|
||||
onChange={(e) => {
|
||||
setBirthday((p) => ({ ...p, dontAge: e.target.checked }));
|
||||
instructions.current.birthday.dontAge = e.target.checked;
|
||||
}}
|
||||
/>
|
||||
<label htmlFor="dontAge" className="text-sm select-none">
|
||||
Don't Age
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-2 mb-2">
|
||||
<hr className="grow border-zinc-300" />
|
||||
<span>Personality</span>
|
||||
<hr className="grow border-zinc-300" />
|
||||
</div>
|
||||
|
||||
<PersonalityViewer
|
||||
data={personality}
|
||||
onClick={(key, i) => {
|
||||
setPersonality((p) => {
|
||||
const updated = { ...p, [key]: i };
|
||||
instructions.current.personality = updated;
|
||||
return updated;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
import { useState } from "react";
|
||||
import type { MiiGender, SwitchMiiInstructions } from "@tomodachi-share/shared";
|
||||
import EnhancedSlider from "../enhanced-slider";
|
||||
import DatingPreferencesViewer from "../../../mii/dating-preferences";
|
||||
import VoiceViewer from "../../../mii/voice-viewer";
|
||||
import PersonalityViewer from "../../../mii/personality-viewer";
|
||||
|
||||
interface Props {
|
||||
instructions: React.RefObject<SwitchMiiInstructions>;
|
||||
}
|
||||
|
||||
export default function MiscTab({ instructions }: Props) {
|
||||
const [height, setHeight] = useState(instructions.current.height ?? 64);
|
||||
const [weight, setWeight] = useState(instructions.current.weight ?? 64);
|
||||
const [datingPreferences, setDatingPreferences] = useState<MiiGender[]>(instructions.current.datingPreferences ?? []);
|
||||
const [voice, setVoice] = useState({
|
||||
speed: instructions.current.voice.speed ?? 25,
|
||||
pitch: instructions.current.voice.pitch ?? 25,
|
||||
depth: instructions.current.voice.depth ?? 25,
|
||||
delivery: instructions.current.voice.delivery ?? 25,
|
||||
tone: instructions.current.voice.tone ?? 0,
|
||||
});
|
||||
const [birthday, setBirthday] = useState({
|
||||
day: instructions.current.birthday.day ?? (null as number | null),
|
||||
month: instructions.current.birthday.month ?? (null as number | null),
|
||||
age: instructions.current.birthday.age ?? (null as number | null),
|
||||
dontAge: instructions.current.birthday.dontAge,
|
||||
});
|
||||
const [personality, setPersonality] = useState({
|
||||
movement: instructions.current.personality.movement ?? -1,
|
||||
speech: instructions.current.personality.speech ?? -1,
|
||||
energy: instructions.current.personality.energy ?? -1,
|
||||
thinking: instructions.current.personality.thinking ?? -1,
|
||||
overall: instructions.current.personality.overall ?? -1,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="font-bold text-xl">Misc</h1>
|
||||
|
||||
<div className="grow h-full overflow-y-auto pb-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium">
|
||||
<hr className="grow border-zinc-300" />
|
||||
<span>Body</span>
|
||||
<hr className="grow border-zinc-300" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<EnhancedSlider
|
||||
label="Height"
|
||||
value={height}
|
||||
onChange={(v) => {
|
||||
setHeight(v);
|
||||
instructions.current.height = v;
|
||||
}}
|
||||
min={0}
|
||||
max={128}
|
||||
mid={64}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<EnhancedSlider
|
||||
label="Weight"
|
||||
value={weight}
|
||||
onChange={(v) => {
|
||||
setWeight(v);
|
||||
instructions.current.weight = v;
|
||||
}}
|
||||
min={0}
|
||||
max={128}
|
||||
mid={64}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-1.5 mb-2">
|
||||
<hr className="grow border-zinc-300" />
|
||||
<span>Dating Preferences</span>
|
||||
<hr className="grow border-zinc-300" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<DatingPreferencesViewer
|
||||
data={datingPreferences}
|
||||
onChecked={(e, gender) => {
|
||||
setDatingPreferences((prev) => {
|
||||
const updated = e.target.checked ? (prev.includes(gender) ? prev : [...prev, gender]) : prev.filter((p) => p !== gender);
|
||||
instructions.current.datingPreferences = updated;
|
||||
return updated;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium">
|
||||
<hr className="grow border-zinc-300" />
|
||||
<span>Voice</span>
|
||||
<hr className="grow border-zinc-300" />
|
||||
</div>
|
||||
|
||||
<VoiceViewer
|
||||
data={voice}
|
||||
onChange={(v, label) => {
|
||||
setVoice((p) => ({ ...p, [label]: v }));
|
||||
instructions.current.voice[label as keyof typeof voice] = v;
|
||||
}}
|
||||
onClickTone={(i) => {
|
||||
setVoice((p) => ({ ...p, tone: i }));
|
||||
instructions.current.voice.tone = i;
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-1.5 mb-2">
|
||||
<hr className="grow border-zinc-300" />
|
||||
<span>Birthday</span>
|
||||
<hr className="grow border-zinc-300" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div>
|
||||
<label htmlFor="day" className="text-xs">
|
||||
Day
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="day"
|
||||
min={1}
|
||||
max={31}
|
||||
className="pill input text-sm py-1! px-3! w-full"
|
||||
value={birthday.day ?? undefined}
|
||||
onChange={(e) => {
|
||||
setBirthday((p) => ({ ...p, day: e.target.valueAsNumber }));
|
||||
instructions.current.birthday.day = e.target.valueAsNumber;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="month" className="text-xs">
|
||||
Month
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="month"
|
||||
min={1}
|
||||
max={12}
|
||||
className="pill input text-sm py-1! px-3! w-full"
|
||||
value={birthday.month ?? undefined}
|
||||
onChange={(e) => {
|
||||
setBirthday((p) => ({ ...p, month: e.target.valueAsNumber }));
|
||||
instructions.current.birthday.month = e.target.valueAsNumber;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="age" className="text-xs">
|
||||
Age
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="age"
|
||||
min={1}
|
||||
max={1000}
|
||||
className="pill input text-sm py-1! px-3! w-full"
|
||||
value={birthday.age ?? undefined}
|
||||
onChange={(e) => {
|
||||
setBirthday((p) => ({ ...p, age: e.target.valueAsNumber }));
|
||||
instructions.current.birthday.age = e.target.valueAsNumber;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-1.5 col-span-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="dontAge"
|
||||
className="checkbox"
|
||||
checked={birthday.dontAge}
|
||||
onChange={(e) => {
|
||||
setBirthday((p) => ({ ...p, dontAge: e.target.checked }));
|
||||
instructions.current.birthday.dontAge = e.target.checked;
|
||||
}}
|
||||
/>
|
||||
<label htmlFor="dontAge" className="text-sm select-none">
|
||||
Don't Age
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-2 mb-2">
|
||||
<hr className="grow border-zinc-300" />
|
||||
<span>Personality</span>
|
||||
<hr className="grow border-zinc-300" />
|
||||
</div>
|
||||
|
||||
<PersonalityViewer
|
||||
data={personality}
|
||||
onClick={(key, i) => {
|
||||
setPersonality((p) => {
|
||||
const updated = { ...p, [key]: i };
|
||||
instructions.current.personality = updated;
|
||||
return updated;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,18 @@
|
|||
import { type SwitchMiiInstructions } from "@tomodachi-share/shared";
|
||||
import NumberInputs from "../number-inputs";
|
||||
|
||||
interface Props {
|
||||
instructions: React.RefObject<SwitchMiiInstructions>;
|
||||
}
|
||||
|
||||
export default function NoseTab({ instructions }: Props) {
|
||||
return (
|
||||
<>
|
||||
<h1 className="absolute font-bold text-xl">Nose</h1>
|
||||
|
||||
<div className="size-full flex flex-col justify-center items-center">
|
||||
<NumberInputs target={instructions.current.nose} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
import { type SwitchMiiInstructions } from "@tomodachi-share/shared";
|
||||
import NumberInputs from "../number-inputs";
|
||||
|
||||
interface Props {
|
||||
instructions: React.RefObject<SwitchMiiInstructions>;
|
||||
}
|
||||
|
||||
export default function NoseTab({ instructions }: Props) {
|
||||
return (
|
||||
<>
|
||||
<h1 className="absolute font-bold text-xl">Nose</h1>
|
||||
|
||||
<div className="size-full flex flex-col justify-center items-center">
|
||||
<NumberInputs target={instructions.current.nose} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,91 +1,91 @@
|
|||
import { type SwitchMiiInstructions } from "@tomodachi-share/shared";
|
||||
import { useState } from "react";
|
||||
import ColorPicker from "../color-picker";
|
||||
import NumberInputs from "../number-inputs";
|
||||
|
||||
interface Props {
|
||||
instructions: React.RefObject<SwitchMiiInstructions>;
|
||||
}
|
||||
|
||||
const TABS: { name: keyof SwitchMiiInstructions["other"]; defaultColor?: number }[] = [
|
||||
{ name: "wrinkles1" },
|
||||
{ name: "wrinkles2" },
|
||||
{ name: "beard" },
|
||||
{ name: "moustache" },
|
||||
{ name: "goatee" },
|
||||
{ name: "mole" },
|
||||
{ name: "eyeShadow", defaultColor: 139 },
|
||||
{ name: "blush" },
|
||||
];
|
||||
|
||||
export default function OtherTab({ instructions }: Props) {
|
||||
const [tab, setTab] = useState(0);
|
||||
const [isFlipped, setIsFlipped] = useState(false);
|
||||
|
||||
const [colors, setColors] = useState<number[]>(() =>
|
||||
TABS.map((t) => {
|
||||
const entry = instructions.current.other[t.name] ?? {};
|
||||
const color = entry && "color" in entry ? entry.color : null;
|
||||
return color ?? t.defaultColor ?? 0;
|
||||
}),
|
||||
);
|
||||
|
||||
const currentTab = TABS[tab];
|
||||
|
||||
const setColor = (value: number) => {
|
||||
setColors((prev) => {
|
||||
const copy = [...prev];
|
||||
copy[tab] = value;
|
||||
return copy;
|
||||
});
|
||||
|
||||
const target = instructions.current.other[currentTab.name];
|
||||
if ("color" in target) {
|
||||
target.color = value;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="absolute font-bold text-xl">Other</h1>
|
||||
|
||||
<div className="absolute right-3 z-10 flex justify-end">
|
||||
<div className="rounded-2xl bg-orange-200">
|
||||
{TABS.map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => setTab(i)}
|
||||
className={`px-3 py-1 rounded-2xl cursor-pointer hover:bg-orange-300/50 transition-colors duration-75 ${tab === i ? "bg-orange-300!" : "orange-200"}`}
|
||||
>
|
||||
{i + 1}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-0 flex flex-col justify-center items-center">
|
||||
<ColorPicker disabled={tab === 0 || tab === 1} color={colors[tab]} setColor={setColor} tab={tab === 6 ? "eyeliner" : "hair"} />
|
||||
<NumberInputs key={tab} target={instructions.current.other[currentTab.name]} />
|
||||
|
||||
{tab === 3 && (
|
||||
<div className="flex gap-1.5 items-center mt-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="subcolor"
|
||||
className="checkbox"
|
||||
checked={isFlipped}
|
||||
onChange={(e) => {
|
||||
setIsFlipped(e.target.checked);
|
||||
instructions.current.other.moustache.isFlipped = e.target.checked;
|
||||
}}
|
||||
/>
|
||||
<label htmlFor="subcolor" className="text-xs">
|
||||
Flip
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
import { type SwitchMiiInstructions } from "@tomodachi-share/shared";
|
||||
import { useState } from "react";
|
||||
import ColorPicker from "../color-picker";
|
||||
import NumberInputs from "../number-inputs";
|
||||
|
||||
interface Props {
|
||||
instructions: React.RefObject<SwitchMiiInstructions>;
|
||||
}
|
||||
|
||||
const TABS: { name: keyof SwitchMiiInstructions["other"]; defaultColor?: number }[] = [
|
||||
{ name: "wrinkles1" },
|
||||
{ name: "wrinkles2" },
|
||||
{ name: "beard" },
|
||||
{ name: "moustache" },
|
||||
{ name: "goatee" },
|
||||
{ name: "mole" },
|
||||
{ name: "eyeShadow", defaultColor: 139 },
|
||||
{ name: "blush" },
|
||||
];
|
||||
|
||||
export default function OtherTab({ instructions }: Props) {
|
||||
const [tab, setTab] = useState(0);
|
||||
const [isFlipped, setIsFlipped] = useState(false);
|
||||
|
||||
const [colors, setColors] = useState<number[]>(() =>
|
||||
TABS.map((t) => {
|
||||
const entry = instructions.current.other[t.name] ?? {};
|
||||
const color = entry && "color" in entry ? entry.color : null;
|
||||
return color ?? t.defaultColor ?? 0;
|
||||
}),
|
||||
);
|
||||
|
||||
const currentTab = TABS[tab];
|
||||
|
||||
const setColor = (value: number) => {
|
||||
setColors((prev) => {
|
||||
const copy = [...prev];
|
||||
copy[tab] = value;
|
||||
return copy;
|
||||
});
|
||||
|
||||
const target = instructions.current.other[currentTab.name];
|
||||
if ("color" in target) {
|
||||
target.color = value;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="absolute font-bold text-xl">Other</h1>
|
||||
|
||||
<div className="absolute right-3 z-10 flex justify-end">
|
||||
<div className="rounded-2xl bg-orange-200">
|
||||
{TABS.map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => setTab(i)}
|
||||
className={`px-3 py-1 rounded-2xl cursor-pointer hover:bg-orange-300/50 transition-colors duration-75 ${tab === i ? "bg-orange-300!" : "orange-200"}`}
|
||||
>
|
||||
{i + 1}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-0 flex flex-col justify-center items-center">
|
||||
<ColorPicker disabled={tab === 0 || tab === 1} color={colors[tab]} setColor={setColor} tab={tab === 6 ? "eyeliner" : "hair"} />
|
||||
<NumberInputs key={tab} target={instructions.current.other[currentTab.name]} />
|
||||
|
||||
{tab === 3 && (
|
||||
<div className="flex gap-1.5 items-center mt-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="subcolor"
|
||||
className="checkbox"
|
||||
checked={isFlipped}
|
||||
onChange={(e) => {
|
||||
setIsFlipped(e.target.checked);
|
||||
instructions.current.other.moustache.isFlipped = e.target.checked;
|
||||
}}
|
||||
/>
|
||||
<label htmlFor="subcolor" className="text-xs">
|
||||
Flip
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,20 @@
|
|||
export default function QrFinder() {
|
||||
return (
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none size-72 z-10">
|
||||
{/* Top-left corner */}
|
||||
<div className="absolute top-0 left-0 size-6 border-t-3 border-l-3 border-amber-500 rounded-tl-lg" />
|
||||
|
||||
{/* Top-right corner */}
|
||||
<div className="absolute top-0 right-0 size-6 border-t-3 border-r-3 border-amber-500 rounded-tr-lg" />
|
||||
|
||||
{/* Bottom-left corner */}
|
||||
<div className="absolute bottom-0 left-0 size-6 border-b-3 border-l-3 border-amber-500 rounded-bl-lg" />
|
||||
|
||||
{/* Bottom-right corner */}
|
||||
<div className="absolute bottom-0 right-0 size-6 border-b-3 border-r-3 border-amber-500 rounded-br-lg" />
|
||||
|
||||
{/* Center point */}
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 size-5 bg-amber-500/70 rounded-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export default function QrFinder() {
|
||||
return (
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none size-72 z-10">
|
||||
{/* Top-left corner */}
|
||||
<div className="absolute top-0 left-0 size-6 border-t-3 border-l-3 border-amber-500 rounded-tl-lg" />
|
||||
|
||||
{/* Top-right corner */}
|
||||
<div className="absolute top-0 right-0 size-6 border-t-3 border-r-3 border-amber-500 rounded-tr-lg" />
|
||||
|
||||
{/* Bottom-left corner */}
|
||||
<div className="absolute bottom-0 left-0 size-6 border-b-3 border-l-3 border-amber-500 rounded-bl-lg" />
|
||||
|
||||
{/* Bottom-right corner */}
|
||||
<div className="absolute bottom-0 right-0 size-6 border-b-3 border-r-3 border-amber-500 rounded-br-lg" />
|
||||
|
||||
{/* Center point */}
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 size-5 bg-amber-500/70 rounded-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,67 +1,67 @@
|
|||
import { useCallback, useRef, useState } from "react";
|
||||
import { type FileWithPath } from "react-dropzone";
|
||||
import jsQR from "jsqr";
|
||||
import Dropzone from "../dropzone";
|
||||
|
||||
interface Props {
|
||||
setQrBytesRaw: React.Dispatch<React.SetStateAction<number[]>>;
|
||||
}
|
||||
|
||||
export default function QrUpload({ setQrBytesRaw }: Props) {
|
||||
const [hasImage, setHasImage] = useState(false);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(acceptedFiles: FileWithPath[]) => {
|
||||
const file = acceptedFiles[0];
|
||||
|
||||
// Scan QR code
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (event) => {
|
||||
const image = new Image();
|
||||
image.onload = () => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
canvas.width = image.width;
|
||||
canvas.height = image.height;
|
||||
ctx.drawImage(image, 0, 0, image.width, image.height);
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, image.width, image.height);
|
||||
const code = jsQR(imageData.data, image.width, image.height);
|
||||
if (!code) return;
|
||||
|
||||
setQrBytesRaw(code.binaryData!);
|
||||
setHasImage(true);
|
||||
};
|
||||
image.src = event.target!.result as string;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
},
|
||||
[setQrBytesRaw],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="max-w-md w-full">
|
||||
<Dropzone onDrop={handleDrop} options={{ maxFiles: 1 }}>
|
||||
<p className="text-center text-sm">
|
||||
{!hasImage ? (
|
||||
<>
|
||||
Drag and drop your QR code image here
|
||||
<br />
|
||||
or click to open
|
||||
</>
|
||||
) : (
|
||||
"Uploaded!"
|
||||
)}
|
||||
</p>
|
||||
</Dropzone>
|
||||
|
||||
{/* Canvas is used to scan the QR code */}
|
||||
<canvas ref={canvasRef} className="hidden" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { type FileWithPath } from "react-dropzone";
|
||||
import jsQR from "jsqr";
|
||||
import Dropzone from "../dropzone";
|
||||
|
||||
interface Props {
|
||||
setQrBytesRaw: React.Dispatch<React.SetStateAction<number[]>>;
|
||||
}
|
||||
|
||||
export default function QrUpload({ setQrBytesRaw }: Props) {
|
||||
const [hasImage, setHasImage] = useState(false);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(acceptedFiles: FileWithPath[]) => {
|
||||
const file = acceptedFiles[0];
|
||||
|
||||
// Scan QR code
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (event) => {
|
||||
const image = new Image();
|
||||
image.onload = () => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
canvas.width = image.width;
|
||||
canvas.height = image.height;
|
||||
ctx.drawImage(image, 0, 0, image.width, image.height);
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, image.width, image.height);
|
||||
const code = jsQR(imageData.data, image.width, image.height);
|
||||
if (!code) return;
|
||||
|
||||
setQrBytesRaw(code.binaryData!);
|
||||
setHasImage(true);
|
||||
};
|
||||
image.src = event.target!.result as string;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
},
|
||||
[setQrBytesRaw],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="max-w-md w-full">
|
||||
<Dropzone onDrop={handleDrop} options={{ maxFiles: 1 }}>
|
||||
<p className="text-center text-sm">
|
||||
{!hasImage ? (
|
||||
<>
|
||||
Drag and drop your QR code image here
|
||||
<br />
|
||||
or click to open
|
||||
</>
|
||||
) : (
|
||||
"Uploaded!"
|
||||
)}
|
||||
</p>
|
||||
</Dropzone>
|
||||
|
||||
{/* Canvas is used to scan the QR code */}
|
||||
<canvas ref={canvasRef} className="hidden" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,73 +1,73 @@
|
|||
import { useCallback, useState } from "react";
|
||||
import { type FileWithPath } from "react-dropzone";
|
||||
import { Icon } from "@iconify/react";
|
||||
import Dropzone from "../dropzone";
|
||||
import Camera from "./camera";
|
||||
import ImageEditorPortrait from "./image-editor";
|
||||
|
||||
interface Props {
|
||||
text: string;
|
||||
forceCrop?: boolean;
|
||||
image?: string | undefined;
|
||||
setImage: (value: string | undefined) => void;
|
||||
}
|
||||
|
||||
export default function SwitchFileUpload({ text, forceCrop, image, setImage }: Props) {
|
||||
const [isCameraOpen, setIsCameraOpen] = useState(false);
|
||||
const [isCropOpen, setIsCropOpen] = useState(false);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(acceptedFiles: FileWithPath[]) => {
|
||||
const file = acceptedFiles[0];
|
||||
// Convert to Data URI
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (event) => {
|
||||
setImage(event.target!.result as string);
|
||||
if (forceCrop) setIsCropOpen(true);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
},
|
||||
[setImage],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="max-w-md w-full flex flex-col items-center gap-2">
|
||||
<Dropzone onDrop={handleDrop} options={{ maxFiles: 1 }}>
|
||||
<p className="text-center text-sm">
|
||||
{!image ? (
|
||||
<>
|
||||
Drag and drop {text}
|
||||
<br />
|
||||
or click to open
|
||||
</>
|
||||
) : (
|
||||
"Uploaded!"
|
||||
)}
|
||||
</p>
|
||||
</Dropzone>
|
||||
|
||||
<span>or</span>
|
||||
|
||||
<div className="flex gap-2 max-sm:flex-col">
|
||||
<button type="button" aria-label="Use your camera" onClick={() => setIsCameraOpen(true)} className="pill button gap-2">
|
||||
<Icon icon="mdi:camera" fontSize={20} />
|
||||
Use your camera
|
||||
</button>
|
||||
<button type="button" aria-label="Crop image" onClick={() => setIsCropOpen(true)} className="pill button gap-2">
|
||||
<Icon icon="mdi:image-edit" fontSize={20} />
|
||||
Edit Image
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Camera
|
||||
isOpen={isCameraOpen}
|
||||
setIsOpen={setIsCameraOpen}
|
||||
setImage={setImage}
|
||||
onCapture={() => {
|
||||
if (forceCrop) setIsCropOpen(true);
|
||||
}}
|
||||
/>
|
||||
<ImageEditorPortrait isOpen={isCropOpen} setIsOpen={setIsCropOpen} image={image} setImage={setImage} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { useCallback, useState } from "react";
|
||||
import { type FileWithPath } from "react-dropzone";
|
||||
import { Icon } from "@iconify/react";
|
||||
import Dropzone from "../dropzone";
|
||||
import Camera from "./camera";
|
||||
import ImageEditorPortrait from "./image-editor";
|
||||
|
||||
interface Props {
|
||||
text: string;
|
||||
forceCrop?: boolean;
|
||||
image?: string | undefined;
|
||||
setImage: (value: string | undefined) => void;
|
||||
}
|
||||
|
||||
export default function SwitchFileUpload({ text, forceCrop, image, setImage }: Props) {
|
||||
const [isCameraOpen, setIsCameraOpen] = useState(false);
|
||||
const [isCropOpen, setIsCropOpen] = useState(false);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(acceptedFiles: FileWithPath[]) => {
|
||||
const file = acceptedFiles[0];
|
||||
// Convert to Data URI
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (event) => {
|
||||
setImage(event.target!.result as string);
|
||||
if (forceCrop) setIsCropOpen(true);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
},
|
||||
[setImage],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="max-w-md w-full flex flex-col items-center gap-2">
|
||||
<Dropzone onDrop={handleDrop} options={{ maxFiles: 1 }}>
|
||||
<p className="text-center text-sm">
|
||||
{!image ? (
|
||||
<>
|
||||
Drag and drop {text}
|
||||
<br />
|
||||
or click to open
|
||||
</>
|
||||
) : (
|
||||
"Uploaded!"
|
||||
)}
|
||||
</p>
|
||||
</Dropzone>
|
||||
|
||||
<span>or</span>
|
||||
|
||||
<div className="flex gap-2 max-sm:flex-col">
|
||||
<button type="button" aria-label="Use your camera" onClick={() => setIsCameraOpen(true)} className="pill button gap-2">
|
||||
<Icon icon="mdi:camera" fontSize={20} />
|
||||
Use your camera
|
||||
</button>
|
||||
<button type="button" aria-label="Crop image" onClick={() => setIsCropOpen(true)} className="pill button gap-2">
|
||||
<Icon icon="mdi:image-edit" fontSize={20} />
|
||||
Edit Image
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Camera
|
||||
isOpen={isCameraOpen}
|
||||
setIsOpen={setIsCameraOpen}
|
||||
setImage={setImage}
|
||||
onCapture={() => {
|
||||
if (forceCrop) setIsCropOpen(true);
|
||||
}}
|
||||
/>
|
||||
<ImageEditorPortrait isOpen={isCropOpen} setIsOpen={setIsCropOpen} image={image} setImage={setImage} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,186 +1,186 @@
|
|||
import React, { useState, useRef } from "react";
|
||||
import { useCombobox } from "downshift";
|
||||
import { Icon } from "@iconify/react";
|
||||
|
||||
interface Props {
|
||||
tags: string[];
|
||||
setTags: React.Dispatch<React.SetStateAction<string[]>>;
|
||||
showTagLimit?: boolean;
|
||||
isExclude?: boolean;
|
||||
}
|
||||
|
||||
const tagRegex = /^[a-z0-9-_]*$/;
|
||||
const predefinedTags = ["anime", "art", "cartoon", "celebrity", "games", "history", "meme", "movie", "oc", "tv"];
|
||||
|
||||
export default function TagSelector({ tags, setTags, showTagLimit, isExclude }: Props) {
|
||||
const [inputValue, setInputValue] = useState<string>("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const getFilteredItems = (): string[] =>
|
||||
predefinedTags.filter((item) => item.toLowerCase().includes(inputValue?.toLowerCase() || "")).filter((item) => !tags.includes(item));
|
||||
|
||||
const filteredItems = getFilteredItems();
|
||||
const isMaxItemsSelected = tags.length >= 8;
|
||||
const hasSelectedItems = tags.length > 0;
|
||||
|
||||
const addTag = (tag: string) => {
|
||||
if (!tags.includes(tag) && tags.length < 8 && tag.length <= 20) {
|
||||
setTags([...tags, tag]);
|
||||
}
|
||||
};
|
||||
|
||||
const removeTag = (tag: string) => {
|
||||
setTags(tags.filter((t) => t !== tag));
|
||||
};
|
||||
|
||||
const { isOpen, openMenu, getToggleButtonProps, getMenuProps, getInputProps, getItemProps, highlightedIndex } = useCombobox<string>({
|
||||
inputValue,
|
||||
items: filteredItems,
|
||||
selectedItem: null,
|
||||
onInputValueChange: ({ inputValue }) => {
|
||||
const newValue = inputValue || "";
|
||||
if (newValue && !tagRegex.test(newValue)) return;
|
||||
setInputValue(newValue);
|
||||
},
|
||||
onSelectedItemChange: ({ type, selectedItem }) => {
|
||||
if (type === useCombobox.stateChangeTypes.ItemClick && selectedItem) {
|
||||
addTag(selectedItem);
|
||||
setInputValue("");
|
||||
}
|
||||
},
|
||||
stateReducer: (_, { type, changes }) => {
|
||||
// Prevent input from being filled when item is selected
|
||||
if (type === useCombobox.stateChangeTypes.ItemClick) {
|
||||
return {
|
||||
...changes,
|
||||
inputValue: "",
|
||||
};
|
||||
}
|
||||
return changes;
|
||||
},
|
||||
});
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === "Enter" && inputValue && !tags.includes(inputValue)) {
|
||||
addTag(inputValue);
|
||||
setInputValue("");
|
||||
} else if (event.key === "Backspace" && inputValue === "") {
|
||||
// Spill onto last tag
|
||||
const lastTag = tags[tags.length - 1];
|
||||
setInputValue(lastTag);
|
||||
removeTag(lastTag);
|
||||
}
|
||||
};
|
||||
|
||||
const handleContainerClick = () => {
|
||||
if (!isMaxItemsSelected) {
|
||||
inputRef.current?.focus();
|
||||
openMenu();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="col-span-2 relative">
|
||||
<div
|
||||
className={`relative justify-between! pill input focus-within:ring-[3px] ring-orange-400/50 cursor-text transition ${
|
||||
tags.length > 0 ? "py-1.5! px-2!" : ""
|
||||
}`}
|
||||
onClick={handleContainerClick}
|
||||
>
|
||||
{/* Tags */}
|
||||
<div className="flex flex-wrap gap-1.5 w-full">
|
||||
{tags.map((tag) => (
|
||||
<span key={tag} className={`py-1 px-3 rounded-2xl flex items-center gap-1 text-sm ${isExclude ? "bg-red-300" : "bg-orange-300"}`}>
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Delete Tag"
|
||||
className="text-slate-800 cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeTag(tag);
|
||||
}}
|
||||
>
|
||||
<Icon icon="mdi:close" className="text-xs" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
|
||||
{/* Input */}
|
||||
<input
|
||||
{...getInputProps({
|
||||
ref: inputRef,
|
||||
onKeyDown: handleKeyDown,
|
||||
disabled: isMaxItemsSelected,
|
||||
placeholder: tags.length > 0 ? "" : "Type or select a tag...",
|
||||
maxLength: 20,
|
||||
className: "w-full flex-1 outline-none placeholder:text-black/40",
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Control buttons */}
|
||||
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
||||
{hasSelectedItems && (
|
||||
<button type="button" aria-label="Remove All Tags" className="text-black cursor-pointer" onClick={() => setTags([])}>
|
||||
<Icon icon="mdi:close" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Toggle Tag Dropdown"
|
||||
{...getToggleButtonProps()}
|
||||
disabled={isMaxItemsSelected}
|
||||
className="text-black cursor-pointer text-xl disabled:text-black/35"
|
||||
>
|
||||
<Icon icon="mdi:chevron-down" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Dropdown menu */}
|
||||
{!isMaxItemsSelected && (
|
||||
<ul
|
||||
{...getMenuProps()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className={`absolute right-0 top-full mt-2 z-50 w-80 bg-orange-200/45 backdrop-blur-md border-2 border-orange-400 rounded-lg shadow-lg shadow-black/25 max-h-60 overflow-y-auto ${
|
||||
isOpen ? "block" : "hidden"
|
||||
}`}
|
||||
>
|
||||
{filteredItems.map((item, index) => (
|
||||
<li
|
||||
key={item}
|
||||
{...getItemProps({ item, index })}
|
||||
className={`px-4 py-1 cursor-pointer text-sm ${highlightedIndex === index ? "bg-black/15" : ""}`}
|
||||
>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
{inputValue && !filteredItems.includes(inputValue) && (
|
||||
<li
|
||||
className="px-4 py-1 cursor-pointer text-sm bg-black/15"
|
||||
onClick={() => {
|
||||
addTag(inputValue);
|
||||
setInputValue("");
|
||||
}}
|
||||
>
|
||||
Add "{inputValue}"
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tag limit message */}
|
||||
{showTagLimit && (
|
||||
<div className="mt-1.5 text-xs min-h-4">
|
||||
{isMaxItemsSelected ? (
|
||||
<span className="text-red-400 font-medium">Maximum of 8 tags reached. Remove a tag to add more.</span>
|
||||
) : (
|
||||
<span className="text-black/60">{tags.length}/8 tags</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import React, { useState, useRef } from "react";
|
||||
import { useCombobox } from "downshift";
|
||||
import { Icon } from "@iconify/react";
|
||||
|
||||
interface Props {
|
||||
tags: string[];
|
||||
setTags: React.Dispatch<React.SetStateAction<string[]>>;
|
||||
showTagLimit?: boolean;
|
||||
isExclude?: boolean;
|
||||
}
|
||||
|
||||
const tagRegex = /^[a-z0-9-_]*$/;
|
||||
const predefinedTags = ["anime", "art", "cartoon", "celebrity", "games", "history", "meme", "movie", "oc", "tv"];
|
||||
|
||||
export default function TagSelector({ tags, setTags, showTagLimit, isExclude }: Props) {
|
||||
const [inputValue, setInputValue] = useState<string>("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const getFilteredItems = (): string[] =>
|
||||
predefinedTags.filter((item) => item.toLowerCase().includes(inputValue?.toLowerCase() || "")).filter((item) => !tags.includes(item));
|
||||
|
||||
const filteredItems = getFilteredItems();
|
||||
const isMaxItemsSelected = tags.length >= 8;
|
||||
const hasSelectedItems = tags.length > 0;
|
||||
|
||||
const addTag = (tag: string) => {
|
||||
if (!tags.includes(tag) && tags.length < 8 && tag.length <= 20) {
|
||||
setTags([...tags, tag]);
|
||||
}
|
||||
};
|
||||
|
||||
const removeTag = (tag: string) => {
|
||||
setTags(tags.filter((t) => t !== tag));
|
||||
};
|
||||
|
||||
const { isOpen, openMenu, getToggleButtonProps, getMenuProps, getInputProps, getItemProps, highlightedIndex } = useCombobox<string>({
|
||||
inputValue,
|
||||
items: filteredItems,
|
||||
selectedItem: null,
|
||||
onInputValueChange: ({ inputValue }) => {
|
||||
const newValue = inputValue || "";
|
||||
if (newValue && !tagRegex.test(newValue)) return;
|
||||
setInputValue(newValue);
|
||||
},
|
||||
onSelectedItemChange: ({ type, selectedItem }) => {
|
||||
if (type === useCombobox.stateChangeTypes.ItemClick && selectedItem) {
|
||||
addTag(selectedItem);
|
||||
setInputValue("");
|
||||
}
|
||||
},
|
||||
stateReducer: (_, { type, changes }) => {
|
||||
// Prevent input from being filled when item is selected
|
||||
if (type === useCombobox.stateChangeTypes.ItemClick) {
|
||||
return {
|
||||
...changes,
|
||||
inputValue: "",
|
||||
};
|
||||
}
|
||||
return changes;
|
||||
},
|
||||
});
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === "Enter" && inputValue && !tags.includes(inputValue)) {
|
||||
addTag(inputValue);
|
||||
setInputValue("");
|
||||
} else if (event.key === "Backspace" && inputValue === "") {
|
||||
// Spill onto last tag
|
||||
const lastTag = tags[tags.length - 1];
|
||||
setInputValue(lastTag);
|
||||
removeTag(lastTag);
|
||||
}
|
||||
};
|
||||
|
||||
const handleContainerClick = () => {
|
||||
if (!isMaxItemsSelected) {
|
||||
inputRef.current?.focus();
|
||||
openMenu();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="col-span-2 relative">
|
||||
<div
|
||||
className={`relative justify-between! pill input focus-within:ring-[3px] ring-orange-400/50 cursor-text transition ${
|
||||
tags.length > 0 ? "py-1.5! px-2!" : ""
|
||||
}`}
|
||||
onClick={handleContainerClick}
|
||||
>
|
||||
{/* Tags */}
|
||||
<div className="flex flex-wrap gap-1.5 w-full">
|
||||
{tags.map((tag) => (
|
||||
<span key={tag} className={`py-1 px-3 rounded-2xl flex items-center gap-1 text-sm ${isExclude ? "bg-red-300" : "bg-orange-300"}`}>
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Delete Tag"
|
||||
className="text-slate-800 cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeTag(tag);
|
||||
}}
|
||||
>
|
||||
<Icon icon="mdi:close" className="text-xs" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
|
||||
{/* Input */}
|
||||
<input
|
||||
{...getInputProps({
|
||||
ref: inputRef,
|
||||
onKeyDown: handleKeyDown,
|
||||
disabled: isMaxItemsSelected,
|
||||
placeholder: tags.length > 0 ? "" : "Type or select a tag...",
|
||||
maxLength: 20,
|
||||
className: "w-full flex-1 outline-none placeholder:text-black/40",
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Control buttons */}
|
||||
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
||||
{hasSelectedItems && (
|
||||
<button type="button" aria-label="Remove All Tags" className="text-black cursor-pointer" onClick={() => setTags([])}>
|
||||
<Icon icon="mdi:close" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Toggle Tag Dropdown"
|
||||
{...getToggleButtonProps()}
|
||||
disabled={isMaxItemsSelected}
|
||||
className="text-black cursor-pointer text-xl disabled:text-black/35"
|
||||
>
|
||||
<Icon icon="mdi:chevron-down" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Dropdown menu */}
|
||||
{!isMaxItemsSelected && (
|
||||
<ul
|
||||
{...getMenuProps()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className={`absolute right-0 top-full mt-2 z-50 w-80 bg-orange-200/45 backdrop-blur-md border-2 border-orange-400 rounded-lg shadow-lg shadow-black/25 max-h-60 overflow-y-auto ${
|
||||
isOpen ? "block" : "hidden"
|
||||
}`}
|
||||
>
|
||||
{filteredItems.map((item, index) => (
|
||||
<li
|
||||
key={item}
|
||||
{...getItemProps({ item, index })}
|
||||
className={`px-4 py-1 cursor-pointer text-sm ${highlightedIndex === index ? "bg-black/15" : ""}`}
|
||||
>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
{inputValue && !filteredItems.includes(inputValue) && (
|
||||
<li
|
||||
className="px-4 py-1 cursor-pointer text-sm bg-black/15"
|
||||
onClick={() => {
|
||||
addTag(inputValue);
|
||||
setInputValue("");
|
||||
}}
|
||||
>
|
||||
Add "{inputValue}"
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tag limit message */}
|
||||
{showTagLimit && (
|
||||
<div className="mt-1.5 text-xs min-h-4">
|
||||
{isMaxItemsSelected ? (
|
||||
<span className="text-red-400 font-medium">Maximum of 8 tags reached. Remove a tag to add more.</span>
|
||||
) : (
|
||||
<span className="text-black/60">{tags.length}/8 tags</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,54 +1,54 @@
|
|||
import { useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Icon } from "@iconify/react";
|
||||
import Tutorial from ".";
|
||||
|
||||
export default function ThreeDsScanTutorialButton() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button aria-label="Tutorial" type="button" onClick={() => setIsOpen(true)} className="text-3xl cursor-pointer">
|
||||
<Icon icon="fa:question-circle" />
|
||||
<span>Tutorial</span>
|
||||
</button>
|
||||
|
||||
{isOpen &&
|
||||
createPortal(
|
||||
<Tutorial
|
||||
tutorials={[
|
||||
{
|
||||
title: "Adding Mii",
|
||||
steps: [
|
||||
{
|
||||
text: "1. Enter the town hall",
|
||||
imageSrc: "/tutorial/3ds/step1.png",
|
||||
},
|
||||
{
|
||||
text: "2. Go into 'QR Code'",
|
||||
imageSrc: "/tutorial/3ds/adding-mii/step2.png",
|
||||
},
|
||||
{
|
||||
text: "3. Press 'Scan QR Code'",
|
||||
imageSrc: "/tutorial/3ds/adding-mii/step3.png",
|
||||
},
|
||||
{
|
||||
text: "4. Click on the QR code below the Mii's image",
|
||||
imageSrc: "/tutorial/3ds/adding-mii/step4.png",
|
||||
},
|
||||
{
|
||||
text: "5. Scan with your 3DS",
|
||||
imageSrc: "/tutorial/3ds/adding-mii/step5.png",
|
||||
},
|
||||
{ type: "finish" },
|
||||
],
|
||||
},
|
||||
]}
|
||||
isOpen={isOpen}
|
||||
setIsOpen={setIsOpen}
|
||||
/>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
import { useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Icon } from "@iconify/react";
|
||||
import Tutorial from ".";
|
||||
|
||||
export default function ThreeDsScanTutorialButton() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button aria-label="Tutorial" type="button" onClick={() => setIsOpen(true)} className="text-3xl cursor-pointer">
|
||||
<Icon icon="fa:question-circle" />
|
||||
<span>Tutorial</span>
|
||||
</button>
|
||||
|
||||
{isOpen &&
|
||||
createPortal(
|
||||
<Tutorial
|
||||
tutorials={[
|
||||
{
|
||||
title: "Adding Mii",
|
||||
steps: [
|
||||
{
|
||||
text: "1. Enter the town hall",
|
||||
imageSrc: "/tutorial/3ds/step1.png",
|
||||
},
|
||||
{
|
||||
text: "2. Go into 'QR Code'",
|
||||
imageSrc: "/tutorial/3ds/adding-mii/step2.png",
|
||||
},
|
||||
{
|
||||
text: "3. Press 'Scan QR Code'",
|
||||
imageSrc: "/tutorial/3ds/adding-mii/step3.png",
|
||||
},
|
||||
{
|
||||
text: "4. Click on the QR code below the Mii's image",
|
||||
imageSrc: "/tutorial/3ds/adding-mii/step4.png",
|
||||
},
|
||||
{
|
||||
text: "5. Scan with your 3DS",
|
||||
imageSrc: "/tutorial/3ds/adding-mii/step5.png",
|
||||
},
|
||||
{ type: "finish" },
|
||||
],
|
||||
},
|
||||
]}
|
||||
isOpen={isOpen}
|
||||
setIsOpen={setIsOpen}
|
||||
/>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,95 +1,95 @@
|
|||
import { useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import Tutorial from ".";
|
||||
|
||||
export default function SubmitTutorialButton() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={() => setIsOpen(true)} className="text-sm text-orange-400 cursor-pointer underline-offset-2 hover:underline">
|
||||
How to?
|
||||
</button>
|
||||
|
||||
{isOpen &&
|
||||
createPortal(
|
||||
<Tutorial
|
||||
tutorials={[
|
||||
{
|
||||
title: "Allow Copying",
|
||||
thumbnail: "/tutorial/3ds/allow-copying/thumbnail.png",
|
||||
hint: "Suggested!",
|
||||
steps: [
|
||||
{ type: "start" },
|
||||
{
|
||||
text: "1. Enter the town hall",
|
||||
imageSrc: "/tutorial/3ds/step1.png",
|
||||
},
|
||||
{
|
||||
text: "2. Go into 'Mii List'",
|
||||
imageSrc: "/tutorial/3ds/allow-copying/step2.png",
|
||||
},
|
||||
{
|
||||
text: "3. Select and edit the Mii you wish to submit",
|
||||
imageSrc: "/tutorial/3ds/allow-copying/step3.png",
|
||||
},
|
||||
{
|
||||
text: "4. Click 'Other Settings' in the information screen",
|
||||
imageSrc: "/tutorial/3ds/allow-copying/step4.png",
|
||||
},
|
||||
{
|
||||
text: "5. Click on 'Don't Allow' under the 'Copying' text",
|
||||
imageSrc: "/tutorial/3ds/allow-copying/step5.png",
|
||||
},
|
||||
{
|
||||
text: "6. Press 'Allow'",
|
||||
imageSrc: "/tutorial/3ds/allow-copying/step6.png",
|
||||
},
|
||||
{
|
||||
text: "7. Confirm the edits to the Mii",
|
||||
imageSrc: "/tutorial/3ds/allow-copying/step7.png",
|
||||
},
|
||||
{ type: "finish" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Create QR Code",
|
||||
thumbnail: "/tutorial/3ds/create-qr-code/thumbnail.png",
|
||||
steps: [
|
||||
{ type: "start" },
|
||||
{
|
||||
text: "1. Enter the town hall",
|
||||
imageSrc: "/tutorial/3ds/step1.png",
|
||||
},
|
||||
{
|
||||
text: "2. Go into 'QR Code'",
|
||||
imageSrc: "/tutorial/3ds/create-qr-code/step2.png",
|
||||
},
|
||||
{
|
||||
text: "3. Press 'Create QR Code'",
|
||||
imageSrc: "/tutorial/3ds/create-qr-code/step3.png",
|
||||
},
|
||||
{
|
||||
text: "4. Select and press 'OK' on the Mii you wish to submit",
|
||||
imageSrc: "/tutorial/3ds/create-qr-code/step4.png",
|
||||
},
|
||||
{
|
||||
text: "5. Pick any option; it doesn't matter since the QR code regenerates upon submission.",
|
||||
imageSrc: "/tutorial/3ds/create-qr-code/step5.png",
|
||||
},
|
||||
{
|
||||
text: "6. Exit the tutorial; Upload the QR code (scan with camera or upload file through SD card).",
|
||||
imageSrc: "/tutorial/3ds/create-qr-code/step6.png",
|
||||
},
|
||||
{ type: "finish" },
|
||||
],
|
||||
},
|
||||
]}
|
||||
isOpen={isOpen}
|
||||
setIsOpen={setIsOpen}
|
||||
/>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
import { useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import Tutorial from ".";
|
||||
|
||||
export default function SubmitTutorialButton() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={() => setIsOpen(true)} className="text-sm text-orange-400 cursor-pointer underline-offset-2 hover:underline">
|
||||
How to?
|
||||
</button>
|
||||
|
||||
{isOpen &&
|
||||
createPortal(
|
||||
<Tutorial
|
||||
tutorials={[
|
||||
{
|
||||
title: "Allow Copying",
|
||||
thumbnail: "/tutorial/3ds/allow-copying/thumbnail.png",
|
||||
hint: "Suggested!",
|
||||
steps: [
|
||||
{ type: "start" },
|
||||
{
|
||||
text: "1. Enter the town hall",
|
||||
imageSrc: "/tutorial/3ds/step1.png",
|
||||
},
|
||||
{
|
||||
text: "2. Go into 'Mii List'",
|
||||
imageSrc: "/tutorial/3ds/allow-copying/step2.png",
|
||||
},
|
||||
{
|
||||
text: "3. Select and edit the Mii you wish to submit",
|
||||
imageSrc: "/tutorial/3ds/allow-copying/step3.png",
|
||||
},
|
||||
{
|
||||
text: "4. Click 'Other Settings' in the information screen",
|
||||
imageSrc: "/tutorial/3ds/allow-copying/step4.png",
|
||||
},
|
||||
{
|
||||
text: "5. Click on 'Don't Allow' under the 'Copying' text",
|
||||
imageSrc: "/tutorial/3ds/allow-copying/step5.png",
|
||||
},
|
||||
{
|
||||
text: "6. Press 'Allow'",
|
||||
imageSrc: "/tutorial/3ds/allow-copying/step6.png",
|
||||
},
|
||||
{
|
||||
text: "7. Confirm the edits to the Mii",
|
||||
imageSrc: "/tutorial/3ds/allow-copying/step7.png",
|
||||
},
|
||||
{ type: "finish" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Create QR Code",
|
||||
thumbnail: "/tutorial/3ds/create-qr-code/thumbnail.png",
|
||||
steps: [
|
||||
{ type: "start" },
|
||||
{
|
||||
text: "1. Enter the town hall",
|
||||
imageSrc: "/tutorial/3ds/step1.png",
|
||||
},
|
||||
{
|
||||
text: "2. Go into 'QR Code'",
|
||||
imageSrc: "/tutorial/3ds/create-qr-code/step2.png",
|
||||
},
|
||||
{
|
||||
text: "3. Press 'Create QR Code'",
|
||||
imageSrc: "/tutorial/3ds/create-qr-code/step3.png",
|
||||
},
|
||||
{
|
||||
text: "4. Select and press 'OK' on the Mii you wish to submit",
|
||||
imageSrc: "/tutorial/3ds/create-qr-code/step4.png",
|
||||
},
|
||||
{
|
||||
text: "5. Pick any option; it doesn't matter since the QR code regenerates upon submission.",
|
||||
imageSrc: "/tutorial/3ds/create-qr-code/step5.png",
|
||||
},
|
||||
{
|
||||
text: "6. Exit the tutorial; Upload the QR code (scan with camera or upload file through SD card).",
|
||||
imageSrc: "/tutorial/3ds/create-qr-code/step6.png",
|
||||
},
|
||||
{ type: "finish" },
|
||||
],
|
||||
},
|
||||
]}
|
||||
isOpen={isOpen}
|
||||
setIsOpen={setIsOpen}
|
||||
/>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,205 +1,205 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import useEmblaCarousel from "embla-carousel-react";
|
||||
import { Icon } from "@iconify/react";
|
||||
import confetti from "canvas-confetti";
|
||||
|
||||
interface Slide {
|
||||
// step is never used, undefined is assumed as a step
|
||||
type?: "start" | "step" | "finish";
|
||||
text?: string;
|
||||
imageSrc?: string;
|
||||
}
|
||||
|
||||
interface Tutorial {
|
||||
title: string;
|
||||
thumbnail?: string;
|
||||
hint?: string;
|
||||
steps: Slide[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
tutorials: Tutorial[];
|
||||
isOpen: boolean;
|
||||
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
export default function Tutorial({ tutorials, isOpen, setIsOpen }: Props) {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true, duration: 15 });
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
// Build index map
|
||||
const slides: Array<Slide & { tutorialTitle: string }> = [];
|
||||
const startSlides: Record<string, number> = {};
|
||||
|
||||
tutorials.forEach((tutorial) => {
|
||||
tutorial.steps.forEach((slide) => {
|
||||
if (slide.type === "start") {
|
||||
startSlides[tutorial.title] = slides.length;
|
||||
}
|
||||
slides.push({ ...slide, tutorialTitle: tutorial.title });
|
||||
});
|
||||
});
|
||||
|
||||
const currentSlide = slides[selectedIndex];
|
||||
const isStartingPage = currentSlide?.type === "start";
|
||||
|
||||
useEffect(() => {
|
||||
if (currentSlide.type !== "finish") return;
|
||||
|
||||
const defaults = { startVelocity: 30, spread: 360, ticks: 120, zIndex: 50 };
|
||||
const randomInRange = (min: number, max: number) => Math.random() * (max - min) + min;
|
||||
|
||||
setTimeout(() => {
|
||||
confetti({
|
||||
...defaults,
|
||||
particleCount: 500,
|
||||
origin: { x: randomInRange(0.1, 0.3), y: Math.random() - 0.2 },
|
||||
});
|
||||
confetti({
|
||||
...defaults,
|
||||
particleCount: 500,
|
||||
origin: { x: randomInRange(0.7, 0.9), y: Math.random() - 0.2 },
|
||||
});
|
||||
}, 300);
|
||||
}, [currentSlide]);
|
||||
|
||||
const close = () => {
|
||||
setIsVisible(false);
|
||||
setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
setSelectedIndex(0);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const goToTutorial = (tutorialTitle: string) => {
|
||||
if (!emblaApi) return;
|
||||
const index = startSlides[tutorialTitle];
|
||||
|
||||
// Jump to next starting slide then transition to actual tutorial
|
||||
emblaApi.scrollTo(index, true);
|
||||
emblaApi.scrollTo(index + 1);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
// slight delay to trigger animation
|
||||
setTimeout(() => setIsVisible(true), 10);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!emblaApi) return;
|
||||
emblaApi.on("select", () => setSelectedIndex(emblaApi.selectedScrollSnap()));
|
||||
}, [emblaApi]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-(--header-height) flex items-center justify-center z-40">
|
||||
<div
|
||||
onClick={close}
|
||||
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${isVisible ? "opacity-100" : "opacity-0"}`}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={`z-50 bg-orange-50 border-2 border-amber-500 rounded-2xl shadow-lg w-full max-w-xl h-120 transition-discrete duration-300 flex flex-col ${
|
||||
isVisible ? "scale-100 opacity-100" : "scale-75 opacity-0"
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between items-center mb-2 p-6 pb-0">
|
||||
<h2 className="text-xl font-bold">Tutorial</h2>
|
||||
<button onClick={close} aria-label="Close" className="text-red-400 hover:text-red-500 text-2xl cursor-pointer">
|
||||
<Icon icon="material-symbols:close-rounded" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col min-h-0 h-full">
|
||||
<div className="overflow-hidden h-full" ref={emblaRef}>
|
||||
<div className="flex h-full">
|
||||
{slides.map((slide, index) => (
|
||||
<div key={index} className={`shrink-0 flex flex-col w-full px-6 ${slide.type === "start" && "py-6"}`}>
|
||||
{slide.type === "start" ? (
|
||||
<>
|
||||
{/* Separator */}
|
||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mb-2">
|
||||
<hr className="grow border-zinc-300" />
|
||||
<span>Pick a tutorial</span>
|
||||
<hr className="grow border-zinc-300" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 h-full">
|
||||
{tutorials.map((tutorial, tutorialIndex) => (
|
||||
<button
|
||||
key={tutorialIndex}
|
||||
onClick={() => goToTutorial(tutorial.title)}
|
||||
aria-label={tutorial.title + " tutorial"}
|
||||
className="flex flex-col justify-center items-center bg-zinc-50 rounded-xl p-4 shadow-md border-2 border-zinc-300 cursor-pointer text-center text-sm transition hover:scale-[1.03] hover:bg-cyan-100 hover:border-cyan-600"
|
||||
>
|
||||
<img
|
||||
src={tutorial.thumbnail!}
|
||||
alt="tutorial thumbnail"
|
||||
width={128}
|
||||
height={128}
|
||||
className="rounded-lg border-2 border-zinc-300 object-cover"
|
||||
/>
|
||||
<p className="mt-2">{tutorial.title}</p>
|
||||
{/* Set opacity to 0 to keep height the same with other tutorials */}
|
||||
<p className={`text-[0.65rem] text-zinc-400 ${!tutorial.hint && "opacity-0"}`}>{tutorial.hint || "placeholder"}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : slide.type === "finish" ? (
|
||||
<div className="h-full flex flex-col justify-center items-center">
|
||||
<Icon icon="fxemoji:partypopper" className="text-9xl" />
|
||||
<h1 className="font-medium text-xl mt-6 animate-bounce">Yatta! You did it!</h1>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-zinc-500 mb-2 text-center">{slide.text}</p>
|
||||
|
||||
<img
|
||||
src={slide.imageSrc ?? "/missing.svg"}
|
||||
alt="step image"
|
||||
width={396}
|
||||
height={320}
|
||||
loading="eager"
|
||||
className="rounded-lg w-full h-full object-contain bg-black flex-1"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Arrows */}
|
||||
<div className={`flex justify-between items-center mt-2 px-6 pb-6 transition-opacity duration-300 ${isStartingPage && "opacity-0"}`}>
|
||||
<button
|
||||
onClick={() => emblaApi?.scrollPrev()}
|
||||
disabled={isStartingPage}
|
||||
className={`pill button p-1! aspect-square text-2xl ${isStartingPage && "cursor-auto!"}`}
|
||||
aria-label="Scroll Carousel Left"
|
||||
>
|
||||
<Icon icon="tabler:chevron-left" />
|
||||
</button>
|
||||
|
||||
{/* Only show tutorial name on step slides */}
|
||||
<span className={`text-sm transition-opacity duration-300 ${(currentSlide.type === "finish" || currentSlide.type === "start") && "opacity-0"}`}>
|
||||
{currentSlide?.tutorialTitle}
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={() => emblaApi?.scrollNext()}
|
||||
disabled={isStartingPage}
|
||||
className={`pill button p-1! aspect-square text-2xl ${isStartingPage && "cursor-auto!"}`}
|
||||
aria-label="Scroll Carousel Right"
|
||||
>
|
||||
<Icon icon="tabler:chevron-right" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { useEffect, useState } from "react";
|
||||
import useEmblaCarousel from "embla-carousel-react";
|
||||
import { Icon } from "@iconify/react";
|
||||
import confetti from "canvas-confetti";
|
||||
|
||||
interface Slide {
|
||||
// step is never used, undefined is assumed as a step
|
||||
type?: "start" | "step" | "finish";
|
||||
text?: string;
|
||||
imageSrc?: string;
|
||||
}
|
||||
|
||||
interface Tutorial {
|
||||
title: string;
|
||||
thumbnail?: string;
|
||||
hint?: string;
|
||||
steps: Slide[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
tutorials: Tutorial[];
|
||||
isOpen: boolean;
|
||||
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
export default function Tutorial({ tutorials, isOpen, setIsOpen }: Props) {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true, duration: 15 });
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
// Build index map
|
||||
const slides: Array<Slide & { tutorialTitle: string }> = [];
|
||||
const startSlides: Record<string, number> = {};
|
||||
|
||||
tutorials.forEach((tutorial) => {
|
||||
tutorial.steps.forEach((slide) => {
|
||||
if (slide.type === "start") {
|
||||
startSlides[tutorial.title] = slides.length;
|
||||
}
|
||||
slides.push({ ...slide, tutorialTitle: tutorial.title });
|
||||
});
|
||||
});
|
||||
|
||||
const currentSlide = slides[selectedIndex];
|
||||
const isStartingPage = currentSlide?.type === "start";
|
||||
|
||||
useEffect(() => {
|
||||
if (currentSlide.type !== "finish") return;
|
||||
|
||||
const defaults = { startVelocity: 30, spread: 360, ticks: 120, zIndex: 50 };
|
||||
const randomInRange = (min: number, max: number) => Math.random() * (max - min) + min;
|
||||
|
||||
setTimeout(() => {
|
||||
confetti({
|
||||
...defaults,
|
||||
particleCount: 500,
|
||||
origin: { x: randomInRange(0.1, 0.3), y: Math.random() - 0.2 },
|
||||
});
|
||||
confetti({
|
||||
...defaults,
|
||||
particleCount: 500,
|
||||
origin: { x: randomInRange(0.7, 0.9), y: Math.random() - 0.2 },
|
||||
});
|
||||
}, 300);
|
||||
}, [currentSlide]);
|
||||
|
||||
const close = () => {
|
||||
setIsVisible(false);
|
||||
setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
setSelectedIndex(0);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const goToTutorial = (tutorialTitle: string) => {
|
||||
if (!emblaApi) return;
|
||||
const index = startSlides[tutorialTitle];
|
||||
|
||||
// Jump to next starting slide then transition to actual tutorial
|
||||
emblaApi.scrollTo(index, true);
|
||||
emblaApi.scrollTo(index + 1);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
// slight delay to trigger animation
|
||||
setTimeout(() => setIsVisible(true), 10);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!emblaApi) return;
|
||||
emblaApi.on("select", () => setSelectedIndex(emblaApi.selectedScrollSnap()));
|
||||
}, [emblaApi]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-(--header-height) flex items-center justify-center z-40">
|
||||
<div
|
||||
onClick={close}
|
||||
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${isVisible ? "opacity-100" : "opacity-0"}`}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={`z-50 bg-orange-50 border-2 border-amber-500 rounded-2xl shadow-lg w-full max-w-xl h-120 transition-discrete duration-300 flex flex-col ${
|
||||
isVisible ? "scale-100 opacity-100" : "scale-75 opacity-0"
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between items-center mb-2 p-6 pb-0">
|
||||
<h2 className="text-xl font-bold">Tutorial</h2>
|
||||
<button onClick={close} aria-label="Close" className="text-red-400 hover:text-red-500 text-2xl cursor-pointer">
|
||||
<Icon icon="material-symbols:close-rounded" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col min-h-0 h-full">
|
||||
<div className="overflow-hidden h-full" ref={emblaRef}>
|
||||
<div className="flex h-full">
|
||||
{slides.map((slide, index) => (
|
||||
<div key={index} className={`shrink-0 flex flex-col w-full px-6 ${slide.type === "start" && "py-6"}`}>
|
||||
{slide.type === "start" ? (
|
||||
<>
|
||||
{/* Separator */}
|
||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mb-2">
|
||||
<hr className="grow border-zinc-300" />
|
||||
<span>Pick a tutorial</span>
|
||||
<hr className="grow border-zinc-300" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 h-full">
|
||||
{tutorials.map((tutorial, tutorialIndex) => (
|
||||
<button
|
||||
key={tutorialIndex}
|
||||
onClick={() => goToTutorial(tutorial.title)}
|
||||
aria-label={tutorial.title + " tutorial"}
|
||||
className="flex flex-col justify-center items-center bg-zinc-50 rounded-xl p-4 shadow-md border-2 border-zinc-300 cursor-pointer text-center text-sm transition hover:scale-[1.03] hover:bg-cyan-100 hover:border-cyan-600"
|
||||
>
|
||||
<img
|
||||
src={tutorial.thumbnail!}
|
||||
alt="tutorial thumbnail"
|
||||
width={128}
|
||||
height={128}
|
||||
className="rounded-lg border-2 border-zinc-300 object-cover"
|
||||
/>
|
||||
<p className="mt-2">{tutorial.title}</p>
|
||||
{/* Set opacity to 0 to keep height the same with other tutorials */}
|
||||
<p className={`text-[0.65rem] text-zinc-400 ${!tutorial.hint && "opacity-0"}`}>{tutorial.hint || "placeholder"}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : slide.type === "finish" ? (
|
||||
<div className="h-full flex flex-col justify-center items-center">
|
||||
<Icon icon="fxemoji:partypopper" className="text-9xl" />
|
||||
<h1 className="font-medium text-xl mt-6 animate-bounce">Yatta! You did it!</h1>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-zinc-500 mb-2 text-center">{slide.text}</p>
|
||||
|
||||
<img
|
||||
src={slide.imageSrc ?? "/missing.svg"}
|
||||
alt="step image"
|
||||
width={396}
|
||||
height={320}
|
||||
loading="eager"
|
||||
className="rounded-lg w-full h-full object-contain bg-black flex-1"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Arrows */}
|
||||
<div className={`flex justify-between items-center mt-2 px-6 pb-6 transition-opacity duration-300 ${isStartingPage && "opacity-0"}`}>
|
||||
<button
|
||||
onClick={() => emblaApi?.scrollPrev()}
|
||||
disabled={isStartingPage}
|
||||
className={`pill button p-1! aspect-square text-2xl ${isStartingPage && "cursor-auto!"}`}
|
||||
aria-label="Scroll Carousel Left"
|
||||
>
|
||||
<Icon icon="tabler:chevron-left" />
|
||||
</button>
|
||||
|
||||
{/* Only show tutorial name on step slides */}
|
||||
<span className={`text-sm transition-opacity duration-300 ${(currentSlide.type === "finish" || currentSlide.type === "start") && "opacity-0"}`}>
|
||||
{currentSlide?.tutorialTitle}
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={() => emblaApi?.scrollNext()}
|
||||
disabled={isStartingPage}
|
||||
className={`pill button p-1! aspect-square text-2xl ${isStartingPage && "cursor-auto!"}`}
|
||||
aria-label="Scroll Carousel Right"
|
||||
>
|
||||
<Icon icon="tabler:chevron-right" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,54 +1,54 @@
|
|||
import { useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Icon } from "@iconify/react";
|
||||
import Tutorial from ".";
|
||||
|
||||
export default function SwitchAddMiiTutorialButton() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button aria-label="Tutorial" type="button" onClick={() => setIsOpen(true)} className="text-3xl cursor-pointer">
|
||||
<Icon icon="fa:question-circle" />
|
||||
<span>Tutorial</span>
|
||||
</button>
|
||||
|
||||
{isOpen &&
|
||||
createPortal(
|
||||
<Tutorial
|
||||
tutorials={[
|
||||
{
|
||||
title: "Adding Mii",
|
||||
steps: [
|
||||
{
|
||||
text: "1. Press X to open the menu, then select 'Add a Mii'",
|
||||
imageSrc: "/tutorial/switch/adding-mii/step1.jpg",
|
||||
},
|
||||
{
|
||||
text: "2. Press 'From scratch' and choose the Male template",
|
||||
imageSrc: "/tutorial/switch/adding-mii/step2.jpg",
|
||||
},
|
||||
{
|
||||
text: "3. Click on the features image on this page to zoom it in and add all features on the mii editor",
|
||||
imageSrc: "/tutorial/switch/adding-mii/step3.png",
|
||||
},
|
||||
{
|
||||
text: "4. If the author added instructions, follow them (not all instructions will be there, check next slide for more)",
|
||||
imageSrc: "/tutorial/switch/adding-mii/step4.jpg",
|
||||
},
|
||||
{
|
||||
text: "5. For instructions like height or distance, use the number of button clicks (positive for buttons on right, negative for buttons on left)",
|
||||
imageSrc: "/tutorial/switch/step4.jpg",
|
||||
},
|
||||
{ type: "finish" },
|
||||
],
|
||||
},
|
||||
]}
|
||||
isOpen={isOpen}
|
||||
setIsOpen={setIsOpen}
|
||||
/>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
import { useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Icon } from "@iconify/react";
|
||||
import Tutorial from ".";
|
||||
|
||||
export default function SwitchAddMiiTutorialButton() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button aria-label="Tutorial" type="button" onClick={() => setIsOpen(true)} className="text-3xl cursor-pointer">
|
||||
<Icon icon="fa:question-circle" />
|
||||
<span>Tutorial</span>
|
||||
</button>
|
||||
|
||||
{isOpen &&
|
||||
createPortal(
|
||||
<Tutorial
|
||||
tutorials={[
|
||||
{
|
||||
title: "Adding Mii",
|
||||
steps: [
|
||||
{
|
||||
text: "1. Press X to open the menu, then select 'Add a Mii'",
|
||||
imageSrc: "/tutorial/switch/adding-mii/step1.jpg",
|
||||
},
|
||||
{
|
||||
text: "2. Press 'From scratch' and choose the Male template",
|
||||
imageSrc: "/tutorial/switch/adding-mii/step2.jpg",
|
||||
},
|
||||
{
|
||||
text: "3. Click on the features image on this page to zoom it in and add all features on the mii editor",
|
||||
imageSrc: "/tutorial/switch/adding-mii/step3.png",
|
||||
},
|
||||
{
|
||||
text: "4. If the author added instructions, follow them (not all instructions will be there, check next slide for more)",
|
||||
imageSrc: "/tutorial/switch/adding-mii/step4.jpg",
|
||||
},
|
||||
{
|
||||
text: "5. For instructions like height or distance, use the number of button clicks (positive for buttons on right, negative for buttons on left)",
|
||||
imageSrc: "/tutorial/switch/step4.jpg",
|
||||
},
|
||||
{ type: "finish" },
|
||||
],
|
||||
},
|
||||
]}
|
||||
isOpen={isOpen}
|
||||
setIsOpen={setIsOpen}
|
||||
/>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,48 +1,48 @@
|
|||
import { useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import Tutorial from ".";
|
||||
|
||||
export default function SwitchSubmitTutorialButton() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={() => setIsOpen(true)} className="text-sm text-orange-400 cursor-pointer underline-offset-2 hover:underline">
|
||||
How to?
|
||||
</button>
|
||||
|
||||
{isOpen &&
|
||||
createPortal(
|
||||
<Tutorial
|
||||
tutorials={[
|
||||
{
|
||||
title: "Submitting",
|
||||
steps: [
|
||||
{
|
||||
text: "1. Press X to open the menu, then select 'Residents'",
|
||||
imageSrc: "/tutorial/switch/submitting/step1.jpg",
|
||||
},
|
||||
{
|
||||
text: "2. Find the Mii you want to submit and edit it",
|
||||
imageSrc: "/tutorial/switch/submitting/step2.jpg",
|
||||
},
|
||||
{
|
||||
text: "3. Press Y to open the features list, then take a screenshot and upload to this submit form",
|
||||
imageSrc: "/tutorial/switch/submitting/step3.jpg",
|
||||
},
|
||||
{
|
||||
text: "4. Adding Mii colors and settings is recommended. All instructions are optional; for values like height or distance, use the number of button clicks (positive for buttons on right, negative for buttons on left)",
|
||||
imageSrc: "/tutorial/switch/step4.jpg",
|
||||
},
|
||||
{ type: "finish" },
|
||||
],
|
||||
},
|
||||
]}
|
||||
isOpen={isOpen}
|
||||
setIsOpen={setIsOpen}
|
||||
/>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
import { useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import Tutorial from ".";
|
||||
|
||||
export default function SwitchSubmitTutorialButton() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={() => setIsOpen(true)} className="text-sm text-orange-400 cursor-pointer underline-offset-2 hover:underline">
|
||||
How to?
|
||||
</button>
|
||||
|
||||
{isOpen &&
|
||||
createPortal(
|
||||
<Tutorial
|
||||
tutorials={[
|
||||
{
|
||||
title: "Submitting",
|
||||
steps: [
|
||||
{
|
||||
text: "1. Press X to open the menu, then select 'Residents'",
|
||||
imageSrc: "/tutorial/switch/submitting/step1.jpg",
|
||||
},
|
||||
{
|
||||
text: "2. Find the Mii you want to submit and edit it",
|
||||
imageSrc: "/tutorial/switch/submitting/step2.jpg",
|
||||
},
|
||||
{
|
||||
text: "3. Press Y to open the features list, then take a screenshot and upload to this submit form",
|
||||
imageSrc: "/tutorial/switch/submitting/step3.jpg",
|
||||
},
|
||||
{
|
||||
text: "4. Adding Mii colors and settings is recommended. All instructions are optional; for values like height or distance, use the number of button clicks (positive for buttons on right, negative for buttons on left)",
|
||||
imageSrc: "/tutorial/switch/step4.jpg",
|
||||
},
|
||||
{ type: "finish" },
|
||||
],
|
||||
},
|
||||
]}
|
||||
isOpen={isOpen}
|
||||
setIsOpen={setIsOpen}
|
||||
/>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,10 @@
|
|||
}
|
||||
}
|
||||
|
||||
#root {
|
||||
@apply antialiased flex flex-col items-center w-screen h-screen;
|
||||
}
|
||||
|
||||
.pill {
|
||||
@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 {
|
||||
@apply bg-amber-50 text-slate-800;
|
||||
font-family: var(--font-lexend);
|
||||
@apply bg-amber-50 text-slate-800 min-h-screen;
|
||||
font-family: "Lexend Variable", sans-serif;
|
||||
|
||||
/* 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,\
|
||||
|
|
@ -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>
|
||||