mirror of
https://github.com/trafficlunar/tomodachi-share.git
synced 2026-05-13 13:17:45 +00:00
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:
parent
7e10cef0b8
commit
13b2bef574
21 changed files with 1417 additions and 53 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -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*
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
177
frontend/src/lib/mii-edit-notifications.test.ts
Normal file
177
frontend/src/lib/mii-edit-notifications.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
74
frontend/src/lib/mii-edit-notifications.ts
Normal file
74
frontend/src/lib/mii-edit-notifications.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
52
frontend/src/lib/submit-response.test.ts
Normal file
52
frontend/src/lib/submit-response.test.ts
Normal 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" },
|
||||
});
|
||||
}
|
||||
30
frontend/src/lib/submit-response.ts
Normal file
30
frontend/src/lib/submit-response.ts
Normal 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) };
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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
11
frontend/vitest.config.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
"test": "pnpm --filter frontend test"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
|
|
|
|||
926
pnpm-lock.yaml
926
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
12
tsconfig.json
Normal file
12
tsconfig.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./frontend/tsconfig.json" },
|
||||
{ "path": "./backend/tsconfig.json" },
|
||||
{ "path": "./shared/tsconfig.json" }
|
||||
]
|
||||
}
|
||||
Loading…
Reference in a new issue