feat(settings): a Pet page for the mascot
Settings -> Pet (/settings/pet, PetSettings.tsx): show/hide, speech bubbles, size with a live preview, and the pet grid - Sagi, featured codex-pets.net pets (downloaded on first pick), your own (deletable) - with image upload and .codex-pet.zip import. The Sagi block leaves General; the mascot menu's Pet settings opens the page. PetMenu and the page share components/Sagi/usePets.ts.
This commit is contained in:
@@ -6,7 +6,8 @@
|
||||
* your own (deletable) — upload an image or import a `.codex-pet.zip`, browse
|
||||
* codex-pets.net for more, and open the Sagi settings. The choice and the size
|
||||
* are viewer preferences (`prefs.ts`); the pets themselves live on the server
|
||||
* (`server/lib/pets.js`).
|
||||
* (`server/lib/pets.js`); the list and its actions are `usePets`, shared
|
||||
* with Settings → Pet.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -25,8 +26,8 @@ import {
|
||||
Trash2,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { api, type PetList, type PetView } from "../../lib/api";
|
||||
import { PET_SIZE, sagiPrefs, type PetChoice } from "./prefs";
|
||||
import { usePets } from "./usePets";
|
||||
|
||||
const ITEM =
|
||||
"flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm text-fg-secondary hover:bg-surface-4 hover:text-fg-primary disabled:opacity-50";
|
||||
@@ -46,19 +47,8 @@ export function PetMenu({
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const imageInput = useRef<HTMLInputElement>(null);
|
||||
const bundleInput = useRef<HTMLInputElement>(null);
|
||||
const [list, setList] = useState<PetList | null>(null);
|
||||
const [choosing, setChoosing] = useState(false);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const reload = () =>
|
||||
api.pets
|
||||
.list()
|
||||
.then(setList)
|
||||
.catch((err: unknown) => setError(err instanceof Error ? err.message : String(err)));
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, []);
|
||||
const { list, own, installed, busy, error, wear, install, add, remove } = usePets();
|
||||
|
||||
// Closes on a press outside the menu, or Escape.
|
||||
useEffect(() => {
|
||||
@@ -74,32 +64,7 @@ export function PetMenu({
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
const wear = (p: PetView) => sagiPrefs.setPet({ id: p.id, kind: p.kind });
|
||||
const run = async (key: string, work: () => Promise<void>) => {
|
||||
setBusy(key);
|
||||
setError(null);
|
||||
try {
|
||||
await work();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
const addFile = (file: File | undefined, bundle: boolean) =>
|
||||
file &&
|
||||
void run(bundle ? "bundle" : "image", async () => {
|
||||
const { pet: added } = bundle
|
||||
? await api.pets.importBundle(file)
|
||||
: await api.pets.upload(file);
|
||||
wear(added);
|
||||
await reload();
|
||||
});
|
||||
|
||||
const own = list?.pets.filter((p) => p.source === "upload") ?? [];
|
||||
const installed = new Map(
|
||||
(list?.pets ?? []).filter((p) => p.featuredId).map((p) => [p.featuredId as string, p])
|
||||
);
|
||||
const addFile = (file: File | undefined, bundle: boolean) => file && void add(file, bundle);
|
||||
const mark = (id: string) =>
|
||||
pet.id === id ? <Check className="ml-auto h-3.5 w-3.5 shrink-0 text-accent" /> : null;
|
||||
|
||||
@@ -176,14 +141,7 @@ export function PetMenu({
|
||||
disabled={busy !== null}
|
||||
title={t("sagi.pet.featuredBy", { by: f.by })}
|
||||
className={ITEM}
|
||||
onClick={() =>
|
||||
have
|
||||
? wear(have)
|
||||
: void run(f.id, async () => {
|
||||
wear((await api.pets.installFeatured(f.id)).pet);
|
||||
await reload();
|
||||
})
|
||||
}
|
||||
onClick={() => (have ? wear(have) : void install(f.id))}
|
||||
>
|
||||
<span className="min-w-0 truncate">{f.displayName}</span>
|
||||
{busy === f.id ? (
|
||||
@@ -219,13 +177,7 @@ export function PetMenu({
|
||||
aria-label={t("sagi.pet.delete", { name: p.displayName })}
|
||||
title={t("sagi.pet.delete", { name: p.displayName })}
|
||||
disabled={busy !== null}
|
||||
onClick={() =>
|
||||
void run(p.id, async () => {
|
||||
await api.pets.remove(p.id);
|
||||
if (pet.id === p.id) sagiPrefs.setPet({ id: "sagi" });
|
||||
await reload();
|
||||
})
|
||||
}
|
||||
onClick={() => void remove(p)}
|
||||
className="rounded p-1 text-fg-muted hover:bg-surface-4 hover:text-status-danger"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
|
||||
@@ -295,7 +295,7 @@ export function Sagi() {
|
||||
onClose={() => setMenu(false)}
|
||||
onSettings={() => {
|
||||
setMenu(false);
|
||||
navigate("/settings/general#sagi");
|
||||
navigate("/settings/pet");
|
||||
}}
|
||||
/>
|
||||
</SagiFlyout>
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
* row of Sagi's mood and steps through its frames (a still image bobs instead),
|
||||
* and `PetMenu` hides the mascot, sizes it, picks Sagi / a featured pet
|
||||
* (downloading it on the first pick) / an own pet, deletes an own pet (falling
|
||||
* back to Sagi when it was the one worn), and uploads an image.
|
||||
* back to Sagi when it was the one worn), and uploads an image; Settings →
|
||||
* Pet (`PetSettings`) does the same from a grid, plus show / bubbles / size.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
@@ -60,6 +61,7 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
import { api } from "../../../lib/api";
|
||||
import { PetSprite } from "../PetSprite";
|
||||
import { PetMenu } from "../PetMenu";
|
||||
import { PetSettings } from "../../settings/PetSettings";
|
||||
import { sagiPrefs } from "../prefs";
|
||||
|
||||
describe("PetSprite", () => {
|
||||
@@ -175,3 +177,46 @@ describe("PetMenu", () => {
|
||||
expect(api.pets.upload).toHaveBeenCalledWith(file);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PetSettings", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("shows, mutes and sizes the mascot, following the prefs live", async () => {
|
||||
render(<PetSettings />);
|
||||
fireEvent.change(screen.getByTestId("pet-settings-size"), { target: { value: "120" } });
|
||||
expect(sagiPrefs.getSize()).toBe(120);
|
||||
expect(screen.getByText("120px")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("switch", { name: /Speech bubbles/ }));
|
||||
expect(sagiPrefs.getMuted()).toBe(true);
|
||||
fireEvent.click(screen.getByRole("switch", { name: /Show Sagi/ }));
|
||||
expect(sagiPrefs.getEnabled()).toBe(false);
|
||||
// A change made elsewhere (the right-click menu) shows here at once.
|
||||
act(() => sagiPrefs.setSize(200));
|
||||
expect(screen.getByText("200px")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("downloads a featured pet on its first pick, wears an own one, deletes it back to Sagi", async () => {
|
||||
render(<PetSettings />);
|
||||
fireEvent.click(await screen.findByTestId("pet-card-featured-goku"));
|
||||
await waitFor(() => expect(sagiPrefs.getPet()).toEqual({ id: "cp-goku", kind: "codex" }));
|
||||
expect(api.pets.installFeatured).toHaveBeenCalledWith("goku");
|
||||
|
||||
fireEvent.click(screen.getByTestId("pet-card-own-p-aaaaaaaaaaaa"));
|
||||
expect(sagiPrefs.getPet()).toEqual({ id: "p-aaaaaaaaaaaa", kind: "image" });
|
||||
fireEvent.click(screen.getByTestId("pet-card-own-p-aaaaaaaaaaaa-delete"));
|
||||
await waitFor(() => expect(sagiPrefs.getPet()).toEqual({ id: "sagi" }));
|
||||
expect(api.pets.remove).toHaveBeenCalledWith("p-aaaaaaaaaaaa");
|
||||
});
|
||||
|
||||
it("uploads an image and wears it", async () => {
|
||||
render(<PetSettings />);
|
||||
const file = new File([new Uint8Array([0x89, 0x50])], "Dog.png", { type: "image/png" });
|
||||
fireEvent.change(screen.getByTestId("pet-settings-image-input"), { target: { files: [file] } });
|
||||
await waitFor(() =>
|
||||
expect(sagiPrefs.getPet()).toEqual({ id: "p-bbbbbbbbbbbb", kind: "image" })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* @file usePets — the pet list and its actions, shared by the mascot's
|
||||
* right-click menu (`PetMenu.tsx`) and Settings → Pet (`settings/PetSettings`):
|
||||
* `GET /api/pets`, wearing a pet (a viewer preference, `prefs.ts`), installing
|
||||
* a featured pet (downloaded on its first pick), uploading an image or a
|
||||
* `.codex-pet.zip` (worn at once), and deleting your own (Sagi comes back
|
||||
* when it was the one worn). Plus `useWornPet`, the live worn pet / size /
|
||||
* shown / speech-bubble prefs.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { api, type PetList, type PetView } from "../../lib/api";
|
||||
import { sagiPrefs, type PetChoice } from "./prefs";
|
||||
|
||||
const message = (err: unknown) => (err instanceof Error ? err.message : String(err));
|
||||
|
||||
export function usePets() {
|
||||
const [list, setList] = useState<PetList | null>(null);
|
||||
// Which action is running: a featured id, a pet id, "image" or "bundle".
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const reload = useCallback(
|
||||
() =>
|
||||
api.pets
|
||||
.list()
|
||||
.then(setList)
|
||||
.catch((err: unknown) => setError(message(err))),
|
||||
[]
|
||||
);
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, [reload]);
|
||||
|
||||
const run = async (key: string, work: () => Promise<void>) => {
|
||||
setBusy(key);
|
||||
setError(null);
|
||||
try {
|
||||
await work();
|
||||
} catch (err) {
|
||||
setError(message(err));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const wear = (p: PetView) => sagiPrefs.setPet({ id: p.id, kind: p.kind });
|
||||
const install = (featuredId: string) =>
|
||||
run(featuredId, async () => {
|
||||
wear((await api.pets.installFeatured(featuredId)).pet);
|
||||
await reload();
|
||||
});
|
||||
const add = (file: File, bundle: boolean) =>
|
||||
run(bundle ? "bundle" : "image", async () => {
|
||||
const { pet } = bundle ? await api.pets.importBundle(file) : await api.pets.upload(file);
|
||||
wear(pet);
|
||||
await reload();
|
||||
});
|
||||
const remove = (p: PetView) =>
|
||||
run(p.id, async () => {
|
||||
await api.pets.remove(p.id);
|
||||
if (sagiPrefs.getPet().id === p.id) sagiPrefs.setPet({ id: "sagi" });
|
||||
await reload();
|
||||
});
|
||||
|
||||
const own = list?.pets.filter((p) => p.source === "upload") ?? [];
|
||||
// Featured id → the installed copy, once downloaded.
|
||||
const installed = new Map(
|
||||
(list?.pets ?? []).filter((p) => p.featuredId).map((p) => [p.featuredId as string, p])
|
||||
);
|
||||
return { list, own, installed, busy, error, wear, install, add, remove };
|
||||
}
|
||||
|
||||
type Worn = { pet: PetChoice; size: number; shown: boolean; bubbles: boolean };
|
||||
const readWorn = (): Worn => ({
|
||||
pet: sagiPrefs.getPet(),
|
||||
size: sagiPrefs.getSize(),
|
||||
shown: sagiPrefs.getEnabled(),
|
||||
bubbles: !sagiPrefs.getMuted(),
|
||||
});
|
||||
|
||||
/** The worn pet and the mascot's prefs, following every change (any tab). */
|
||||
export function useWornPet(): Worn {
|
||||
const [worn, setWorn] = useState(readWorn);
|
||||
useEffect(() => sagiPrefs.subscribe(() => setWorn(readWorn())), []);
|
||||
return worn;
|
||||
}
|
||||
Reference in New Issue
Block a user