feat: mii edit UX, a11y fixes, and frontend unit tests

- API: return success message on mii edit
- FE: flash banner + helpers, submit response helper, Vitest
- A11y: labels, alt text, banner close; css checkerboard class
- DX: root tsconfig refs, strict/tsconfig, next dev --webpack

Made-with: Cursor
This commit is contained in:
Lepre-CHAU-n 2026-04-29 00:25:17 -07:00
parent 7e10cef0b8
commit 13b2bef574
21 changed files with 1417 additions and 53 deletions

3
.gitignore vendored
View file

@ -25,6 +25,9 @@ certificates/
.DS_Store
*.pem
# Cursor IDE (local/agent rules — do not ship unless team chooses to share)
.cursor/
# debug
npm-debug.log*
yarn-debug.log*

View file

@ -4,7 +4,7 @@
"private": true,
"packageManager": "pnpm@10.33.0",
"scripts": {
"dev": "next dev",
"dev": "next dev --webpack",
"build": "next build",
"start": "next start",
"lint": "next lint",

View file

@ -281,5 +281,8 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
console.error("Cloudflare cache purge failed:", err);
});
return rateLimit.sendResponse({ success: true });
return rateLimit.sendResponse({
success: true,
message: "Mii updated successfully.",
});
}

View file

@ -7,7 +7,9 @@
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@bprogress/react": "^1.2.7",
@ -47,6 +49,8 @@
"globals": "^17.4.0",
"typescript": "~6.0.2",
"typescript-eslint": "^8.58.0",
"vite": "^8.0.4"
"vite": "^8.0.4",
"vitest": "^3.2.4",
"jsdom": "^26.1.0"
}
}

View file

