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" />