feat(sagi): custom pets — Codex pet bundles, images, featured downloads
The mascot can wear a pet. Right-click it for Hide pet, Size (48-250 px), Choose pet (Sagi, featured, your own - deletable), Upload your own..., Import .codex-pet bundle..., Find more on codex-pets.net and Pet settings.... The worn pet and its size are viewer prefs (agenthub.pet, agenthub.pet.size); the pets live on the server under <data dir>/pets/<id>/ (server/lib/pets.js, /api/pets). A Codex pet is the .codex-pet.zip format: pet.json + a 1536x1872 spritesheet.webp, 8x9 frames of 192x208, one row per state. PetSprite.tsx plays the row of the mascot's mood; an uploaded image bobs. Only the two bundle files are read, sizes are checked from the zip directory before inflating, the sheet's WebP header must match, uploads are raster only (no SVG), ids are made by the server, every write is same-origin. The four featured pets (Goku, Bac Ho, Border Collie, 11) are codex-pets.net uploads with no redistribution licence, so none is bundled: a pick downloads it from codex-pets.net by a fixed id.
This commit is contained in:
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* @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`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
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 { api, type PetList, type PetView } from "../../lib/api";
|
||||
import { PET_SIZE, sagiPrefs, type PetChoice } from "./prefs";
|
||||
|
||||
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<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();
|
||||
}, []);
|
||||
|
||||
// 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 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 mark = (id: string) =>
|
||||
pet.id === id ? <Check className="ml-auto h-3.5 w-3.5 shrink-0 text-accent" /> : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role="menu"
|
||||
data-testid="pet-menu"
|
||||
className="w-72 rounded-xl border border-border bg-surface-2 p-1.5 text-sm shadow-lg"
|
||||
>
|
||||
<div className="px-2.5 pb-1 pt-0.5 text-xs font-medium text-fg-muted">
|
||||
{t("sagi.pet.title")}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={ITEM}
|
||||
onClick={() => {
|
||||
sagiPrefs.setEnabled(false);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<EyeOff className="h-4 w-4" />
|
||||
{t("sagi.pet.hide")}
|
||||
</button>
|
||||
<label className="block px-2.5 py-1.5">
|
||||
<span className="flex items-center justify-between text-sm text-fg-secondary">
|
||||
{t("sagi.pet.size")}
|
||||
<span className="tabular-nums text-xs text-fg-muted">{size}px</span>
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
data-testid="pet-size"
|
||||
min={PET_SIZE.min}
|
||||
max={PET_SIZE.max}
|
||||
value={size}
|
||||
onChange={(e) => sagiPrefs.setSize(Number(e.target.value))}
|
||||
className="mt-1 w-full accent-[rgb(var(--accent))]"
|
||||
/>
|
||||
</label>
|
||||
<div className="my-1.5 border-t border-border" />
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
aria-expanded={choosing}
|
||||
data-testid="pet-choose"
|
||||
className={ITEM}
|
||||
onClick={() => setChoosing((v) => !v)}
|
||||
>
|
||||
{choosing ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
{t("sagi.pet.choose")}
|
||||
</button>
|
||||
{choosing && (
|
||||
<div className="max-h-80 overflow-y-auto pl-2">
|
||||
<button
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={pet.id === "sagi"}
|
||||
className={ITEM}
|
||||
onClick={() => sagiPrefs.setPet({ id: "sagi" })}
|
||||
>
|
||||
Sagi
|
||||
{mark("sagi")}
|
||||
</button>
|
||||
{list?.featured.map((f) => {
|
||||
const have = installed.get(f.id);
|
||||
return (
|
||||
<button
|
||||
key={f.id}
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={!!have && pet.id === have.id}
|
||||
data-testid={`pet-featured-${f.id}`}
|
||||
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();
|
||||
})
|
||||
}
|
||||
>
|
||||
<span className="min-w-0 truncate">{f.displayName}</span>
|
||||
{busy === f.id ? (
|
||||
<Loader2 className="ml-auto h-3.5 w-3.5 shrink-0 animate-spin" />
|
||||
) : have ? (
|
||||
mark(have.id)
|
||||
) : (
|
||||
<Download
|
||||
aria-label={t("sagi.pet.download")}
|
||||
className="ml-auto h-3.5 w-3.5 shrink-0 text-fg-muted"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{own.length > 0 && <div className="my-1.5 border-t border-border" />}
|
||||
{own.map((p) => (
|
||||
<div key={p.id} className="group flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={pet.id === p.id}
|
||||
data-testid={`pet-own-${p.id}`}
|
||||
className={ITEM}
|
||||
onClick={() => wear(p)}
|
||||
>
|
||||
<span className="min-w-0 truncate">{p.displayName}</span>
|
||||
{mark(p.id)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`pet-delete-${p.id}`}
|
||||
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();
|
||||
})
|
||||
}
|
||||
className="rounded p-1 text-fg-muted hover:bg-surface-4 hover:text-status-danger"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div className="my-1.5 border-t border-border" />
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={busy !== null}
|
||||
className={ITEM}
|
||||
onClick={() => imageInput.current?.click()}
|
||||
>
|
||||
{busy === "image" ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Upload className="h-4 w-4" />
|
||||
)}
|
||||
{t("sagi.pet.upload")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={busy !== null}
|
||||
className={ITEM}
|
||||
onClick={() => bundleInput.current?.click()}
|
||||
>
|
||||
{busy === "bundle" ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<PackageOpen className="h-4 w-4" />
|
||||
)}
|
||||
{t("sagi.pet.import")}
|
||||
</button>
|
||||
<a
|
||||
href="https://codex-pets.net/"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className={`${ITEM} !text-fg-muted`}
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
{t("sagi.pet.browse")}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={imageInput}
|
||||
type="file"
|
||||
hidden
|
||||
data-testid="pet-image-input"
|
||||
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||
onChange={(e) => {
|
||||
addFile(e.target.files?.[0], false);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
ref={bundleInput}
|
||||
type="file"
|
||||
hidden
|
||||
data-testid="pet-bundle-input"
|
||||
accept=".zip,application/zip"
|
||||
onChange={(e) => {
|
||||
addFile(e.target.files?.[0], true);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
{error && (
|
||||
<p role="alert" className="px-2.5 py-1 text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<div className="my-1.5 border-t border-border" />
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={`${ITEM} !text-fg-muted`}
|
||||
onClick={onSettings}
|
||||
>
|
||||
<Settings2 className="h-4 w-4" />
|
||||
{t("sagi.pet.settings")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user