@ -57,7 +57,7 @@ export default function AdminBanner() {
<span>{message}</span>
</div>
<button onClick={handleClose} className="sm:absolute right-2 cursor-pointer p-1.5">
<button onClick={handleClose} aria-label="Close banner" title="Close banner" className="sm:absolute right-2 cursor-pointer p-1.5">
<Icon icon="humbleicons:times" className="text-2xl min-w-6" />
</button>
</div>

View file

@ -94,8 +94,11 @@ export default function DeleteMiiButton({ miiId, miiName, likes, inMiiPage }: Pr
</div>
</div>
<p className="text-sm text-zinc-500 my-2">Type the Mii's name below to delete:</p>
<label htmlFor={`delete-mii-name-${miiId}`} className="text-sm text-zinc-500 my-2 block">
Type the Mii's name below to delete:
</label>
<input
id={`delete-mii-name-${miiId}`}
type="text"
className="pill input"
value={inputMiiName}

View file

@ -85,7 +85,16 @@ export default function ShareMiiButton({ miiId }: Props) {
</div>
<div className="relative">
<input type="text" disabled className="pill input w-full text-sm" value={url} />
<label htmlFor={`share-mii-url-${miiId}`} className="sr-only">
Share link
</label>
<input
id={`share-mii-url-${miiId}`}
type="text"
disabled
className="pill input w-full text-sm"
value={url}
/>
{/* Copy button */}
<button className="absolute! top-2.5 right-2.5 cursor-pointer" data-tooltip={hasCopiedUrl ? "Copied!" : "Copy URL"} onClick={handleCopyUrl}>

View file

@ -137,6 +137,16 @@ input[type="range"]:hover::-moz-range-thumb {
@apply not-disabled:bg-orange-500;
}
.checkerboard {
background-image:
linear-gradient(45deg, #ccc 25%, transparent 25%),
linear-gradient(-45deg, #ccc 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #ccc 75%),
linear-gradient(-45deg, transparent 75%, #ccc 75%);
background-size: 16px 16px;
background-position: 0 0, 0 8px, 8px -8px, -8px 0px;
}
body {
@apply bg-amber-50 text-slate-800 min-h-screen;
font-family: "Lexend Variable", sans-serif;

View file

@ -0,0 +1,177 @@
import { describe, expect, it } from "vitest";
import {
deriveEditSuccessFlashFromLocationState,
errorMessageAfterJsonFailure,
interpretMiiEditResponse,
} from "./mii-edit-notifications";
function jsonResponse(status: number, body: unknown): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
describe("interpretMiiEditResponse", () => {
it("returns success navigation with server message for the green banner", () => {
const body = { success: true, message: "Mii updated successfully." };
const response = jsonResponse(200, body);
const out = interpretMiiEditResponse(response, body, 42);
expect(out).toEqual({
kind: "success",
navigate: {
path: "/mii/42",
state: { miiEdited: true, miiEditedMessage: "Mii updated successfully." },
},
});
});
it("returns success without miiEditedMessage when message is missing (default copy on Mii page)", () => {
const body = { success: true };
const response = jsonResponse(200, body);
const out = interpretMiiEditResponse(response, body, 7);
expect(out).toEqual({
kind: "success",
navigate: {
path: "/mii/7",
state: { miiEdited: true },
},
});
});
it("returns API error message on non-OK response", () => {
const body = { error: "Nothing was changed" };
const response = jsonResponse(400, body);
const out = interpretMiiEditResponse(response, body, 1);
expect(out).toEqual({ kind: "failure", error: "Nothing was changed" });
});
it("uses status fallback when OK but success is false", () => {
const response = jsonResponse(200, { success: false, error: "bad" });
const out = interpretMiiEditResponse(response, { success: false, error: "bad" }, 2);
expect(out).toEqual({ kind: "failure", error: "bad" });
});
it("uses status fallback when OK, success false, and error is not a string", () => {
const response = jsonResponse(200, { success: false });
const out = interpretMiiEditResponse(response, { success: false }, 3);
expect(out).toEqual({ kind: "failure", error: "Could not save changes. Please try again." });
});
it("formats error when non-OK and error field is missing", () => {
const response = jsonResponse(500, {});
const out = interpretMiiEditResponse(response, {}, 9);
expect(out).toEqual({ kind: "failure", error: "Could not save changes (500). Please try again." });
});
it("429 rate limit preserves string message from API", () => {
const body = { error: "Rate limit exceeded. Please try again later." };
const response = jsonResponse(429, body);
expect(interpretMiiEditResponse(response, body, 1)).toEqual({
kind: "failure",
error: "Rate limit exceeded. Please try again later.",
});
});
it("non-OK converts non-string error via String()", () => {
const payload = { error: { nest: true } };
const response = jsonResponse(400, payload);
expect(interpretMiiEditResponse(response, payload as unknown, 1)).toEqual({
kind: "failure",
error: "[object Object]",
});
});
it("OK with success missing is treated like success false", () => {
const response = jsonResponse(200, {});
expect(interpretMiiEditResponse(response, {}, 55)).toEqual({
kind: "failure",
error: "Could not save changes. Please try again.",
});
});
it('empty-string message keys are kept (shows empty banner vs default — contract test)', () => {
const body = { success: true, message: "" };
const response = jsonResponse(200, body);
expect(interpretMiiEditResponse(response, body, 2)).toEqual({
kind: "success",
navigate: {
path: "/mii/2",
state: { miiEdited: true, miiEditedMessage: "" },
},
});
});
it("numeric message field is ignored (not a banner string)", () => {
const body = { success: true, message: 12345 };
const response = jsonResponse(200, body as unknown as object);
expect(interpretMiiEditResponse(response, body, 3)).toEqual({
kind: "success",
navigate: { path: "/mii/3", state: { miiEdited: true } },
});
});
it("uses large mii id in path without mangling", () => {
const body = { success: true };
const response = jsonResponse(200, body);
expect(interpretMiiEditResponse(response, body, 9_876_543)).toEqual({
kind: "success",
navigate: { path: "/mii/9876543", state: { miiEdited: true } },
});
});
});
describe("errorMessageAfterJsonFailure", () => {
it("invalid JSON when response was OK", () => {
const r = new Response("not json", { status: 200 });
expect(errorMessageAfterJsonFailure(r)).toBe("Invalid response from server.");
});
it("invalid JSON when response was error", () => {
const r = new Response("<html>", { status: 502 });
expect(errorMessageAfterJsonFailure(r)).toBe("Something went wrong (502). Please try again.");
});
});
describe("deriveEditSuccessFlashFromLocationState", () => {
it("shows banner with custom message after navigate from edit", () => {
expect(
deriveEditSuccessFlashFromLocationState({
miiEdited: true,
miiEditedMessage: "Mii updated successfully.",
}),
).toEqual({ show: true, message: "Mii updated successfully." });
});
it("shows banner with undefined message (UI uses default string)", () => {
expect(deriveEditSuccessFlashFromLocationState({ miiEdited: true })).toEqual({
show: true,
message: undefined,
});
});
it("hides banner when navigation has no edit flag", () => {
expect(deriveEditSuccessFlashFromLocationState(null)).toEqual({ show: false });
expect(deriveEditSuccessFlashFromLocationState(undefined)).toEqual({ show: false });
expect(deriveEditSuccessFlashFromLocationState({})).toEqual({ show: false });
});
it("hides banner when miiEdited is explicitly false", () => {
expect(
deriveEditSuccessFlashFromLocationState({
miiEdited: false,
miiEditedMessage: "ignored",
}),
).toEqual({ show: false });
});
it("ignores non-string miiEditedMessage in location state", () => {
expect(
deriveEditSuccessFlashFromLocationState({
miiEdited: true,
miiEditedMessage: 123 as unknown as string,
}),
).toEqual({ show: true, message: undefined });
});
});

View file

@ -0,0 +1,74 @@
/**
* Pure helpers for POST /api/mii/:id/edit responses and Router state for the edit-success banner.
* Kept dependency-free so they are easy to unit test.
*/
export type MiiEditApiBody = {
success?: boolean;
error?: unknown;
message?: unknown;
};
export type NavigateAfterEditState = {
miiEdited: boolean;
miiEditedMessage?: string;
};
/** What to render on the Mii page from Router location.state (edit success navigation). */
export function deriveEditSuccessFlashFromLocationState(locationState: unknown): { show: boolean; message?: string } {
const s = locationState as { miiEdited?: boolean; miiEditedMessage?: string } | null;
return s?.miiEdited ? { show: true, message: typeof s.miiEditedMessage === "string" ? s.miiEditedMessage : undefined } : { show: false };
}
/** Error string when fetch().json() throws but we already received a Response. */
export function errorMessageAfterJsonFailure(response: Response): string {
return response.ok ? "Invalid response from server." : `Something went wrong (${response.status}). Please try again.`;
}
/** Result after a successful JSON body parse — either show error on edit page or navigate with flash state. */
export type InterpretMiiEditOutcome =
| { kind: "failure"; error: string }
| {
kind: "success";
navigate: {
path: string;
state: NavigateAfterEditState;
};
};
/**
* Maps HTTP status + parsed JSON from the edit endpoint to either an inline error string
* or the navigation payload used after a successful save (including Router state for the green banner).
*/
export function interpretMiiEditResponse(response: Response, data: unknown, miiId: number): InterpretMiiEditOutcome {
const body = data as MiiEditApiBody;
if (!response.ok) {
const msg =
typeof body.error === "string"
? body.error
: body.error !== undefined
? String(body.error)
: `Could not save changes (${response.status}). Please try again.`;
return { kind: "failure", error: msg };
}
if (!body.success) {
const err = typeof body.error === "string" ? body.error : "Could not save changes. Please try again.";
return { kind: "failure", error: err };
}
const message = typeof body.message === "string" ? body.message : undefined;
const state: NavigateAfterEditState = {
miiEdited: true,
...(message !== undefined ? { miiEditedMessage: message } : {}),
};
return {
kind: "success",
navigate: {
path: `/mii/${miiId}`,
state,
},
};
}

View file

@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";
import { interpretSubmitResponse } from "./submit-response";
describe("interpretSubmitResponse", () => {
it("success returns numeric mii id", () => {
const response = jsonResponse(200, { id: 99 });
expect(interpretSubmitResponse(response, { id: 99 })).toEqual({ kind: "success", miiId: 99 });
});
it("failure forwards string error when not ok", () => {
const response = jsonResponse(503, { error: "Submissions are temporarily disabled" });
expect(interpretSubmitResponse(response, { error: "Submissions are temporarily disabled" })).toEqual({
kind: "failure",
error: "Submissions are temporarily disabled",
});
});
it("failure mirrors String(undefined) when not ok and error missing", () => {
const response = jsonResponse(400, {});
expect(interpretSubmitResponse(response, {}).error).toBe("undefined");
});
it("invalid id shape on OK response is rejected", () => {
const response = jsonResponse(200, { id: "not-a-number" });
expect(interpretSubmitResponse(response, { id: "not-a-number" })).toEqual({
kind: "failure",
error: "Invalid response from server.",
});
});
it("non-OK with object error matches String semantics", () => {
const errObj = { field: "x" };
const response = jsonResponse(422, { error: errObj });
expect(interpretSubmitResponse(response, { error: errObj })).toEqual({
kind: "failure",
error: String(errObj),
});
});
it("truncates float id when server returned a whole number coerced oddly", () => {
const response = jsonResponse(200, { id: 12.0 });
expect(interpretSubmitResponse(response, { id: 12 })).toEqual({ kind: "success", miiId: 12 });
});
});
function jsonResponse(status: number, body: unknown): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}

View file

@ -0,0 +1,30 @@
/**
* Pure helper for POST /api/submit responses (new Mii ID or error).
* Unit-testable without running the Next server.
*/
export type SubmitApiBody = {
id?: unknown;
error?: unknown;
};
export type InterpretSubmitOutcome =
| { kind: "failure"; error: string }
| { kind: "success"; miiId: number };
/**
* Mirrors the submit page: `error` destructured from JSON `String(error)` includes `"undefined"` if missing.
*/
export function interpretSubmitResponse(response: Response, data: unknown): InterpretSubmitOutcome {
const body = data as SubmitApiBody;
if (!response.ok) {
return { kind: "failure", error: String(body.error) };
}
if (typeof body.id !== "number" || !Number.isFinite(body.id)) {
return { kind: "failure", error: "Invalid response from server." };
}
return { kind: "success", miiId: Math.trunc(body.id) };
}

View file

@ -6,6 +6,7 @@ import { type FileWithPath } from "react-dropzone";
import { nameSchema, tagsSchema } from "@tomodachi-share/shared/schemas";
import { type MiiGender, type MiiMakeup, type SwitchMiiInstructions, deepMerge, defaultInstructions, minifyInstructions } from "@tomodachi-share/shared";
import { errorMessageAfterJsonFailure, interpretMiiEditResponse } from "../lib/mii-edit-notifications";
import Carousel from "../components/carousel";
import LikeButton from "../components/like-button";
import TagSelector from "../components/tag-selector";
@ -60,6 +61,8 @@ export default function EditMiiPage() {
const hasMiiFeaturesChanged = useRef(false);
const handleSubmit = async () => {
setError(undefined);
// Validate before sending request
const nameValidation = nameSchema.safeParse(name);
if (!nameValidation.success) {
@ -118,19 +121,34 @@ export default function EditMiiPage() {
if (blob) formData.append("miiFeaturesImage", blob);
}
const response = await fetch(`${import.meta.env.VITE_API_URL}/api/mii/${mii.id}/edit`, {
setError(undefined);
let response: Response;
try {
response = await fetch(`${import.meta.env.VITE_API_URL}/api/mii/${mii.id}/edit`, {
method: "POST",
body: formData,
credentials: "include",
});
const { error } = await response.json();
if (!response.ok) {
setError(error);
} catch {
setError("Network error. Check your connection and try again.");
return;
}
navigate(`/mii/${mii.id}`);
let data: { success?: boolean; error?: unknown; message?: unknown };
try {
data = await response.json();
} catch {
setError(errorMessageAfterJsonFailure(response));
return;
}
const outcome = interpretMiiEditResponse(response, data, mii.id);
if (outcome.kind === "failure") {
setError(outcome.error);
return;
}
navigate(outcome.navigate.path, { state: outcome.navigate.state });
};
const handleMiiPortraitChange = (uri: string | undefined) => {
@ -244,6 +262,17 @@ export default function EditMiiPage() {
<p className="text-sm text-zinc-500">Make changes to your existing Mii.</p>
</div>
{error && (
<div
className="bg-red-50 border-2 border-red-400 rounded-xl p-3 flex items-start gap-2 text-red-800 text-sm font-medium"
role="alert"
aria-live="assertive"
>
<Icon icon="material-symbols:error-rounded" className="text-xl shrink-0 mt-0.5" />
<span>{error}</span>
</div>
)}
{/* Separator */}
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium my-1">
<hr className="grow border-zinc-300" />
@ -481,9 +510,7 @@ export default function EditMiiPage() {
<ImageList files={files} setFiles={handleFilesChange} />
<hr className="border-zinc-300 my-2" />
<div className="flex justify-between items-center">
{error && <span className="text-red-400 font-bold">Error: {error}</span>}
<div className="flex justify-end items-center">
<SubmitButton onClick={handleSubmit} text="Edit" className="ml-auto" />
</div>
</div>

View file

@ -9,21 +9,32 @@ import SwitchAddMiiTutorialButton from "../components/tutorial/switch-add-mii";
import MiiInstructions from "../components/mii/instructions";
import { Icon } from "@iconify/react";
import { useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router";
import { Link, useLocation, useNavigate, useParams } from "react-router";
import AuthorButtons from "../components/mii/author-buttons";
import { useStore } from "@nanostores/react";
import { session } from "../session";
import { deriveEditSuccessFlashFromLocationState } from "../lib/mii-edit-notifications";
export default function MiiPage() {
const { id } = useParams();
const navigate = useNavigate();
const location = useLocation();
const $session = useStore(session);
const [mii, setMii] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [isLiked, setIsLiked] = useState(false);
const [editSuccessFlash, setEditSuccessFlash] = useState<{ show: boolean; message?: string }>(() =>
deriveEditSuccessFlashFromLocationState(location.state),
);
const API_URL = import.meta.env.VITE_API_URL;
useEffect(() => {
const s = location.state as { miiEdited?: boolean } | null;
if (!s?.miiEdited) return;
navigate(".", { replace: true, state: null });
}, [location.state, navigate]);
useEffect(() => {
fetch(`${API_URL}/api/mii/${id}/info`)
.then((res) => {
@ -85,6 +96,22 @@ export default function MiiPage() {
<meta name="twitter:creator" content={`@${mii.user.name}`} />
<div className="max-w-5xl w-full flex flex-col gap-4">
{editSuccessFlash.show && (
<div className="bg-green-50 border-2 border-green-500 rounded-2xl shadow-lg p-4 flex items-start gap-3 text-green-900">
<Icon icon="mdi:check-circle" className="text-2xl shrink-0 mt-0.5" aria-hidden />
<p className="font-medium grow">
{editSuccessFlash.message ?? "Your Mii was updated successfully."}
</p>
<button
type="button"
className="pill button text-sm shrink-0"
onClick={() => setEditSuccessFlash({ show: false })}
aria-label="Dismiss update confirmation"
>
Dismiss
</button>
</div>
)}
{mii.quarantined && (
<div className="bg-red-100 border-2 border-red-400 rounded-2xl shadow-lg p-4 flex items-center gap-3 text-red-700">
<Icon icon="material-symbols:warning-rounded" className="text-2xl shrink-0" />
@ -152,19 +179,7 @@ export default function MiiPage() {
<hr className="grow border-zinc-300" />
</div>
<div
className="rounded-lg mb-4 overflow-hidden"
style={{
backgroundImage: `
linear-gradient(45deg, #ccc 25%, transparent 25%),
linear-gradient(-45deg, #ccc 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #ccc 75%),
linear-gradient(-45deg, transparent 75%, #ccc 75%)
`,
backgroundSize: "16px 16px",
backgroundPosition: "0 0, 0 8px, 8px -8px, -8px 0px",
}}
>
<div className="rounded-lg mb-4 overflow-hidden checkerboard">
<ImageViewer
src={`${API_URL}/mii/${mii.id}/image?type=facepaint`}
alt="mii facepaint"
@ -192,7 +207,7 @@ export default function MiiPage() {
From: <span className="text-right font-medium">{mii.islandName} Island</span>
</li>
<li>
Allowed Copying: <input type="checkbox" checked={mii.allowedCopying ?? false} disabled className="checkbox cursor-auto!" />
Allowed Copying: <input type="checkbox" checked={mii.allowedCopying ?? false} disabled aria-label="Allowed copying" title="Allowed copying" className="checkbox cursor-auto!" />
</li>
</ul>
)}

View file

@ -80,6 +80,7 @@ export default function ProfileLayout() {
<Link to={`/profile/${user.id}`} className="size-28 aspect-square">
<img
src={user.image ? (user.image.startsWith("/profile") ? `${import.meta.env.VITE_API_URL}${user.image}` : user.image) : "/guest.png"}
alt={`${user.name}'s profile picture`}
onError={(e) => {
e.currentTarget.onerror = null; // Prevent infinite loops
e.currentTarget.src = "/guest.png";

View file

@ -30,6 +30,7 @@ import SubmitButton from "../components/submit-button";
import MiiEditor from "../components/submit-form/mii-editor";
import { session } from "../session";
import { interpretSubmitResponse } from "../lib/submit-response";
import qrcode from "qrcode-generator";
export default function SubmitPage() {
@ -137,14 +138,19 @@ export default function SubmitPage() {
body: formData,
credentials: "include",
});
const { id, error } = await response.json();
if (!response.ok) {
setError(String(error));
let data: unknown;
try {
data = await response.json();
} catch {
setError("Invalid response from server.");
return;
}
navigate(`/mii/${id}`);
const outcome = interpretSubmitResponse(response, data);
if (outcome.kind === "failure") {
setError(outcome.error);
return;
}
navigate(`/mii/${outcome.miiId}`);
};
useEffect(() => {

View file

@ -1,11 +1,13 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"target": "es2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "esnext",
"types": ["vite/client", "node"],
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
/* Bundler mode */
"moduleResolution": "bundler",
@ -21,5 +23,6 @@
// "erasableSyntaxOnly": true
// "noFallthroughCasesInSwitch": true
},
"include": ["src"]
"include": ["src"],
"exclude": ["**/*.test.ts", "**/*.test.tsx"]
}

11
frontend/vitest.config.ts Normal file
View file

@ -0,0 +1,11 @@
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "vitest/config";
export default defineConfig({
plugins: [react(), tailwindcss()],
test: {
include: ["src/**/*.test.{ts,tsx}"],
environment: "jsdom",
},
});

View file

@ -4,7 +4,7 @@
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
"test": "pnpm --filter frontend test"
},
"keywords": [],
"author": "",

File diff suppressed because it is too large Load diff

12
tsconfig.json Normal file
View file

@ -0,0 +1,12 @@
{
"compilerOptions": {
"strict": true,
"forceConsistentCasingInFileNames": true
},
"files": [],
"references": [
{ "path": "./frontend/tsconfig.json" },
{ "path": "./backend/tsconfig.json" },
{ "path": "./shared/tsconfig.json" }
]
}