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:
@@ -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