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" />
|
||||
|
||||
Reference in New Issue
Block a user