/** * @file PetSettings — the pet block of Settings → Appearance (`#pet`). 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`). Previews follow the OS reduced-motion preference; size * and bubbles are disabled while the pet is hidden; a delete asks first; and * **Reset to defaults** puts show / bubbles / size / pet back. * @author Nguyễn Ngọc Trí Vĩ */ import { useRef, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { Check, Download, ExternalLink, Loader2, PackageOpen, RotateCcw, 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 { usePrefersReducedMotion } from "../Sagi/Sagi"; import { confirmDialog } from "../../lib/confirm"; import { toast } from "../../lib/toast"; 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 (
{onDelete && ( )}
); } function Preview({ pet, size }: { pet: PetView; size: number }) { const reducedMotion = usePrefersReducedMotion(); return ( {}} /> ); } export function PetSettings() { const { t } = useTranslation("settings"); const worn = useWornPet(); const { list, own, installed, busy, error, wear, install, add, remove } = usePets(); const imageInput = useRef(null); const bundleInput = useRef(null); const wornPet = list?.pets.find((p) => p.id === worn.pet.id); const sizePreview = Math.min(worn.size, 120); const reducedMotion = usePrefersReducedMotion(); const resetDefaults = async () => { const ok = await confirmDialog({ title: t("sagi.pet.reset"), message: t("sagi.pet.resetMessage"), confirmLabel: t("sagi.pet.reset"), destructive: false, }); if (!ok) return; sagiPrefs.setEnabled(true); sagiPrefs.setMuted(false); sagiPrefs.setSize(PET_SIZE.default); sagiPrefs.setPet({ id: "sagi" }); toast.success(t("sagi.pet.resetDone")); }; return (
sagiPrefs.setEnabled(v)} label={t("sagi.enable")} description={t("sagi.enableDesc")} /> sagiPrefs.setMuted(!v)} label={t("sagi.bubbles")} description={t("sagi.bubblesDesc")} disabled={!worn.shown} /> sagiPrefs.setSize(Number(e.target.value))} className="w-40 accent-[rgb(var(--accent))] disabled:opacity-50" /> {worn.size}px
} />
{wornPet ? ( ) : ( )}
{worn.size > 120 && (

{t("sagi.pet.previewCapped", { size: worn.size })}

)} {!worn.shown && (

{t("sagi.pet.hiddenNote")}

)}

{t("sagi.pet.choose")}

{t("sagi.chooseDesc")}

} onPick={() => sagiPrefs.setPet({ id: "sagi" })} /> {list?.featured.map((f) => { const have = installed.get(f.id); return ( ) : ( {t("sagi.pet.download")} ) } onPick={() => (have ? wear(have) : void install(f.id))} /> ); })} {own.map((p) => ( } onPick={() => wear(p)} onDelete={() => void remove(p)} deleteLabel={t("sagi.pet.delete", { name: p.displayName })} /> ))}
{t("sagi.pet.browse")}

{t("sagi.uploadHint")}

{error && (

{error}

)} { const file = e.target.files?.[0]; if (file) void add(file, false); e.target.value = ""; }} /> { const file = e.target.files?.[0]; if (file) void add(file, true); e.target.value = ""; }} />
); }