feat: needs fixing miis

This commit is contained in:
trafficlunar 2026-04-24 21:32:53 +01:00
parent 781682e24e
commit 4275f710b0
9 changed files with 100 additions and 38 deletions

View file

@ -4,36 +4,9 @@ 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 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. 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, 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
```
## 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:
@ -85,6 +58,33 @@ 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.
## 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, 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 ## Development Server
The frontend and backend need to be ran simulatenously, therefore you need two separate terminals. The frontend and backend need to be ran simulatenously, therefore you need two separate terminals.

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "miis" ADD COLUMN "needsFixing" TEXT;

View file

@ -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?

View file

@ -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;

View file

@ -99,6 +99,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 && {

View file

@ -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 },

View file

@ -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"}`}
> >
<div className="absolute top-2 left-2 z-10 flex flex-col gap-1">
{mii.in_queue && ( {mii.in_queue && (
<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"> <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">
<Icon icon="mdi:clock-outline" className="text-base" /> <Icon icon="mdi:clock-outline" className="text-base" />
In Queue In Queue
</div> </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

View file

@ -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>
</> </>
)} )}

View file

@ -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 */}