/** * @file PetMenu.tsx * @description The mascot's right-click menu: hide it, size it, choose the * pet it wears — Sagi, a featured pet (downloaded from codex-pets.net into * this install on the first pick, `POST /api/pets/featured/:id`), or one of * 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`); the list and its actions are `usePets`, shared * with Settings → Appearance. * @author Nguyễn Ngọc Trí Vĩ */ import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Check, ChevronDown, ChevronRight, Download, EyeOff, ExternalLink, Loader2, PackageOpen, Settings2, Trash2, Upload, } from "lucide-react"; import { PET_SIZE, sagiPrefs, type PetChoice } from "./prefs"; import { usePets } from "./usePets"; import { toast } from "../../lib/toast"; 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"; export function PetMenu({ pet, size, onClose, onSettings, }: { pet: PetChoice; size: number; onClose: () => void; onSettings: () => void; }) { const { t } = useTranslation("settings"); const ref = useRef(null); const imageInput = useRef(null); const bundleInput = useRef(null); const [choosing, setChoosing] = useState(false); const { list, own, installed, busy, error, wear, install, add, remove } = usePets(); // Closes on a press outside the menu, or Escape. useEffect(() => { const onDown = (e: MouseEvent) => { if (!ref.current?.contains(e.target as Node)) onClose(); }; const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose(); document.addEventListener("mousedown", onDown); document.addEventListener("keydown", onKey); return () => { document.removeEventListener("mousedown", onDown); document.removeEventListener("keydown", onKey); }; }, [onClose]); const addFile = (file: File | undefined, bundle: boolean) => file && void add(file, bundle); const mark = (id: string) => pet.id === id ? : null; return (
{t("sagi.pet.title")}
{choosing && (
{list?.featured.map((f) => { const have = installed.get(f.id); return ( ); })} {own.length > 0 &&
} {own.map((p) => (
))}
{t("sagi.pet.browse")}
)} { addFile(e.target.files?.[0], false); e.target.value = ""; }} /> { addFile(e.target.files?.[0], true); e.target.value = ""; }} /> {error && (

{error}

)}
); }