mirror of
https://github.com/trafficlunar/tomodachi-share.git
synced 2026-05-13 13:17:45 +00:00
Compare commits
10 commits
4635d74d72
...
90c2f4dc94
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90c2f4dc94 | ||
| e8249154d9 | |||
| d52d428f1f | |||
| 48d388b1a7 | |||
| dbc468acd6 | |||
| 4275f710b0 | |||
| 781682e24e | |||
| af7f1380bc | |||
| 51d46fc9ce | |||
|
|
25dafcc24b |
22 changed files with 451 additions and 364 deletions
|
|
@ -2,38 +2,11 @@
|
||||||
|
|
||||||
This is probably outdated.
|
This is probably outdated.
|
||||||
|
|
||||||
Welcome to the TomodachiShare development guide! This project uses [pnpm](https://pnpm.io/) for package management, [Next.js](https://nextjs.org/) with the app router for the front-end and back-end, [Prisma](https://prisma.io) for the database, [TailwindCSS](https://tailwindcss.com/) for styling, and [TypeScript](https://www.typescriptlang.org/) for type safety.
|
Welcome to the TomodachiShare development guide! This project uses [pnpm](https://pnpm.io/) for package management, [Next.js](https://nextjs.org/) with the app router for the backend, [Vite with React](https://vite.dev/) for the frontend, [Prisma](https://prisma.io) for the database, [TailwindCSS](https://tailwindcss.com/) for styling, and [TypeScript](https://www.typescriptlang.org/) for type safety.
|
||||||
|
|
||||||
## Getting started
|
|
||||||
|
|
||||||
To get the project up and running locally, follow these steps:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
$ git clone https://github.com/trafficlunar/tomodachi-share
|
|
||||||
$ cd tomodachi-share
|
|
||||||
$ pnpm install
|
|
||||||
```
|
|
||||||
|
|
||||||
Prisma types are generated automatically, however, sometimes you might need to:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Generate Prisma client types
|
|
||||||
$ pnpm prisma generate
|
|
||||||
|
|
||||||
# Or, if you've added new database properties
|
|
||||||
$ pnpm prisma migrate dev
|
|
||||||
$ pnpm prisma generate
|
|
||||||
```
|
|
||||||
|
|
||||||
I recommend opting out of Next.js' telemetry program but it is not a requirement.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
$ pnpm exec next telemetry disable
|
|
||||||
```
|
|
||||||
|
|
||||||
## Environment variables
|
## Environment variables
|
||||||
|
|
||||||
You'll need a PostgreSQL database and Redis database. I would recommend using [Docker](https://www.docker.com/) to set these up quickly. Just create a `docker-compose.yaml` with the following content and run `docker compose up -d`:
|
This step needs to be done before installing packages. You'll need a PostgreSQL database and Redis database. I would recommend using [Docker](https://www.docker.com/) to set these up quickly. Just create a `docker-compose.yaml` with the following content and run `docker compose up -d`:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
|
|
@ -62,10 +35,11 @@ services:
|
||||||
After starting the docker applications, apply TomodachiShare's database schema migrations.
|
After starting the docker applications, apply TomodachiShare's database schema migrations.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
$ pnpm prisma migrate dev
|
$ pnpm --filter backend prisma migrate dev
|
||||||
```
|
```
|
||||||
|
|
||||||
After, make a copy of the `.env.example` file and rename it to `.env`. The database variables should be pre-configured, but you'll need to fill in the rest of the variables.
|
After, in both the backend and frontend, make a copy of the `.env.example` file and rename it to `.env`.
|
||||||
|
For the backend, the database variables should be pre-configured, but you'll need to fill in the rest of the variables.
|
||||||
|
|
||||||
For the `AUTH_SECRET`, run the following in the command line:
|
For the `AUTH_SECRET`, run the following in the command line:
|
||||||
|
|
||||||
|
|
@ -74,7 +48,7 @@ $ pnpx auth secret
|
||||||
```
|
```
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> This command may put the secret in a file named `.env.local`, if that happens copy it and paste it into `.env`
|
> This command may put the secret in a file named `.env.local`, if that happens copy it and paste it into `backend/.env`
|
||||||
|
|
||||||
Now, let's get the Discord and GitHub authentication set up. If you don't plan on editing any code associated with authentication, you likely only need to setup one of these services.
|
Now, let's get the Discord and GitHub authentication set up. If you don't plan on editing any code associated with authentication, you likely only need to setup one of these services.
|
||||||
|
|
||||||
|
|
@ -84,10 +58,43 @@ For GitHub, navigate to your profile settings, then 'Developer Settings', and cr
|
||||||
|
|
||||||
Google is annoying so I'm not explaining it.
|
Google is annoying so I'm not explaining it.
|
||||||
|
|
||||||
After configuring the environment variables, you can run a development server.
|
## Getting started
|
||||||
|
|
||||||
|
To get the project up and running locally, follow these steps:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
$ pnpm dev
|
$ git clone https://github.com/trafficlunar/tomodachi-share
|
||||||
|
$ cd tomodachi-share
|
||||||
|
$ pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
Prisma types are generated automatically, however, if you changed the schema or need to trigger a manual refresh:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generate Prisma client types
|
||||||
|
$ pnpm --filter backend prisma generate
|
||||||
|
|
||||||
|
# Or, if you've added new database properties
|
||||||
|
$ pnpm --filter backend prisma migrate dev
|
||||||
|
$ pnpm --filter backend prisma generate
|
||||||
|
```
|
||||||
|
|
||||||
|
I recommend opting out of Next.js' telemetry program but it is not a requirement.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ pnpm --filter backend exec next telemetry disable
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development Server
|
||||||
|
|
||||||
|
The frontend and backend need to be ran simulatenously, therefore you need two separate terminals.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Terminal 1
|
||||||
|
$ pnpm --filter backend dev
|
||||||
|
|
||||||
|
# Terminal 2
|
||||||
|
$ pnpm --filter frontend dev
|
||||||
```
|
```
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
|
|
@ -96,8 +103,10 @@ It's a good idea to build the project locally before submitting a pull request.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Build the project
|
# Build the project
|
||||||
$ pnpm build
|
$ pnpm --filter backend build
|
||||||
|
$ pnpm --filter frontend build
|
||||||
|
|
||||||
# Run the built version
|
# Run the built version (Note: Vite likes to change the port when this happens, so you probably need to change both .env files)
|
||||||
$ pnpm start
|
$ pnpm --filter backend start
|
||||||
|
$ pnpm --filter frontend build
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,14 @@ const nextConfig: NextConfig = {
|
||||||
{ key: "Access-Control-Allow-Headers", value: "Content-Type" },
|
{ key: "Access-Control-Allow-Headers", value: "Content-Type" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// for images
|
||||||
|
source: "/mii/:path*",
|
||||||
|
headers: [
|
||||||
|
{ key: "Access-Control-Allow-Origin", value: process.env.NEXT_PUBLIC_FRONTEND_URL || "http://localhost:4321" },
|
||||||
|
{ key: "Access-Control-Allow-Credentials", value: "true" },
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "miis" ADD COLUMN "needsFixing" TEXT;
|
||||||
|
|
@ -77,6 +77,7 @@ model Mii {
|
||||||
platform MiiPlatform @default(THREE_DS)
|
platform MiiPlatform @default(THREE_DS)
|
||||||
quarantined Boolean @default(false)
|
quarantined Boolean @default(false)
|
||||||
in_queue Boolean @default(false)
|
in_queue Boolean @default(false)
|
||||||
|
needsFixing String?
|
||||||
|
|
||||||
instructions Json?
|
instructions Json?
|
||||||
youtubeId String?
|
youtubeId String?
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,11 @@ const editSchema = z.object({
|
||||||
.enum(["true", "false"])
|
.enum(["true", "false"])
|
||||||
.transform((v) => v === "true")
|
.transform((v) => v === "true")
|
||||||
.optional(),
|
.optional(),
|
||||||
|
needsFixingReason: z
|
||||||
|
.string()
|
||||||
|
.max(256)
|
||||||
|
.optional()
|
||||||
|
.transform((val) => (val === "" ? null : val)),
|
||||||
gender: z.enum(MiiGender).optional(),
|
gender: z.enum(MiiGender).optional(),
|
||||||
makeup: z.enum(MiiMakeup).optional(),
|
makeup: z.enum(MiiMakeup).optional(),
|
||||||
miiPortraitImage: z.union([z.instanceof(File), z.any()]).optional(),
|
miiPortraitImage: z.union([z.instanceof(File), z.any()]).optional(),
|
||||||
|
|
@ -86,6 +91,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||||
tags: rawTags,
|
tags: rawTags,
|
||||||
description: formData.get("description") ?? undefined,
|
description: formData.get("description") ?? undefined,
|
||||||
quarantined: formData.get("quarantined") ?? undefined,
|
quarantined: formData.get("quarantined") ?? undefined,
|
||||||
|
needsFixingReason: formData.get("needsFixingReason") ?? undefined,
|
||||||
gender: formData.get("gender") ?? undefined,
|
gender: formData.get("gender") ?? undefined,
|
||||||
makeup: formData.get("makeup") ?? undefined,
|
makeup: formData.get("makeup") ?? undefined,
|
||||||
miiPortraitImage: formData.get("miiPortraitImage"),
|
miiPortraitImage: formData.get("miiPortraitImage"),
|
||||||
|
|
@ -103,8 +109,22 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||||
const error = `${path}: ${firstIssue.message}`;
|
const error = `${path}: ${firstIssue.message}`;
|
||||||
return rateLimit.sendResponse({ error }, 400);
|
return rateLimit.sendResponse({ error }, 400);
|
||||||
}
|
}
|
||||||
const { name, tags, description, quarantined, gender, makeup, miiPortraitImage, miiFeaturesImage, youtubeId, instructions, image1, image2, image3 } =
|
const {
|
||||||
parsed.data;
|
name,
|
||||||
|
tags,
|
||||||
|
description,
|
||||||
|
quarantined,
|
||||||
|
needsFixingReason,
|
||||||
|
gender,
|
||||||
|
makeup,
|
||||||
|
miiPortraitImage,
|
||||||
|
miiFeaturesImage,
|
||||||
|
youtubeId,
|
||||||
|
instructions,
|
||||||
|
image1,
|
||||||
|
image2,
|
||||||
|
image3,
|
||||||
|
} = parsed.data;
|
||||||
|
|
||||||
// Validate image files
|
// Validate image files
|
||||||
const customImages: File[] = [];
|
const customImages: File[] = [];
|
||||||
|
|
@ -133,7 +153,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prevent non-admins from quarantining Miis
|
// Prevent non-admins from quarantining Miis
|
||||||
if (quarantined && session.user?.id?.toString() !== process.env.NEXT_PUBLIC_ADMIN_USER_ID)
|
if (quarantined && needsFixingReason && session.user?.id?.toString() !== process.env.NEXT_PUBLIC_ADMIN_USER_ID)
|
||||||
return rateLimit.sendResponse({ error: `You're not an admin!` }, 401);
|
return rateLimit.sendResponse({ error: `You're not an admin!` }, 401);
|
||||||
|
|
||||||
// Edit Mii in database
|
// Edit Mii in database
|
||||||
|
|
@ -142,6 +162,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||||
if (tags !== undefined) updateData.tags = tags.map((t) => profanity.censor(t));
|
if (tags !== undefined) updateData.tags = tags.map((t) => profanity.censor(t));
|
||||||
if (description !== undefined) updateData.description = profanity.censor(description);
|
if (description !== undefined) updateData.description = profanity.censor(description);
|
||||||
if (quarantined !== undefined) updateData.quarantined = quarantined;
|
if (quarantined !== undefined) updateData.quarantined = quarantined;
|
||||||
|
if (needsFixingReason !== undefined) updateData.needsFixing = needsFixingReason;
|
||||||
if (mii.platform === "SWITCH" && gender !== undefined) updateData.gender = gender;
|
if (mii.platform === "SWITCH" && gender !== undefined) updateData.gender = gender;
|
||||||
if (makeup !== undefined) updateData.makeup = makeup;
|
if (makeup !== undefined) updateData.makeup = makeup;
|
||||||
if (youtubeId !== undefined) updateData.youtubeId = youtubeId;
|
if (youtubeId !== undefined) updateData.youtubeId = youtubeId;
|
||||||
|
|
|
||||||
|
|
@ -43,13 +43,12 @@ export async function GET(request: NextRequest) {
|
||||||
? { in_queue: true } // Only show queued Miis
|
? { in_queue: true } // Only show queued Miis
|
||||||
: userId
|
: userId
|
||||||
? {
|
? {
|
||||||
// Include queued Miis if user is on their profile
|
|
||||||
...(Number(session?.user?.id) === userId ? {} : { in_queue: false }),
|
|
||||||
userId,
|
userId,
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
// Don't show queued Miis on main page
|
// Don't show queued Miis on main page
|
||||||
in_queue: false,
|
in_queue: false,
|
||||||
|
needsFixing: null,
|
||||||
}),
|
}),
|
||||||
// Only show liked miis on likes page
|
// Only show liked miis on likes page
|
||||||
...(parentPage === "likes" && miiIdsLiked && { id: { in: miiIdsLiked } }),
|
...(parentPage === "likes" && miiIdsLiked && { id: { in: miiIdsLiked } }),
|
||||||
|
|
@ -99,6 +98,7 @@ export async function GET(request: NextRequest) {
|
||||||
allowedCopying: true,
|
allowedCopying: true,
|
||||||
quarantined: true,
|
quarantined: true,
|
||||||
in_queue: true,
|
in_queue: true,
|
||||||
|
needsFixing: true,
|
||||||
likeCount: true,
|
likeCount: true,
|
||||||
// Mii liked check
|
// Mii liked check
|
||||||
...(session?.user?.id && {
|
...(session?.user?.id && {
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ export default async function RandomPage() {
|
||||||
|
|
||||||
const randomIndex = Math.floor(Math.random() * count);
|
const randomIndex = Math.floor(Math.random() * count);
|
||||||
const randomMii = await prisma.mii.findFirst({
|
const randomMii = await prisma.mii.findFirst({
|
||||||
where: { in_queue: false, quarantined: false },
|
where: { in_queue: false, quarantined: false, needsFixing: { not: null } },
|
||||||
skip: randomIndex,
|
skip: randomIndex,
|
||||||
take: 1,
|
take: 1,
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
|
|
|
||||||
4
frontend/.env.example
Normal file
4
frontend/.env.example
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
VITE_BASE_URL="http://localhost:5173"
|
||||||
|
VITE_API_URL="http://localhost:3000"
|
||||||
|
VITE_ADMIN_USER_ID=1
|
||||||
|
VITE_CONTRIBUTORS_USER_IDS=1
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState, useTransition } from "react";
|
import { useTransition } from "react";
|
||||||
import { Icon } from "@iconify/react";
|
import { Icon } from "@iconify/react";
|
||||||
import type { MiiGender, MiiPlatform } from "@tomodachi-share/shared";
|
import type { MiiGender, MiiPlatform } from "@tomodachi-share/shared";
|
||||||
import { useNavigate, useSearchParams } from "react-router";
|
import { useNavigate, useSearchParams } from "react-router";
|
||||||
|
|
@ -8,12 +8,11 @@ export default function GenderSelect() {
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const [, startTransition] = useTransition();
|
const [, startTransition] = useTransition();
|
||||||
|
|
||||||
const [selected, setSelected] = useState<MiiGender | null>((searchParams.get("gender") as MiiGender) ?? null);
|
const selected = (searchParams.get("gender") as MiiGender) ?? null;
|
||||||
const platform = (searchParams.get("platform") as MiiPlatform) || undefined;
|
const platform = (searchParams.get("platform") as MiiPlatform) || undefined;
|
||||||
|
|
||||||
const handleClick = (gender: MiiGender) => {
|
const handleClick = (gender: MiiGender) => {
|
||||||
const filter = selected === gender ? null : gender;
|
const filter = selected === gender ? null : gender;
|
||||||
setSelected(filter);
|
|
||||||
|
|
||||||
const params = new URLSearchParams(searchParams);
|
const params = new URLSearchParams(searchParams);
|
||||||
params.set("page", "1");
|
params.set("page", "1");
|
||||||
|
|
|
||||||
|
|
@ -86,12 +86,20 @@ export default function MiiList({ parentPage, userId, bypassCache }: Props) {
|
||||||
key={mii.id}
|
key={mii.id}
|
||||||
className={`flex flex-col relative bg-zinc-50 rounded-3xl border-2 shadow-lg p-[0.8rem] transition hover:scale-105 hover:bg-cyan-100 hover:border-cyan-600 ${mii.quarantined ? "border-red-300 bg-red-50!" : mii.in_queue ? "border-zinc-400 opacity-70" : "border-zinc-300"}`}
|
className={`flex flex-col relative bg-zinc-50 rounded-3xl border-2 shadow-lg p-[0.8rem] transition hover:scale-105 hover:bg-cyan-100 hover:border-cyan-600 ${mii.quarantined ? "border-red-300 bg-red-50!" : mii.in_queue ? "border-zinc-400 opacity-70" : "border-zinc-300"}`}
|
||||||
>
|
>
|
||||||
{mii.in_queue && (
|
<div className="absolute top-2 left-2 z-10 flex flex-col gap-1">
|
||||||
<div className="absolute top-2 left-2 z-10 bg-zinc-500 text-white text-xs font-semibold px-2 py-1 rounded-full shadow-sm flex items-center gap-1">
|
{mii.in_queue && (
|
||||||
<Icon icon="mdi:clock-outline" className="text-base" />
|
<div className="bg-zinc-500 text-white text-xs font-semibold px-2 py-1 rounded-full shadow-sm flex items-center gap-1 w-fit">
|
||||||
In Queue
|
<Icon icon="mdi:clock-outline" className="text-base" />
|
||||||
</div>
|
In Queue
|
||||||
)}
|
</div>
|
||||||
|
)}
|
||||||
|
{mii.needsFixing && (
|
||||||
|
<div className="bg-orange-500 text-white text-xs font-semibold px-2 py-1 rounded-full shadow-sm flex items-center gap-1 w-fit">
|
||||||
|
<Icon icon="mdi:alert-outline" className="text-base" />
|
||||||
|
Needs Fixing
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<Link to={`/mii/${mii.id}`} className="overflow-hidden rounded-xl bg-zinc-300 shrink-0">
|
<Link to={`/mii/${mii.id}`} className="overflow-hidden rounded-xl bg-zinc-300 shrink-0">
|
||||||
<img
|
<img
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState, useTransition } from "react";
|
import { useTransition } from "react";
|
||||||
import { Icon } from "@iconify/react";
|
import { Icon } from "@iconify/react";
|
||||||
import type { MiiMakeup } from "@tomodachi-share/shared";
|
import type { MiiMakeup } from "@tomodachi-share/shared";
|
||||||
import { useNavigate, useSearchParams } from "react-router";
|
import { useNavigate, useSearchParams } from "react-router";
|
||||||
|
|
@ -8,11 +8,10 @@ export default function MakeupSelect() {
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const [, startTransition] = useTransition();
|
const [, startTransition] = useTransition();
|
||||||
|
|
||||||
const [selected, setSelected] = useState<MiiMakeup | null>((searchParams.get("makeup") as MiiMakeup) ?? null);
|
const selected = (searchParams.get("makeup") as MiiMakeup) ?? null;
|
||||||
|
|
||||||
const handleClick = (makeup: MiiMakeup) => {
|
const handleClick = (makeup: MiiMakeup) => {
|
||||||
const filter = selected === makeup ? null : makeup;
|
const filter = selected === makeup ? null : makeup;
|
||||||
setSelected(filter);
|
|
||||||
|
|
||||||
const params = new URLSearchParams(searchParams);
|
const params = new URLSearchParams(searchParams);
|
||||||
params.set("page", "1");
|
params.set("page", "1");
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { MiiPlatform } from "@tomodachi-share/shared";
|
import type { MiiPlatform } from "@tomodachi-share/shared";
|
||||||
import { type ChangeEvent, useState, useTransition } from "react";
|
import { type ChangeEvent, useTransition } from "react";
|
||||||
import { useLocation, useNavigate, useSearchParams } from "react-router";
|
import { useLocation, useNavigate, useSearchParams } from "react-router";
|
||||||
|
|
||||||
export default function OtherFilters() {
|
export default function OtherFilters() {
|
||||||
|
|
@ -9,16 +9,14 @@ export default function OtherFilters() {
|
||||||
const [, startTransition] = useTransition();
|
const [, startTransition] = useTransition();
|
||||||
|
|
||||||
const platform = (searchParams.get("platform") as MiiPlatform) || undefined;
|
const platform = (searchParams.get("platform") as MiiPlatform) || undefined;
|
||||||
const [allowCopying, setAllowCopying] = useState<boolean>((searchParams.get("allowCopying") as unknown as boolean) ?? false);
|
const allowCopying = searchParams.get("allowCopying") === "true";
|
||||||
const [quarantined, setQuarantined] = useState<boolean>((searchParams.get("quarantined") as unknown as boolean) ?? false);
|
const quarantined = searchParams.get("quarantined") === "true";
|
||||||
|
|
||||||
const handleChangeAllowCopying = (e: ChangeEvent<HTMLInputElement>) => {
|
const handleChangeAllowCopying = (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
setAllowCopying(e.target.checked);
|
|
||||||
|
|
||||||
const params = new URLSearchParams(searchParams);
|
const params = new URLSearchParams(searchParams);
|
||||||
params.set("page", "1");
|
params.set("page", "1");
|
||||||
|
|
||||||
if (!allowCopying) {
|
if (e.target.checked) {
|
||||||
params.set("allowCopying", "true");
|
params.set("allowCopying", "true");
|
||||||
} else {
|
} else {
|
||||||
params.delete("allowCopying");
|
params.delete("allowCopying");
|
||||||
|
|
@ -30,12 +28,10 @@ export default function OtherFilters() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleChangeQuarantined = (e: ChangeEvent<HTMLInputElement>) => {
|
const handleChangeQuarantined = (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
setQuarantined(e.target.checked);
|
|
||||||
|
|
||||||
const params = new URLSearchParams(searchParams);
|
const params = new URLSearchParams(searchParams);
|
||||||
params.set("page", "1");
|
params.set("page", "1");
|
||||||
|
|
||||||
if (!quarantined) {
|
if (e.target.checked) {
|
||||||
params.set("quarantined", "true");
|
params.set("quarantined", "true");
|
||||||
} else {
|
} else {
|
||||||
params.delete("quarantined");
|
params.delete("quarantined");
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState, useTransition } from "react";
|
import { useTransition } from "react";
|
||||||
import { Icon } from "@iconify/react";
|
import { Icon } from "@iconify/react";
|
||||||
import type { MiiPlatform } from "@tomodachi-share/shared";
|
import type { MiiPlatform } from "@tomodachi-share/shared";
|
||||||
import { useNavigate, useSearchParams } from "react-router";
|
import { useNavigate, useSearchParams } from "react-router";
|
||||||
|
|
@ -7,19 +7,22 @@ export default function PlatformSelect() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const [, startTransition] = useTransition();
|
const [, startTransition] = useTransition();
|
||||||
|
const selected = (searchParams.get("platform") as MiiPlatform) ?? null;
|
||||||
const [selected, setSelected] = useState<MiiPlatform | null>((searchParams.get("platform") as MiiPlatform) ?? null);
|
|
||||||
|
|
||||||
const handleClick = (platform: MiiPlatform) => {
|
const handleClick = (platform: MiiPlatform) => {
|
||||||
const filter = selected === platform ? null : platform;
|
const filter = selected === platform ? null : platform;
|
||||||
setSelected(filter);
|
|
||||||
|
|
||||||
const params = new URLSearchParams(searchParams);
|
const params = new URLSearchParams(searchParams);
|
||||||
|
params.set("page", "1");
|
||||||
|
|
||||||
if (filter) {
|
if (filter) {
|
||||||
params.set("platform", filter);
|
params.set("platform", filter);
|
||||||
} else {
|
} else {
|
||||||
params.delete("platform");
|
params.delete("platform");
|
||||||
}
|
}
|
||||||
|
if (params.get("gender") === "NONBINARY") params.delete("gender");
|
||||||
|
params.delete("makeup");
|
||||||
|
params.delete("allowCopying");
|
||||||
|
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
navigate(`?${params.toString()}`);
|
navigate(`?${params.toString()}`);
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,7 @@ export default function ImageEditorPortrait({ isOpen, setIsOpen, image, setImage
|
||||||
|
|
||||||
<div className="relative w-full flex justify-center">
|
<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">
|
<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} />
|
<img ref={imageRef} src={image} crossOrigin="anonymous" />
|
||||||
</ReactCrop>
|
</ReactCrop>
|
||||||
<canvas ref={canvasRef} className="hidden" />
|
<canvas ref={canvasRef} className="hidden" />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,80 +1,80 @@
|
||||||
import { type SwitchMiiInstructions } from "@tomodachi-share/shared";
|
import { type SwitchMiiInstructions } from "@tomodachi-share/shared";
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { Icon } from "@iconify/react";
|
import { Icon } from "@iconify/react";
|
||||||
|
|
||||||
import HeadTab from "./tabs/head";
|
import HeadTab from "./tabs/head";
|
||||||
import HairTab from "./tabs/hair";
|
import HairTab from "./tabs/hair";
|
||||||
import EyebrowsTab from "./tabs/eyebrows";
|
import EyebrowsTab from "./tabs/eyebrows";
|
||||||
import EyesTab from "./tabs/eyes";
|
import EyesTab from "./tabs/eyes";
|
||||||
import NoseTab from "./tabs/nose";
|
import NoseTab from "./tabs/nose";
|
||||||
import LipsTab from "./tabs/lips";
|
import LipsTab from "./tabs/lips";
|
||||||
import EarsTab from "./tabs/ears";
|
import EarsTab from "./tabs/ears";
|
||||||
import GlassesTab from "./tabs/glasses";
|
import GlassesTab from "./tabs/glasses";
|
||||||
import OtherTab from "./tabs/other";
|
import OtherTab from "./tabs/other";
|
||||||
import MiscTab from "./tabs/misc";
|
import MiscTab from "./tabs/misc";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
instructions: React.RefObject<SwitchMiiInstructions>;
|
instructions: React.RefObject<SwitchMiiInstructions>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Tab = "head" | "hair" | "eyebrows" | "eyes" | "nose" | "lips" | "ears" | "glasses" | "other" | "misc";
|
type Tab = "head" | "hair" | "eyebrows" | "eyes" | "nose" | "lips" | "ears" | "glasses" | "other" | "misc";
|
||||||
|
|
||||||
export const TAB_ICONS: Record<Tab, string> = {
|
export const TAB_ICONS: Record<Tab, string> = {
|
||||||
head: "mingcute:head-fill",
|
head: "mingcute:head-fill",
|
||||||
hair: "mingcute:hair-fill",
|
hair: "mingcute:hair-fill",
|
||||||
eyebrows: "material-symbols:eyebrow",
|
eyebrows: "material-symbols:eyebrow",
|
||||||
eyes: "mdi:eye",
|
eyes: "mdi:eye",
|
||||||
nose: "mingcute:nose-fill",
|
nose: "mingcute:nose-fill",
|
||||||
lips: "material-symbols-light:lips",
|
lips: "material-symbols-light:lips",
|
||||||
ears: "ion:ear",
|
ears: "ion:ear",
|
||||||
glasses: "solar:glasses-bold",
|
glasses: "solar:glasses-bold",
|
||||||
other: "mdi:sparkles",
|
other: "mdi:sparkles",
|
||||||
misc: "material-symbols:settings",
|
misc: "material-symbols:settings",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TAB_COMPONENTS: Record<Tab, React.ComponentType<any>> = {
|
export const TAB_COMPONENTS: Record<Tab, React.ComponentType<any>> = {
|
||||||
head: HeadTab,
|
head: HeadTab,
|
||||||
hair: HairTab,
|
hair: HairTab,
|
||||||
eyebrows: EyebrowsTab,
|
eyebrows: EyebrowsTab,
|
||||||
eyes: EyesTab,
|
eyes: EyesTab,
|
||||||
nose: NoseTab,
|
nose: NoseTab,
|
||||||
lips: LipsTab,
|
lips: LipsTab,
|
||||||
ears: EarsTab,
|
ears: EarsTab,
|
||||||
glasses: GlassesTab,
|
glasses: GlassesTab,
|
||||||
other: OtherTab,
|
other: OtherTab,
|
||||||
misc: MiscTab,
|
misc: MiscTab,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function MiiEditor({ instructions }: Props) {
|
export default function MiiEditor({ instructions }: Props) {
|
||||||
const [tab, setTab] = useState<Tab>("head");
|
const [tab, setTab] = useState<Tab>("head");
|
||||||
|
|
||||||
return (
|
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 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">
|
<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) => (
|
{(Object.keys(TAB_COMPONENTS) as Tab[]).map((t) => (
|
||||||
<button
|
<button
|
||||||
key={t}
|
key={t}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setTab(t)}
|
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!" : ""}`}
|
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 */}
|
{/* ml because of border on left causing icons to look miscentered */}
|
||||||
<Icon icon={TAB_ICONS[t]} className="-ml-0.5" />
|
<Icon icon={TAB_ICONS[t]} className="-ml-0.5" />
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Keep all tabs loaded to avoid flickering */}
|
{/* Keep all tabs loaded to avoid flickering */}
|
||||||
{(Object.keys(TAB_COMPONENTS) as Tab[]).map((t) => {
|
{(Object.keys(TAB_COMPONENTS) as Tab[]).map((t) => {
|
||||||
const TabComponent = TAB_COMPONENTS[t];
|
const TabComponent = TAB_COMPONENTS[t];
|
||||||
return (
|
return (
|
||||||
<div key={t} className={t === tab ? "grow relative p-3" : "hidden"}>
|
<div key={t} className={t === tab ? "grow relative p-3 min-h-0" : "hidden"}>
|
||||||
<TabComponent instructions={instructions} />
|
<TabComponent instructions={instructions} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,213 +1,213 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import type { MiiGender, SwitchMiiInstructions } from "@tomodachi-share/shared";
|
import type { MiiGender, SwitchMiiInstructions } from "@tomodachi-share/shared";
|
||||||
import EnhancedSlider from "../enhanced-slider";
|
import EnhancedSlider from "../enhanced-slider";
|
||||||
import DatingPreferencesViewer from "../../../mii/dating-preferences";
|
import DatingPreferencesViewer from "../../../mii/dating-preferences";
|
||||||
import VoiceViewer from "../../../mii/voice-viewer";
|
import VoiceViewer from "../../../mii/voice-viewer";
|
||||||
import PersonalityViewer from "../../../mii/personality-viewer";
|
import PersonalityViewer from "../../../mii/personality-viewer";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
instructions: React.RefObject<SwitchMiiInstructions>;
|
instructions: React.RefObject<SwitchMiiInstructions>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MiscTab({ instructions }: Props) {
|
export default function MiscTab({ instructions }: Props) {
|
||||||
const [height, setHeight] = useState(instructions.current.height ?? 64);
|
const [height, setHeight] = useState(instructions.current.height ?? 64);
|
||||||
const [weight, setWeight] = useState(instructions.current.weight ?? 64);
|
const [weight, setWeight] = useState(instructions.current.weight ?? 64);
|
||||||
const [datingPreferences, setDatingPreferences] = useState<MiiGender[]>(instructions.current.datingPreferences ?? []);
|
const [datingPreferences, setDatingPreferences] = useState<MiiGender[]>(instructions.current.datingPreferences ?? []);
|
||||||
const [voice, setVoice] = useState({
|
const [voice, setVoice] = useState({
|
||||||
speed: instructions.current.voice.speed ?? 25,
|
speed: instructions.current.voice.speed ?? 25,
|
||||||
pitch: instructions.current.voice.pitch ?? 25,
|
pitch: instructions.current.voice.pitch ?? 25,
|
||||||
depth: instructions.current.voice.depth ?? 25,
|
depth: instructions.current.voice.depth ?? 25,
|
||||||
delivery: instructions.current.voice.delivery ?? 25,
|
delivery: instructions.current.voice.delivery ?? 25,
|
||||||
tone: instructions.current.voice.tone ?? 0,
|
tone: instructions.current.voice.tone ?? 0,
|
||||||
});
|
});
|
||||||
const [birthday, setBirthday] = useState({
|
const [birthday, setBirthday] = useState({
|
||||||
day: instructions.current.birthday.day ?? (null as number | null),
|
day: instructions.current.birthday.day ?? (null as number | null),
|
||||||
month: instructions.current.birthday.month ?? (null as number | null),
|
month: instructions.current.birthday.month ?? (null as number | null),
|
||||||
age: instructions.current.birthday.age ?? (null as number | null),
|
age: instructions.current.birthday.age ?? (null as number | null),
|
||||||
dontAge: instructions.current.birthday.dontAge,
|
dontAge: instructions.current.birthday.dontAge,
|
||||||
});
|
});
|
||||||
const [personality, setPersonality] = useState({
|
const [personality, setPersonality] = useState({
|
||||||
movement: instructions.current.personality.movement ?? -1,
|
movement: instructions.current.personality.movement ?? -1,
|
||||||
speech: instructions.current.personality.speech ?? -1,
|
speech: instructions.current.personality.speech ?? -1,
|
||||||
energy: instructions.current.personality.energy ?? -1,
|
energy: instructions.current.personality.energy ?? -1,
|
||||||
thinking: instructions.current.personality.thinking ?? -1,
|
thinking: instructions.current.personality.thinking ?? -1,
|
||||||
overall: instructions.current.personality.overall ?? -1,
|
overall: instructions.current.personality.overall ?? -1,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h1 className="font-bold text-xl">Misc</h1>
|
<h1 className="font-bold text-xl">Misc</h1>
|
||||||
|
|
||||||
<div className="grow h-full overflow-y-auto pb-3">
|
<div className="grow h-full overflow-y-auto pb-3">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium">
|
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium">
|
||||||
<hr className="grow border-zinc-300" />
|
<hr className="grow border-zinc-300" />
|
||||||
<span>Body</span>
|
<span>Body</span>
|
||||||
<hr className="grow border-zinc-300" />
|
<hr className="grow border-zinc-300" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<EnhancedSlider
|
<EnhancedSlider
|
||||||
label="Height"
|
label="Height"
|
||||||
value={height}
|
value={height}
|
||||||
onChange={(v) => {
|
onChange={(v) => {
|
||||||
setHeight(v);
|
setHeight(v);
|
||||||
instructions.current.height = v;
|
instructions.current.height = v;
|
||||||
}}
|
}}
|
||||||
min={0}
|
min={0}
|
||||||
max={128}
|
max={128}
|
||||||
mid={64}
|
mid={64}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<EnhancedSlider
|
<EnhancedSlider
|
||||||
label="Weight"
|
label="Weight"
|
||||||
value={weight}
|
value={weight}
|
||||||
onChange={(v) => {
|
onChange={(v) => {
|
||||||
setWeight(v);
|
setWeight(v);
|
||||||
instructions.current.weight = v;
|
instructions.current.weight = v;
|
||||||
}}
|
}}
|
||||||
min={0}
|
min={0}
|
||||||
max={128}
|
max={128}
|
||||||
mid={64}
|
mid={64}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-1.5 mb-2">
|
<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" />
|
<hr className="grow border-zinc-300" />
|
||||||
<span>Dating Preferences</span>
|
<span>Dating Preferences</span>
|
||||||
<hr className="grow border-zinc-300" />
|
<hr className="grow border-zinc-300" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<DatingPreferencesViewer
|
<DatingPreferencesViewer
|
||||||
data={datingPreferences}
|
data={datingPreferences}
|
||||||
onChecked={(e, gender) => {
|
onChecked={(e, gender) => {
|
||||||
setDatingPreferences((prev) => {
|
setDatingPreferences((prev) => {
|
||||||
const updated = e.target.checked ? (prev.includes(gender) ? prev : [...prev, gender]) : prev.filter((p) => p !== gender);
|
const updated = e.target.checked ? (prev.includes(gender) ? prev : [...prev, gender]) : prev.filter((p) => p !== gender);
|
||||||
instructions.current.datingPreferences = updated;
|
instructions.current.datingPreferences = updated;
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium">
|
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium">
|
||||||
<hr className="grow border-zinc-300" />
|
<hr className="grow border-zinc-300" />
|
||||||
<span>Voice</span>
|
<span>Voice</span>
|
||||||
<hr className="grow border-zinc-300" />
|
<hr className="grow border-zinc-300" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<VoiceViewer
|
<VoiceViewer
|
||||||
data={voice}
|
data={voice}
|
||||||
onChange={(v, label) => {
|
onChange={(v, label) => {
|
||||||
setVoice((p) => ({ ...p, [label]: v }));
|
setVoice((p) => ({ ...p, [label]: v }));
|
||||||
instructions.current.voice[label as keyof typeof voice] = v;
|
instructions.current.voice[label as keyof typeof voice] = v;
|
||||||
}}
|
}}
|
||||||
onClickTone={(i) => {
|
onClickTone={(i) => {
|
||||||
setVoice((p) => ({ ...p, tone: i }));
|
setVoice((p) => ({ ...p, tone: i }));
|
||||||
instructions.current.voice.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">
|
<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" />
|
<hr className="grow border-zinc-300" />
|
||||||
<span>Birthday</span>
|
<span>Birthday</span>
|
||||||
<hr className="grow border-zinc-300" />
|
<hr className="grow border-zinc-300" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<div className="grid grid-cols-3 gap-2">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="day" className="text-xs">
|
<label htmlFor="day" className="text-xs">
|
||||||
Day
|
Day
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
id="day"
|
id="day"
|
||||||
min={1}
|
min={1}
|
||||||
max={31}
|
max={31}
|
||||||
className="pill input text-sm py-1! px-3! w-full"
|
className="pill input text-sm py-1! px-3! w-full"
|
||||||
value={birthday.day ?? undefined}
|
value={birthday.day ?? undefined}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setBirthday((p) => ({ ...p, day: e.target.valueAsNumber }));
|
setBirthday((p) => ({ ...p, day: e.target.valueAsNumber }));
|
||||||
instructions.current.birthday.day = e.target.valueAsNumber;
|
instructions.current.birthday.day = e.target.valueAsNumber;
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="month" className="text-xs">
|
<label htmlFor="month" className="text-xs">
|
||||||
Month
|
Month
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
id="month"
|
id="month"
|
||||||
min={1}
|
min={1}
|
||||||
max={12}
|
max={12}
|
||||||
className="pill input text-sm py-1! px-3! w-full"
|
className="pill input text-sm py-1! px-3! w-full"
|
||||||
value={birthday.month ?? undefined}
|
value={birthday.month ?? undefined}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setBirthday((p) => ({ ...p, month: e.target.valueAsNumber }));
|
setBirthday((p) => ({ ...p, month: e.target.valueAsNumber }));
|
||||||
instructions.current.birthday.month = e.target.valueAsNumber;
|
instructions.current.birthday.month = e.target.valueAsNumber;
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="age" className="text-xs">
|
<label htmlFor="age" className="text-xs">
|
||||||
Age
|
Age
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
id="age"
|
id="age"
|
||||||
min={1}
|
min={1}
|
||||||
max={1000}
|
max={1000}
|
||||||
className="pill input text-sm py-1! px-3! w-full"
|
className="pill input text-sm py-1! px-3! w-full"
|
||||||
value={birthday.age ?? undefined}
|
value={birthday.age ?? undefined}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setBirthday((p) => ({ ...p, age: e.target.valueAsNumber }));
|
setBirthday((p) => ({ ...p, age: e.target.valueAsNumber }));
|
||||||
instructions.current.birthday.age = e.target.valueAsNumber;
|
instructions.current.birthday.age = e.target.valueAsNumber;
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1.5 col-span-2">
|
<div className="flex gap-1.5 col-span-2">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
id="dontAge"
|
id="dontAge"
|
||||||
className="checkbox"
|
className="checkbox"
|
||||||
checked={birthday.dontAge}
|
checked={birthday.dontAge}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setBirthday((p) => ({ ...p, dontAge: e.target.checked }));
|
setBirthday((p) => ({ ...p, dontAge: e.target.checked }));
|
||||||
instructions.current.birthday.dontAge = e.target.checked;
|
instructions.current.birthday.dontAge = e.target.checked;
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<label htmlFor="dontAge" className="text-sm select-none">
|
<label htmlFor="dontAge" className="text-sm select-none">
|
||||||
Don't Age
|
Don't Age
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-2 mb-2">
|
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-2 mb-2">
|
||||||
<hr className="grow border-zinc-300" />
|
<hr className="grow border-zinc-300" />
|
||||||
<span>Personality</span>
|
<span>Personality</span>
|
||||||
<hr className="grow border-zinc-300" />
|
<hr className="grow border-zinc-300" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PersonalityViewer
|
<PersonalityViewer
|
||||||
data={personality}
|
data={personality}
|
||||||
onClick={(key, i) => {
|
onClick={(key, i) => {
|
||||||
setPersonality((p) => {
|
setPersonality((p) => {
|
||||||
const updated = { ...p, [key]: i };
|
const updated = { ...p, [key]: i };
|
||||||
instructions.current.personality = updated;
|
instructions.current.personality = updated;
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ export default function EditMiiPage() {
|
||||||
const instructions = useRef<SwitchMiiInstructions>(defaultInstructions);
|
const instructions = useRef<SwitchMiiInstructions>(defaultInstructions);
|
||||||
|
|
||||||
const [quarantined, setQuarantined] = useState(false);
|
const [quarantined, setQuarantined] = useState(false);
|
||||||
|
const [needsFixingReason, setNeedsFixingReason] = useState("");
|
||||||
const hasCustomImagesChanged = useRef(false);
|
const hasCustomImagesChanged = useRef(false);
|
||||||
const hasMiiPortraitChanged = useRef(false);
|
const hasMiiPortraitChanged = useRef(false);
|
||||||
const hasMiiFeaturesChanged = useRef(false);
|
const hasMiiFeaturesChanged = useRef(false);
|
||||||
|
|
@ -80,6 +81,7 @@ export default function EditMiiPage() {
|
||||||
if (makeup != mii.makeup) formData.append("makeup", makeup);
|
if (makeup != mii.makeup) formData.append("makeup", makeup);
|
||||||
if (miiPortraitUri) formData.append("miiPortraitUri", miiPortraitUri);
|
if (miiPortraitUri) formData.append("miiPortraitUri", miiPortraitUri);
|
||||||
if (quarantined != mii.quarantined) formData.append("quarantined", JSON.stringify(quarantined));
|
if (quarantined != mii.quarantined) formData.append("quarantined", JSON.stringify(quarantined));
|
||||||
|
if (needsFixingReason !== mii.needsFixing) formData.append("needsFixingReason", needsFixingReason);
|
||||||
if (youtubeId != mii.youtubeId) formData.append("youtubeId", youtubeId);
|
if (youtubeId != mii.youtubeId) formData.append("youtubeId", youtubeId);
|
||||||
if (minifyInstructions(structuredClone(instructions.current)) !== (mii.instructions as object))
|
if (minifyInstructions(structuredClone(instructions.current)) !== (mii.instructions as object))
|
||||||
formData.append("instructions", JSON.stringify(instructions.current));
|
formData.append("instructions", JSON.stringify(instructions.current));
|
||||||
|
|
@ -185,6 +187,7 @@ export default function EditMiiPage() {
|
||||||
setMiiFeaturesUri(`${API_URL}/mii/${data.id}/image?type=features`);
|
setMiiFeaturesUri(`${API_URL}/mii/${data.id}/image?type=features`);
|
||||||
setYouTubeId(data.youtubeId ?? "");
|
setYouTubeId(data.youtubeId ?? "");
|
||||||
setQuarantined(data.quarantined);
|
setQuarantined(data.quarantined);
|
||||||
|
setNeedsFixingReason(data.needsFixing);
|
||||||
instructions.current = deepMerge(defaultInstructions, (data.instructions as object) ?? {});
|
instructions.current = deepMerge(defaultInstructions, (data.instructions as object) ?? {});
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
})
|
})
|
||||||
|
|
@ -297,6 +300,22 @@ export default function EditMiiPage() {
|
||||||
<input type="checkbox" id="quarantined" className="checkbox-alt" checked={quarantined} onChange={(e) => setQuarantined(e.target.checked)} />
|
<input type="checkbox" id="quarantined" className="checkbox-alt" checked={quarantined} onChange={(e) => setQuarantined(e.target.checked)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full grid grid-cols-3 items-center">
|
||||||
|
<label htmlFor="needsFixing" className="font-semibold py-2">
|
||||||
|
Needs Fixing
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="col-span-2 flex gap-1">
|
||||||
|
<input
|
||||||
|
id="needsFixing"
|
||||||
|
placeholder="Put the reason here..."
|
||||||
|
className="pill input w-full col-span-2"
|
||||||
|
value={needsFixingReason ?? ""}
|
||||||
|
onChange={(e) => setNeedsFixingReason(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ export default function IndexPage() {
|
||||||
<h1 className="sr-only">
|
<h1 className="sr-only">
|
||||||
{searchParams.get("tags") ? `Miis tagged with '${searchParams.get("tags")}' - TomodachiShare` : "TomodachiShare - index mii list"}
|
{searchParams.get("tags") ? `Miis tagged with '${searchParams.get("tags")}' - TomodachiShare` : "TomodachiShare - index mii list"}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-center mb-4">We're currently going through some major code changes therefore some features won't work.</p>
|
|
||||||
<MiiList />
|
<MiiList />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,16 @@ export default function MiiPage() {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{mii.needsFixing && (
|
||||||
|
<div className="bg-orange-50 border-2 border-orange-400 rounded-2xl shadow-lg p-4 flex items-start gap-3 text-orange-700">
|
||||||
|
<Icon icon="material-symbols:warning-rounded" className="text-2xl shrink-0" />
|
||||||
|
<p className="font-medium">
|
||||||
|
This Mii won't show up on the main page until fixes are made.
|
||||||
|
<br />
|
||||||
|
Reason: {mii.needsFixing}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="relative grid grid-cols-3 gap-4 max-md:grid-cols-1">
|
<div className="relative grid grid-cols-3 gap-4 max-md:grid-cols-1">
|
||||||
<div className="bg-amber-50 rounded-3xl border-2 border-amber-500 shadow-lg p-4 h-min flex flex-col items-center max-w-md w-full max-md:place-self-center max-md:row-start-2">
|
<div className="bg-amber-50 rounded-3xl border-2 border-amber-500 shadow-lg p-4 h-min flex flex-col items-center max-w-md w-full max-md:place-self-center max-md:row-start-2">
|
||||||
{/* Mii Image */}
|
{/* Mii Image */}
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,11 @@ export default function ReportMiiPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm bg-orange-100 border border-orange-400 rounded-lg px-3 py-2">
|
||||||
|
<span className="font-semibold">REMINDER:</span> Miis without instructions are allowed, as mentioned in the submit form. Be sure to add notes so we
|
||||||
|
know what's wrong.
|
||||||
|
</p>
|
||||||
|
|
||||||
<div className="w-full grid grid-cols-3 items-center">
|
<div className="w-full grid grid-cols-3 items-center">
|
||||||
<label htmlFor="reason" className="font-semibold">
|
<label htmlFor="reason" className="font-semibold">
|
||||||
Reason
|
Reason
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,10 @@ export default function ReportUserPage() {
|
||||||
<p className="text-xl font-bold overflow-hidden text-ellipsis">{user.name}</p>
|
<p className="text-xl font-bold overflow-hidden text-ellipsis">{user.name}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm bg-orange-100 border border-orange-400 rounded-lg px-3 py-2">
|
||||||
|
<span className="font-semibold">REMINDER:</span> Be sure to add notes so we know what's wrong.
|
||||||
|
</p>
|
||||||
|
|
||||||
<div className="w-full grid grid-cols-3 items-center">
|
<div className="w-full grid grid-cols-3 items-center">
|
||||||
<label htmlFor="reason" className="font-semibold">
|
<label htmlFor="reason" className="font-semibold">
|
||||||
Reason
|
Reason
|
||||||
|
|
|
||||||
|
|
@ -413,7 +413,7 @@ export default function SubmitPage() {
|
||||||
className="size-20 object-cover rounded-xl border-2 border-orange-300 shrink-0 opacity-70"
|
className="size-20 object-cover rounded-xl border-2 border-orange-300 shrink-0 opacity-70"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<SwitchFileUpload text="a screenshot of your Mii's features here" image={miiFeaturesUri} setImage={setMiiFeaturesUri} />
|
<SwitchFileUpload text="a screenshot of your Mii's features here" image={miiFeaturesUri} setImage={setMiiFeaturesUri} forceCrop />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue