mirror of
https://github.com/trafficlunar/tomodachi-share.git
synced 2026-05-13 13:17:45 +00:00
Compare commits
No commits in common. "576cb698d2f9abc4b5dfc910a2f255cd3cd396b0" and "41a31311b5aa6bf87191434b00a5c3d5ea7d4e05" have entirely different histories.
576cb698d2
...
41a31311b5
7 changed files with 617 additions and 786 deletions
18
package.json
18
package.json
|
|
@ -11,20 +11,20 @@
|
|||
"postinstall": "prisma generate"
|
||||
},
|
||||
"dependencies": {
|
||||
"@2toad/profanity": "^3.3.0",
|
||||
"@2toad/profanity": "^3.2.0",
|
||||
"@auth/prisma-adapter": "2.11.1",
|
||||
"@bprogress/next": "^3.2.12",
|
||||
"@hello-pangea/dnd": "^18.0.1",
|
||||
"@prisma/client": "^6.19.2",
|
||||
"@sentry/nextjs": "^10.46.0",
|
||||
"@sentry/nextjs": "^10.45.0",
|
||||
"bit-buffer": "^0.3.0",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"dayjs": "^1.11.20",
|
||||
"downshift": "^9.3.2",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"file-type": "^22.0.0",
|
||||
"file-type": "^21.3.3",
|
||||
"jsqr": "^1.4.0",
|
||||
"next": "16.2.1",
|
||||
"next": "16.2.0",
|
||||
"next-auth": "5.0.0-beta.30",
|
||||
"qrcode-generator": "^2.0.4",
|
||||
"react": "^19.2.4",
|
||||
|
|
@ -32,7 +32,7 @@
|
|||
"react-dropzone": "^15.0.0",
|
||||
"react-image-crop": "^11.0.10",
|
||||
"redis": "^5.11.0",
|
||||
"satori": "^0.26.0",
|
||||
"satori": "^0.25.0",
|
||||
"seedrandom": "^3.0.5",
|
||||
"sharp": "^0.34.5",
|
||||
"sjcl-with-all": "1.0.8",
|
||||
|
|
@ -49,11 +49,11 @@
|
|||
"@types/react-dom": "^19.2.3",
|
||||
"@types/seedrandom": "^3.0.8",
|
||||
"@types/sjcl": "^1.0.34",
|
||||
"eslint": "^10.1.0",
|
||||
"eslint-config-next": "16.2.1",
|
||||
"eslint": "^10.0.3",
|
||||
"eslint-config-next": "16.2.0",
|
||||
"prisma": "^6.19.2",
|
||||
"schema-dts": "^2.0.0",
|
||||
"schema-dts": "^1.1.5",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "^6.0.2"
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1207
pnpm-lock.yaml
1207
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
|
|
@ -24,8 +24,6 @@ const editSchema = z.object({
|
|||
tags: tagsSchema.optional(),
|
||||
description: z.string().trim().max(512).optional(),
|
||||
makeup: z.enum(MiiMakeup).optional(),
|
||||
miiPortraitImage: z.union([z.instanceof(File), z.any()]).optional(),
|
||||
miiFeaturesImage: z.union([z.instanceof(File), z.any()]).optional(),
|
||||
instructions: switchMiiInstructionsSchema,
|
||||
image1: z.union([z.instanceof(File), z.any()]).optional(),
|
||||
image2: z.union([z.instanceof(File), z.any()]).optional(),
|
||||
|
|
@ -78,8 +76,6 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
|
|||
tags: rawTags,
|
||||
description: formData.get("description") ?? undefined,
|
||||
makeup: formData.get("makeup") ?? undefined,
|
||||
miiPortraitImage: formData.get("miiPortraitImage"),
|
||||
miiFeaturesImage: formData.get("miiFeaturesImage"),
|
||||
instructions: minifiedInstructions,
|
||||
image1: formData.get("image1"),
|
||||
image2: formData.get("image2"),
|
||||
|
|
@ -87,7 +83,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
|
|||
});
|
||||
|
||||
if (!parsed.success) return rateLimit.sendResponse({ error: parsed.error.issues[0].message }, 400);
|
||||
const { name, tags, description, makeup, miiPortraitImage, miiFeaturesImage, instructions, image1, image2, image3 } = parsed.data;
|
||||
const { name, tags, description, makeup, instructions, image1, image2, image3 } = parsed.data;
|
||||
|
||||
// Validate image files
|
||||
const images: File[] = [];
|
||||
|
|
@ -103,18 +99,6 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
|
|||
}
|
||||
}
|
||||
|
||||
// Check Mii portrait & features image (Switch)
|
||||
if (mii.platform === "SWITCH") {
|
||||
if (miiPortraitImage) {
|
||||
const validation = await validateImage(miiPortraitImage);
|
||||
if (!validation.valid) return rateLimit.sendResponse({ error: `Failed to verify portrait: ${validation.error}` }, validation.status ?? 400);
|
||||
}
|
||||
if (miiFeaturesImage) {
|
||||
const validation = await validateImage(miiFeaturesImage);
|
||||
if (!validation.valid) return rateLimit.sendResponse({ error: `Failed to verify features: ${validation.error}` }, validation.status ?? 400);
|
||||
}
|
||||
}
|
||||
|
||||
// Edit Mii in database
|
||||
const updateData: Prisma.MiiUpdateInput = {};
|
||||
if (name !== undefined) updateData.name = profanity.censor(name); // Censor potentially inappropriate words
|
||||
|
|
@ -139,12 +123,12 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
|
|||
},
|
||||
});
|
||||
|
||||
// Ensure directories exist
|
||||
const miiUploadsDirectory = path.join(uploadsDirectory, miiId.toString());
|
||||
await fs.mkdir(miiUploadsDirectory, { recursive: true });
|
||||
|
||||
// Only touch files if new images were uploaded
|
||||
if (images.length > 0) {
|
||||
// Ensure directories exist
|
||||
const miiUploadsDirectory = path.join(uploadsDirectory, miiId.toString());
|
||||
await fs.mkdir(miiUploadsDirectory, { recursive: true });
|
||||
|
||||
// Delete all custom images
|
||||
const files = await fs.readdir(miiUploadsDirectory);
|
||||
await Promise.all(files.filter((file) => file.startsWith("image")).map((file) => fs.unlink(path.join(miiUploadsDirectory, file))));
|
||||
|
|
@ -165,60 +149,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
|
|||
Sentry.captureException(error, { extra: { stage: "edit-custom-images" } });
|
||||
return rateLimit.sendResponse({ error: "Failed to store user images" }, 500);
|
||||
}
|
||||
}
|
||||
|
||||
// Only save portrait & features for Switch Miis when they are provided
|
||||
if (mii.platform === "SWITCH" && (miiPortraitImage || miiFeaturesImage)) {
|
||||
try {
|
||||
// Delete existing portrait/features if they're being replaced
|
||||
await Promise.all(
|
||||
["mii.png", "features.png"]
|
||||
.filter((file) => {
|
||||
if (file === "mii.png") return miiPortraitImage;
|
||||
if (file === "features.png") return miiFeaturesImage;
|
||||
return false;
|
||||
})
|
||||
.map((file) => fs.unlink(path.join(miiUploadsDirectory, file))),
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
[
|
||||
miiPortraitImage &&
|
||||
(async () => {
|
||||
const portraitBuffer = Buffer.from(await miiPortraitImage.arrayBuffer());
|
||||
const pngBuffer = await sharp(portraitBuffer)
|
||||
.resize({
|
||||
height: 500,
|
||||
fit: "inside",
|
||||
withoutEnlargement: true,
|
||||
})
|
||||
.png({ quality: 85 })
|
||||
.toBuffer();
|
||||
await fs.writeFile(path.join(miiUploadsDirectory, "mii.png"), pngBuffer);
|
||||
})(),
|
||||
miiFeaturesImage &&
|
||||
(async () => {
|
||||
const featuresBuffer = Buffer.from(await miiFeaturesImage.arrayBuffer());
|
||||
const pngBuffer = await sharp(featuresBuffer)
|
||||
.resize({
|
||||
height: 800,
|
||||
fit: "inside",
|
||||
withoutEnlargement: true,
|
||||
})
|
||||
.png({ quality: 85 })
|
||||
.toBuffer();
|
||||
await fs.writeFile(path.join(miiUploadsDirectory, "features.png"), pngBuffer);
|
||||
})(),
|
||||
].filter(Boolean),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error uploading portrait/features images:", error);
|
||||
Sentry.captureException(error, { extra: { stage: "edit-portrait-features" } });
|
||||
return rateLimit.sendResponse({ error: "Failed to store portrait/features images" }, 500);
|
||||
}
|
||||
}
|
||||
|
||||
if (description === undefined) {
|
||||
} else if (description === undefined) {
|
||||
// If images or description were not changed, regenerate the metadata image
|
||||
try {
|
||||
await generateMetadataImage(updatedMii, updatedMii.user.name!);
|
||||
|
|
|
|||
|
|
@ -206,7 +206,7 @@ export default async function MiiList({ searchParams, userId, inLikesPage }: Pro
|
|||
|
||||
<div className="p-4 flex flex-col gap-1 h-full">
|
||||
<div className="flex justify-between items-center">
|
||||
<Link href={`/mii/${mii.id}`} className="relative font-bold text-2xl line-clamp-1 w-full text-ellipsis wrap-break-word" title={mii.name}>
|
||||
<Link href={`/mii/${mii.id}`} className="relative font-bold text-2xl line-clamp-1 w-full" title={mii.name}>
|
||||
{mii.name}
|
||||
</Link>
|
||||
<div title={mii.platform === "SWITCH" ? "Switch" : "3DS"} className="-mr-3 text-[1.25rem] opacity-25">
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ interface Props {
|
|||
setImage: React.Dispatch<React.SetStateAction<string | undefined>>;
|
||||
}
|
||||
|
||||
export default function ImageEditorPortrait({ isOpen, setIsOpen, image, setImage }: Props) {
|
||||
export default function CropPortrait({ isOpen, setIsOpen, image, setImage }: Props) {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [crop, setCrop] = useState<Crop>();
|
||||
|
||||
|
|
@ -38,27 +38,9 @@ export default function ImageEditorPortrait({ isOpen, setIsOpen, image, setImage
|
|||
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);
|
||||
close();
|
||||
}, [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(() => {
|
||||
|
|
@ -86,7 +68,7 @@ export default function ImageEditorPortrait({ isOpen, setIsOpen, image, setImage
|
|||
}`}
|
||||
>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<h2 className="text-xl font-bold">Edit Image</h2>
|
||||
<h2 className="text-xl font-bold">Crop Portrait</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>
|
||||
|
|
@ -101,14 +83,11 @@ export default function ImageEditorPortrait({ isOpen, setIsOpen, image, setImage
|
|||
|
||||
<div className="mt-4 flex justify-center gap-2">
|
||||
<button type="button" onClick={close} className="pill button">
|
||||
Done
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button" onClick={applyCrop} className="pill button">
|
||||
Crop
|
||||
</button>
|
||||
<button type="button" onClick={rotate} className="pill button">
|
||||
Rotate
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -19,7 +19,6 @@ 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;
|
||||
|
|
@ -64,8 +63,6 @@ export default function EditForm({ mii, likes }: Props) {
|
|||
const [tags, setTags] = useState(mii.tags);
|
||||
const [description, setDescription] = useState(mii.description);
|
||||
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 hasFilesChanged = useRef(false);
|
||||
|
||||
const instructions = useRef<SwitchMiiInstructions>(deepMerge(defaultInstructions, (mii.instructions as object) ?? {}));
|
||||
|
|
@ -89,7 +86,6 @@ export default function EditForm({ mii, likes }: Props) {
|
|||
if (tags != mii.tags) formData.append("tags", JSON.stringify(tags));
|
||||
if (description && description != mii.description) formData.append("description", description);
|
||||
if (makeup != mii.makeup) formData.append("makeup", makeup);
|
||||
if (miiPortraitUri) formData.append("miiPortraitUri", miiPortraitUri);
|
||||
if (minifyInstructions(structuredClone(instructions.current)) !== (mii.instructions as object))
|
||||
formData.append("instructions", JSON.stringify(instructions.current));
|
||||
|
||||
|
|
@ -100,32 +96,6 @@ export default function EditForm({ mii, likes }: Props) {
|
|||
});
|
||||
}
|
||||
|
||||
// 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) {
|
||||
const blob = await getBlob(miiPortraitUri);
|
||||
if (blob) formData.append("miiPortraitImage", blob);
|
||||
}
|
||||
if (miiFeaturesUri) {
|
||||
const blob = await getBlob(miiFeaturesUri);
|
||||
if (blob) formData.append("miiFeaturesImage", blob);
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/mii/${mii.id}/edit`, {
|
||||
method: "PATCH",
|
||||
body: formData,
|
||||
|
|
@ -167,13 +137,7 @@ export default function EditForm({ mii, likes }: Props) {
|
|||
<form 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)),
|
||||
]}
|
||||
/>
|
||||
<Carousel images={[`/mii/${mii.id}/image?type=mii`, `/mii/${mii.id}/image?type=qr-code`, ...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}>
|
||||
|
|
@ -245,7 +209,7 @@ export default function EditForm({ mii, likes }: Props) {
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* Makeup/Images/Instructions (Switch only) */}
|
||||
{/* Instructions (Switch only) */}
|
||||
{mii.platform === "SWITCH" && (
|
||||
<>
|
||||
<div className="w-full grid grid-cols-3 items-start">
|
||||
|
|
@ -295,24 +259,6 @@ export default function EditForm({ mii, likes }: Props) {
|
|||
</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={setMiiPortraitUri} forceCrop />
|
||||
<SwitchFileUpload text="a screenshot of your Mii's features here" image={miiFeaturesUri} setImage={setMiiFeaturesUri} />
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { FileWithPath } from "react-dropzone";
|
|||
import { Icon } from "@iconify/react";
|
||||
import Dropzone from "../dropzone";
|
||||
import Camera from "./camera";
|
||||
import ImageEditorPortrait from "./image-editor";
|
||||
import CropPortrait from "./crop-portrait";
|
||||
|
||||
interface Props {
|
||||
text: string;
|
||||
|
|
@ -55,8 +55,8 @@ export default function SwitchFileUpload({ text, forceCrop, image, setImage }: P
|
|||
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
|
||||
<Icon icon="material-symbols:crop" fontSize={20} />
|
||||
Crop Image
|
||||
</button>
|
||||
|
||||
<Camera
|
||||
|
|
@ -67,7 +67,7 @@ export default function SwitchFileUpload({ text, forceCrop, image, setImage }: P
|
|||
if (forceCrop) setIsCropOpen(true);
|
||||
}}
|
||||
/>
|
||||
<ImageEditorPortrait isOpen={isCropOpen} setIsOpen={setIsCropOpen} image={image} setImage={setImage} />
|
||||
<CropPortrait isOpen={isCropOpen} setIsOpen={setIsCropOpen} image={image} setImage={setImage} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue