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,282 @@
|
||||
/**
|
||||
* @file PetSettings — Settings → Pet: the mascot's own page. 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`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useRef, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Check, Download, ExternalLink, Loader2, PackageOpen, 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 { 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 (
|
||||
<div className="group relative">
|
||||
<button
|
||||
type="button"
|
||||
data-testid={testId}
|
||||
onClick={onPick}
|
||||
disabled={disabled}
|
||||
aria-pressed={selected}
|
||||
title={hint}
|
||||
className={`flex w-full flex-col items-center gap-2 rounded-lg border p-3 transition-colors disabled:opacity-60 ${
|
||||
selected
|
||||
? "border-accent ring-2 ring-accent/40"
|
||||
: "border-border hover:border-border-light"
|
||||
}`}
|
||||
>
|
||||
<span className="flex h-[76px] items-center justify-center">{preview}</span>
|
||||
<span className="flex max-w-full items-center gap-1 text-xs text-fg-primary">
|
||||
<span className="truncate">{label}</span>
|
||||
{busy ? (
|
||||
<Loader2 className="h-3 w-3 shrink-0 animate-spin" />
|
||||
) : selected ? (
|
||||
<Check className="h-3 w-3 shrink-0 text-accent" />
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
{onDelete && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`${testId}-delete`}
|
||||
onClick={onDelete}
|
||||
disabled={disabled}
|
||||
aria-label={deleteLabel}
|
||||
title={deleteLabel}
|
||||
className="absolute right-1.5 top-1.5 rounded p-1 text-fg-muted opacity-0 transition-opacity hover:bg-surface-4 hover:text-status-danger focus:opacity-100 group-hover:opacity-100"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Preview({ pet, size }: { pet: PetView; size: number }) {
|
||||
return (
|
||||
<PetSprite
|
||||
id={pet.id}
|
||||
kind={pet.kind}
|
||||
mood="idle"
|
||||
size={size}
|
||||
reducedMotion={false}
|
||||
onBroken={() => {}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function PetSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const worn = useWornPet();
|
||||
const { list, own, installed, busy, error, wear, install, add, remove } = usePets();
|
||||
const imageInput = useRef<HTMLInputElement>(null);
|
||||
const bundleInput = useRef<HTMLInputElement>(null);
|
||||
const wornPet = list?.pets.find((p) => p.id === worn.pet.id);
|
||||
const sizePreview = Math.min(worn.size, 120);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="card p-5">
|
||||
<SettingsSwitchRow
|
||||
checked={worn.shown}
|
||||
onChange={(v) => sagiPrefs.setEnabled(v)}
|
||||
label={t("sagi.enable")}
|
||||
description={t("sagi.enableDesc")}
|
||||
/>
|
||||
<SettingsSwitchRow
|
||||
checked={worn.bubbles}
|
||||
onChange={(v) => sagiPrefs.setMuted(!v)}
|
||||
label={t("sagi.bubbles")}
|
||||
description={t("sagi.bubblesDesc")}
|
||||
/>
|
||||
<SettingsRow
|
||||
label={t("sagi.pet.size")}
|
||||
description={t("sagi.sizeDesc", { min: PET_SIZE.min, max: PET_SIZE.max })}
|
||||
control={
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="range"
|
||||
data-testid="pet-settings-size"
|
||||
aria-label={t("sagi.pet.size")}
|
||||
min={PET_SIZE.min}
|
||||
max={PET_SIZE.max}
|
||||
value={worn.size}
|
||||
onChange={(e) => sagiPrefs.setSize(Number(e.target.value))}
|
||||
className="w-40 accent-[rgb(var(--accent))]"
|
||||
/>
|
||||
<span className="w-12 text-right text-xs tabular-nums text-fg-muted">
|
||||
{worn.size}px
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div
|
||||
data-testid="pet-settings-preview"
|
||||
className="mt-3 flex h-[132px] items-center justify-center rounded-lg bg-surface-0"
|
||||
>
|
||||
{wornPet ? (
|
||||
<Preview pet={wornPet} size={sizePreview} />
|
||||
) : (
|
||||
<SagiAvatar mood="idle" reducedMotion={false} size={sizePreview} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-5">
|
||||
<p className="mb-1 text-sm text-fg-secondary">{t("sagi.pet.choose")}</p>
|
||||
<p className="mb-4 text-xs text-fg-muted">{t("sagi.chooseDesc")}</p>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
||||
<PetCard
|
||||
testId="pet-card-sagi"
|
||||
label="Sagi"
|
||||
selected={worn.pet.id === "sagi"}
|
||||
preview={<SagiAvatar mood="idle" reducedMotion size={PREVIEW} />}
|
||||
onPick={() => sagiPrefs.setPet({ id: "sagi" })}
|
||||
/>
|
||||
{list?.featured.map((f) => {
|
||||
const have = installed.get(f.id);
|
||||
return (
|
||||
<PetCard
|
||||
key={f.id}
|
||||
testId={`pet-card-featured-${f.id}`}
|
||||
label={f.displayName}
|
||||
hint={t("sagi.pet.featuredBy", { by: f.by })}
|
||||
selected={!!have && worn.pet.id === have.id}
|
||||
busy={busy === f.id}
|
||||
disabled={busy !== null}
|
||||
preview={
|
||||
have ? (
|
||||
<Preview pet={have} size={PREVIEW} />
|
||||
) : (
|
||||
<span className="flex flex-col items-center gap-1 text-[11px] text-fg-muted">
|
||||
<Download className="h-6 w-6" />
|
||||
{t("sagi.pet.download")}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
onPick={() => (have ? wear(have) : void install(f.id))}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{own.map((p) => (
|
||||
<PetCard
|
||||
key={p.id}
|
||||
testId={`pet-card-own-${p.id}`}
|
||||
label={p.displayName}
|
||||
selected={worn.pet.id === p.id}
|
||||
busy={busy === p.id}
|
||||
disabled={busy !== null}
|
||||
preview={<Preview pet={p} size={PREVIEW} />}
|
||||
onPick={() => wear(p)}
|
||||
onDelete={() => void remove(p)}
|
||||
deleteLabel={t("sagi.pet.delete", { name: p.displayName })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost text-xs"
|
||||
disabled={busy !== null}
|
||||
onClick={() => imageInput.current?.click()}
|
||||
>
|
||||
{busy === "image" ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("sagi.pet.upload")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost text-xs"
|
||||
disabled={busy !== null}
|
||||
onClick={() => bundleInput.current?.click()}
|
||||
>
|
||||
{busy === "bundle" ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<PackageOpen className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("sagi.pet.import")}
|
||||
</button>
|
||||
<a
|
||||
href="https://codex-pets.net/"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="btn-ghost text-xs"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
{t("sagi.pet.browse")}
|
||||
</a>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-fg-muted">{t("sagi.uploadHint")}</p>
|
||||
{error && (
|
||||
<p role="alert" className="mt-2 text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<input
|
||||
ref={imageInput}
|
||||
type="file"
|
||||
hidden
|
||||
data-testid="pet-settings-image-input"
|
||||
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) void add(file, false);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
ref={bundleInput}
|
||||
type="file"
|
||||
hidden
|
||||
data-testid="pet-settings-bundle-input"
|
||||
accept=".zip,application/zip"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) void add(file, true);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user