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:
2026-10-05 09:54:36 +07:00
parent ad6e80192d
commit 151dc8a480
13 changed files with 463 additions and 196 deletions
+7 -55
View File
@@ -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" />
+1 -1
View File
@@ -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" })
);
});
});
+88
View File
@@ -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;
}
@@ -0,0 +1,282 @@
/**
* @file PetSettings — Settings → Pet: the mascot's own page. Show it or not,
* its speech bubbles, its size (with a live preview), and which pet it wears
* from a grid — Sagi, the featured codex-pets.net pets (downloaded into this
* install on the first pick, never bundled), and your own (deletable) — plus
* uploading an image or importing a `.codex-pet.zip`. Everything here is the
* same state and calls as the mascot's right-click menu (`Sagi/usePets.ts`,
* `Sagi/prefs.ts`): the choice and size are per browser, the pets live on the
* server (`/api/pets`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useRef, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Check, Download, ExternalLink, Loader2, PackageOpen, Trash2, Upload } from "lucide-react";
import { SettingsRow, SettingsSwitchRow } from "./SettingsRow";
import { SagiAvatar } from "../Sagi/SagiAvatar";
import { PetSprite } from "../Sagi/PetSprite";
import { PET_SIZE, sagiPrefs } from "../Sagi/prefs";
import { usePets, useWornPet } from "../Sagi/usePets";
import type { PetView } from "../../lib/api";
const PREVIEW = 72;
function PetCard({
label,
hint,
selected,
busy,
disabled,
preview,
onPick,
onDelete,
deleteLabel,
testId,
}: {
label: string;
hint?: string;
selected: boolean;
busy?: boolean;
disabled?: boolean;
preview: ReactNode;
onPick: () => void;
onDelete?: () => void;
deleteLabel?: string;
testId: string;
}) {
return (
<div className="group relative">
<button
type="button"
data-testid={testId}
onClick={onPick}
disabled={disabled}
aria-pressed={selected}
title={hint}
className={`flex w-full flex-col items-center gap-2 rounded-lg border p-3 transition-colors disabled:opacity-60 ${
selected
? "border-accent ring-2 ring-accent/40"
: "border-border hover:border-border-light"
}`}
>
<span className="flex h-[76px] items-center justify-center">{preview}</span>
<span className="flex max-w-full items-center gap-1 text-xs text-fg-primary">
<span className="truncate">{label}</span>
{busy ? (
<Loader2 className="h-3 w-3 shrink-0 animate-spin" />
) : selected ? (
<Check className="h-3 w-3 shrink-0 text-accent" />
) : null}
</span>
</button>
{onDelete && (
<button
type="button"
data-testid={`${testId}-delete`}
onClick={onDelete}
disabled={disabled}
aria-label={deleteLabel}
title={deleteLabel}
className="absolute right-1.5 top-1.5 rounded p-1 text-fg-muted opacity-0 transition-opacity hover:bg-surface-4 hover:text-status-danger focus:opacity-100 group-hover:opacity-100"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
);
}
function Preview({ pet, size }: { pet: PetView; size: number }) {
return (
<PetSprite
id={pet.id}
kind={pet.kind}
mood="idle"
size={size}
reducedMotion={false}
onBroken={() => {}}
/>
);
}
export function PetSettings() {
const { t } = useTranslation("settings");
const worn = useWornPet();
const { list, own, installed, busy, error, wear, install, add, remove } = usePets();
const imageInput = useRef<HTMLInputElement>(null);
const bundleInput = useRef<HTMLInputElement>(null);
const wornPet = list?.pets.find((p) => p.id === worn.pet.id);
const sizePreview = Math.min(worn.size, 120);
return (
<div className="space-y-6">
<div className="card p-5">
<SettingsSwitchRow
checked={worn.shown}
onChange={(v) => sagiPrefs.setEnabled(v)}
label={t("sagi.enable")}
description={t("sagi.enableDesc")}
/>
<SettingsSwitchRow
checked={worn.bubbles}
onChange={(v) => sagiPrefs.setMuted(!v)}
label={t("sagi.bubbles")}
description={t("sagi.bubblesDesc")}
/>
<SettingsRow
label={t("sagi.pet.size")}
description={t("sagi.sizeDesc", { min: PET_SIZE.min, max: PET_SIZE.max })}
control={
<div className="flex items-center gap-3">
<input
type="range"
data-testid="pet-settings-size"
aria-label={t("sagi.pet.size")}
min={PET_SIZE.min}
max={PET_SIZE.max}
value={worn.size}
onChange={(e) => sagiPrefs.setSize(Number(e.target.value))}
className="w-40 accent-[rgb(var(--accent))]"
/>
<span className="w-12 text-right text-xs tabular-nums text-fg-muted">
{worn.size}px
</span>
</div>
}
/>
<div
data-testid="pet-settings-preview"
className="mt-3 flex h-[132px] items-center justify-center rounded-lg bg-surface-0"
>
{wornPet ? (
<Preview pet={wornPet} size={sizePreview} />
) : (
<SagiAvatar mood="idle" reducedMotion={false} size={sizePreview} />
)}
</div>
</div>
<div className="card p-5">
<p className="mb-1 text-sm text-fg-secondary">{t("sagi.pet.choose")}</p>
<p className="mb-4 text-xs text-fg-muted">{t("sagi.chooseDesc")}</p>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
<PetCard
testId="pet-card-sagi"
label="Sagi"
selected={worn.pet.id === "sagi"}
preview={<SagiAvatar mood="idle" reducedMotion size={PREVIEW} />}
onPick={() => sagiPrefs.setPet({ id: "sagi" })}
/>
{list?.featured.map((f) => {
const have = installed.get(f.id);
return (
<PetCard
key={f.id}
testId={`pet-card-featured-${f.id}`}
label={f.displayName}
hint={t("sagi.pet.featuredBy", { by: f.by })}
selected={!!have && worn.pet.id === have.id}
busy={busy === f.id}
disabled={busy !== null}
preview={
have ? (
<Preview pet={have} size={PREVIEW} />
) : (
<span className="flex flex-col items-center gap-1 text-[11px] text-fg-muted">
<Download className="h-6 w-6" />
{t("sagi.pet.download")}
</span>
)
}
onPick={() => (have ? wear(have) : void install(f.id))}
/>
);
})}
{own.map((p) => (
<PetCard
key={p.id}
testId={`pet-card-own-${p.id}`}
label={p.displayName}
selected={worn.pet.id === p.id}
busy={busy === p.id}
disabled={busy !== null}
preview={<Preview pet={p} size={PREVIEW} />}
onPick={() => wear(p)}
onDelete={() => void remove(p)}
deleteLabel={t("sagi.pet.delete", { name: p.displayName })}
/>
))}
</div>
<div className="mt-4 flex flex-wrap items-center gap-2">
<button
type="button"
className="btn-ghost text-xs"
disabled={busy !== null}
onClick={() => imageInput.current?.click()}
>
{busy === "image" ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Upload className="h-3.5 w-3.5" />
)}
{t("sagi.pet.upload")}
</button>
<button
type="button"
className="btn-ghost text-xs"
disabled={busy !== null}
onClick={() => bundleInput.current?.click()}
>
{busy === "bundle" ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<PackageOpen className="h-3.5 w-3.5" />
)}
{t("sagi.pet.import")}
</button>
<a
href="https://codex-pets.net/"
target="_blank"
rel="noreferrer noopener"
className="btn-ghost text-xs"
>
<ExternalLink className="h-3.5 w-3.5" />
{t("sagi.pet.browse")}
</a>
</div>
<p className="mt-2 text-xs text-fg-muted">{t("sagi.uploadHint")}</p>
{error && (
<p role="alert" className="mt-2 text-xs text-status-danger">
{error}
</p>
)}
<input
ref={imageInput}
type="file"
hidden
data-testid="pet-settings-image-input"
accept="image/png,image/jpeg,image/gif,image/webp"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) void add(file, false);
e.target.value = "";
}}
/>
<input
ref={bundleInput}
type="file"
hidden
data-testid="pet-settings-bundle-input"
accept=".zip,application/zip"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) void add(file, true);
e.target.value = "";
}}
/>
</div>
</div>
);
}