mirror of
https://github.com/trafficlunar/tomodachi-share.git
synced 2026-03-28 11:13:16 +00:00
Merge branch 'main' into feat/living-the-dream-qr-code
This commit is contained in:
commit
0b1516e930
62 changed files with 2973 additions and 1841 deletions
11
.editorconfig
Normal file
11
.editorconfig
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
indent_style = tab
|
||||||
|
indent_size = 2
|
||||||
|
tab_width = 2
|
||||||
|
max_line_length = 160
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
end_of_line = lf
|
||||||
|
|
@ -6,6 +6,10 @@ REDIS_URL="redis://localhost:6379/0"
|
||||||
# Used for metadata, sitemaps, etc.
|
# Used for metadata, sitemaps, etc.
|
||||||
NEXT_PUBLIC_BASE_URL=http://localhost:3000
|
NEXT_PUBLIC_BASE_URL=http://localhost:3000
|
||||||
|
|
||||||
|
# Used for error tracking
|
||||||
|
NEXT_PUBLIC_SENTRY_DSN=""
|
||||||
|
SENTRY_URL=""
|
||||||
|
|
||||||
# Check Auth.js docs for information
|
# Check Auth.js docs for information
|
||||||
AUTH_URL=http://localhost:3000 # This should be the same as NEXT_PUBLIC_BASE_URL
|
AUTH_URL=http://localhost:3000 # This should be the same as NEXT_PUBLIC_BASE_URL
|
||||||
AUTH_TRUST_HOST=true
|
AUTH_TRUST_HOST=true
|
||||||
|
|
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -43,4 +43,6 @@ yarn-error.log*
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
# tomodachi-share
|
# tomodachi-share
|
||||||
uploads/
|
uploads/
|
||||||
|
# Sentry Config File
|
||||||
|
.env.sentry-build-plugin
|
||||||
|
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
{
|
|
||||||
"tabWidth": 2,
|
|
||||||
"useTabs": true,
|
|
||||||
"printWidth": 160
|
|
||||||
}
|
|
||||||
|
|
@ -6,11 +6,9 @@ const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = dirname(__filename);
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
const compat = new FlatCompat({
|
const compat = new FlatCompat({
|
||||||
baseDirectory: __dirname,
|
baseDirectory: __dirname,
|
||||||
});
|
});
|
||||||
|
|
||||||
const eslintConfig = [
|
const eslintConfig = [...compat.extends("next/core-web-vitals", "next/typescript")];
|
||||||
...compat.extends("next/core-web-vitals", "next/typescript"),
|
|
||||||
];
|
|
||||||
|
|
||||||
export default eslintConfig;
|
export default eslintConfig;
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { withSentryConfig } from "@sentry/nextjs";
|
||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
|
|
@ -31,4 +32,41 @@ const nextConfig: NextConfig = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default withSentryConfig(nextConfig, {
|
||||||
|
// For all available options, see:
|
||||||
|
// https://www.npmjs.com/package/@sentry/webpack-plugin#options
|
||||||
|
|
||||||
|
org: "trafficlunar",
|
||||||
|
|
||||||
|
project: "tomodachishare",
|
||||||
|
sentryUrl: process.env.SENTRY_URL,
|
||||||
|
|
||||||
|
// Only print logs for uploading source maps in CI
|
||||||
|
silent: !process.env.CI,
|
||||||
|
|
||||||
|
// For all available options, see:
|
||||||
|
// https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/
|
||||||
|
|
||||||
|
// Upload a larger set of source maps for prettier stack traces (increases build time)
|
||||||
|
widenClientFileUpload: true,
|
||||||
|
|
||||||
|
// Uncomment to route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers.
|
||||||
|
// This can increase your server load as well as your hosting bill.
|
||||||
|
// Note: Check that the configured route will not match with your Next.js middleware, otherwise reporting of client-
|
||||||
|
// side errors will fail.
|
||||||
|
// tunnelRoute: "/monitoring",
|
||||||
|
|
||||||
|
webpack: {
|
||||||
|
// Enables automatic instrumentation of Vercel Cron Monitors. (Does not yet work with App Router route handlers.)
|
||||||
|
// See the following for more information:
|
||||||
|
// https://docs.sentry.io/product/crons/
|
||||||
|
// https://vercel.com/docs/cron-jobs
|
||||||
|
automaticVercelMonitors: false,
|
||||||
|
|
||||||
|
// Tree-shaking options for reducing bundle size
|
||||||
|
treeshake: {
|
||||||
|
// Automatically tree-shake Sentry logger statements to reduce bundle size
|
||||||
|
removeDebugLogging: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
|
||||||
27
package.json
27
package.json
|
|
@ -8,8 +8,7 @@
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
"postinstall": "prisma generate",
|
"postinstall": "prisma generate"
|
||||||
"test": "vitest"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@2toad/profanity": "^3.2.0",
|
"@2toad/profanity": "^3.2.0",
|
||||||
|
|
@ -17,10 +16,11 @@
|
||||||
"@bprogress/next": "^3.2.12",
|
"@bprogress/next": "^3.2.12",
|
||||||
"@hello-pangea/dnd": "^18.0.1",
|
"@hello-pangea/dnd": "^18.0.1",
|
||||||
"@prisma/client": "^6.19.2",
|
"@prisma/client": "^6.19.2",
|
||||||
|
"@sentry/nextjs": "^10.39.0",
|
||||||
"bit-buffer": "^0.3.0",
|
"bit-buffer": "^0.3.0",
|
||||||
"canvas-confetti": "^1.9.4",
|
"canvas-confetti": "^1.9.4",
|
||||||
"dayjs": "^1.11.19",
|
"dayjs": "^1.11.19",
|
||||||
"downshift": "^9.0.13",
|
"downshift": "^9.3.2",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"file-type": "^21.3.0",
|
"file-type": "^21.3.0",
|
||||||
"jsqr": "^1.4.0",
|
"jsqr": "^1.4.0",
|
||||||
|
|
@ -29,31 +29,30 @@
|
||||||
"qrcode-generator": "^2.0.4",
|
"qrcode-generator": "^2.0.4",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-dropzone": "^14.3.8",
|
"react-dropzone": "^15.0.0",
|
||||||
"redis": "^5.10.0",
|
"redis": "^5.11.0",
|
||||||
"satori": "^0.19.1",
|
"satori": "^0.19.2",
|
||||||
"seedrandom": "^3.0.5",
|
"seedrandom": "^3.0.5",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"sjcl-with-all": "1.0.8",
|
"sjcl-with-all": "1.0.8",
|
||||||
"swr": "^2.3.8",
|
"swr": "^2.4.0",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.3.3",
|
"@eslint/eslintrc": "^3.3.3",
|
||||||
"@iconify/react": "^6.0.2",
|
"@iconify/react": "^6.0.2",
|
||||||
"@tailwindcss/postcss": "^4.1.18",
|
"@tailwindcss/postcss": "^4.2.0",
|
||||||
"@types/canvas-confetti": "^1.9.0",
|
"@types/canvas-confetti": "^1.9.0",
|
||||||
"@types/node": "^25.1.0",
|
"@types/node": "^25.3.0",
|
||||||
"@types/react": "^19.2.10",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@types/seedrandom": "^3.0.8",
|
"@types/seedrandom": "^3.0.8",
|
||||||
"@types/sjcl": "^1.0.34",
|
"@types/sjcl": "^1.0.34",
|
||||||
"eslint": "^9.39.2",
|
"eslint": "^10.0.1",
|
||||||
"eslint-config-next": "16.1.6",
|
"eslint-config-next": "16.1.6",
|
||||||
"prisma": "^6.19.2",
|
"prisma": "^6.19.2",
|
||||||
"schema-dts": "^1.1.5",
|
"schema-dts": "^1.1.5",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.2.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3"
|
||||||
"vitest": "^4.0.18"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
3530
pnpm-lock.yaml
3530
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
|
|
@ -1,5 +1,5 @@
|
||||||
const config = {
|
const config = {
|
||||||
plugins: ["@tailwindcss/postcss"],
|
plugins: ["@tailwindcss/postcss"],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|
|
||||||
16
sentry.server.config.ts
Normal file
16
sentry.server.config.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
// This file configures the initialization of Sentry on the server.
|
||||||
|
// The config you add here will be used whenever the server handles a request.
|
||||||
|
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
||||||
|
|
||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
|
||||||
|
Sentry.init({
|
||||||
|
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||||
|
// Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control.
|
||||||
|
tracesSampleRate: 0.1,
|
||||||
|
// Enable logs to be sent to Sentry
|
||||||
|
enableLogs: true,
|
||||||
|
// Enable sending user PII (Personally Identifiable Information)
|
||||||
|
// https://docs.sentry.io/platforms/javascript/guides/nextjs/configuration/options/#sendDefaultPii
|
||||||
|
sendDefaultPii: false,
|
||||||
|
});
|
||||||
|
|
@ -21,7 +21,7 @@ const punishSchema = z.object({
|
||||||
z.object({
|
z.object({
|
||||||
id: z.number({ error: "Mii ID must be a number" }).int({ error: "Mii ID must be an integer" }).positive({ error: "Mii ID must be valid" }),
|
id: z.number({ error: "Mii ID must be a number" }).int({ error: "Mii ID must be an integer" }).positive({ error: "Mii ID must be valid" }),
|
||||||
reason: z.string(),
|
reason: z.string(),
|
||||||
})
|
}),
|
||||||
)
|
)
|
||||||
.optional(),
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
import { profanity } from "@2toad/profanity";
|
import { profanity } from "@2toad/profanity";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
|
|
||||||
|
|
@ -9,6 +10,7 @@ import { RateLimit } from "@/lib/rate-limit";
|
||||||
export async function PATCH(request: NextRequest) {
|
export async function PATCH(request: NextRequest) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
Sentry.setUser({ id: session.user.id, username: session.user.username });
|
||||||
|
|
||||||
const rateLimit = new RateLimit(request, 3);
|
const rateLimit = new RateLimit(request, 3);
|
||||||
const check = await rateLimit.handle();
|
const check = await rateLimit.handle();
|
||||||
|
|
@ -27,6 +29,7 @@ export async function PATCH(request: NextRequest) {
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to update description:", error);
|
console.error("Failed to update description:", error);
|
||||||
|
Sentry.captureException(error, { extra: { stage: "update-about-me" } });
|
||||||
return rateLimit.sendResponse({ error: "Failed to update description" }, 500);
|
return rateLimit.sendResponse({ error: "Failed to update description" }, 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
@ -7,6 +8,7 @@ import { RateLimit } from "@/lib/rate-limit";
|
||||||
export async function DELETE(request: NextRequest) {
|
export async function DELETE(request: NextRequest) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session || !session.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
if (!session || !session.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
Sentry.setUser({ id: session.user.id, username: session.user.username });
|
||||||
|
|
||||||
const rateLimit = new RateLimit(request, 1);
|
const rateLimit = new RateLimit(request, 1);
|
||||||
const check = await rateLimit.handle();
|
const check = await rateLimit.handle();
|
||||||
|
|
@ -18,6 +20,7 @@ export async function DELETE(request: NextRequest) {
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to delete user:", error);
|
console.error("Failed to delete user:", error);
|
||||||
|
Sentry.captureException(error, { extra: { stage: "delete-account" } });
|
||||||
return rateLimit.sendResponse({ error: "Failed to delete account" }, 500);
|
return rateLimit.sendResponse({ error: "Failed to delete account" }, 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
import { profanity } from "@2toad/profanity";
|
import { profanity } from "@2toad/profanity";
|
||||||
|
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
|
|
@ -9,6 +10,7 @@ import { RateLimit } from "@/lib/rate-limit";
|
||||||
export async function PATCH(request: NextRequest) {
|
export async function PATCH(request: NextRequest) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
Sentry.setUser({ id: session.user.id, username: session.user.username });
|
||||||
|
|
||||||
const rateLimit = new RateLimit(request, 3);
|
const rateLimit = new RateLimit(request, 3);
|
||||||
const check = await rateLimit.handle();
|
const check = await rateLimit.handle();
|
||||||
|
|
@ -30,6 +32,7 @@ export async function PATCH(request: NextRequest) {
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to update display name:", error);
|
console.error("Failed to update display name:", error);
|
||||||
|
Sentry.captureException(error, { extra: { stage: "update-display-name" } });
|
||||||
return rateLimit.sendResponse({ error: "Failed to update display name" }, 500);
|
return rateLimit.sendResponse({ error: "Failed to update display name" }, 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
|
@ -20,6 +21,7 @@ const formDataSchema = z.object({
|
||||||
export async function PATCH(request: NextRequest) {
|
export async function PATCH(request: NextRequest) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
Sentry.setUser({ id: session.user.id, username: session.user.username });
|
||||||
|
|
||||||
const rateLimit = new RateLimit(request, 3);
|
const rateLimit = new RateLimit(request, 3);
|
||||||
const check = await rateLimit.handle();
|
const check = await rateLimit.handle();
|
||||||
|
|
@ -68,6 +70,7 @@ export async function PATCH(request: NextRequest) {
|
||||||
await fs.writeFile(fileLocation, webpBuffer);
|
await fs.writeFile(fileLocation, webpBuffer);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error uploading profile picture:", error);
|
console.error("Error uploading profile picture:", error);
|
||||||
|
Sentry.captureException(error, { extra: { stage: "upload-profile-picture" } });
|
||||||
return rateLimit.sendResponse({ error: "Failed to store profile picture" }, 500);
|
return rateLimit.sendResponse({ error: "Failed to store profile picture" }, 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -78,6 +81,7 @@ export async function PATCH(request: NextRequest) {
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to update profile picture:", error);
|
console.error("Failed to update profile picture:", error);
|
||||||
|
Sentry.captureException(error, { extra: { stage: "update-profile-picture" } });
|
||||||
return rateLimit.sendResponse({ error: "Failed to update profile picture" }, 500);
|
return rateLimit.sendResponse({ error: "Failed to update profile picture" }, 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import { profanity } from "@2toad/profanity";
|
import { profanity } from "@2toad/profanity";
|
||||||
|
|
@ -11,6 +12,7 @@ import { RateLimit } from "@/lib/rate-limit";
|
||||||
export async function PATCH(request: NextRequest) {
|
export async function PATCH(request: NextRequest) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
Sentry.setUser({ id: session.user.id, username: session.user.username });
|
||||||
|
|
||||||
const rateLimit = new RateLimit(request, 3);
|
const rateLimit = new RateLimit(request, 3);
|
||||||
const check = await rateLimit.handle();
|
const check = await rateLimit.handle();
|
||||||
|
|
@ -44,6 +46,7 @@ export async function PATCH(request: NextRequest) {
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to update username:", error);
|
console.error("Failed to update username:", error);
|
||||||
|
Sentry.captureException(error, { extra: { stage: "update-username" } });
|
||||||
return rateLimit.sendResponse({ error: "Failed to update username" }, 500);
|
return rateLimit.sendResponse({ error: "Failed to update username" }, 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
|
||||||
import fs from "fs/promises";
|
import fs from "fs/promises";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
@ -13,6 +14,7 @@ const uploadsDirectory = path.join(process.cwd(), "uploads", "mii");
|
||||||
export async function DELETE(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
export async function DELETE(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
Sentry.setUser({ id: session.user.id, username: session.user.username });
|
||||||
|
|
||||||
const rateLimit = new RateLimit(request, 30, "/api/mii/delete");
|
const rateLimit = new RateLimit(request, 30, "/api/mii/delete");
|
||||||
const check = await rateLimit.handle();
|
const check = await rateLimit.handle();
|
||||||
|
|
@ -42,6 +44,7 @@ export async function DELETE(request: NextRequest, { params }: { params: Promise
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to delete Mii from database:", error);
|
console.error("Failed to delete Mii from database:", error);
|
||||||
|
Sentry.captureException(error, { extra: { stage: "delete-mii" } });
|
||||||
return rateLimit.sendResponse({ error: "Failed to delete Mii" }, 500);
|
return rateLimit.sendResponse({ error: "Failed to delete Mii" }, 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -49,6 +52,7 @@ export async function DELETE(request: NextRequest, { params }: { params: Promise
|
||||||
await fs.rm(miiUploadsDirectory, { recursive: true, force: true });
|
await fs.rm(miiUploadsDirectory, { recursive: true, force: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("Failed to delete Mii image files:", error);
|
console.warn("Failed to delete Mii image files:", error);
|
||||||
|
Sentry.captureException(error, { extra: { stage: "delete-mii-images" } });
|
||||||
}
|
}
|
||||||
|
|
||||||
return rateLimit.sendResponse({ success: true });
|
return rateLimit.sendResponse({ success: true });
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { Mii } from "@prisma/client";
|
import { Mii } from "@prisma/client";
|
||||||
|
|
||||||
|
|
@ -28,6 +29,7 @@ const editSchema = z.object({
|
||||||
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
Sentry.setUser({ id: session.user.id, username: session.user.username });
|
||||||
|
|
||||||
const rateLimit = new RateLimit(request, 1); // no grouped pathname; edit each mii 1 time a minute
|
const rateLimit = new RateLimit(request, 1); // no grouped pathname; edit each mii 1 time a minute
|
||||||
const check = await rateLimit.handle();
|
const check = await rateLimit.handle();
|
||||||
|
|
@ -128,10 +130,11 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
|
||||||
const fileLocation = path.join(miiUploadsDirectory, `image${index}.webp`);
|
const fileLocation = path.join(miiUploadsDirectory, `image${index}.webp`);
|
||||||
|
|
||||||
await fs.writeFile(fileLocation, webpBuffer);
|
await fs.writeFile(fileLocation, webpBuffer);
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error uploading user images:", error);
|
console.error("Error uploading user images:", error);
|
||||||
|
Sentry.captureException(error, { extra: { stage: "edit-custom-images" } });
|
||||||
return rateLimit.sendResponse({ error: "Failed to store user images" }, 500);
|
return rateLimit.sendResponse({ error: "Failed to store user images" }, 500);
|
||||||
}
|
}
|
||||||
} else if (description === undefined) {
|
} else if (description === undefined) {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { Prisma, ReportReason, ReportType } from "@prisma/client";
|
import { Prisma, ReportReason, ReportType } from "@prisma/client";
|
||||||
|
|
||||||
|
|
@ -18,6 +19,7 @@ const reportSchema = z.object({
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
Sentry.setUser({ id: session.user.id, username: session.user.username });
|
||||||
|
|
||||||
const rateLimit = new RateLimit(request, 2);
|
const rateLimit = new RateLimit(request, 2);
|
||||||
const check = await rateLimit.handle();
|
const check = await rateLimit.handle();
|
||||||
|
|
@ -83,6 +85,7 @@ export async function POST(request: NextRequest) {
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Report creation failed", error);
|
console.error("Report creation failed", error);
|
||||||
|
Sentry.captureException(error, { extra: { stage: "create-report" } });
|
||||||
return rateLimit.sendResponse({ error: "Failed to create report" }, 500);
|
return rateLimit.sendResponse({ error: "Failed to create report" }, 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import fs from "fs/promises";
|
import fs from "fs/promises";
|
||||||
|
|
@ -60,11 +61,13 @@ const submitSchema = z
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
Sentry.setUser({ id: session.user.id, username: session.user.username });
|
||||||
|
|
||||||
const rateLimit = new RateLimit(request, 3);
|
const rateLimit = new RateLimit(request, 3);
|
||||||
const check = await rateLimit.handle();
|
const check = await rateLimit.handle();
|
||||||
if (check) return check;
|
if (check) return check;
|
||||||
|
|
||||||
|
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/admin/can-submit`);
|
||||||
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/admin/can-submit`);
|
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/admin/can-submit`);
|
||||||
const { value } = await response.json();
|
const { value } = await response.json();
|
||||||
if (!value) return rateLimit.sendResponse({ error: "Submissions are temporarily disabled" }, 503);
|
if (!value) return rateLimit.sendResponse({ error: "Submissions are temporarily disabled" }, 503);
|
||||||
|
|
@ -77,7 +80,10 @@ export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
rawTags = JSON.parse(formData.get("tags") as string);
|
rawTags = JSON.parse(formData.get("tags") as string);
|
||||||
rawQrBytesRaw = JSON.parse(formData.get("qrBytesRaw") as string);
|
rawQrBytesRaw = JSON.parse(formData.get("qrBytesRaw") as string);
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
Sentry.captureException(error, {
|
||||||
|
extra: { stage: "submit-json-parse" },
|
||||||
|
});
|
||||||
return rateLimit.sendResponse({ error: "Invalid JSON in tags or QR code data" }, 400);
|
return rateLimit.sendResponse({ error: "Invalid JSON in tags or QR code data" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -145,7 +151,8 @@ export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
conversion = convertQrCode(qrBytes);
|
conversion = convertQrCode(qrBytes);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return rateLimit.sendResponse({ error }, 400);
|
Sentry.captureException(error, { extra: { stage: "qr-conversion" } });
|
||||||
|
return rateLimit.sendResponse({ error: error instanceof Error ? error.message : String(error) }, 400);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -202,6 +209,7 @@ export async function POST(request: NextRequest) {
|
||||||
await prisma.mii.delete({ where: { id: miiRecord.id } });
|
await prisma.mii.delete({ where: { id: miiRecord.id } });
|
||||||
|
|
||||||
console.error("Failed to download/store Mii portrait:", error);
|
console.error("Failed to download/store Mii portrait:", error);
|
||||||
|
Sentry.captureException(error, { extra: { miiId: miiRecord.id, stage: "studio-image-download" } });
|
||||||
return rateLimit.sendResponse({ error: "Failed to download/store Mii portrait" }, 500);
|
return rateLimit.sendResponse({ error: "Failed to download/store Mii portrait" }, 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -227,21 +235,8 @@ export async function POST(request: NextRequest) {
|
||||||
await prisma.mii.delete({ where: { id: miiRecord.id } });
|
await prisma.mii.delete({ where: { id: miiRecord.id } });
|
||||||
|
|
||||||
console.error("Error processing Mii files:", error);
|
console.error("Error processing Mii files:", error);
|
||||||
|
Sentry.captureException(error, { extra: { miiId: miiRecord.id, stage: "file-processing" } });
|
||||||
return rateLimit.sendResponse({ error: "Failed to process and store Mii files" }, 500);
|
return rateLimit.sendResponse({ error: "Failed to process and store Mii files" }, 500);
|
||||||
console.error("Error generating QR code:", error);
|
|
||||||
return rateLimit.sendResponse({ error: "Failed to generate QR code" }, 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await generateMetadataImage(miiRecord, session.user.name!);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
return rateLimit.sendResponse(
|
|
||||||
{
|
|
||||||
error: `Failed to generate 'metadata' type image for mii ${miiRecord.id}`,
|
|
||||||
},
|
|
||||||
500,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compress and store user images
|
// Compress and store user images
|
||||||
|
|
@ -267,6 +262,8 @@ export async function POST(request: NextRequest) {
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error storing user images:", error);
|
console.error("Error storing user images:", error);
|
||||||
|
|
||||||
|
Sentry.captureException(error, { extra: { miiId: miiRecord.id, stage: "user-image-storage" } });
|
||||||
return rateLimit.sendResponse({ error: "Failed to store user images" }, 500);
|
return rateLimit.sendResponse({ error: "Failed to store user images" }, 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,8 +44,7 @@ export default async function MiiPage({ params }: Props) {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check ownership
|
// Check ownership
|
||||||
if (!mii || (Number(session?.user.id) !== mii.userId && Number(session?.user.id) !== Number(process.env.NEXT_PUBLIC_ADMIN_USER_ID)))
|
if (!mii || (Number(session?.user.id) !== mii.userId && Number(session?.user.id) !== Number(process.env.NEXT_PUBLIC_ADMIN_USER_ID))) redirect("/404");
|
||||||
redirect("/404");
|
|
||||||
|
|
||||||
return <EditForm mii={mii} likes={mii._count.likedBy} />;
|
return <EditForm mii={mii} likes={mii._count.likedBy} />;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
23
src/app/global-error.tsx
Normal file
23
src/app/global-error.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
import NextError from "next/error";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
export default function GlobalError({ error }: { error: Error & { digest?: string } }) {
|
||||||
|
useEffect(() => {
|
||||||
|
Sentry.captureException(error);
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body>
|
||||||
|
{/* `NextError` is the default Next.js error page component. Its type
|
||||||
|
definition requires a `statusCode` prop. However, since the App Router
|
||||||
|
does not expose status codes for errors, we simply pass 0 to render a
|
||||||
|
generic error message. */}
|
||||||
|
<NextError statusCode={0} />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -44,17 +44,14 @@ body {
|
||||||
|
|
||||||
.button:disabled {
|
.button:disabled {
|
||||||
@apply text-zinc-600 bg-zinc-100! border-zinc-300! cursor-auto;
|
@apply text-zinc-600 bg-zinc-100! border-zinc-300! cursor-auto;
|
||||||
@apply text-zinc-600 bg-zinc-100! border-zinc-300! cursor-auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.input {
|
.input {
|
||||||
@apply bg-orange-200! outline-0 focus:ring-[3px] ring-orange-400/50 transition placeholder:text-black/40;
|
@apply bg-orange-200! outline-0 focus:ring-[3px] ring-orange-400/50 transition placeholder:text-black/40;
|
||||||
@apply bg-orange-200! outline-0 focus:ring-[3px] ring-orange-400/50 transition placeholder:text-black/40;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.input:disabled {
|
.input:disabled {
|
||||||
@apply text-zinc-600 bg-zinc-100! border-zinc-300!;
|
@apply text-zinc-600 bg-zinc-100! border-zinc-300!;
|
||||||
@apply text-zinc-600 bg-zinc-100! border-zinc-300!;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkbox {
|
.checkbox {
|
||||||
|
|
@ -94,7 +91,24 @@ body {
|
||||||
@apply opacity-100 scale-100;
|
@apply opacity-100 scale-100;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Scrollbars */
|
/* Fallback Tooltips */
|
||||||
|
[data-tooltip-span] {
|
||||||
|
@apply relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-tooltip-span] > .tooltip {
|
||||||
|
@apply absolute left-1/2 top-full mt-2 px-2 py-1 bg-orange-400 border border-orange-400 rounded-md text-sm text-white whitespace-nowrap select-none pointer-events-none shadow-md opacity-0 scale-75 transition-all duration-200 ease-out origin-top -translate-x-1/2 z-999999;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-tooltip-span] > .tooltip::before {
|
||||||
|
@apply content-[''] absolute left-1/2 -translate-x-1/2 -top-2 border-4 border-transparent border-b-orange-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-tooltip-span]:hover > .tooltip {
|
||||||
|
@apply opacity-100 scale-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar */
|
||||||
/* Firefox */
|
/* Firefox */
|
||||||
* {
|
* {
|
||||||
scrollbar-color: #ff8903 transparent;
|
scrollbar-color: #ff8903 transparent;
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import ShareMiiButton from "@/components/share-mii-button";
|
||||||
import ThreeDsScanTutorialButton from "@/components/tutorial/3ds-scan";
|
import ThreeDsScanTutorialButton from "@/components/tutorial/3ds-scan";
|
||||||
import SwitchScanTutorialButton from "@/components/tutorial/switch-scan";
|
import SwitchScanTutorialButton from "@/components/tutorial/switch-scan";
|
||||||
import Description from "@/components/description";
|
import Description from "@/components/description";
|
||||||
|
import { MiiPlatform } from "@prisma/client";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
|
|
@ -48,13 +49,13 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||||
return {
|
return {
|
||||||
metadataBase: new URL(process.env.NEXT_PUBLIC_BASE_URL!),
|
metadataBase: new URL(process.env.NEXT_PUBLIC_BASE_URL!),
|
||||||
title: `${mii.name} - TomodachiShare`,
|
title: `${mii.name} - TomodachiShare`,
|
||||||
description: `Check out '${mii.name}', a Tomodachi Life Mii created by ${mii.user.name} on TomodachiShare with ${mii._count.likedBy} likes.`,
|
description: `Check out '${mii.name}', a ${mii.platform === MiiPlatform.SWITCH ? "Switch Living the Dream" : "3DS"} Tomodachi Life Mii created by ${mii.user.name} on TomodachiShare with ${mii._count.likedBy} likes.`,
|
||||||
keywords: ["mii", "tomodachi life", "nintendo", "tomodachishare", "tomodachi-share", "mii creator", "mii collection", ...mii.tags],
|
keywords: ["mii", "tomodachi life", "nintendo", "tomodachishare", "tomodachi-share", "mii creator", "mii collection", ...mii.tags],
|
||||||
creator: mii.user.username,
|
creator: mii.user.username,
|
||||||
openGraph: {
|
openGraph: {
|
||||||
type: "article",
|
type: "article",
|
||||||
title: `${mii.name} - TomodachiShare`,
|
title: `${mii.name} - TomodachiShare`,
|
||||||
description: `Check out '${mii.name}', a Tomodachi Life Mii created by ${mii.user.name} on TomodachiShare with ${mii._count.likedBy} likes.`,
|
description: `Check out '${mii.name}', a ${mii.platform === MiiPlatform.SWITCH ? "Switch Living the Dream" : "3DS"} Tomodachi Life Mii created by ${mii.user.name} on TomodachiShare with ${mii._count.likedBy} likes.`,
|
||||||
images: [
|
images: [
|
||||||
{
|
{
|
||||||
url: metadataImageUrl,
|
url: metadataImageUrl,
|
||||||
|
|
@ -67,7 +68,7 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||||
twitter: {
|
twitter: {
|
||||||
card: "summary_large_image",
|
card: "summary_large_image",
|
||||||
title: `${mii.name} - TomodachiShare`,
|
title: `${mii.name} - TomodachiShare`,
|
||||||
description: `Check out '${mii.name}', a Tomodachi Life Mii created by ${mii.user.name} on TomodachiShare with ${mii._count.likedBy} likes.`,
|
description: `Check out '${mii.name}', a ${mii.platform === MiiPlatform.SWITCH ? "Switch Living the Dream" : "3DS"} Tomodachi Life Mii created by ${mii.user.name} on TomodachiShare with ${mii._count.likedBy} likes.`,
|
||||||
images: [
|
images: [
|
||||||
{
|
{
|
||||||
url: metadataImageUrl,
|
url: metadataImageUrl,
|
||||||
|
|
@ -125,8 +126,8 @@ export default async function MiiPage({ params }: Props) {
|
||||||
<ImageViewer
|
<ImageViewer
|
||||||
src={`/mii/${mii.id}/image?type=mii`}
|
src={`/mii/${mii.id}/image?type=mii`}
|
||||||
alt="mii headshot"
|
alt="mii headshot"
|
||||||
width={200}
|
width={250}
|
||||||
height={200}
|
height={250}
|
||||||
className="drop-shadow-lg hover:scale-105 transition-transform"
|
className="drop-shadow-lg hover:scale-105 transition-transform"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -235,7 +236,6 @@ export default async function MiiPage({ params }: Props) {
|
||||||
<h1 className="text-4xl font-extrabold wrap-break-word text-amber-700">{mii.name}</h1>
|
<h1 className="text-4xl font-extrabold wrap-break-word text-amber-700">{mii.name}</h1>
|
||||||
{/* Like button */}
|
{/* Like button */}
|
||||||
<LikeButton likes={mii._count.likedBy ?? 0} miiId={mii.id} isLiked={(mii.likedBy ?? []).length > 0} isLoggedIn={session?.user != null} big />
|
<LikeButton likes={mii._count.likedBy ?? 0} miiId={mii.id} isLiked={(mii.likedBy ?? []).length > 0} isLoggedIn={session?.user != null} big />
|
||||||
<LikeButton likes={mii._count.likedBy ?? 0} miiId={mii.id} isLiked={(mii.likedBy ?? []).length > 0} isLoggedIn={session?.user != null} big />
|
|
||||||
</div>
|
</div>
|
||||||
{/* Tags */}
|
{/* Tags */}
|
||||||
<div id="tags" className="flex flex-wrap gap-1 mt-1 *:px-2 *:py-1 *:bg-orange-300 *:rounded-full *:text-xs">
|
<div id="tags" className="flex flex-wrap gap-1 mt-1 *:px-2 *:py-1 *:bg-orange-300 *:rounded-full *:text-xs">
|
||||||
|
|
|
||||||
|
|
@ -57,8 +57,8 @@ export default async function ExiledPage() {
|
||||||
{activePunishment.type === "PERM_EXILE"
|
{activePunishment.type === "PERM_EXILE"
|
||||||
? "Exiled permanently"
|
? "Exiled permanently"
|
||||||
: activePunishment.type === "TEMP_EXILE"
|
: activePunishment.type === "TEMP_EXILE"
|
||||||
? `Exiled for ${duration} ${duration === 1 ? "day" : "days"}`
|
? `Exiled for ${duration} ${duration === 1 ? "day" : "days"}`
|
||||||
: "Warning"}
|
: "Warning"}
|
||||||
</h2>
|
</h2>
|
||||||
<p>
|
<p>
|
||||||
You have been exiled from the TomodachiShare island because you violated the{" "}
|
You have been exiled from the TomodachiShare island because you violated the{" "}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ export default function PrivacyPage() {
|
||||||
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl p-6">
|
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl p-6">
|
||||||
<h1 className="text-2xl font-bold">Privacy Policy</h1>
|
<h1 className="text-2xl font-bold">Privacy Policy</h1>
|
||||||
<h2 className="font-light">
|
<h2 className="font-light">
|
||||||
<strong className="font-medium">Effective Date:</strong> April 06, 2025
|
<strong className="font-medium">Effective Date:</strong> 21 February 2026
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<hr className="border-black/20 mt-1 mb-4" />
|
<hr className="border-black/20 mt-1 mb-4" />
|
||||||
|
|
@ -32,12 +32,11 @@ export default function PrivacyPage() {
|
||||||
<p className="mb-2">The following types of information are stored when you use this website:</p>
|
<p className="mb-2">The following types of information are stored when you use this website:</p>
|
||||||
<ul className="list-disc list-inside">
|
<ul className="list-disc list-inside">
|
||||||
<li>
|
<li>
|
||||||
<strong>Account Information:</strong> When you sign up or log in using Discord or Github, your username, e-mail, and profile picture
|
<strong>Account Information:</strong> When you sign up or log in using Discord or Github, your username, e-mail, and profile picture are
|
||||||
are collected. Your authentication tokens may also be temporarily stored to maintain your login session.
|
collected. Your authentication tokens may also be temporarily stored to maintain your login session.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong>Miis:</strong> We store any Miis you submit, including associated images (such as a picture of your Mii, QR codes, and custom
|
<strong>Miis:</strong> We store any Miis you submit, including associated images (such as a picture of your Mii, QR codes, and custom images).
|
||||||
images).
|
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong>Interaction Data:</strong> The Miis you like.
|
<strong>Interaction Data:</strong> The Miis you like.
|
||||||
|
|
@ -49,9 +48,7 @@ export default function PrivacyPage() {
|
||||||
<h3 className="text-xl font-semibold mt-6 mb-2">Use of Cookies</h3>
|
<h3 className="text-xl font-semibold mt-6 mb-2">Use of Cookies</h3>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<p className="mb-2">
|
<p className="mb-2">Cookies are necessary for user sessions and authentication. We do not use cookies for tracking or advertising purposes.</p>
|
||||||
Cookies are necessary for user sessions and authentication. We do not use cookies for tracking or advertising purposes.
|
|
||||||
</p>
|
|
||||||
</section>
|
</section>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
|
|
@ -63,18 +60,35 @@ export default function PrivacyPage() {
|
||||||
<a href="https://umami.is/" className="text-blue-700">
|
<a href="https://umami.is/" className="text-blue-700">
|
||||||
Umami
|
Umami
|
||||||
</a>{" "}
|
</a>{" "}
|
||||||
to collect anonymous data about how users interact with the site. Umami is fully GDPR-compliant, and no personally identifiable
|
to collect anonymous data about how users interact with the site. Umami is fully GDPR-compliant, and no personally identifiable information is
|
||||||
information is collected through this service.
|
collected through this service.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<h3 className="text-xl font-semibold mt-6 mb-2">Error Reporting</h3>
|
||||||
|
<section>
|
||||||
|
<p className="mb-2">
|
||||||
|
This website uses{" "}
|
||||||
|
<a href="https://glitchtip.com/" className="text-blue-700">
|
||||||
|
GlitchTip
|
||||||
|
</a>{" "}
|
||||||
|
(a self-hosted Sentry-like instance) to monitor errors and site performance. To protect your privacy:
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc list-inside ml-4">
|
||||||
|
<li>Errors and performance data is collected.</li>
|
||||||
|
<li>Only your user ID and username are sent, no other personally identifiable information is collected.</li>
|
||||||
|
<li>You can use ad blockers or browser privacy features to opt out.</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<h3 className="text-xl font-semibold mt-6 mb-2">Data Sharing</h3>
|
<h3 className="text-xl font-semibold mt-6 mb-2">Data Sharing</h3>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<p className="mb-2">
|
<p className="mb-2">
|
||||||
We do not sell your personal data to third parties. Your data may be sent anonymously to self-hosted third-party services or trusted
|
We do not sell your personal data to third parties. Your data may be sent anonymously to self-hosted third-party services or trusted third-party
|
||||||
third-party tools (such as analytics) but these services are used solely to keep the site functional.
|
tools (such as analytics) but these services are used solely to keep the site functional.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
</li>
|
</li>
|
||||||
|
|
@ -95,9 +109,9 @@ export default function PrivacyPage() {
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<p className="mb-2">
|
<p className="mb-2">
|
||||||
Your data, including your Miis, will be retained for as long as you have an account on the site. You may request that your data be
|
Your data, including your Miis, will be retained for as long as you have an account on the site. You may request that your data be deleted at any
|
||||||
deleted at any time by going to your profile page, clicking the settings icon, and clicking the 'Delete Account' button. Upon
|
time by going to your profile page, clicking the settings icon, and clicking the 'Delete Account' button. Upon clicking, your data will
|
||||||
clicking, your data will be promptly removed from our servers.
|
be promptly removed from our servers.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
</li>
|
</li>
|
||||||
|
|
@ -106,8 +120,7 @@ export default function PrivacyPage() {
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<p className="mb-2">
|
<p className="mb-2">
|
||||||
This Privacy Policy may be updated from time to time. We encourage you to review this policy periodically to stay informed about your
|
This Privacy Policy may be updated from time to time. We encourage you to review this policy periodically to stay informed about your privacy.
|
||||||
privacy.
|
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
</li>
|
</li>
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||||
changeFrequency: "weekly",
|
changeFrequency: "weekly",
|
||||||
priority: 0.7,
|
priority: 0.7,
|
||||||
images: [`${baseUrl}/mii/${mii.id}/image?type=metadata`],
|
images: [`${baseUrl}/mii/${mii.id}/image?type=metadata`],
|
||||||
} as SitemapRoute)
|
}) as SitemapRoute,
|
||||||
),
|
),
|
||||||
...users.map(
|
...users.map(
|
||||||
(user) =>
|
(user) =>
|
||||||
|
|
@ -44,7 +44,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||||
lastModified: user.updatedAt,
|
lastModified: user.updatedAt,
|
||||||
changeFrequency: "weekly",
|
changeFrequency: "weekly",
|
||||||
priority: 0.2,
|
priority: 0.2,
|
||||||
} as SitemapRoute)
|
}) as SitemapRoute,
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,26 +19,26 @@ export const metadata: Metadata = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function SubmitPage() {
|
export default async function SubmitPage() {
|
||||||
const session = await auth();
|
// const session = await auth();
|
||||||
|
|
||||||
if (!session) redirect("/login");
|
// if (!session) redirect("/login");
|
||||||
if (!session.user.username) redirect("/create-username");
|
// if (!session.user.username) redirect("/create-username");
|
||||||
const activePunishment = await prisma.punishment.findFirst({
|
// const activePunishment = await prisma.punishment.findFirst({
|
||||||
where: {
|
// where: {
|
||||||
userId: Number(session?.user.id),
|
// userId: Number(session?.user.id),
|
||||||
returned: false,
|
// returned: false,
|
||||||
},
|
// },
|
||||||
});
|
// });
|
||||||
if (activePunishment) redirect("/off-the-island");
|
// if (activePunishment) redirect("/off-the-island");
|
||||||
|
|
||||||
// Check if submissions are disabled
|
// Check if submissions are disabled
|
||||||
let value: boolean | null = true;
|
let value: boolean | null = true;
|
||||||
try {
|
// try {
|
||||||
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/admin/can-submit`);
|
// const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/admin/can-submit`);
|
||||||
value = await response.json();
|
// value = await response.json();
|
||||||
} catch (error) {
|
// } catch (error) {
|
||||||
return <p>An error occurred!</p>;
|
// return <p>An error occurred!</p>;
|
||||||
}
|
// }
|
||||||
|
|
||||||
if (!value)
|
if (!value)
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,8 @@ export default function PrivacyPage() {
|
||||||
<hr className="border-black/20 mt-1 mb-4" />
|
<hr className="border-black/20 mt-1 mb-4" />
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
By registering for, or using this service, you confirm that you understand and agree to the terms below. If you do not agree to these terms,
|
By registering for, or using this service, you confirm that you understand and agree to the terms below. If you do not agree to these terms, you should
|
||||||
you should not use the service.
|
not use the service.
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1">
|
<p className="mt-1">
|
||||||
If you have any questions or concerns, please contact me at:{" "}
|
If you have any questions or concerns, please contact me at:{" "}
|
||||||
|
|
@ -54,8 +54,8 @@ export default function PrivacyPage() {
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<p className="mb-2">
|
<p className="mb-2">
|
||||||
We reserve the right to suspend or terminate your access to the site at any time if you violate these Terms of Service or engage in any
|
We reserve the right to suspend or terminate your access to the site at any time if you violate these Terms of Service or engage in any activities
|
||||||
activities that disrupt the functionality of the site.
|
that disrupt the functionality of the site.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
To request deletion of your account and personal data, please refer to the{" "}
|
To request deletion of your account and personal data, please refer to the{" "}
|
||||||
|
|
@ -81,12 +81,12 @@ export default function PrivacyPage() {
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<p className="mb-2">
|
<p className="mb-2">
|
||||||
This service is provided "as is" and without any warranties. We are not responsible for any user-generated content or the
|
This service is provided "as is" and without any warranties. We are not responsible for any user-generated content or the actions of
|
||||||
actions of users on the site. You use the site at your own risk.
|
users on the site. You use the site at your own risk.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
We do not guarantee continuous or secure access to the service and are not liable for any damages resulting from interruptions, loss of
|
We do not guarantee continuous or secure access to the service and are not liable for any damages resulting from interruptions, loss of data, or
|
||||||
data, or unauthorized access.
|
unauthorized access.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
</li>
|
</li>
|
||||||
|
|
@ -98,7 +98,7 @@ export default function PrivacyPage() {
|
||||||
If you believe that content uploaded to this site infringes on your copyright, you may submit a DMCA takedown request by emailing{" "}
|
If you believe that content uploaded to this site infringes on your copyright, you may submit a DMCA takedown request by emailing{" "}
|
||||||
<a href="mailto:hello@trafficlunar.net" className="text-blue-700">
|
<a href="mailto:hello@trafficlunar.net" className="text-blue-700">
|
||||||
hello@trafficlunar.net
|
hello@trafficlunar.net
|
||||||
</a>
|
</a>{" "}
|
||||||
or by reporting the Mii on its page.
|
or by reporting the Mii on its page.
|
||||||
</p>
|
</p>
|
||||||
<p className="mb-2">Please include:</p>
|
<p className="mb-2">Please include:</p>
|
||||||
|
|
@ -108,8 +108,8 @@ export default function PrivacyPage() {
|
||||||
<li>A link to the allegedly infringing material</li>
|
<li>A link to the allegedly infringing material</li>
|
||||||
<li>A statement that you have a good faith belief that the use is not authorized</li>
|
<li>A statement that you have a good faith belief that the use is not authorized</li>
|
||||||
<li>
|
<li>
|
||||||
A statement that the information in the notice is accurate and, under penalty of perjury, that you are authorized to act on behalf of
|
A statement that the information in the notice is accurate and, under penalty of perjury, that you are authorized to act on behalf of the
|
||||||
the copyright owner
|
copyright owner
|
||||||
</li>
|
</li>
|
||||||
<li>Your electronic or physical signature</li>
|
<li>Your electronic or physical signature</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
@ -120,12 +120,12 @@ export default function PrivacyPage() {
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<p className="mb-2">
|
<p className="mb-2">
|
||||||
This site is not affiliated with, endorsed by, or associated with Nintendo in any way. "Mii" and all related character designs
|
This site is not affiliated with, endorsed by, or associated with Nintendo in any way. "Mii" and all related character designs are
|
||||||
are trademarks of Nintendo Co., Ltd.
|
trademarks of Nintendo Co., Ltd.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
All Mii-related content is shared by users under the assumption that it does not violate any third-party rights. If you believe your
|
All Mii-related content is shared by users under the assumption that it does not violate any third-party rights. If you believe your rights have
|
||||||
rights have been infringed, please see the DMCA section above.
|
been infringed, please see the DMCA section above.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
</li>
|
</li>
|
||||||
|
|
@ -134,8 +134,8 @@ export default function PrivacyPage() {
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<p className="mb-2">
|
<p className="mb-2">
|
||||||
This Terms of Service may be updated from time to time. We encourage you to review the terms periodically to stay informed about the use
|
This Terms of Service may be updated from time to time. We encourage you to review the terms periodically to stay informed about the use of the
|
||||||
of the site. We may notify users via a site banner or other means if changes are made to the Terms of Service.
|
site. We may notify users via a site banner or other means if changes are made to the Terms of Service.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
</li>
|
</li>
|
||||||
|
|
|
||||||
|
|
@ -54,10 +54,7 @@ export default function AdminBanner() {
|
||||||
<span>{data.message}</span>
|
<span>{data.message}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button onClick={handleClose} className="min-sm:absolute right-2 cursor-pointer p-1.5">
|
||||||
onClick={handleClose}
|
|
||||||
className="min-sm:absolute right-2 cursor-pointer p-1.5"
|
|
||||||
>
|
|
||||||
<Icon icon="humbleicons:times" className="text-2xl min-w-6" />
|
<Icon icon="humbleicons:times" className="text-2xl min-w-6" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,7 @@ export default function PunishmentDeletionDialog({ punishmentId }: Props) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>,
|
</div>,
|
||||||
document.body
|
document.body,
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,7 @@ export default function RegenerateImagesButton() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>,
|
</div>,
|
||||||
document.body
|
document.body,
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -42,8 +42,8 @@ export default async function Reports() {
|
||||||
report.status == "OPEN"
|
report.status == "OPEN"
|
||||||
? "bg-orange-200 text-orange-800 border-orange-400"
|
? "bg-orange-200 text-orange-800 border-orange-400"
|
||||||
: report.status == "RESOLVED"
|
: report.status == "RESOLVED"
|
||||||
? "bg-green-200 text-green-800 border-green-400"
|
? "bg-green-200 text-green-800 border-green-400"
|
||||||
: "bg-zinc-200 text-zinc-800 border-zinc-400"
|
: "bg-zinc-200 text-zinc-800 border-zinc-400"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{report.status}
|
{report.status}
|
||||||
|
|
@ -68,10 +68,7 @@ export default async function Reports() {
|
||||||
<div className="grid grid-cols-4 text-xs text-zinc-600 mt-4 max-sm:grid-cols-2">
|
<div className="grid grid-cols-4 text-xs text-zinc-600 mt-4 max-sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<p>Target ID</p>
|
<p>Target ID</p>
|
||||||
<Link
|
<Link href={report.reportType === "MII" ? `/mii/${report.targetId}` : `/profile/${report.targetId}`} className="text-blue-600 text-sm">
|
||||||
href={report.reportType === "MII" ? `/mii/${report.targetId}` : `/profile/${report.targetId}`}
|
|
||||||
className="text-blue-600 text-sm"
|
|
||||||
>
|
|
||||||
{report.targetId}
|
{report.targetId}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -146,8 +146,8 @@ export default function Punishments() {
|
||||||
punishment.type === "WARNING"
|
punishment.type === "WARNING"
|
||||||
? "bg-yellow-50 border-yellow-400"
|
? "bg-yellow-50 border-yellow-400"
|
||||||
: punishment.type === "TEMP_EXILE"
|
: punishment.type === "TEMP_EXILE"
|
||||||
? "bg-orange-100 border-orange-200"
|
? "bg-orange-100 border-orange-200"
|
||||||
: "bg-red-50 border-red-200"
|
: "bg-red-50 border-red-200"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
|
@ -156,8 +156,8 @@ export default function Punishments() {
|
||||||
punishment.type === "WARNING"
|
punishment.type === "WARNING"
|
||||||
? "bg-yellow-200 text-yellow-800 border-yellow-500"
|
? "bg-yellow-200 text-yellow-800 border-yellow-500"
|
||||||
: punishment.type === "TEMP_EXILE"
|
: punishment.type === "TEMP_EXILE"
|
||||||
? "bg-orange-200 text-orange-800 border-orange-500"
|
? "bg-orange-200 text-orange-800 border-orange-500"
|
||||||
: "bg-red-200 text-red-800 border-red-500"
|
: "bg-red-200 text-red-800 border-red-500"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{punishment.type}
|
{punishment.type}
|
||||||
|
|
@ -286,9 +286,7 @@ export default function Punishments() {
|
||||||
<div key={index} className="bg-white border border-orange-200 rounded-md p-3 flex items-center justify-between">
|
<div key={index} className="bg-white border border-orange-200 rounded-md p-3 flex items-center justify-between">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="bg-orange-200 text-orange-800 border border-orange-400 px-2 py-1 rounded text-xs font-semibold">
|
<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>
|
||||||
ID: {mii.id}
|
|
||||||
</span>
|
|
||||||
<span className="text-sm text-gray-500">{mii.reason}</span>
|
<span className="text-sm text-gray-500">{mii.reason}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ interface Props {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Carousel({ images, className }: Props) {
|
export default function Carousel({ images, className }: Props) {
|
||||||
const [emblaRef, emblaApi] = useEmblaCarousel();
|
const [emblaRef, emblaApi] = useEmblaCarousel({ duration: 15 });
|
||||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
const [scrollSnaps, setScrollSnaps] = useState<number[]>([]);
|
const [scrollSnaps, setScrollSnaps] = useState<number[]>([]);
|
||||||
const [isFocused, setIsFocused] = useState(false);
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
|
|
|
||||||
|
|
@ -56,13 +56,7 @@ export default function DeleteMiiButton({ miiId, miiName, likes, inMiiPage }: Pr
|
||||||
<span>Delete</span>
|
<span>Delete</span>
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button onClick={() => setIsOpen(true)} aria-label="Delete Mii" title="Delete Mii" data-tooltip="Delete" className="cursor-pointer aspect-square">
|
||||||
onClick={() => setIsOpen(true)}
|
|
||||||
aria-label="Delete Mii"
|
|
||||||
title="Delete Mii"
|
|
||||||
data-tooltip="Delete"
|
|
||||||
className="cursor-pointer aspect-square"
|
|
||||||
>
|
|
||||||
<Icon icon="mdi:trash" />
|
<Icon icon="mdi:trash" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
@ -111,7 +105,7 @@ export default function DeleteMiiButton({ miiId, miiName, likes, inMiiPage }: Pr
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>,
|
</div>,
|
||||||
document.body
|
document.body,
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -63,12 +63,7 @@ export default function Description({ text, className }: Props) {
|
||||||
href={`/profile/${id}`}
|
href={`/profile/${id}`}
|
||||||
className="inline-flex items-center align-bottom gap-1.5 pr-2 bg-orange-100 border border-orange-400 rounded-lg mx-1 text-orange-800 text-xs"
|
className="inline-flex items-center align-bottom gap-1.5 pr-2 bg-orange-100 border border-orange-400 rounded-lg mx-1 text-orange-800 text-xs"
|
||||||
>
|
>
|
||||||
<ProfilePicture
|
<ProfilePicture src={linkedProfile.image || "/guest.webp"} width={24} height={24} className="bg-white rounded-lg border-r border-orange-400" />
|
||||||
src={linkedProfile.image || "/guest.webp"}
|
|
||||||
width={24}
|
|
||||||
height={24}
|
|
||||||
className="bg-white rounded-lg border-r border-orange-400"
|
|
||||||
/>
|
|
||||||
{linkedProfile.name}
|
{linkedProfile.name}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -54,11 +54,7 @@ export default function Footer() {
|
||||||
•
|
•
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<a
|
<a href="https://trafficlunar.net" target="_blank" className="text-zinc-500 hover:text-zinc-700 transition-colors duration-200 hover:underline group">
|
||||||
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>
|
Made by <span className="text-orange-400 group-hover:text-orange-500 font-medium transition-colors duration-200">trafficlunar</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -118,7 +118,7 @@ export default function ImageViewer({ src, alt, width, height, className, images
|
||||||
<>
|
<>
|
||||||
{/* Carousel counter */}
|
{/* Carousel counter */}
|
||||||
<div
|
<div
|
||||||
className={`flex justify-center gap-2 bg-orange-300/25 text-orange-300 w-15 font-semibold text-sm py-1 rounded-full border border-orange-300 absolute top-4 left-4 transition-opacity duration-300 ${
|
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"
|
isVisible ? "opacity-100" : "opacity-0"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|
@ -147,7 +147,7 @@ export default function ImageViewer({ src, alt, width, height, className, images
|
||||||
|
|
||||||
{/* Carousel snaps */}
|
{/* Carousel snaps */}
|
||||||
<div
|
<div
|
||||||
className={`flex justify-center gap-2 bg-orange-300/25 p-2.5 rounded-full border border-orange-300 absolute left-1/2 -translate-x-1/2 bottom-4 transition-opacity duration-300 ${
|
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"
|
isVisible ? "opacity-100" : "opacity-0"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|
@ -156,7 +156,7 @@ export default function ImageViewer({ src, alt, width, height, className, images
|
||||||
key={index}
|
key={index}
|
||||||
aria-label={`Go to ${index} in Carousel`}
|
aria-label={`Go to ${index} in Carousel`}
|
||||||
onClick={() => emblaApi?.scrollTo(index)}
|
onClick={() => emblaApi?.scrollTo(index)}
|
||||||
className={`size-2 cursor-pointer rounded-full transition-all duration-300 ${index === selectedIndex ? "bg-orange-300 w-8" : "bg-orange-300/40"}`}
|
className={`size-2 cursor-pointer rounded-full transition-all duration-300 ${index === selectedIndex ? "bg-slate-800 w-8" : "bg-slate-800/30"}`}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ export default function Pagination({ lastPage }: Props) {
|
||||||
params.set("page", pageNumber.toString());
|
params.set("page", pageNumber.toString());
|
||||||
return `${pathname}?${params.toString()}`;
|
return `${pathname}?${params.toString()}`;
|
||||||
},
|
},
|
||||||
[searchParams, pathname]
|
[searchParams, pathname],
|
||||||
);
|
);
|
||||||
|
|
||||||
const numbers = useMemo(() => {
|
const numbers = useMemo(() => {
|
||||||
|
|
@ -44,9 +44,7 @@ export default function Pagination({ lastPage }: Props) {
|
||||||
aria-label="Go to First Page"
|
aria-label="Go to First Page"
|
||||||
aria-disabled={page === 1}
|
aria-disabled={page === 1}
|
||||||
tabIndex={page === 1 ? -1 : undefined}
|
tabIndex={page === 1 ? -1 : undefined}
|
||||||
className={`pill button bg-orange-100! p-0.5! aspect-square text-2xl ${
|
className={`pill button bg-orange-100! p-0.5! aspect-square text-2xl ${page === 1 ? "pointer-events-none opacity-50" : "hover:bg-orange-400!"}`}
|
||||||
page === 1 ? "pointer-events-none opacity-50" : "hover:bg-orange-400!"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<Icon icon="stash:chevron-double-left" />
|
<Icon icon="stash:chevron-double-left" />
|
||||||
</Link>
|
</Link>
|
||||||
|
|
@ -83,9 +81,7 @@ export default function Pagination({ lastPage }: Props) {
|
||||||
aria-label="Go to Next Page"
|
aria-label="Go to Next Page"
|
||||||
aria-disabled={page >= lastPage}
|
aria-disabled={page >= lastPage}
|
||||||
tabIndex={page >= lastPage ? -1 : undefined}
|
tabIndex={page >= lastPage ? -1 : undefined}
|
||||||
className={`pill button bg-orange-100! p-0.5! aspect-square text-2xl ${
|
className={`pill button bg-orange-100! p-0.5! aspect-square text-2xl ${page >= lastPage ? "pointer-events-none opacity-50" : "hover:bg-orange-400!"}`}
|
||||||
page >= lastPage ? "pointer-events-none opacity-50" : "hover:bg-orange-400!"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<Icon icon="stash:chevron-right" />
|
<Icon icon="stash:chevron-right" />
|
||||||
</Link>
|
</Link>
|
||||||
|
|
@ -96,9 +92,7 @@ export default function Pagination({ lastPage }: Props) {
|
||||||
aria-label="Go to Last Page"
|
aria-label="Go to Last Page"
|
||||||
aria-disabled={page >= lastPage}
|
aria-disabled={page >= lastPage}
|
||||||
tabIndex={page >= lastPage ? -1 : undefined}
|
tabIndex={page >= lastPage ? -1 : undefined}
|
||||||
className={`pill button bg-orange-100! p-0.5! aspect-square text-2xl ${
|
className={`pill button bg-orange-100! p-0.5! aspect-square text-2xl ${page >= lastPage ? "pointer-events-none opacity-50" : "hover:bg-orange-400!"}`}
|
||||||
page >= lastPage ? "pointer-events-none opacity-50" : "hover:bg-orange-400!"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<Icon icon="stash:chevron-double-right" />
|
<Icon icon="stash:chevron-double-right" />
|
||||||
</Link>
|
</Link>
|
||||||
|
|
|
||||||
|
|
@ -51,8 +51,7 @@ export default async function ProfileInformation({ userId, page }: Props) {
|
||||||
|
|
||||||
<div className="mt-3 text-sm flex gap-8">
|
<div className="mt-3 text-sm flex gap-8">
|
||||||
<h4 title={`${user.createdAt.toLocaleTimeString("en-GB", { timeZone: "UTC" })} UTC`}>
|
<h4 title={`${user.createdAt.toLocaleTimeString("en-GB", { timeZone: "UTC" })} UTC`}>
|
||||||
<span className="font-medium">Created:</span>{" "}
|
<span className="font-medium">Created:</span> {user.createdAt.toLocaleDateString("en-GB", { month: "long", day: "2-digit", year: "numeric" })}
|
||||||
{user.createdAt.toLocaleDateString("en-GB", { month: "long", day: "2-digit", year: "numeric" })}
|
|
||||||
</h4>
|
</h4>
|
||||||
<h4>
|
<h4>
|
||||||
Liked <span className="font-bold">{likedMiis}</span> Miis
|
Liked <span className="font-bold">{likedMiis}</span> Miis
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,7 @@ export default async function ProfileOverview() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li title="Your profile">
|
<li title="Your profile">
|
||||||
<Link
|
<Link href={`/profile/${session?.user.id}`} aria-label="Go to profile" className="pill button gap-2! p-0! h-full max-w-64" data-tooltip="Your Profile">
|
||||||
href={`/profile/${session?.user.id}`}
|
|
||||||
aria-label="Go to profile"
|
|
||||||
className="pill button gap-2! p-0! h-full max-w-64"
|
|
||||||
data-tooltip="Your Profile"
|
|
||||||
>
|
|
||||||
<Image
|
<Image
|
||||||
src={session?.user?.image ?? "/guest.webp"}
|
src={session?.user?.image ?? "/guest.webp"}
|
||||||
alt="profile picture"
|
alt="profile picture"
|
||||||
|
|
|
||||||
|
|
@ -39,11 +39,7 @@ export default function DeleteAccount() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<button name="deletion" onClick={() => setIsOpen(true)} className="pill button w-fit h-min ml-auto bg-red-400! border-red-500! hover:bg-red-500!">
|
||||||
name="deletion"
|
|
||||||
onClick={() => setIsOpen(true)}
|
|
||||||
className="pill button w-fit h-min ml-auto bg-red-400! border-red-500! hover:bg-red-500!"
|
|
||||||
>
|
|
||||||
Delete Account
|
Delete Account
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
|
@ -69,9 +65,7 @@ export default function DeleteAccount() {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-sm text-zinc-500">
|
<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>
|
||||||
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>}
|
{error && <span className="text-red-400 font-bold mt-2">Error: {error}</span>}
|
||||||
|
|
||||||
|
|
@ -83,7 +77,7 @@ export default function DeleteAccount() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>,
|
</div>,
|
||||||
document.body
|
document.body,
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -151,13 +151,7 @@ export default function ProfileSettings({ currentDescription }: Props) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-1 h-min col-span-2">
|
<div className="flex justify-end gap-1 h-min col-span-2">
|
||||||
<input
|
<input type="text" className="pill input flex-1" placeholder="Type here..." value={displayName} onChange={(e) => setDisplayName(e.target.value)} />
|
||||||
type="text"
|
|
||||||
className="pill input flex-1"
|
|
||||||
placeholder="Type here..."
|
|
||||||
value={displayName}
|
|
||||||
onChange={(e) => setDisplayName(e.target.value)}
|
|
||||||
/>
|
|
||||||
<SubmitDialogButton
|
<SubmitDialogButton
|
||||||
title="Confirm Display Name Change"
|
title="Confirm Display Name Change"
|
||||||
description="Are you sure? This will only be visible on your profile. You can change it again later."
|
description="Are you sure? This will only be visible on your profile. You can change it again later."
|
||||||
|
|
|
||||||
|
|
@ -86,8 +86,8 @@ export default function ProfilePictureSettings() {
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
>
|
>
|
||||||
<p className="text-sm text-zinc-500 mt-2">
|
<p className="text-sm text-zinc-500 mt-2">
|
||||||
After submitting, you can change it again on{" "}
|
After submitting, you can change it again on {changeDate.toDate().toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })}
|
||||||
{changeDate.toDate().toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })}.
|
.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="bg-orange-100 rounded-xl border-2 border-amber-500 mt-4 px-2 py-1 flex items-center">
|
<div className="bg-orange-100 rounded-xl border-2 border-amber-500 mt-4 px-2 py-1 flex items-center">
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,7 @@ export default function SubmitDialogButton({ title, description, onSubmit, error
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>,
|
</div>,
|
||||||
document.body
|
document.body,
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -36,12 +36,7 @@ export default function ReasonSelector({ reason, setReason }: Props) {
|
||||||
return (
|
return (
|
||||||
<div className="relative w-full col-span-2">
|
<div className="relative w-full col-span-2">
|
||||||
{/* Toggle button to open the dropdown */}
|
{/* Toggle button to open the dropdown */}
|
||||||
<button
|
<button type="button" {...getToggleButtonProps()} aria-label="Report reason dropdown" className="pill input w-full gap-1 justify-between! text-nowrap">
|
||||||
type="button"
|
|
||||||
{...getToggleButtonProps()}
|
|
||||||
aria-label="Report reason dropdown"
|
|
||||||
className="pill input w-full gap-1 justify-between! text-nowrap"
|
|
||||||
>
|
|
||||||
{selectedItem?.label || <span className="text-black/40">Select a reason for the report...</span>}
|
{selectedItem?.label || <span className="text-black/40">Select a reason for the report...</span>}
|
||||||
<Icon icon="tabler:chevron-down" className="ml-2 size-5" />
|
<Icon icon="tabler:chevron-down" className="ml-2 size-5" />
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -91,11 +91,7 @@ export default function ShareMiiButton({ miiId }: Props) {
|
||||||
<input type="text" disabled className="pill input w-full text-sm" value={url} />
|
<input type="text" disabled className="pill input w-full text-sm" value={url} />
|
||||||
|
|
||||||
{/* Copy button */}
|
{/* Copy button */}
|
||||||
<button
|
<button className="absolute! top-2.5 right-2.5 cursor-pointer" data-tooltip={hasCopiedUrl ? "Copied!" : "Copy URL"} onClick={handleCopyUrl}>
|
||||||
className="absolute! top-2.5 right-2.5 cursor-pointer"
|
|
||||||
data-tooltip={hasCopiedUrl ? "Copied!" : "Copy URL"}
|
|
||||||
onClick={handleCopyUrl}
|
|
||||||
>
|
|
||||||
<div className="relative text-xl">
|
<div className="relative text-xl">
|
||||||
{/* Copy icon */}
|
{/* Copy icon */}
|
||||||
<Icon
|
<Icon
|
||||||
|
|
@ -124,14 +120,7 @@ export default function ShareMiiButton({ miiId }: Props) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-center items-center p-4 w-full bg-orange-100 border border-orange-400 rounded-lg">
|
<div className="flex justify-center items-center p-4 w-full bg-orange-100 border border-orange-400 rounded-lg">
|
||||||
<Image
|
<Image src={`/mii/${miiId}/image?type=metadata`} alt="mii 'metadata' image" width={248} height={248} unoptimized className="drop-shadow-md" />
|
||||||
src={`/mii/${miiId}/image?type=metadata`}
|
|
||||||
alt="mii 'metadata' image"
|
|
||||||
width={248}
|
|
||||||
height={248}
|
|
||||||
unoptimized
|
|
||||||
className="drop-shadow-md"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 mt-4">
|
<div className="flex justify-end gap-2 mt-4">
|
||||||
|
|
@ -158,9 +147,7 @@ export default function ShareMiiButton({ miiId }: Props) {
|
||||||
{/* Copy icon */}
|
{/* Copy icon */}
|
||||||
<Icon
|
<Icon
|
||||||
icon="solar:copy-bold"
|
icon="solar:copy-bold"
|
||||||
className={` transition-all duration-300 ${
|
className={` transition-all duration-300 ${hasCopiedImage ? "opacity-0 scale-75 rotate-12" : "opacity-100 scale-100 rotate-0"}`}
|
||||||
hasCopiedImage ? "opacity-0 scale-75 rotate-12" : "opacity-100 scale-100 rotate-0"
|
|
||||||
}`}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Check icon */}
|
{/* Check icon */}
|
||||||
|
|
@ -180,7 +167,7 @@ export default function ShareMiiButton({ miiId }: Props) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>,
|
</div>,
|
||||||
document.body
|
document.body,
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ export default function EditForm({ mii, likes }: Props) {
|
||||||
|
|
||||||
setFiles((prev) => [...prev, ...acceptedFiles]);
|
setFiles((prev) => [...prev, ...acceptedFiles]);
|
||||||
},
|
},
|
||||||
[files.length]
|
[files.length],
|
||||||
);
|
);
|
||||||
|
|
||||||
const [error, setError] = useState<string | undefined>(undefined);
|
const [error, setError] = useState<string | undefined>(undefined);
|
||||||
|
|
@ -91,7 +91,7 @@ export default function EditForm({ mii, likes }: Props) {
|
||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
|
|
||||||
return Object.assign(new File([blob], `image${index}.webp`, { type: "image/webp" }), { path });
|
return Object.assign(new File([blob], `image${index}.webp`, { type: "image/webp" }), { path });
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
setFiles(existing);
|
setFiles(existing);
|
||||||
|
|
@ -107,9 +107,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">
|
<form className="flex justify-center gap-4 w-full max-lg:flex-col max-lg:items-center">
|
||||||
<div className="flex justify-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">
|
<div className="w-75 h-min flex flex-col bg-zinc-50 rounded-3xl border-2 border-zinc-300 shadow-lg p-3">
|
||||||
<Carousel
|
<Carousel images={[`/mii/${mii.id}/image?type=mii`, `/mii/${mii.id}/image?type=qr-code`, ...files.map((file) => URL.createObjectURL(file))]} />
|
||||||
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">
|
<div className="p-4 flex flex-col gap-1 h-full">
|
||||||
<h1 className="font-bold text-2xl line-clamp-1" title={name}>
|
<h1 className="font-bold text-2xl line-clamp-1" title={name}>
|
||||||
|
|
|
||||||
|
|
@ -144,10 +144,8 @@ export default function QrScanner({ isOpen, setIsOpen, setQrBytesRaw }: Props) {
|
||||||
};
|
};
|
||||||
}, [isOpen, permissionGranted, selectedDeviceId, scanQRCode]);
|
}, [isOpen, permissionGranted, selectedDeviceId, scanQRCode]);
|
||||||
|
|
||||||
if (!isOpen) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-(--header-height) flex items-center justify-center z-40">
|
<div className={`fixed inset-0 h-[calc(100%-var(--header-height))] top-(--header-height) flex items-center justify-center z-40 ${!isOpen ? "hidden" : ""}`}>
|
||||||
<div
|
<div
|
||||||
onClick={close}
|
onClick={close}
|
||||||
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${isVisible ? "opacity-100" : "opacity-0"}`}
|
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${isVisible ? "opacity-100" : "opacity-0"}`}
|
||||||
|
|
@ -165,43 +163,41 @@ export default function QrScanner({ isOpen, setIsOpen, setQrBytesRaw }: Props) {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{devices.length > 1 && (
|
<div className={`mb-4 flex flex-col gap-1 ${devices.length <= 1 ? "hidden" : ""}`}>
|
||||||
<div className="mb-4 flex flex-col gap-1">
|
<label className="text-sm font-semibold">Camera:</label>
|
||||||
<label className="text-sm font-semibold">Camera:</label>
|
<div className="relative w-full">
|
||||||
<div className="relative w-full">
|
{/* Toggle button to open the dropdown */}
|
||||||
{/* Toggle button to open the dropdown */}
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
aria-label="Select camera dropdown"
|
||||||
aria-label="Select camera dropdown"
|
{...getToggleButtonProps({}, { suppressRefError: true })}
|
||||||
{...getToggleButtonProps({}, { suppressRefError: true })}
|
className="pill input w-full px-2! py-0.5! justify-between! text-sm"
|
||||||
className="pill input w-full px-2! py-0.5! justify-between! text-sm"
|
>
|
||||||
>
|
{selectedItem?.label || "Select a camera"}
|
||||||
{selectedItem?.label || "Select a camera"}
|
|
||||||
|
|
||||||
<Icon icon="tabler:chevron-down" className="ml-2 size-5" />
|
<Icon icon="tabler:chevron-down" className="ml-2 size-5" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Dropdown menu */}
|
{/* Dropdown menu */}
|
||||||
<ul
|
<ul
|
||||||
{...getMenuProps({}, { suppressRefError: true })}
|
{...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 ${
|
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 ? "block" : "hidden"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{isDropdownOpen &&
|
{isDropdownOpen &&
|
||||||
cameraItems.map((item, index) => (
|
cameraItems.map((item, index) => (
|
||||||
<li
|
<li
|
||||||
key={item.value}
|
key={item.value}
|
||||||
{...getItemProps({ item, index })}
|
{...getItemProps({ item, index })}
|
||||||
className={`px-4 py-1 cursor-pointer text-sm ${highlightedIndex === index ? "bg-black/15" : ""}`}
|
className={`px-4 py-1 cursor-pointer text-sm ${highlightedIndex === index ? "bg-black/15" : ""}`}
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
<div className="relative w-full aspect-square">
|
<div className="relative w-full aspect-square">
|
||||||
{!permissionGranted && (
|
{!permissionGranted && (
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ export default function QrUpload({ setQrBytesRaw }: Props) {
|
||||||
};
|
};
|
||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file);
|
||||||
},
|
},
|
||||||
[setQrBytesRaw]
|
[setQrBytesRaw],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import { useEffect, useState } from "react";
|
||||||
import useEmblaCarousel from "embla-carousel-react";
|
import useEmblaCarousel from "embla-carousel-react";
|
||||||
import { Icon } from "@iconify/react";
|
import { Icon } from "@iconify/react";
|
||||||
import confetti from "canvas-confetti";
|
import confetti from "canvas-confetti";
|
||||||
import ReturnToIsland from "../admin/return-to-island";
|
|
||||||
|
|
||||||
interface Slide {
|
interface Slide {
|
||||||
// step is never used, undefined is assumed as a step
|
// step is never used, undefined is assumed as a step
|
||||||
|
|
@ -30,7 +29,7 @@ interface Props {
|
||||||
export default function Tutorial({ tutorials, isOpen, setIsOpen }: Props) {
|
export default function Tutorial({ tutorials, isOpen, setIsOpen }: Props) {
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
|
||||||
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true });
|
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true, duration: 15 });
|
||||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
|
|
||||||
// Build index map
|
// Build index map
|
||||||
|
|
@ -102,9 +101,7 @@ export default function Tutorial({ tutorials, isOpen, setIsOpen }: Props) {
|
||||||
<div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-(--header-height) flex items-center justify-center z-40">
|
<div className="fixed inset-0 h-[calc(100%-var(--header-height))] top-(--header-height) flex items-center justify-center z-40">
|
||||||
<div
|
<div
|
||||||
onClick={close}
|
onClick={close}
|
||||||
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${
|
className={`z-40 absolute inset-0 backdrop-brightness-75 backdrop-blur-xs transition-opacity duration-300 ${isVisible ? "opacity-100" : "opacity-0"}`}
|
||||||
isVisible ? "opacity-100" : "opacity-0"
|
|
||||||
}`}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|
@ -191,11 +188,7 @@ export default function Tutorial({ tutorials, isOpen, setIsOpen }: Props) {
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Only show tutorial name on step slides */}
|
{/* Only show tutorial name on step slides */}
|
||||||
<span
|
<span className={`text-sm transition-opacity duration-300 ${(currentSlide.type === "finish" || currentSlide.type === "start") && "opacity-0"}`}>
|
||||||
className={`text-sm transition-opacity duration-300 ${
|
|
||||||
(currentSlide.type === "finish" || currentSlide.type === "start") && "opacity-0"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{currentSlide?.tutorialTitle}
|
{currentSlide?.tutorialTitle}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
|
|
||||||
42
src/components/tutorial/scan.tsx
Normal file
42
src/components/tutorial/scan.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { Icon } from "@iconify/react";
|
||||||
|
|
||||||
|
import Tutorial from ".";
|
||||||
|
|
||||||
|
export default function ScanTutorialButton() {
|
||||||
|
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/step1.png" },
|
||||||
|
{ text: "2. Go into 'QR Code'", imageSrc: "/tutorial/adding-mii/step2.png" },
|
||||||
|
{ text: "3. Press 'Scan QR Code'", imageSrc: "/tutorial/adding-mii/step3.png" },
|
||||||
|
{ text: "4. Click on the QR code below the Mii's image", imageSrc: "/tutorial/adding-mii/step4.png" },
|
||||||
|
{ text: "5. Scan with your 3DS", imageSrc: "/tutorial/adding-mii/step5.png" },
|
||||||
|
{ type: "finish" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
isOpen={isOpen}
|
||||||
|
setIsOpen={setIsOpen}
|
||||||
|
/>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
64
src/components/tutorial/submit.tsx
Normal file
64
src/components/tutorial/submit.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
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/allow-copying/thumbnail.png",
|
||||||
|
hint: "Suggested!",
|
||||||
|
steps: [
|
||||||
|
{ type: "start" },
|
||||||
|
{ text: "1. Enter the town hall", imageSrc: "/tutorial/step1.png" },
|
||||||
|
{ text: "2. Go into 'Mii List'", imageSrc: "/tutorial/allow-copying/step2.png" },
|
||||||
|
{ text: "3. Select and edit the Mii you wish to submit", imageSrc: "/tutorial/allow-copying/step3.png" },
|
||||||
|
{ text: "4. Click 'Other Settings' in the information screen", imageSrc: "/tutorial/allow-copying/step4.png" },
|
||||||
|
{ text: "5. Click on 'Don't Allow' under the 'Copying' text", imageSrc: "/tutorial/allow-copying/step5.png" },
|
||||||
|
{ text: "6. Press 'Allow'", imageSrc: "/tutorial/allow-copying/step6.png" },
|
||||||
|
{ text: "7. Confirm the edits to the Mii", imageSrc: "/tutorial/allow-copying/step7.png" },
|
||||||
|
{ type: "finish" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Create QR Code",
|
||||||
|
thumbnail: "/tutorial/create-qr-code/thumbnail.png",
|
||||||
|
steps: [
|
||||||
|
{ type: "start" },
|
||||||
|
{ text: "1. Enter the town hall", imageSrc: "/tutorial/step1.png" },
|
||||||
|
{ text: "2. Go into 'QR Code'", imageSrc: "/tutorial/create-qr-code/step2.png" },
|
||||||
|
{ text: "3. Press 'Create QR Code'", imageSrc: "/tutorial/create-qr-code/step3.png" },
|
||||||
|
{ text: "4. Select and press 'OK' on the Mii you wish to submit", imageSrc: "/tutorial/create-qr-code/step4.png" },
|
||||||
|
{
|
||||||
|
text: "5. Pick any option; it doesn't matter since the QR code regenerates upon submission.",
|
||||||
|
imageSrc: "/tutorial/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/create-qr-code/step6.png",
|
||||||
|
},
|
||||||
|
{ type: "finish" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
isOpen={isOpen}
|
||||||
|
setIsOpen={setIsOpen}
|
||||||
|
/>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
src/instrumentation-client.ts
Normal file
18
src/instrumentation-client.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
// This file configures the initialization of Sentry on the client.
|
||||||
|
// The added config here will be used whenever a users loads a page in their browser.
|
||||||
|
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
||||||
|
|
||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
|
||||||
|
Sentry.init({
|
||||||
|
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||||
|
// Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control.
|
||||||
|
tracesSampleRate: 0.1,
|
||||||
|
// Enable logs to be sent to Sentry
|
||||||
|
enableLogs: true,
|
||||||
|
// Enable sending user PII (Personally Identifiable Information)
|
||||||
|
// https://docs.sentry.io/platforms/javascript/guides/nextjs/configuration/options/#sendDefaultPii
|
||||||
|
sendDefaultPii: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
|
||||||
9
src/instrumentation.ts
Normal file
9
src/instrumentation.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
|
||||||
|
export async function register() {
|
||||||
|
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||||
|
await import("../sentry.server.config");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const onRequestError = Sentry.captureRequestError;
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
|
||||||
import fs from "fs/promises";
|
import fs from "fs/promises";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
@ -62,6 +63,7 @@ export async function validateImage(file: File): Promise<{ valid: boolean; error
|
||||||
|
|
||||||
if (!moderationResponse.ok) {
|
if (!moderationResponse.ok) {
|
||||||
console.error("Moderation API error");
|
console.error("Moderation API error");
|
||||||
|
Sentry.captureException("Moderation API error", { extra: { stage: "moderation-api-response", status: moderationResponse.status } });
|
||||||
return { valid: false, error: "Content moderation check failed", status: 500 };
|
return { valid: false, error: "Content moderation check failed", status: 500 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -71,13 +73,15 @@ export async function validateImage(file: File): Promise<{ valid: boolean; error
|
||||||
}
|
}
|
||||||
} catch (moderationError) {
|
} catch (moderationError) {
|
||||||
console.error("Error fetching moderation API:", moderationError);
|
console.error("Error fetching moderation API:", moderationError);
|
||||||
|
Sentry.captureException(moderationError, { extra: { stage: "moderation-api-fetch" } });
|
||||||
return { valid: false, error: "Moderation API is down", status: 503 };
|
return { valid: false, error: "Moderation API is down", status: 503 };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { valid: true };
|
return { valid: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error validating image:", error);
|
console.error("Error validating image:", error);
|
||||||
return { valid: false, error: "Failed to process image file.", status: 500 };
|
Sentry.captureException(error, { extra: { stage: "image-validation" } });
|
||||||
|
return { valid: false, error: "Failed to process image file", status: 500 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
//#endregion
|
//#endregion
|
||||||
|
|
@ -117,7 +121,7 @@ const loadFonts = async (): Promise<Font[]> => {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return fontCache[weight]!;
|
return fontCache[weight]!;
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -131,13 +135,13 @@ export async function generateMetadataImage(mii: Mii, author: string): Promise<{
|
||||||
sharp(buffer)
|
sharp(buffer)
|
||||||
.png()
|
.png()
|
||||||
.toBuffer()
|
.toBuffer()
|
||||||
.then((pngBuffer) => `data:image/png;base64,${pngBuffer.toString("base64")}`)
|
.then((pngBuffer) => `data:image/png;base64,${pngBuffer.toString("base64")}`),
|
||||||
),
|
),
|
||||||
fs.readFile(path.join(miiUploadsDirectory, "qr-code.webp")).then((buffer) =>
|
fs.readFile(path.join(miiUploadsDirectory, "qr-code.webp")).then((buffer) =>
|
||||||
sharp(buffer)
|
sharp(buffer)
|
||||||
.png()
|
.png()
|
||||||
.toBuffer()
|
.toBuffer()
|
||||||
.then((pngBuffer) => `data:image/png;base64,${pngBuffer.toString("base64")}`)
|
.then((pngBuffer) => `data:image/png;base64,${pngBuffer.toString("base64")}`),
|
||||||
),
|
),
|
||||||
loadFonts(),
|
loadFonts(),
|
||||||
]);
|
]);
|
||||||
|
|
@ -225,6 +229,7 @@ export async function generateMetadataImage(mii: Mii, author: string): Promise<{
|
||||||
await fs.writeFile(fileLocation, buffer);
|
await fs.writeFile(fileLocation, buffer);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error storing 'metadata' image type", error);
|
console.error("Error storing 'metadata' image type", error);
|
||||||
|
Sentry.captureException(error, { extra: { stage: "metadata-image-storage", miiId: mii.id } });
|
||||||
return { error: `Failed to store metadata image for ${mii.id}`, status: 500 };
|
return { error: `Failed to store metadata image for ${mii.id}`, status: 500 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -66,14 +66,7 @@ const STUDIO_RENDER_CLOTHES_COLORS = [
|
||||||
"black",
|
"black",
|
||||||
];
|
];
|
||||||
|
|
||||||
const STUDIO_RENDER_LIGHT_DIRECTION_MODS = [
|
const STUDIO_RENDER_LIGHT_DIRECTION_MODS = ["none", "zerox", "flipx", "camera", "offset", "set"];
|
||||||
"none",
|
|
||||||
"zerox",
|
|
||||||
"flipx",
|
|
||||||
"camera",
|
|
||||||
"offset",
|
|
||||||
"set",
|
|
||||||
];
|
|
||||||
|
|
||||||
const STUDIO_RENDER_INSTANCE_ROTATION_MODES = ["model", "camera", "both"];
|
const STUDIO_RENDER_INSTANCE_ROTATION_MODES = ["model", "camera", "both"];
|
||||||
|
|
||||||
|
|
@ -165,285 +158,79 @@ export default class Mii {
|
||||||
|
|
||||||
public validate(): void {
|
public validate(): void {
|
||||||
// Size check
|
// Size check
|
||||||
assert.equal(
|
assert.equal(this.bitStream.length / 8, 0x60, `Invalid Mii data size. Got ${this.bitStream.length / 8}, expected 96`);
|
||||||
this.bitStream.length / 8,
|
|
||||||
0x60,
|
|
||||||
`Invalid Mii data size. Got ${this.bitStream.length / 8}, expected 96`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Value range and type checks
|
// Value range and type checks
|
||||||
assert.ok(
|
assert.ok(this.version === 0 || this.version === 3, `Invalid Mii version. Got ${this.version}, expected 0 or 3`);
|
||||||
this.version === 0 || this.version === 3,
|
assert.equal(typeof this.allowCopying, "boolean", `Invalid Mii allow copying. Got ${this.allowCopying}, expected true or false`);
|
||||||
`Invalid Mii version. Got ${this.version}, expected 0 or 3`,
|
assert.equal(typeof this.profanityFlag, "boolean", `Invalid Mii profanity flag. Got ${this.profanityFlag}, expected true or false`);
|
||||||
);
|
assert.ok(Util.inRange(this.regionLock, Util.range(4)), `Invalid Mii region lock. Got ${this.regionLock}, expected 0-3`);
|
||||||
assert.equal(
|
assert.ok(Util.inRange(this.characterSet, Util.range(4)), `Invalid Mii region lock. Got ${this.characterSet}, expected 0-3`);
|
||||||
typeof this.allowCopying,
|
assert.ok(Util.inRange(this.pageIndex, Util.range(10)), `Invalid Mii page index. Got ${this.pageIndex}, expected 0-9`);
|
||||||
"boolean",
|
assert.ok(Util.inRange(this.slotIndex, Util.range(10)), `Invalid Mii slot index. Got ${this.slotIndex}, expected 0-9`);
|
||||||
`Invalid Mii allow copying. Got ${this.allowCopying}, expected true or false`,
|
assert.equal(this.unknown1, 0, `Invalid Mii unknown1. Got ${this.unknown1}, expected 0`);
|
||||||
);
|
assert.ok(Util.inRange(this.deviceOrigin, Util.range(1, 5)), `Invalid Mii device origin. Got ${this.deviceOrigin}, expected 1-4`);
|
||||||
assert.equal(
|
assert.equal(this.systemId.length, 8, `Invalid Mii system ID size. Got ${this.systemId.length}, system IDs must be 8 bytes long`);
|
||||||
typeof this.profanityFlag,
|
assert.equal(typeof this.normalMii, "boolean", `Invalid normal Mii flag. Got ${this.normalMii}, expected true or false`);
|
||||||
"boolean",
|
assert.equal(typeof this.dsMii, "boolean", `Invalid DS Mii flag. Got ${this.dsMii}, expected true or false`);
|
||||||
`Invalid Mii profanity flag. Got ${this.profanityFlag}, expected true or false`,
|
assert.equal(typeof this.nonUserMii, "boolean", `Invalid non-user Mii flag. Got ${this.nonUserMii}, expected true or false`);
|
||||||
);
|
assert.equal(typeof this.isValid, "boolean", `Invalid Mii valid flag. Got ${this.isValid}, expected true or false`);
|
||||||
assert.ok(
|
assert.ok(this.creationTime < 268435456, `Invalid Mii creation time. Got ${this.creationTime}, max value for 28 bit integer is 268,435,456`);
|
||||||
Util.inRange(this.regionLock, Util.range(4)),
|
assert.equal(this.consoleMAC.length, 6, `Invalid Mii console MAC address size. Got ${this.consoleMAC.length}, console MAC addresses must be 6 bytes long`);
|
||||||
`Invalid Mii region lock. Got ${this.regionLock}, expected 0-3`,
|
assert.ok(Util.inRange(this.gender, Util.range(2)), `Invalid Mii gender. Got ${this.gender}, expected 0 or 1`);
|
||||||
);
|
assert.ok(Util.inRange(this.birthMonth, Util.range(13)), `Invalid Mii birth month. Got ${this.birthMonth}, expected 0-12`);
|
||||||
assert.ok(
|
assert.ok(Util.inRange(this.birthDay, Util.range(32)), `Invalid Mii birth day. Got ${this.birthDay}, expected 0-31`);
|
||||||
Util.inRange(this.characterSet, Util.range(4)),
|
assert.ok(Util.inRange(this.favoriteColor, Util.range(12)), `Invalid Mii favorite color. Got ${this.favoriteColor}, expected 0-11`);
|
||||||
`Invalid Mii region lock. Got ${this.characterSet}, expected 0-3`,
|
assert.equal(typeof this.favorite, "boolean", `Invalid favorite Mii flag. Got ${this.favorite}, expected true or false`);
|
||||||
);
|
assert.ok(Buffer.from(this.miiName, "utf16le").length <= 0x14, `Invalid Mii name. Got ${this.miiName}, name may only be up to 10 characters`);
|
||||||
assert.ok(
|
assert.ok(Util.inRange(this.height, Util.range(128)), `Invalid Mii height. Got ${this.height}, expected 0-127`);
|
||||||
Util.inRange(this.pageIndex, Util.range(10)),
|
assert.ok(Util.inRange(this.build, Util.range(128)), `Invalid Mii build. Got ${this.build}, expected 0-127`);
|
||||||
`Invalid Mii page index. Got ${this.pageIndex}, expected 0-9`,
|
assert.equal(typeof this.disableSharing, "boolean", `Invalid disable sharing Mii flag. Got ${this.disableSharing}, expected true or false`);
|
||||||
);
|
assert.ok(Util.inRange(this.faceType, Util.range(12)), `Invalid Mii face type. Got ${this.faceType}, expected 0-11`);
|
||||||
assert.ok(
|
assert.ok(Util.inRange(this.skinColor, Util.range(7)), `Invalid Mii skin color. Got ${this.skinColor}, expected 0-6`);
|
||||||
Util.inRange(this.slotIndex, Util.range(10)),
|
assert.ok(Util.inRange(this.wrinklesType, Util.range(12)), `Invalid Mii wrinkles type. Got ${this.wrinklesType}, expected 0-11`);
|
||||||
`Invalid Mii slot index. Got ${this.slotIndex}, expected 0-9`,
|
assert.ok(Util.inRange(this.makeupType, Util.range(12)), `Invalid Mii makeup type. Got ${this.makeupType}, expected 0-11`);
|
||||||
);
|
assert.ok(Util.inRange(this.hairType, Util.range(132)), `Invalid Mii hair type. Got ${this.hairType}, expected 0-131`);
|
||||||
assert.equal(
|
|
||||||
this.unknown1,
|
|
||||||
0,
|
|
||||||
`Invalid Mii unknown1. Got ${this.unknown1}, expected 0`,
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
Util.inRange(this.deviceOrigin, Util.range(1, 5)),
|
|
||||||
`Invalid Mii device origin. Got ${this.deviceOrigin}, expected 1-4`,
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
this.systemId.length,
|
|
||||||
8,
|
|
||||||
`Invalid Mii system ID size. Got ${this.systemId.length}, system IDs must be 8 bytes long`,
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
typeof this.normalMii,
|
|
||||||
"boolean",
|
|
||||||
`Invalid normal Mii flag. Got ${this.normalMii}, expected true or false`,
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
typeof this.dsMii,
|
|
||||||
"boolean",
|
|
||||||
`Invalid DS Mii flag. Got ${this.dsMii}, expected true or false`,
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
typeof this.nonUserMii,
|
|
||||||
"boolean",
|
|
||||||
`Invalid non-user Mii flag. Got ${this.nonUserMii}, expected true or false`,
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
typeof this.isValid,
|
|
||||||
"boolean",
|
|
||||||
`Invalid Mii valid flag. Got ${this.isValid}, expected true or false`,
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
this.creationTime < 268435456,
|
|
||||||
`Invalid Mii creation time. Got ${this.creationTime}, max value for 28 bit integer is 268,435,456`,
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
this.consoleMAC.length,
|
|
||||||
6,
|
|
||||||
`Invalid Mii console MAC address size. Got ${this.consoleMAC.length}, console MAC addresses must be 6 bytes long`,
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
Util.inRange(this.gender, Util.range(2)),
|
|
||||||
`Invalid Mii gender. Got ${this.gender}, expected 0 or 1`,
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
Util.inRange(this.birthMonth, Util.range(13)),
|
|
||||||
`Invalid Mii birth month. Got ${this.birthMonth}, expected 0-12`,
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
Util.inRange(this.birthDay, Util.range(32)),
|
|
||||||
`Invalid Mii birth day. Got ${this.birthDay}, expected 0-31`,
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
Util.inRange(this.favoriteColor, Util.range(12)),
|
|
||||||
`Invalid Mii favorite color. Got ${this.favoriteColor}, expected 0-11`,
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
typeof this.favorite,
|
|
||||||
"boolean",
|
|
||||||
`Invalid favorite Mii flag. Got ${this.favorite}, expected true or false`,
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
Buffer.from(this.miiName, "utf16le").length <= 0x14,
|
|
||||||
`Invalid Mii name. Got ${this.miiName}, name may only be up to 10 characters`,
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
Util.inRange(this.height, Util.range(128)),
|
|
||||||
`Invalid Mii height. Got ${this.height}, expected 0-127`,
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
Util.inRange(this.build, Util.range(128)),
|
|
||||||
`Invalid Mii build. Got ${this.build}, expected 0-127`,
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
typeof this.disableSharing,
|
|
||||||
"boolean",
|
|
||||||
`Invalid disable sharing Mii flag. Got ${this.disableSharing}, expected true or false`,
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
Util.inRange(this.faceType, Util.range(12)),
|
|
||||||
`Invalid Mii face type. Got ${this.faceType}, expected 0-11`,
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
Util.inRange(this.skinColor, Util.range(7)),
|
|
||||||
`Invalid Mii skin color. Got ${this.skinColor}, expected 0-6`,
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
Util.inRange(this.wrinklesType, Util.range(12)),
|
|
||||||
`Invalid Mii wrinkles type. Got ${this.wrinklesType}, expected 0-11`,
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
Util.inRange(this.makeupType, Util.range(12)),
|
|
||||||
`Invalid Mii makeup type. Got ${this.makeupType}, expected 0-11`,
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
Util.inRange(this.hairType, Util.range(132)),
|
|
||||||
`Invalid Mii hair type. Got ${this.hairType}, expected 0-131`,
|
|
||||||
);
|
|
||||||
// assert.ok(Util.inRange(this.hairColor, Util.range(8)), `Invalid Mii hair color. Got ${this.hairColor}, expected 0-7`);
|
// assert.ok(Util.inRange(this.hairColor, Util.range(8)), `Invalid Mii hair color. Got ${this.hairColor}, expected 0-7`);
|
||||||
assert.equal(
|
assert.equal(typeof this.flipHair, "boolean", `Invalid flip hair flag. Got ${this.flipHair}, expected true or false`);
|
||||||
typeof this.flipHair,
|
assert.ok(Util.inRange(this.eyeType, Util.range(60)), `Invalid Mii eye type. Got ${this.eyeType}, expected 0-59`);
|
||||||
"boolean",
|
assert.ok(Util.inRange(this.eyeColor, Util.range(6)), `Invalid Mii eye color. Got ${this.eyeColor}, expected 0-5`);
|
||||||
`Invalid flip hair flag. Got ${this.flipHair}, expected true or false`,
|
assert.ok(Util.inRange(this.eyeScale, Util.range(8)), `Invalid Mii eye scale. Got ${this.eyeScale}, expected 0-7`);
|
||||||
);
|
assert.ok(Util.inRange(this.eyeVerticalStretch, Util.range(7)), `Invalid Mii eye vertical stretch. Got ${this.eyeVerticalStretch}, expected 0-6`);
|
||||||
assert.ok(
|
assert.ok(Util.inRange(this.eyeRotation, Util.range(8)), `Invalid Mii eye rotation. Got ${this.eyeRotation}, expected 0-7`);
|
||||||
Util.inRange(this.eyeType, Util.range(60)),
|
assert.ok(Util.inRange(this.eyeSpacing, Util.range(13)), `Invalid Mii eye spacing. Got ${this.eyeSpacing}, expected 0-12`);
|
||||||
`Invalid Mii eye type. Got ${this.eyeType}, expected 0-59`,
|
assert.ok(Util.inRange(this.eyeYPosition, Util.range(19)), `Invalid Mii eye Y position. Got ${this.eyeYPosition}, expected 0-18`);
|
||||||
);
|
assert.ok(Util.inRange(this.eyebrowType, Util.range(25)), `Invalid Mii eyebrow type. Got ${this.eyebrowType}, expected 0-24`);
|
||||||
assert.ok(
|
|
||||||
Util.inRange(this.eyeColor, Util.range(6)),
|
|
||||||
`Invalid Mii eye color. Got ${this.eyeColor}, expected 0-5`,
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
Util.inRange(this.eyeScale, Util.range(8)),
|
|
||||||
`Invalid Mii eye scale. Got ${this.eyeScale}, expected 0-7`,
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
Util.inRange(this.eyeVerticalStretch, Util.range(7)),
|
|
||||||
`Invalid Mii eye vertical stretch. Got ${this.eyeVerticalStretch}, expected 0-6`,
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
Util.inRange(this.eyeRotation, Util.range(8)),
|
|
||||||
`Invalid Mii eye rotation. Got ${this.eyeRotation}, expected 0-7`,
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
Util.inRange(this.eyeSpacing, Util.range(13)),
|
|
||||||
`Invalid Mii eye spacing. Got ${this.eyeSpacing}, expected 0-12`,
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
Util.inRange(this.eyeYPosition, Util.range(19)),
|
|
||||||
`Invalid Mii eye Y position. Got ${this.eyeYPosition}, expected 0-18`,
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
Util.inRange(this.eyebrowType, Util.range(25)),
|
|
||||||
`Invalid Mii eyebrow type. Got ${this.eyebrowType}, expected 0-24`,
|
|
||||||
);
|
|
||||||
// assert.ok(Util.inRange(this.eyebrowColor, Util.range(8)), `Invalid Mii eyebrow color. Got ${this.eyebrowColor}, expected 0-7`);
|
// assert.ok(Util.inRange(this.eyebrowColor, Util.range(8)), `Invalid Mii eyebrow color. Got ${this.eyebrowColor}, expected 0-7`);
|
||||||
assert.ok(
|
assert.ok(Util.inRange(this.eyebrowScale, Util.range(9)), `Invalid Mii eyebrow scale. Got ${this.eyebrowScale}, expected 0-8`);
|
||||||
Util.inRange(this.eyebrowScale, Util.range(9)),
|
|
||||||
`Invalid Mii eyebrow scale. Got ${this.eyebrowScale}, expected 0-8`,
|
|
||||||
);
|
|
||||||
assert.ok(
|
assert.ok(
|
||||||
Util.inRange(this.eyebrowVerticalStretch, Util.range(7)),
|
Util.inRange(this.eyebrowVerticalStretch, Util.range(7)),
|
||||||
`Invalid Mii eyebrow vertical stretch. Got ${this.eyebrowVerticalStretch}, expected 0-6`,
|
`Invalid Mii eyebrow vertical stretch. Got ${this.eyebrowVerticalStretch}, expected 0-6`,
|
||||||
);
|
);
|
||||||
assert.ok(
|
assert.ok(Util.inRange(this.eyebrowRotation, Util.range(12)), `Invalid Mii eyebrow rotation. Got ${this.eyebrowRotation}, expected 0-11`);
|
||||||
Util.inRange(this.eyebrowRotation, Util.range(12)),
|
assert.ok(Util.inRange(this.eyebrowSpacing, Util.range(13)), `Invalid Mii eyebrow spacing. Got ${this.eyebrowSpacing}, expected 0-12`);
|
||||||
`Invalid Mii eyebrow rotation. Got ${this.eyebrowRotation}, expected 0-11`,
|
assert.ok(Util.inRange(this.eyebrowYPosition, Util.range(3, 19)), `Invalid Mii eyebrow Y position. Got ${this.eyebrowYPosition}, expected 3-18`);
|
||||||
);
|
assert.ok(Util.inRange(this.noseType, Util.range(18)), `Invalid Mii nose type. Got ${this.noseType}, expected 0-17`);
|
||||||
assert.ok(
|
assert.ok(Util.inRange(this.noseScale, Util.range(9)), `Invalid Mii nose scale. Got ${this.noseScale}, expected 0-8`);
|
||||||
Util.inRange(this.eyebrowSpacing, Util.range(13)),
|
assert.ok(Util.inRange(this.noseYPosition, Util.range(19)), `Invalid Mii nose Y position. Got ${this.noseYPosition}, expected 0-18`);
|
||||||
`Invalid Mii eyebrow spacing. Got ${this.eyebrowSpacing}, expected 0-12`,
|
assert.ok(Util.inRange(this.mouthType, Util.range(36)), `Invalid Mii mouth type. Got ${this.mouthType}, expected 0-35`);
|
||||||
);
|
assert.ok(Util.inRange(this.mouthColor, Util.range(5)), `Invalid Mii mouth color. Got ${this.mouthColor}, expected 0-4`);
|
||||||
assert.ok(
|
assert.ok(Util.inRange(this.mouthScale, Util.range(9)), `Invalid Mii mouth scale. Got ${this.mouthScale}, expected 0-8`);
|
||||||
Util.inRange(this.eyebrowYPosition, Util.range(3, 19)),
|
assert.ok(Util.inRange(this.mouthHorizontalStretch, Util.range(7)), `Invalid Mii mouth stretch. Got ${this.mouthHorizontalStretch}, expected 0-6`);
|
||||||
`Invalid Mii eyebrow Y position. Got ${this.eyebrowYPosition}, expected 3-18`,
|
assert.ok(Util.inRange(this.mouthYPosition, Util.range(19)), `Invalid Mii mouth Y position. Got ${this.mouthYPosition}, expected 0-18`);
|
||||||
);
|
assert.ok(Util.inRange(this.mustacheType, Util.range(6)), `Invalid Mii mustache type. Got ${this.mustacheType}, expected 0-5`);
|
||||||
assert.ok(
|
assert.ok(Util.inRange(this.beardType, Util.range(6)), `Invalid Mii beard type. Got ${this.beardType}, expected 0-5`);
|
||||||
Util.inRange(this.noseType, Util.range(18)),
|
|
||||||
`Invalid Mii nose type. Got ${this.noseType}, expected 0-17`,
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
Util.inRange(this.noseScale, Util.range(9)),
|
|
||||||
`Invalid Mii nose scale. Got ${this.noseScale}, expected 0-8`,
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
Util.inRange(this.noseYPosition, Util.range(19)),
|
|
||||||
`Invalid Mii nose Y position. Got ${this.noseYPosition}, expected 0-18`,
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
Util.inRange(this.mouthType, Util.range(36)),
|
|
||||||
`Invalid Mii mouth type. Got ${this.mouthType}, expected 0-35`,
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
Util.inRange(this.mouthColor, Util.range(5)),
|
|
||||||
`Invalid Mii mouth color. Got ${this.mouthColor}, expected 0-4`,
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
Util.inRange(this.mouthScale, Util.range(9)),
|
|
||||||
`Invalid Mii mouth scale. Got ${this.mouthScale}, expected 0-8`,
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
Util.inRange(this.mouthHorizontalStretch, Util.range(7)),
|
|
||||||
`Invalid Mii mouth stretch. Got ${this.mouthHorizontalStretch}, expected 0-6`,
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
Util.inRange(this.mouthYPosition, Util.range(19)),
|
|
||||||
`Invalid Mii mouth Y position. Got ${this.mouthYPosition}, expected 0-18`,
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
Util.inRange(this.mustacheType, Util.range(6)),
|
|
||||||
`Invalid Mii mustache type. Got ${this.mustacheType}, expected 0-5`,
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
Util.inRange(this.beardType, Util.range(6)),
|
|
||||||
`Invalid Mii beard type. Got ${this.beardType}, expected 0-5`,
|
|
||||||
);
|
|
||||||
// assert.ok(Util.inRange(this.facialHairColor, Util.range(8)), `Invalid Mii beard type. Got ${this.facialHairColor}, expected 0-7`);
|
// assert.ok(Util.inRange(this.facialHairColor, Util.range(8)), `Invalid Mii beard type. Got ${this.facialHairColor}, expected 0-7`);
|
||||||
assert.ok(
|
assert.ok(Util.inRange(this.mustacheScale, Util.range(9)), `Invalid Mii mustache scale. Got ${this.mustacheScale}, expected 0-8`);
|
||||||
Util.inRange(this.mustacheScale, Util.range(9)),
|
assert.ok(Util.inRange(this.mustacheYPosition, Util.range(17)), `Invalid Mii mustache Y position. Got ${this.mustacheYPosition}, expected 0-16`);
|
||||||
`Invalid Mii mustache scale. Got ${this.mustacheScale}, expected 0-8`,
|
assert.ok(Util.inRange(this.glassesType, Util.range(9)), `Invalid Mii glassess type. Got ${this.glassesType}, expected 0-8`);
|
||||||
);
|
assert.ok(Util.inRange(this.glassesColor, Util.range(6)), `Invalid Mii glassess type. Got ${this.glassesColor}, expected 0-5`);
|
||||||
assert.ok(
|
assert.ok(Util.inRange(this.glassesScale, Util.range(8)), `Invalid Mii glassess type. Got ${this.glassesScale}, expected 0-7`);
|
||||||
Util.inRange(this.mustacheYPosition, Util.range(17)),
|
assert.ok(Util.inRange(this.glassesYPosition, Util.range(21)), `Invalid Mii glassess Y position. Got ${this.glassesYPosition}, expected 0-20`);
|
||||||
`Invalid Mii mustache Y position. Got ${this.mustacheYPosition}, expected 0-16`,
|
assert.equal(typeof this.moleEnabled, "boolean", `Invalid mole enabled flag. Got ${this.moleEnabled}, expected true or false`);
|
||||||
);
|
assert.ok(Util.inRange(this.moleScale, Util.range(9)), `Invalid Mii mole scale. Got ${this.moleScale}, expected 0-8`);
|
||||||
assert.ok(
|
assert.ok(Util.inRange(this.moleXPosition, Util.range(17)), `Invalid Mii mole X position. Got ${this.moleXPosition}, expected 0-16`);
|
||||||
Util.inRange(this.glassesType, Util.range(9)),
|
assert.ok(Util.inRange(this.moleYPosition, Util.range(31)), `Invalid Mii mole Y position. Got ${this.moleYPosition}, expected 0-30`);
|
||||||
`Invalid Mii glassess type. Got ${this.glassesType}, expected 0-8`,
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
Util.inRange(this.glassesColor, Util.range(6)),
|
|
||||||
`Invalid Mii glassess type. Got ${this.glassesColor}, expected 0-5`,
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
Util.inRange(this.glassesScale, Util.range(8)),
|
|
||||||
`Invalid Mii glassess type. Got ${this.glassesScale}, expected 0-7`,
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
Util.inRange(this.glassesYPosition, Util.range(21)),
|
|
||||||
`Invalid Mii glassess Y position. Got ${this.glassesYPosition}, expected 0-20`,
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
typeof this.moleEnabled,
|
|
||||||
"boolean",
|
|
||||||
`Invalid mole enabled flag. Got ${this.moleEnabled}, expected true or false`,
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
Util.inRange(this.moleScale, Util.range(9)),
|
|
||||||
`Invalid Mii mole scale. Got ${this.moleScale}, expected 0-8`,
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
Util.inRange(this.moleXPosition, Util.range(17)),
|
|
||||||
`Invalid Mii mole X position. Got ${this.moleXPosition}, expected 0-16`,
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
Util.inRange(this.moleYPosition, Util.range(31)),
|
|
||||||
`Invalid Mii mole Y position. Got ${this.moleYPosition}, expected 0-30`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Sanity checks
|
// Sanity checks
|
||||||
/*
|
/*
|
||||||
|
|
@ -459,10 +246,7 @@ export default class Mii {
|
||||||
}
|
}
|
||||||
*/
|
*/
|
||||||
|
|
||||||
if (
|
if (this.nonUserMii && (this.creationTime !== 0 || this.isValid || this.dsMii || this.normalMii)) {
|
||||||
this.nonUserMii &&
|
|
||||||
(this.creationTime !== 0 || this.isValid || this.dsMii || this.normalMii)
|
|
||||||
) {
|
|
||||||
assert.fail("Non-user Mii's must have all other Mii ID bits set to 0");
|
assert.fail("Non-user Mii's must have all other Mii ID bits set to 0");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -569,11 +353,7 @@ export default class Mii {
|
||||||
|
|
||||||
public calculateCRC(): number {
|
public calculateCRC(): number {
|
||||||
// #view is inaccessible
|
// #view is inaccessible
|
||||||
const data = new Uint8Array(
|
const data = new Uint8Array(this.buffer.buffer, this.buffer.byteOffset, this.buffer.length).subarray(0, 0x5e);
|
||||||
this.buffer.buffer,
|
|
||||||
this.buffer.byteOffset,
|
|
||||||
this.buffer.length,
|
|
||||||
).subarray(0, 0x5e);
|
|
||||||
|
|
||||||
let crc = 0x0000;
|
let crc = 0x0000;
|
||||||
|
|
||||||
|
|
@ -727,23 +507,11 @@ export default class Mii {
|
||||||
data: this.encodeStudio().toString("hex"),
|
data: this.encodeStudio().toString("hex"),
|
||||||
};
|
};
|
||||||
|
|
||||||
params.type = STUDIO_RENDER_TYPES.includes(params.type as string)
|
params.type = STUDIO_RENDER_TYPES.includes(params.type as string) ? params.type : STUDIO_RENDER_DEFAULTS.type;
|
||||||
? params.type
|
params.expression = STUDIO_RENDER_EXPRESSIONS.includes(params.expression as string) ? params.expression : STUDIO_RENDER_DEFAULTS.expression;
|
||||||
: STUDIO_RENDER_DEFAULTS.type;
|
|
||||||
params.expression = STUDIO_RENDER_EXPRESSIONS.includes(
|
|
||||||
params.expression as string,
|
|
||||||
)
|
|
||||||
? params.expression
|
|
||||||
: STUDIO_RENDER_DEFAULTS.expression;
|
|
||||||
params.width = Util.clamp(params.width, 512);
|
params.width = Util.clamp(params.width, 512);
|
||||||
params.bgColor = STUDIO_BG_COLOR_REGEX.test(params.bgColor as string)
|
params.bgColor = STUDIO_BG_COLOR_REGEX.test(params.bgColor as string) ? params.bgColor : STUDIO_RENDER_DEFAULTS.bgColor;
|
||||||
? params.bgColor
|
params.clothesColor = STUDIO_RENDER_CLOTHES_COLORS.includes(params.clothesColor) ? params.clothesColor : STUDIO_RENDER_DEFAULTS.clothesColor;
|
||||||
: STUDIO_RENDER_DEFAULTS.bgColor;
|
|
||||||
params.clothesColor = STUDIO_RENDER_CLOTHES_COLORS.includes(
|
|
||||||
params.clothesColor,
|
|
||||||
)
|
|
||||||
? params.clothesColor
|
|
||||||
: STUDIO_RENDER_DEFAULTS.clothesColor;
|
|
||||||
params.cameraXRotate = Util.clamp(params.cameraXRotate, 359);
|
params.cameraXRotate = Util.clamp(params.cameraXRotate, 359);
|
||||||
params.cameraYRotate = Util.clamp(params.cameraYRotate, 359);
|
params.cameraYRotate = Util.clamp(params.cameraYRotate, 359);
|
||||||
params.cameraZRotate = Util.clamp(params.cameraZRotate, 359);
|
params.cameraZRotate = Util.clamp(params.cameraZRotate, 359);
|
||||||
|
|
@ -753,25 +521,16 @@ export default class Mii {
|
||||||
params.lightXDirection = Util.clamp(params.lightXDirection, 359);
|
params.lightXDirection = Util.clamp(params.lightXDirection, 359);
|
||||||
params.lightYDirection = Util.clamp(params.lightYDirection, 359);
|
params.lightYDirection = Util.clamp(params.lightYDirection, 359);
|
||||||
params.lightZDirection = Util.clamp(params.lightZDirection, 359);
|
params.lightZDirection = Util.clamp(params.lightZDirection, 359);
|
||||||
params.lightDirectionMode = STUDIO_RENDER_LIGHT_DIRECTION_MODS.includes(
|
params.lightDirectionMode = STUDIO_RENDER_LIGHT_DIRECTION_MODS.includes(params.lightDirectionMode)
|
||||||
params.lightDirectionMode,
|
|
||||||
)
|
|
||||||
? params.lightDirectionMode
|
? params.lightDirectionMode
|
||||||
: STUDIO_RENDER_DEFAULTS.lightDirectionMode;
|
: STUDIO_RENDER_DEFAULTS.lightDirectionMode;
|
||||||
params.instanceCount = Util.clamp(params.instanceCount, 1, 16);
|
params.instanceCount = Util.clamp(params.instanceCount, 1, 16);
|
||||||
params.instanceRotationMode =
|
params.instanceRotationMode = STUDIO_RENDER_INSTANCE_ROTATION_MODES.includes(params.instanceRotationMode)
|
||||||
STUDIO_RENDER_INSTANCE_ROTATION_MODES.includes(
|
? params.instanceRotationMode
|
||||||
params.instanceRotationMode,
|
: STUDIO_RENDER_DEFAULTS.instanceRotationMode;
|
||||||
)
|
|
||||||
? params.instanceRotationMode
|
|
||||||
: STUDIO_RENDER_DEFAULTS.instanceRotationMode;
|
|
||||||
|
|
||||||
// converts non-string params to strings
|
// converts non-string params to strings
|
||||||
const query = new URLSearchParams(
|
const query = new URLSearchParams(Object.fromEntries(Object.entries(params).map(([key, value]) => [key, value.toString()])));
|
||||||
Object.fromEntries(
|
|
||||||
Object.entries(params).map(([key, value]) => [key, value.toString()]),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (params.lightDirectionMode === "none") {
|
if (params.lightDirectionMode === "none") {
|
||||||
query.delete("lightDirectionMode");
|
query.delete("lightDirectionMode");
|
||||||
|
|
|
||||||
|
|
@ -1,126 +0,0 @@
|
||||||
import { convertQrCode } from "./qr-codes";
|
|
||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import Mii from "./mii.js/mii";
|
|
||||||
import { TomodachiLifeMii, HairDyeMode } from "./tomodachi-life-mii";
|
|
||||||
|
|
||||||
// List of encrypted QR code data to test against.
|
|
||||||
const validQrCodes = [
|
|
||||||
// Frieren (from step6.webp)
|
|
||||||
{
|
|
||||||
base64: "lymEx6TA4eLgfwTEceW+DbdYBTfBfPKM4VesJkq1qzoGGXnk/3OohvPPuGS8mmnzvpvyC36ge8+C2c4IIhJ0l7Lx9KKxnPEAuLBM1YtCnSowjVNHo3CmjE7D7lkDUBuhO3qKjAqWXfQthtqBqjTe4Hv95TKNVPimaxNXhAVZGSmOMh++0Z2N0TvIDpU/8kxLIsgntsQH4PNlaFcVF2HeOERXRdTm/TMFb6pozO57nJ9NKi5bxh1ClNArbpyTQgBe7cfvnNretFVNWzGJBWctZC7weecYIKyU3qbH5c4kogmj4WcfJhYsuOT3odvv2WBaGhchgR779Ztc0E76COxNEaJa6M7QDyHGw8XQfxCH3j4pMkhFOlPn/ObS3rNUADYUCY8d+Wt4fBbO7PTW7ppDnDaCyxwEIbglsMtkD/cIPIr4f0RPnpV7ZlOmrJ3HdIbmwXi6TqKTwqkHtBmBPvZVpXm4RJN8A6gF22Uc7NY8B3KMYY7Q",
|
|
||||||
expected: {
|
|
||||||
miiName: "Frieren",
|
|
||||||
firstName: "Frieren",
|
|
||||||
lastName: "the Slayer",
|
|
||||||
islandName: "Wuhu",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
base64: "ky1cf9hr9xotl250Y8cDOGqPd7t51NS8tNVrJMRAI2bfXr4LNirKvqu7ZrvC00vgz70NU8kQRR6H3uAnRaHupxjLbeYfU0s6CduruAEnXP8rZanCeSePQQH0NSL3QqiilT2WEt7nCAMvHwR9fT/LE/k6NBMDHqoK3zqzemr4OlQro0YWBeRJ501EawWan/k/rq2VSGTeLO09CsQD6AFHECtxF9+sSKyJxK1aiu7VhmOZLY6L9VKrlpvahQ1/vHVyYVpFJvc3bdHE8D94bBXkZ18mnXj++ST+j1Had4aki7oqTT83fgs7asRg3DRRZArw8PiKVmZWJ4ODRWR/LNvjIxb1FQqWk9I6S3DEo6AMuBRbXgj1H4YWrRuTkWlEpP2Y/P5+Mvfv5GbNQKYSKwpTYFOCTn13yQ1wtDbF4yG+Ro4Xf45cNBT6k3yqswrKt9bkP2wiULqYZR7McaD1SJ4whFFqcadjpLvbn8zQNFY0lOUTQMGI",
|
|
||||||
expected: {
|
|
||||||
miiName: "ミク",
|
|
||||||
firstName: "ミク",
|
|
||||||
lastName: "はつね",
|
|
||||||
islandName: "LOSTの",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// 372 bytes of zeroes.
|
|
||||||
const zeroes372 = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
|
||||||
|
|
||||||
// 32 bytes of zeroes (too short to be a Mii QR code).
|
|
||||||
const length32 = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
|
|
||||||
|
|
||||||
// Mii QR code encrypted correctly but has an invalid CRC-16 checksum.
|
|
||||||
const badCrc = "ULENrXILeXZooDaJvwHMcFaBjGH7k5pj+DDPDV0irq66c01Fh82TIrZerZX2xiB+TbHBzYIaDHdryA9IbR1uH/slSalGCScmjDiL9zpOXlvh8z38NGkmjtL1fVVrshCcJHKeQApdbZU4S6h+wZd0LA==";
|
|
||||||
|
|
||||||
// Hair dye should be applied to hair only.
|
|
||||||
const hairDyeHairOnly = "lHDnfRgqe2+drU303Slkj7+o4JldrKcIdOl5zgLM0LpwQKQY+i3cpt5IIg7LBNAr7TCzHOvi698oUV0QkcyNj71MgtAAaw4MvOdT4dsv0PLof6E7IcjgnCA1ZAJ2Bs5PQTnM/yuVBUIXdq6WYh+nmG3HtxV7zKbEpSy4bqVep8uuvUlfZcB+BQgucPXQLmDnS8ECKwOlANcKTI+ZjIZggVaEsyY88pjRWyXnwe1z4Favw16bIzecesehGlqXzZh9U5Vm5dZP8wmKc3G6TGylYmbnloRd99UYRNULvTQCUer8WljGuV30ftXlJOwfsnwoAiVOGoG3KvbsBpPtPLywR5DavRgQIPd0/b+XUzHQDhkyftMXeqVEalsuEmU/b/1/j4yVL+2lWgD1i2xyET65uJawAnd8jbKbG8lxPMgzIKGVqJB4QmJOl9/dTf21r9GgRFRaFEz+66bVfiYhzXKmJUQv2qx/t/V3r96QzYd08nrWSHK0";
|
|
||||||
const hairDyeHairOnlyExpectedCommonColor = 99; // Must apply to hair but not eyebrow and beard.
|
|
||||||
// Hair dye should be applied to hair, eyebrow, and beard.
|
|
||||||
const hairDyeHairEyebrowBeard = validQrCodes[1].base64; // Miku has hair dye.
|
|
||||||
const hairDyeHairEyebrowBeardExpectedCommonColor = 67; // Must apply to hair, eyebrow, and beard.
|
|
||||||
|
|
||||||
// Should not have hair dye enabled.
|
|
||||||
const hairDyeMode0 = validQrCodes[0].base64; // Frieren doesn't have hair dye
|
|
||||||
|
|
||||||
function base64ToUint8Array(base64: string): Uint8Array {
|
|
||||||
const binary = Buffer.from(base64, "base64");
|
|
||||||
return new Uint8Array(binary.buffer, binary.byteOffset, binary.byteLength);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("convertQrCode", () => {
|
|
||||||
it.each(validQrCodes.map((t, i) => [i, t]))(
|
|
||||||
"should convert valid QR #%i into Mii + TomodachiLifeMii",
|
|
||||||
(_, { base64, expected }) => {
|
|
||||||
const bytes = base64ToUint8Array(base64);
|
|
||||||
const { mii, tomodachiLifeMii } = convertQrCode(bytes);
|
|
||||||
|
|
||||||
expect(mii).toBeInstanceOf(Mii);
|
|
||||||
expect(tomodachiLifeMii).toBeInstanceOf(TomodachiLifeMii);
|
|
||||||
|
|
||||||
expect(mii.miiName).toBe(expected.miiName);
|
|
||||||
expect(tomodachiLifeMii.firstName).toBe(expected.firstName);
|
|
||||||
expect(tomodachiLifeMii.lastName).toBe(expected.lastName);
|
|
||||||
expect(tomodachiLifeMii.islandName).toBe(expected.islandName);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should throw an error on zeroed out data", () => {
|
|
||||||
const bytes = base64ToUint8Array(zeroes372);
|
|
||||||
// Will decrypt wrongly, leading to the expected stream
|
|
||||||
// of zeroes in the decrypted data not being intact.
|
|
||||||
expect(() => convertQrCode(bytes)).toThrow("QR code is not a valid Mii QR code");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should throw an error on wrong length", () => {
|
|
||||||
const bytes = base64ToUint8Array(length32);
|
|
||||||
// Thrown at the beginning of the function.
|
|
||||||
expect(() => convertQrCode(bytes)).toThrow("Mii QR code has wrong size (got 32, expected 112 or longer)");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should throw an error on data with bad CRC-16", () => {
|
|
||||||
const bytes = base64ToUint8Array(badCrc);
|
|
||||||
// Verified by new Mii() constructor from mii-js.
|
|
||||||
expect(() => convertQrCode(bytes)).toThrow("Mii data is not valid");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should apply hair dye to hair, eyebrow, and beard", () => {
|
|
||||||
const bytes = base64ToUint8Array(hairDyeHairEyebrowBeard);
|
|
||||||
const { mii, tomodachiLifeMii } = convertQrCode(bytes);
|
|
||||||
|
|
||||||
expect(tomodachiLifeMii.hairDyeMode).toBe(HairDyeMode.HairEyebrowBeard);
|
|
||||||
expect(tomodachiLifeMii.studioHairColor).toBe(hairDyeHairEyebrowBeardExpectedCommonColor);
|
|
||||||
expect(mii.hairColor).toBe(hairDyeHairEyebrowBeardExpectedCommonColor);
|
|
||||||
expect(mii.eyebrowColor).toBe(hairDyeHairEyebrowBeardExpectedCommonColor);
|
|
||||||
expect(mii.facialHairColor).toBe(hairDyeHairEyebrowBeardExpectedCommonColor);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should apply hair dye to hair only", () => {
|
|
||||||
const bytes = base64ToUint8Array(hairDyeHairOnly);
|
|
||||||
const { mii, tomodachiLifeMii } = convertQrCode(bytes);
|
|
||||||
|
|
||||||
expect(tomodachiLifeMii.hairDyeMode).toBe(HairDyeMode.Hair);
|
|
||||||
expect(tomodachiLifeMii.studioHairColor).toBe(hairDyeHairOnlyExpectedCommonColor);
|
|
||||||
expect(mii.hairColor).toBe(hairDyeHairOnlyExpectedCommonColor);
|
|
||||||
expect(mii.eyebrowColor === hairDyeHairOnlyExpectedCommonColor).toBe(false);
|
|
||||||
expect(mii.facialHairColor === hairDyeHairOnlyExpectedCommonColor).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not apply hair dye if mode is 0", () => {
|
|
||||||
const bytes = base64ToUint8Array(hairDyeMode0);
|
|
||||||
const { mii, tomodachiLifeMii } = convertQrCode(bytes);
|
|
||||||
|
|
||||||
expect(tomodachiLifeMii.hairDyeMode).toBe(HairDyeMode.None);
|
|
||||||
expect(mii.hairColor === tomodachiLifeMii.studioHairColor).toBe(false);
|
|
||||||
expect(mii.hairColor === mii.facialHairColor).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
/*
|
|
||||||
it('should censor bad words in names', () => {
|
|
||||||
const qrWithSwears = // TODO TODO
|
|
||||||
const { tomodachiLifeMii } = convertQrCode(qrWithSwears);
|
|
||||||
expect(tomodachiLifeMii.firstName).not.toMatch(/INSERT_SWEARS_HERE/i);
|
|
||||||
});
|
|
||||||
*/
|
|
||||||
});
|
|
||||||
|
|
@ -17,14 +17,13 @@ import { TomodachiLifeMii, HairDyeMode } from "./tomodachi-life-mii";
|
||||||
// In "sjcl-with-all" v1.0.8 from npm, the name is "u"
|
// In "sjcl-with-all" v1.0.8 from npm, the name is "u"
|
||||||
|
|
||||||
/** Private _ctrMode function defined here: {@link https://github.com/bitwiseshiftleft/sjcl/blob/85caa53c281eeeb502310013312c775d35fe0867/core/ccm.js#L194} */
|
/** Private _ctrMode function defined here: {@link https://github.com/bitwiseshiftleft/sjcl/blob/85caa53c281eeeb502310013312c775d35fe0867/core/ccm.js#L194} */
|
||||||
const sjclCcmCtrMode: ((
|
const sjclCcmCtrMode:
|
||||||
prf: sjcl.SjclCipher, data: sjcl.BitArray, iv: sjcl.BitArray,
|
| ((prf: sjcl.SjclCipher, data: sjcl.BitArray, iv: sjcl.BitArray, tag: sjcl.BitArray, tlen: number, L: number) => { data: sjcl.BitArray; tag: sjcl.BitArray })
|
||||||
tag: sjcl.BitArray, tlen: number, L: number
|
| undefined =
|
||||||
) => { data: sjcl.BitArray; tag: sjcl.BitArray }) | undefined =
|
|
||||||
// @ts-expect-error -- Referencing a private function that is not in the types.
|
// @ts-expect-error -- Referencing a private function that is not in the types.
|
||||||
sjcl.mode.ccm.u; // NOTE: This may need to be changed with a different sjcl build. Read above
|
sjcl.mode.ccm.u; // NOTE: This may need to be changed with a different sjcl build. Read above
|
||||||
|
|
||||||
export function convertQrCode(bytes: Uint8Array): { mii: Mii; tomodachiLifeMii: TomodachiLifeMii } {
|
export function convertQrCode(bytes: Uint8Array): { mii: Mii; tomodachiLifeMii: TomodachiLifeMii } | never {
|
||||||
// Decrypt 96 byte 3DS/Wii U format Mii data from the QR code.
|
// Decrypt 96 byte 3DS/Wii U format Mii data from the QR code.
|
||||||
// References (Credits: jaames, kazuki-4ys):
|
// References (Credits: jaames, kazuki-4ys):
|
||||||
// - https://gist.github.com/jaames/96ce8daa11b61b758b6b0227b55f9f78
|
// - https://gist.github.com/jaames/96ce8daa11b61b758b6b0227b55f9f78
|
||||||
|
|
@ -32,7 +31,9 @@ export function convertQrCode(bytes: Uint8Array): { mii: Mii; tomodachiLifeMii:
|
||||||
|
|
||||||
// Check that the private _ctrMode function is defined.
|
// Check that the private _ctrMode function is defined.
|
||||||
if (!sjclCcmCtrMode) {
|
if (!sjclCcmCtrMode) {
|
||||||
throw new Error("Private sjcl.mode.ccm._ctrMode function cannot be found. The build of sjcl expected may have changed. Read src/lib/qr-codes.ts for more details.");
|
throw new Error(
|
||||||
|
"Private sjcl.mode.ccm._ctrMode function cannot be found. The build of sjcl expected may have changed. Read src/lib/qr-codes.ts for more details.",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify that the length is not smaller than expected.
|
// Verify that the length is not smaller than expected.
|
||||||
|
|
@ -52,9 +53,11 @@ export function convertQrCode(bytes: Uint8Array): { mii: Mii; tomodachiLifeMii:
|
||||||
// Isolate the actual ciphertext from the tag and adjust IV.
|
// Isolate the actual ciphertext from the tag and adjust IV.
|
||||||
// Copied from sjcl.mode.ccm.decrypt: https://github.com/bitwiseshiftleft/sjcl/blob/85caa53c281eeeb502310013312c775d35fe0867/core/ccm.js#L83
|
// Copied from sjcl.mode.ccm.decrypt: https://github.com/bitwiseshiftleft/sjcl/blob/85caa53c281eeeb502310013312c775d35fe0867/core/ccm.js#L83
|
||||||
const tlen = 128; // Tag length in bits.
|
const tlen = 128; // Tag length in bits.
|
||||||
const dataWithoutTag = sjcl.bitArray.clamp(encryptedBits,
|
const dataWithoutTag = sjcl.bitArray.clamp(
|
||||||
|
encryptedBits,
|
||||||
// remove tag from out, tag length = 128
|
// remove tag from out, tag length = 128
|
||||||
sjcl.bitArray.bitLength(encryptedBits) - tlen);
|
sjcl.bitArray.bitLength(encryptedBits) - tlen,
|
||||||
|
);
|
||||||
|
|
||||||
let decryptedBits: { data: sjcl.BitArray };
|
let decryptedBits: { data: sjcl.BitArray };
|
||||||
try {
|
try {
|
||||||
|
|
@ -93,7 +96,7 @@ export function convertQrCode(bytes: Uint8Array): { mii: Mii; tomodachiLifeMii:
|
||||||
case HairDyeMode.HairEyebrowBeard:
|
case HairDyeMode.HairEyebrowBeard:
|
||||||
mii.eyebrowColor = tomodachiLifeMii.studioHairColor;
|
mii.eyebrowColor = tomodachiLifeMii.studioHairColor;
|
||||||
mii.facialHairColor = tomodachiLifeMii.studioHairColor;
|
mii.facialHairColor = tomodachiLifeMii.studioHairColor;
|
||||||
// Fall-through and also apply to hair.
|
// Fall-through and also apply to hair.
|
||||||
case HairDyeMode.Hair:
|
case HairDyeMode.Hair:
|
||||||
mii.hairColor = tomodachiLifeMii.studioHairColor;
|
mii.hairColor = tomodachiLifeMii.studioHairColor;
|
||||||
break;
|
break;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { createClient, RedisClientType } from "redis";
|
import { createClient, RedisClientType } from "redis";
|
||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
import { auth } from "./auth";
|
import { auth } from "./auth";
|
||||||
|
|
||||||
const WINDOW_SIZE = 60;
|
const WINDOW_SIZE = 60;
|
||||||
|
|
@ -17,7 +18,10 @@ async function getRedisClient() {
|
||||||
client = createClient({
|
client = createClient({
|
||||||
url: process.env.REDIS_URL,
|
url: process.env.REDIS_URL,
|
||||||
});
|
});
|
||||||
client.on("error", (err) => console.error("Redis client error", err));
|
client.on("error", (error) => {
|
||||||
|
console.error("Redis client error", error);
|
||||||
|
Sentry.captureException(error, { tags: { source: "redis-client" } });
|
||||||
|
});
|
||||||
await client.connect();
|
await client.connect();
|
||||||
}
|
}
|
||||||
return client;
|
return client;
|
||||||
|
|
@ -67,6 +71,7 @@ export class RateLimit {
|
||||||
return { success, limit: this.maxRequests, remaining, expires: expireAt };
|
return { success, limit: this.maxRequests, remaining, expires: expireAt };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Rate limit check failed", error);
|
console.error("Rate limit check failed", error);
|
||||||
|
Sentry.captureException(error, { tags: { source: "rate-limit-check" } });
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
limit: this.maxRequests,
|
limit: this.maxRequests,
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,7 @@ sjcl.beware["CTR mode is dangerous because it doesn't protect message integrity.
|
||||||
// Converts hair dye to studio color
|
// Converts hair dye to studio color
|
||||||
// Reference: https://github.com/ariankordi/nwf-mii-cemu-toy/blob/9906440c1dafbe3f40ac8b95e10a22ebd85b441e/assets/data-conversion.js#L282
|
// Reference: https://github.com/ariankordi/nwf-mii-cemu-toy/blob/9906440c1dafbe3f40ac8b95e10a22ebd85b441e/assets/data-conversion.js#L282
|
||||||
// (Credits to kat21)
|
// (Credits to kat21)
|
||||||
const hairDyeConverter = [
|
const hairDyeConverter = [55, 51, 50, 12, 16, 12, 67, 61, 51, 64, 69, 66, 65, 86, 85, 93, 92, 19, 20, 20, 15, 32, 35, 26, 38, 41, 43, 18, 95, 97, 97, 99];
|
||||||
55, 51, 50, 12, 16, 12, 67, 61, 51, 64, 69, 66, 65, 86, 85, 93, 92, 19, 20, 20, 15, 32, 35, 26, 38, 41, 43, 18, 95, 97, 97, 99,
|
|
||||||
];
|
|
||||||
|
|
||||||
// All possible values for 2-bit hair dye mode.
|
// All possible values for 2-bit hair dye mode.
|
||||||
export enum HairDyeMode {
|
export enum HairDyeMode {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue